陌上人如玉
公子世无双

7.9 上传进度与请求取消

7.9 上传进度与请求取消

一句话:fetch 没有原生上传进度(要用 XMLHttpRequest 或手动消费 ReadableStream),取消请求用 AbortController(XHR 用 xhr.abort());大文件要分片上传(并发 + 断点续传 + 秒传)。

一、为什么上传进度需要 XHR

| 能力 | XMLHttpRequest | fetch |
| 上传进度 | ✅ `upload.onprogress` | ❌ 无原生(需手动读 ReadableStream) |
| 下载进度 | ✅ `onprogress` | ⚠️ 可通过 response.body 计算(但要自己算) |
| 取消 | `xhr.abort()` | `AbortController` |
| 超时 | `xhr.timeout` | 需 AbortController |
| 基于 Promise | ❌(要自己包装) | ✅ 原生 |
| 流式请求体 | ❌ | ⚠️(部分浏览器支持) |

⭐ 结论:
· 需要「上传进度」→ 用 XHR(axios 的 onUploadProgress 也是基于 XHR)
· 不需要进度 → fetch 更好(标准、简洁)
· 下载进度 → fetch + ReadableStream 也可以(但要自己算)
// ✅ fetch 计算下载进度(需要响应体是流)
async function downloadWithProgress(url, onProgress) {
  const res = await fetch(url);
  const total = Number(res.headers.get('content-length') ?? 0);
  const reader = res.body!.getReader();
  const chunks = [];
  let received = 0;

  while (true) {
    const { done, value } = await reader.read();
    if (done) break;
    chunks.push(value);
    received += value.length;
    onProgress(total ? received / total : 0, received, total);
  }

  return new Blob(chunks);
}
// ⚠️ 需要服务端返回 Content-Length(没有的话无法算百分比)
// ⚠️ 需要支持 ReadableStream(现代浏览器)

二、XHR 上传进度

// utils/upload.ts
export interface UploadOptions {
  onProgress?: (percent: number, loaded: number, total: number) => void;
  signal?: AbortSignal;
  headers?: Record<string, string>;
}

export function uploadFile(
  url: string,
  file: File | Blob | FormData,
  { onProgress, signal, headers }: UploadOptions = {}
): Promise<unknown> {
  return new Promise((resolve, reject) => {
    const xhr = new XMLHttpRequest();
    xhr.open('POST', url, true);

    // ⚠️ 不要手动设置 Content-Type(FormData 需要浏览器加 boundary)
    Object.entries(headers ?? {}).forEach(([k, v]) => xhr.setRequestHeader(k, v));

    // ① 上传进度(关键)
    xhr.upload.onprogress = (e) => {
      if (e.lengthComputable) {
        onProgress?.(Math.round((e.loaded / e.total) * 100), e.loaded, e.total);
      }
    };

    // ② 下载进度(响应体,可选)
    xhr.onprogress = (e) => {
      // 主要用于「下载」场景
    };

    xhr.onload = () => {
      if (xhr.status >= 200 && xhr.status < 300) {
        try {
          resolve(JSON.parse(xhr.responseText));
        } catch {
          resolve(xhr.responseText);
        }
      } else {
        reject(new Error(`HTTP ${xhr.status}: ${xhr.responseText.slice(0, 200)}`));
      }
    };

    xhr.onerror = () => reject(new Error('网络错误'));
    xhr.ontimeout = () => reject(new Error('上传超时'));
    xhr.onabort = () => reject(new DOMException('Upload aborted', 'AbortError'));

    // ③ 支持外部取消
    signal?.addEventListener('abort', () => xhr.abort(), { once: true });

    const body = file instanceof FormData ? file : toFormData(file);
    xhr.send(body);
  });
}

function toFormData(file: File | Blob): FormData {
  const fd = new FormData();
  fd.append('file', file, (file as File).name ?? 'blob');
  return fd;
}
// ✅ 组件里的用法
function FileUploader() {
  const [percent, setPercent] = useState(0);
  const [status, setStatus] = useState<'idle' | 'uploading' | 'done' | 'error'>('idle');
  const controllerRef = useRef<AbortController | null>(null);

  const handleChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
    const file = e.target.files?.[0];
    if (!file) return;

    const controller = new AbortController();
    controllerRef.current = controller;
    setStatus('uploading');
    setPercent(0);

    try {
      await uploadFile('/api/upload', file, {
        onProgress: setPercent,
        signal: controller.signal,
      });
      setStatus('done');
    } catch (err) {
      if ((err as Error).name === 'AbortError') {
        setStatus('idle');                        // ⭐ 主动取消 → 安静地回到初始态
        return;
      }
      setStatus('error');
    } finally {
      controllerRef.current = null;
    }
  };

  return (
    <div>
      <input type="file" onChange={handleChange} disabled={status === 'uploading'} />

      {status === 'uploading' && (
        <>
          <progress value={percent} max={100} />     {/* ⭐ 进度条 */}
          <span>{percent}%</span>
          <button onClick={() => controllerRef.current?.abort()}>取消上传</button>
        </>
      )}

      {status === 'done' && <p>上传完成</p>}
      {status === 'error' && <p role="alert">上传失败,请重试</p>}
    </div>
  );
}

三、axios 的进度回调

// ✅ axios 简化了进度处理(内部基于 XHR)
const controller = new AbortController();

axios.post('/api/upload', formData, {
  signal: controller.signal,
  onUploadProgress: (e) => {
    if (e.total) {
      const percent = Math.round((e.loaded / e.total) * 100);
      setPercent(percent);
    }
  },
  onDownloadProgress: (e) => {
    // 用于下载场景
  },
});

// 取消
controller.abort();
⚠️ axios 的两个细节:
① `e.total` 可能为 undefined(服务端没返回 Content-Length)→ 要判空
② 进度会「先快后慢」——因为 `loaded` 反映的是「已发送到内核缓冲区」的字节数,
   不代表「服务端已接收」(大文件时更明显)
   需要「更真实的服务端进度」→ 用分片上传(每片返回后才能确认)

四、取消请求

// ✅ 统一用 AbortController(fetch / axios 新版 / XHR 包装都支持)
const controller = new AbortController();

// 传给请求
fetch(url, { signal: controller.signal });
axios.get(url, { signal: controller.signal });

// 取消
controller.abort();

// 判断「是不是取消导致的错误」
err.name === 'AbortError'                     // fetch / XHR 包装
axios.isCancel(err) || err.code === 'ERR_CANCELED'   // axios
// ✅ 组件里的标准模式:在 useEffect 清理里取消
useEffect(() => {
  const controller = new AbortController();

  upload(file, { signal: controller.signal }).catch((err) => {
    if (err.name === 'AbortError') return;         // ⭐ 取消 → 不提示
    setError(err.message);
  });

  return () => controller.abort();                 // 依赖变化/卸载时取消
}, [file]);
⚠️ 取消的四个注意点:
① 取消会产生一个错误(AbortError)→ 必须【区分】它与真实错误(不要弹提示)
② 取消后「服务端可能仍在处理」(请求已发出)→ 重要操作要有「服务端幂等/补偿」
③ 上传取消后再重试,要「从头开始」(HTTP 层没有续传能力)→ 分片可以续传
④ 取消「不保证资源立刻释放」(浏览器会异步处理)

五、分片上传

为什么需要分片:
① 大文件一次上传容易超时/失败(一次失败就要全部重来)
② 无法做「断点续传」
③ 无法「并发上传」加速
④ 服务端也要一次性处理整个文件(内存压力)

分片上传的完整流程:
① 【前端切片】把文件按固定大小(如 5MB)切成 N 片
② 【计算哈希】对文件算一个唯一标识(如 md5 / 秒传用)
③ 【上传前询问】调「检查接口」问服务端「这个文件/这些片是否已存在」→ 秒传/续传
④ 【并发上传】把未上传的片并发请求(限流,如 3~5 并发)
⑤ 【合并】全部成功后调「合并接口」,服务端按顺序拼接
⑥ 【校验】服务端返回最终文件地址
// utils/chunkUpload.ts
const CHUNK_SIZE = 5 * 1024 * 1024;      // 5MB

interface ChunkUploadOptions {
  file: File;
  onProgress?: (percent: number) => void;
  concurrency?: number;
  signal?: AbortSignal;
  onChunkDone?: (index: number) => void;
}

export async function chunkUpload({
  file,
  onProgress,
  concurrency = 3,
  signal,
  onChunkDone,
}: ChunkUploadOptions) {
  // ① 切片
  const chunks: Blob[] = [];
  for (let start = 0; start < file.size; start += CHUNK_SIZE) {
    chunks.push(file.slice(start, Math.min(start + CHUNK_SIZE, file.size)));
  }

  // ② 文件指纹(用于秒传/续传;大文件可以用「抽样哈希」加速)
  const fileHash = await computeFileHash(file);

  // ③ 询问服务端:文件是否已存在(秒传)/ 已上传了哪些片(续传)
  const { uploaded, fileId } = await api.checkFile({
    hash: fileHash,
    name: file.name,
    size: file.size,
  });

  if (uploaded) {
    onProgress?.(100);
    return { fileId, url: `/files/${fileId}` };        // ⭐ 秒传
  }

  // ④ 并发上传未完成的片(带限流)
  const pending = chunks
    .map((blob, index) => ({ blob, index }))
    .filter(({ index }) => !uploadedChunkSet.has(index));

  let completed = uploadedChunkSet.size;
  const updateProgress = () => {
    completed++;
    onProgress?.(Math.round((completed / chunks.length) * 100));
  };

  await runWithConcurrency(pending, concurrency, async ({ blob, index }) => {
    const fd = new FormData();
    fd.append('file', blob);
    fd.append('hash', fileHash);
    fd.append('index', String(index));
    fd.append('total', String(chunks.length));

    await api.uploadChunk(fd, { signal });
    onChunkDone?.(index);
    updateProgress();
  });

  // ⑤ 合并
  const result = await api.mergeChunks({ hash: fileHash, name: file.name, total: chunks.length });
  return result;
}

// 并发限流(不引入库的最小实现)
async function runWithConcurrency<T>(items: T[], limit: number, worker: (item: T) => Promise<void>) {
  let cursor = 0;
  const runners = Array.from({ length: Math.min(limit, items.length) }, async () => {
    while (cursor < items.length) {
      const index = cursor++;
      await worker(items[index]!);
    }
  });
  await Promise.all(runners);
}

// 文件哈希(用 Web Worker 加速,避免阻塞主线程)
async function computeFileHash(file: File): Promise<string> {
  // ⚠️ 完整 md5 对大文件很慢 → 常见优化:取首尾 + 中间若干块算哈希
  const SAMPLE = 2 * 1024 * 1024;
  const samples: Blob[] = [];
  for (let offset = 0; offset < file.size; offset += SAMPLE) {
    samples.push(file.slice(offset, Math.min(offset + SAMPLE, file.size)));
  }
  // 简化:这里用 crypto.subtle 算 SHA-256(生产上常用 spark-md5)
  const buffer = await new Blob([file.slice(0, SAMPLE), file.slice(-SAMPLE)]).arrayBuffer();
  const digest = await crypto.subtle.digest('SHA-256', buffer);
  return Array.from(new Uint8Array(digest), (b) => b.toString(16).padStart(2, '0')).join('');
}
// ✅ 组件里带进度与暂停/恢复
function BigFileUploader() {
  const [percent, setPercent] = useState(0);
  const [status, setStatus] = useState<'idle' | 'uploading' | 'paused' | 'done' | 'error'>('idle');
  const controllerRef = useRef<AbortController | null>(null);
  const fileRef = useRef<File | null>(null);

  const start = async (file: File) => {
    fileRef.current = file;
    const controller = new AbortController();
    controllerRef.current = controller;
    setStatus('uploading');

    try {
      await chunkUpload({
        file,
        onProgress: setPercent,
        signal: controller.signal,
      });
      setStatus('done');
    } catch (err) {
      if ((err as Error).name === 'AbortError') {
        setStatus('paused');                  // ⭐ 暂停(下次可续传)
        return;
      }
      setStatus('error');
    }
  };

  return (
    <div>
      <input type="file" onChange={(e) => e.target.files?.[0] && start(e.target.files[0])} />
      <progress value={percent} max={100} />

      {status === 'uploading' && (
        <button onClick={() => controllerRef.current?.abort()}>暂停</button>
      )}
      {status === 'paused' && (
        <button onClick={() => fileRef.current && start(fileRef.current)}>
          继续上传                    {/* ⭐ 续传:已上传的片会被检查接口跳过 */}
        </button>
      )}
    </div>
  );
}
能力 实现方式
切片 file.slice(start, end)
并发 手写限流(或 p-limit)
秒传 前端算「文件哈希」→ 服务端查是否已存在
续传 上传前问「已上传哪些片」→ 只传缺的
进度 「已完成片数 / 总片数」(更真实)
暂停 AbortController.abort()
合并 全部完成后调「合并接口」
限速 限制并发数 + 片之间加间隔(必要时)

六、常见坑

// ① 用 fetch 做上传进度(没有原生支持)
// ② 手动设置 multipart 的 Content-Type(丢 boundary)
fetch('/upload', { headers: { 'Content-Type': 'multipart/form-data' }, body: fd });   // ❌

// ③ 认为 e.loaded / e.total 是「服务端已接收」
// → 只是「已发送到内核缓冲区」

// ④ 取消后没判断 AbortError(弹出「上传失败」提示)
// ⑤ 用 axios 的 CancelToken(已废弃)而不是 AbortController
// ⑥ 大文件不分片(一次失败全部重来)
// ⑦ 分片上传「并发数过高」(把带宽/服务端打满)→ 3~5 并发较稳
// ⑧ 分片上传「没做顺序保证或校验」(服务端应该校验 index 与总数)
// ⑨ 文件哈希在主线程算(大文件会「卡死界面」)→ 用 Web Worker
// ⑩ 没做「秒传」(重复上传同一个文件)
// ⑪ 没做「续传」(网络中断后从头传)
// ⑫ 分片大小不合适(太小 → 请求数多;太大 → 单次失败成本高)
//    ✅ 常见 2~5MB;移动网络可小一些
// ⑬ 合并时「没有校验文件完整性」(应该用哈希校验)
// ⑭ 上传进度用「单片的 onprogress」而不是「已完成片数」
//    → 单片进度会「在 90% 停很久」(因为最后一片还在传)
// ⑮ 服务端没做「分片过期清理」(大量临时片占满磁盘)
// ⑯ 忘记「上传前的类型/大小校验」(用户体验差 + 服务端浪费)
// ⑰ 没处理「token 过期」(长上传过程中 token 失效)
// ⑱ 移动端「后台切换」导致上传中断(需要考虑「切后台暂停/恢复」)
// ⑯ 的前端校验(应该在上传前做)
function validateFile(file: File) {
  const MAX_SIZE = 100 * 1024 * 1024;                    // 100MB
  const ALLOWED = ['image/jpeg', 'image/png', 'application/pdf'];

  if (file.size > MAX_SIZE) throw new Error('文件不能超过 100MB');
  if (!ALLOWED.includes(file.type)) throw new Error('只支持 JPG/PNG/PDF');
}

七、面试延伸

  1. 「fetch 能获取上传进度吗?」
    不能(没有原生的上传进度事件)。XMLHttpRequest 提供 xhr.upload.onprogress(e.loaded / e.total / e.lengthComputable),axios 的 onUploadProgress 也是基于 XHR 实现的。如果一定要用 fetch:下载进度可以通过读取 res.body(ReadableStream)边读边累加来算,但上传进度在目前的浏览器里没有标准 API(ReadableStream 作为请求体的支持有限)。所以「需要上传进度」就用 XHR(或 axios)。

  2. 「上传进度到 100% 就说明服务端收到了吗?」
    不一定。onprogress 的 loaded 反映的是「已写入浏览器/操作系统发送缓冲区的字节数」,不代表「服务端已经接收并处理完」。所以大文件常见「进度条到 100% 后还要等一会儿才成功」。要得到「更真实的进度」,应该用分片上传——以「已成功返回的片数 / 总片数」作为进度(每片的成功响应意味着服务端确实收到了这一片)。

  3. 「怎么取消一个正在进行的请求?」
    用 AbortController:创建 controller,把 controller.signal 传给请求(fetch 的 signal 选项 / axios 的 config.signal / XHR 包装里监听 signal 并调 xhr.abort()),需要取消时调 controller.abort()。取消会让 Promise reject 一个 AbortError(axios 是 CanceledError,可用 axios.isCancel(err) 判断)。关键细节:① 在 React 里通常在 useEffect 的清理函数里 abort;② catch 里要区分取消与真实错误(取消不应该弹提示)。

  4. 「为什么要分片上传?」
    四个理由:① 可靠性——大文件一次上传容易超时/中断,失败要全部重来;分片后「只重传失败的片」;② 断点续传——可以「暂停后继续」(跳过已上传的片);③ 并发加速——多片并行上传(配合限流),充分利用带宽;④ 服务端友好——服务端处理小片的内存压力更小,也便于「多机/多存储」并行处理。

  5. 「分片上传的完整流程是什么?」
    六步:① 切片——file.slice(start, end) 按固定大小(常见 2~5MB)切分;② 算哈希——对文件算唯一标识(用于秒传与续传;大文件用「抽样哈希 + Web Worker」避免卡界面);③ 检查——调接口问「这个文件是否已存在(秒传)/ 已上传了哪些片(续传)」;④ 并发上传——只传缺失的片,用限流(3~5 并发)避免打满带宽;⑤ 合并——全部成功后调「合并接口」,服务端按 index 顺序拼接;⑥ 校验——服务端返回最终地址(最好做「整体哈希校验」确保完整)。

  6. 「什么是秒传?」
    指「上传前先算文件的哈希,问服务端这个文件是否已存在」——如果存在,直接返回已有文件的地址(不上传任何数据)。它依赖「内容寻址」(相同内容的文件哈希相同)。实现要点:① 前端用 Web Worker 算哈希(避免卡主线程);② 大文件用「抽样哈希」(如取首尾 + 中间若干块)来加速;③ 服务端要维护「哈希 → 文件」的索引。它是网盘类产品的标配能力。

  7. 「大文件的哈希怎么算才不卡界面?」
    三点:① 放到 Web Worker 里算(crypto.subtle.digest 或 spark-md5),主线程只负责展示进度;② 分块计算(用 FileReader/Blob.arrayBuffer() 边读边更新哈希状态),避免一次性读入内存;③ 抽样哈希——只取「文件头部 + 尾部 + 中间的若干块」参与哈希(速度提升几十倍,碰撞概率极低),实践中足以作为「秒传」的标识(若担心碰撞,可在服务端做二次校验)。

  8. 「上传/下载还有什么优化?」
    四个方面:① 分片 + 并发 + 续传 + 秒传(上面已述);② 限速与优先级——限制并发数、必要时给「用户主动操作的文件」更高优先级;③ 压缩与转码——图片可以「前端压缩/生成缩略图」(用 Canvas 或 createImageBitmap),减少上传体积;④ CDN 与直传——大文件走「客户端直传对象存储」(服务端只发「临时上传凭证」),避免经过自己的服务器(省带宽、更稳、更快)。

一句话速记

fetch 没有上传进度(要用 XHR 的 xhr.upload.onprogress 或 axios.onUploadProgress;下载进度可以用 ReadableStream 自己算);取消统一用 AbortController(fetch/axios/XHR 包装都支持;catch 里要区分 AbortError 不要弹提示;React 里在 useEffect 清理函数里 abort);大文件要分片上传——「切片(2~5MB)→ 算哈希(Web Worker + 抽样)→ 检查(秒传/续传)→ 限流并发(3~5)→ 合并 → 校验」,进度应该用「已完成片数 / 总片数」(onprogress 的 100% 只代表「发进了缓冲区」);别手动设 multipart 的 Content-Type;生产上大文件推荐客户端直传对象存储。

赞(0) 打赏
未经允许不得转载:陌上寒 » 7.9 上传进度与请求取消

评论 抢沙发

觉得文章有用就打赏一下文章作者

非常感谢你的打赏,我们将继续给力更多优质内容,让我们一起创建更加美好的网络世界!

微信扫一扫

支付宝扫一扫