- 文档/教程
- 前端
【免费下载链接】en.javascript.info
Modern JavaScript Tutorial
在现代 Web 应用中,用户经常需要下载较大的数据:日志文件、导出报表、媒体资源等。此时如果能实时显示"已接收 X / 共 Y 字节",体验会好很多。本教程围绕 Modern JavaScript Tutorial(en.javascript.info)网络篇的 Fetch 下载进度章节展开,讲解如何利用fetch的response.body(ReadableStream)逐块读取响应体、统计下载字节数,并最终拼回完整的文本或二进制结果。读完本文,你将掌握一套可复制的流式下载进度追踪方案,并清楚理解fetch在此场景下的能力边界(如无法追踪上传进度、Content-Length可能缺失等问题)。
为什么 fetch 默认无法得知下载进度
fetch是 JavaScript 中发起网络请求的现代方案,其基本用法是先await fetch(url)拿到Response对象,再调用response.text()、response.json()、response.blob()、response.arrayBuffer()等方法来读取响应体(参见 Fetch 章节)。
但这类方法有个共同特点:它们会一次性把整个响应体消费完毕,期间不会暴露任何进度信息——你只能在读取结束后拿到完整结果,无法知道"读了多少、还剩多少"。这正是 Fetch: Download progress 章节要解决的问题。
好消息是,Response对象还暴露了另一个属性:response.body,它是一个ReadableStream。该流允许我们按"块(chunk)"逐个读取响应体,数据每到达一块就能感知一次,从而精确统计任意时刻已消费的字节数。
追踪下载进度的核心:response.body 与 ReadableStream
认识 ReadableStream
ReadableStream是 Streams API 规范定义的对象,它把数据按块提供给使用者,而非一次性给出全部内容。在fetch的场景中,response.body对应的正是服务器响应的原始字节流。
与response.text()、response.json()等方法相比,response.body把读取过程的控制权完全交还给你:你可以决定读取节奏,可以在任意时刻统计已消费的字节数。这正是实现进度追踪的前提。
要读取流,首先获取它的 reader:
const reader = response.body.getReader();reader.read()返回一个 Promise,其解包结果是一个包含两个属性的对象:
done—— 布尔值,true表示读取完成(已到达流末尾),否则为false;value—— 字节类型的定型数组Uint8Array,即本次收到的数据块。
最小读取骨架
下面是最小化的流式读取骨架(来自 article.md):
// 代替 response.json() 等方法 const reader = response.body.getReader(); // 在响应体下载期间不断循环 while (true) { // done 在最后一块数据时为 true // value 是当前块的 Uint8Array 字节数据 const { done, value } = await reader.read(); if (done) { break; } console.log(`Received ${value.length} bytes`); }循环会持续执行await reader.read(),直到done变为true。每收到一块数据,value.length就是该块的字节数。把每一块的字节数累加起来,就是实时下载进度。
完整实战:追踪下载进度并还原响应内容
完整代码示例
只统计字节数还不够,真实场景下我们往往还要在下载结束后拿到完整的响应内容。下面这段代码取自 article.md,它请求 GitHub API 返回最近的提交记录,一边打印下载进度,一边把分块数据拼回完整结果:
// Step 1: 发起 fetch 并获取 reader let response = await fetch('https://api.github.com/repos/javascript-tutorial/en.javascript.info/commits?per_page=100'); const reader = response.body.getReader(); // Step 2: 获取总长度 const contentLength = +response.headers.get('Content-Length'); // Step 3: 读取数据 let receivedLength = 0; // 当前已接收的字节数 let chunks = []; // 已接收的二进制块数组(组成响应体) while (true) { const { done, value } = await reader.read(); if (done) { break; } chunks.push(value); receivedLength += value.length; console.log(`Received ${receivedLength} of ${contentLength}`) } // Step 4: 将各块拼接成一个 Uint8Array let chunksAll = new Uint8Array(receivedLength); // (4.1) let position = 0; for (let chunk of chunks) { chunksAll.set(chunk, position); // (4.2) position += chunk.length; } // Step 5: 解码为字符串 let result = new TextDecoder("utf-8").decode(chunksAll); // 完成! let commits = JSON.parse(result); alert(commits[0].author.login);分步讲解
参照 article.md,我们逐步拆解这段代码:
发起 fetch,但不调用
response.json():而是通过response.body.getReader()获取流读取器。请注意,这两种读取方式是互斥的——同一个响应要么用 reader 读取,要么用response.json()等方法读取,不能混用,否则会报错。读取前先获取响应总长度:通过
response.headers.get('Content-Length')拿到服务器声明的总字节数,前面的+一元运算符把它转成数字。这个头在跨源请求中可能缺失(详见 Fetch:跨源请求),而且服务器技术上也没有义务设置它,不过通常情况下它是存在的。循环
await reader.read()直到done为true:把每一块Uint8Array收集进chunks数组,同时累加receivedLength。这一步非常关键:响应体被消费后就无法"重读"了——之后再调用response.json()或其他方法都会报错,所以必须先保存所有块。拼接所有块为单个
Uint8Array:JavaScript 没有现成的方法直接拼接多个Uint8Array,所以需要自己处理:- 先创建
chunksAll = new Uint8Array(receivedLength),一个总长度相同的新定型数组(4.1); - 再用
.set(chunk, position)方法把每个chunk依次拷贝进去,position记录写入偏移量(4.2)。
- 先创建
把字节数组解码为字符串:
chunksAll只是字节数组,不是字符串。内置的 TextDecoder 与 TextEncoder 负责把字节按指定编码解释成字符串,之后如有需要再用JSON.parse解析。
直接获得 Blob 的简化方案
如果响应内容是二进制数据(图片、压缩包等),而不是文本,那么第 4、5 步可以合并成一行——用Blob包装所有块:
let blob = new Blob(chunks);Blob天然支持由多个BufferSource/字符串/Blob组成,因此new Blob(chunks)即可直接得到完整文件对象(Blob的详细操作见 Blob 章节)。至此,下载过程既拿到了实时进度,也拿到了最终结果——文本或Blob任选其一。
仓库配套可运行示例
仓库在 progress.view 目录下提供了配套的可运行示例。其 index.html 用完全相同的模式请求同目录下的 long.txt(一个约 2 万行的多语言文本文件,每行为A long file. Длинный файл. 长文件.,非常适合观察分块下载):
<!doctype html> <script> (async () => { const response = await fetch('long.txt'); const reader = response.body.getReader(); const contentLength = +response.headers.get('Content-Length'); let receivedLength = 0; let chunks = []; while (true) { const chunk = await reader.read(); if (chunk.done) { console.log("done!"); break; } chunks.push(chunk.value); receivedLength += chunk.value.length; console.log(`${receivedLength}/${contentLength} received`) } let chunksMerged = new Uint8Array(receivedLength); let length = 0; for (let chunk of chunks) { chunksMerged.set(chunk, length); length += chunk.length; } let result = new TextDecoder("utf-8").decode(chunksMerged); console.log(result); })(); </script>你可以直接用浏览器打开这个 HTML 文件,在控制台观察receivedLength/contentLength的递增日志,直观感受分块到达的过程。它用本地文件演示了与 GitHub API 示例完全相同的五步流程:fetch → getReader → 循环读取 → 拼接 → 解码。类似的流式读取模式在仓库其他章节中也有复用(例如 Fetch API 章节的 post.view),可以作为对照参考。
边界情况与注意事项
响应只能读取一次
response.body是流式对象,读取过程会"消耗"流。一旦用 reader 读完(或读了一部分),就无法再用response.text()、response.json()等方法来读取同一个响应——这正是示例中必须先把所有块存进chunks数组的原因。方案选择上必须二选一:要么用 reader 流式读取并自行拼装,要么用响应方法一次性获取。
Content-Length 缺失与内存防护
进度百分比依赖总长度,但Content-Length并非总是存在:跨源请求下服务器可能不提供该头,分块传输编码(chunked)场景下也可能没有。此时contentLength会得到NaN,进度只能显示"已接收字节数",无法计算百分比。
更需要注意的是内存防护:所有块都会被暂存在chunks数组中,如果响应体非常大且总长度未知,数组会持续膨胀直至耗尽内存。正如 article.md 所强调的:当大小未知时,应在循环中检查receivedLength,一旦达到某个上限就主动break,防止chunks撑爆内存。也就是说,进度追踪和内存边界管理需要一并考虑。
关于 for await..of 异步迭代
Streams API 其实还描述了ReadableStream的异步迭代用法(for await..of),代码可以更简洁。但 article.md 指出,在其编写时该特性的浏览器支持尚不广泛,因此教程采用兼容性更稳妥的while循环。若你的目标浏览器已支持,可以自行尝试用异步迭代改写,但while循环在绝大多数环境下都不会有问题。
多字节字符跨块解码
用TextDecoder解码时,还有一个流式场景的细节值得注意:UTF-8 等多字节编码中,一个字符可能被拆到相邻两个块里。TextDecoder 的decode(input, { stream: true })选项专门应对这种情况——它会记住"未完成"的字符,在下一块数据到达时继续解码(详见 TextDecoder 章节)。因此,如果要在边下载边解码的场景下保证文本不出现乱码,应使用stream: true逐块调用decode,而不是等所有块收齐后再一次性解码。
fetch 无法追踪上传进度:改用 XMLHttpRequest
必须强调一个fetch的能力边界:目前fetch没有追踪上传(upload)进度的机制,上述方案只能追踪下载(download)进度。如果应用需要上传进度条,应改用XMLHttpRequest(XMLHttpRequest 章节会专门介绍)。
作为对照,仓库的 XHR 章节展示了xhr.onprogress事件的使用(见 08-xmlhttprequest/article.md):该事件周期性触发,event.loaded表示已下载字节数,event.total表示总字节数(前提是服务器发送了Content-Length,此时event.lengthComputable为true);若没有该头,则只能显示event.loaded。这正是 XHR 在进度能力上相对fetch的优势所在。
小结
通过response.body(ReadableStream)+getReader()逐块读取,我们可以在fetch下完整实现下载进度追踪,流程总结为五步:
fetch获取响应后,用response.body.getReader()取得读取器(不要混用response.json()等方法);- 用
response.headers.get('Content-Length')获取总长度(可能缺失,需兼容处理); - 循环
await reader.read(),累加receivedLength,并把每块Uint8Array存入数组; - 用
new Uint8Array(receivedLength)加.set(chunk, position)拼接所有块; - 文本内容用
TextDecoder解码,二进制内容用new Blob(chunks)直接打包。
同时要牢记三个关键约束:响应体只能消费一次;未知大小时需设置内存上限;上传进度请改用XMLHttpRequest。掌握了这套模式,你就能为文件下载、数据同步等场景轻松加上实时进度反馈。
延伸阅读:Fetch 基础 | Fetch:跨源请求与 Content-Length | TextDecoder 与 TextEncoder | Blob | XMLHttpRequest 与进度事件
- 文档/教程
- 前端
【免费下载链接】en.javascript.info
Modern JavaScript Tutorial
相关推荐
JavaScript教程:使用Fetch API实现下载进度追踪
JavaScript教程:使用Fetch API实现下载进度追踪 引言 在现代Web开发中,处理网络请求是常见的需求。Fetch API作为XMLHttpReq
JavaScript教程:使用Fetch API追踪下载进度
JavaScript教程:使用Fetch API追踪下载进度 理解Fetch API的进度追踪机制 在现代Web开发中,Fetch API已成为发起网络请求的主
CopilotKit 实战:基于 CrewAI Conversational Flows 构建流式任务进度追踪的 Agentic Generative UI
CopilotKit 实战:基于 CrewAI Conversational Flows 构建流式任务进度追踪的 Agentic Generative UI 导
人工智能AI AgentAgent 框架前端后端
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考