You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

使用tus-js-client实现Tus多文件顺序上传及进度条波动修复

问题修复方案

核心问题定位

  • 进度条波动原因:所有上传任务共用同一条进度更新逻辑,每个文件的onProgress回调都会直接覆盖全局进度值,没有统计所有文件的累计上传量,导致数值在不同文件的进度之间跳变
  • 无法串行上传原因:遍历文件列表时给开始按钮重复绑定了多次点击事件,点击后所有文件的上传任务会同时启动,没有做队列顺序控制
  • 额外逻辑bug:单个文件上传完成就调用reset()清空进度,且每次选择文件都会重复绑定按钮事件,多次选文件后会出现重复上传问题

实现思路

  1. 串行上传控制:选中文件后先把所有上传任务存入队列,点击开始时仅启动队列第一个任务,当前文件上传成功后自动触发下一个任务,直到队列全部执行完成
  2. 全局进度计算:提前统计所有待上传文件的总字节数,维护全局累计已上传字节数映射表,每个文件触发进度更新时,先更新该文件自身的已上传值,再汇总所有文件的已上传量计算全局进度,避免进度跳变
  3. 事件绑定优化:将开始按钮的点击事件移到文件选择逻辑外部,避免重复绑定

修正后完整代码

JavaScript 部分

const fileInput = document.querySelector('#js-file-input')
const dataDiv = document.querySelector('#data-pre');
const toggleBtn = document.querySelector('#toggle-btn')
const progressBar = document.querySelector('.progress-bar')
const textProgress = document.querySelector('#js-upload-text-progress')
const uploadList = document.querySelector('#upload-list')

// 全局状态管理
let uploadQueue = [] // 上传任务队列
let totalBytes = 0 // 所有文件总大小
let fileUploadedBytes = [] // 每个文件已上传的字节数,索引和队列对应
let currentUploadIndex = 0 // 当前正在上传的文件索引

function reset() {
  progressBar.style.width = `${0}%`
  textProgress.textContent = ""
  // 重置所有全局状态
  uploadQueue = []
  totalBytes = 0
  fileUploadedBytes = []
  currentUploadIndex = 0
  toggleBtn.textContent = 'start'
  toggleBtn.disabled = false
}

/**
 * 字节数格式化
 */
function formatBytes(bytes, decimals = 2) {
  if (bytes === 0) return '0 Bytes'
  const k = 1024
  const dm = decimals < 0 ? 0 : decimals
  const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB']
  const i = Math.floor(Math.log(bytes) / Math.log(k))
  return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + ' ' + sizes[i]
}

/**
 * 更新全局进度
 */
function updateGlobalProgress() {
  const totalUploaded = fileUploadedBytes.reduce((sum, val) => sum + val, 0)
  const percentage = totalBytes === 0 ? 0 : ((totalUploaded / totalBytes) * 100).toFixed(2)
  progressBar.style.width = `${percentage}%`
  textProgress.textContent = `已上传 ${formatBytes(totalUploaded)} / ${formatBytes(totalBytes)} (${percentage}%)`
}

/**
 * 启动下一个上传任务
 */
function startNextUpload() {
  if (currentUploadIndex >= uploadQueue.length) {
    // 所有文件上传完成
    textProgress.textContent = "全部文件上传完成"
    toggleBtn.disabled = true
    return
  }
  const currentUpload = uploadQueue[currentUploadIndex]
  currentUpload.start()
}

// 按钮点击事件仅绑定一次
toggleBtn.addEventListener('click', () => {
  if (uploadQueue.length === 0) return
  toggleBtn.disabled = true
  toggleBtn.textContent = '上传中'
  startNextUpload()
})

fileInput.addEventListener('change', (e) => {
  // 每次选新文件先重置状态
  reset()
  const files = e.target.files;
  dataDiv.innerHTML = ''

  Object.keys(files).forEach((k, index) => {
    const file = files[k]
    const fileId = URL.createObjectURL(file)
    totalBytes += file.size
    fileUploadedBytes[index] = 0 // 初始化每个文件的已上传字节为0

    const chunkSize = parseInt(5242880, 10)
    const upload = new tus.Upload(file, {
      endpoint: "https://tusd.tusdemo.net/files/",
      chunkSize,
      retryDelays: [0, 1000, 3000, 5000],
      metadata: {
        filename: file.name,
        filetype: file.type
      },
      onError: function (error) {
        console.log("上传失败: " + error);
        toggleBtn.disabled = false
        toggleBtn.textContent = '重试'
      },
      onProgress: function (bytesUploaded, bytesTotal) {
        // 更新当前文件的已上传字节数
        fileUploadedBytes[index] = bytesUploaded
        updateGlobalProgress()
      },
      onSuccess: function () {
        const anchor = document.createElement('a')
        anchor.textContent = `下载 ${upload.file.name} (${formatBytes(upload.file.size)})`
        anchor.href = upload.url
        anchor.className = 'btn btn-success'
        uploadList.appendChild(anchor)
        // 启动下一个文件上传
        currentUploadIndex++
        startNextUpload()
      }
    });

    // 把上传任务加入队列
    uploadQueue.push(upload)

    // 渲染文件信息
    dataDiv.innerHTML += `
      <div class="file-data" key=${fileId}>
        <img alt="文件预览" src=${fileId} width="150" />
        <div>文件名: ${file.name}</div>
        <div>文件类型: ${file.type}</div>
        <div>文件大小: ${formatBytes(file.size)}</div>
      </div>
    `
  });
});

HTML 部分

<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.0.2/dist/css/bootstrap.min.css" rel="stylesheet"
    integrity="sha384-EVSTQN3/azprG1Anm3QDgpJLIm9Nao0Yz1ztcQTwFspd3yD65VohhpuuCOmLASjC" crossorigin="anonymous">

<script src="https://cdnjs.cloudflare.com/ajax/libs/jquery/3.3.1/jquery.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/tus-js-client@3.0.0-0/dist/tus.min.js"></script>
<div class="upload-card">
      <div class="row">
        <div class="col-md-12">
          <div id="input-section">
            <div class="heading">选择需要上传的文件:</div>
            <input type="file" id="js-file-input" accept="image/*, video/*" multiple>
          </div>
          <div id="progress-section">
            <div class="heading" id="heading"></div>
            <div class="d-flex">
              <div class="flex-grow-1">
                <div class="progress">
                  <div class="progress-bar progress-bar-striped bg-success" role="progressbar"></div>
                </div>
                <div class="upload-text-progress" id="js-upload-text-progress"></div>
              </div>
              <div class="js-action-btn">
                <button class="btn stop" id="toggle-btn">start</button>
              </div>
            </div>
          </div>
        </div>
      </div>
    </div>
    <hr>
    <h3>已完成上传</h3>
    <p>上传成功的文件会在此处展示下载入口</p>
    <p id="upload-list"></p>
    <br>
    <hr>
    <div id="data-pre"></div>

关键改动说明

  • 移除了遍历文件时重复绑定按钮事件的逻辑,按钮点击事件仅在页面初始化时绑定一次,避免事件叠加
  • 新增上传队列机制,每个文件上传成功后自动触发下一个任务,严格保证串行执行
  • 全局进度基于所有文件的累计上传量计算,从根本上解决单个文件进度覆盖全局值导致的跳变问题
  • 调整重置逻辑,仅在选择新文件、所有任务完成时才重置状态,单个文件传完不会清空进度

内容的提问来源于stack exchange,提问作者afi

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.08.27 00:33:24