本文目录
直播不必等整场录完才开播。编码器先推出第一批画面,观众就能看;后面的帧继续生产。
网络响应也可以分块到达。浏览器收到响应头和第一批正文后,JavaScript 能逐步读取,不必总等完整文件进入内存。但“分块”只发生在字节层,离一条完整业务消息还差好几步。
不必等整场录完才开播
普通写法把读取隐藏在一个 Promise 后面:
const response = await fetch('/api/report')
const report = await response.json()
render(report)第一行等待 Fetch 响应可用,第二行继续读取全部正文并解析 JSON。对一个小对象,这正是最简单可靠的方式。对持续日志、逐段回答或很大的数据集,等全部结束才显示会增加首屏等待和峰值内存。
流式处理把过程拆成“读取一块、解码一块、从缓冲区切出完整消息、处理消息”。它更灵活,也要求我们自己面对边界。
先猜时机:fetch 完成时正文下载完了吗
const response = await fetch('/slow-report')
console.log('fetch 完成')
const text = await response.text()
console.log('正文完成', text.length)通常先出现“fetch 完成”,过一段时间才出现“正文完成”。Fetch 返回的 Promise 在响应可用时 fulfilled,常见情况是状态和响应头已经到达;正文可能仍在传输。
因此,await fetch() 不等于“整个文件下载完成”。response.text()、json()、arrayBuffer() 等正文消费方法才继续读取至结束。具体网络缓存与到达时机由浏览器处理,代码不应依赖某个包恰好怎样分段。
还要先检查状态:
if (!response.ok) {
throw new Error(`HTTP ${response.status}`)
}404 和 500 响应通常仍会让 fetch fulfilled;它们是有效 HTTP 响应,不是网络层失败。
Response.body 提供的是字节流
response.body 在支持流式正文时是 ReadableStream<Uint8Array>。每一块是字节,不是天然字符串:
const response = await fetch('/api/logs')
if (!response.body) {
throw new Error('当前响应没有可读取的正文流')
}
const reader = response.body.getReader()getReader() 会锁定流。锁定期间,不能再用另一个 reader 同时读取同一条流,也不能又调用 response.text()。正文通常还是一次性消费的;读完后不能假设可以从头再读。确实需要分支时可以了解 tee(),但它带来缓冲和内存权衡,不是免费复制。
Uint8Array 表示到达的原始字节。服务器发送 UTF-8 文本时,我们还需 TextDecoder;发送压缩内容时,浏览器网络栈通常根据响应编码处理传输层解压,但应用看到什么仍要以 Fetch API 的响应语义为准。
reader.read() 如何交出下一块数据
reader 的 read() 返回 Promise,fulfilled 为 { value, done }:
const reader = response.body.getReader()
try {
while (true) {
const { value, done } = await reader.read()
if (done) break
console.log('收到字节数:', value.byteLength)
}
} finally {
reader.releaseLock()
}没有数据时,await reader.read() 暂停当前 async 函数。浏览器继续处理网络和其他工作;数据可读后,Promise 完成,函数后半段通过异步调度恢复。
这已经串起前几章:当前同步片段运行在调用栈上;等待期间控制权归还事件循环;Promise reaction 帮助恢复 async 函数;read() 的结果形状又与异步迭代协议相似。
done: true 表示流关闭且不再有块。此时 value 常为 undefined。出错时 read() Promise rejected,而不是返回 { done: true, error }。
releaseLock() 释放 reader 对流的锁,不等于取消网络。若只想放弃剩余内容,应根据场景调用 reader.cancel()、流取消或 AbortController,并理解它们如何传播到底层来源。
中文字符可能被切在两个 chunk 中间
UTF-8 中文字符通常由多个字节组成。网络 chunk 可以在任何字节边界结束,所以不能每块都独立 new TextDecoder().decode(value):
const decoder = new TextDecoder()
let text = ''
while (true) {
const { value, done } = await reader.read()
text += decoder.decode(value, { stream: !done })
if (done) break
}stream: true 告诉 decoder 后面还可能有字节,它会保留不完整的多字节序列。最终一次使用 stream: false,也就是示例中的 done 分支,让 decoder 刷新剩余状态。
若服务端字符编码不是默认 UTF-8,应按协议选择正确 label。生产系统还要考虑无效字节如何处理,TextDecoder 的 fatal 选项可决定替换还是抛错。
字符边界正确后,也只得到文本片段,还没有得到完整业务记录。
chunk 不是一行,更不是一条 JSON 消息
假设服务端每行发送一个 JSON:
{"type":"start"}
{"type":"delta","text":"你"}
{"type":"end"}一次 read() 可能拿到半行:
{"type":"del也可能一次拿到三行,或刚好把 \r\n 切开。TCP、HTTP 和 Streams API 不承诺 chunk 与应用消息边界一致。直接对每个 chunk 调 JSON.parse,只是在本机小数据测试中碰巧成功。
正确做法是维护文本缓冲区,找到协议规定的分隔符后才交出完整帧。若协议不是逐行 JSON,而是 SSE、NDJSON、长度前缀或自定义二进制格式,应按对应规则解析。真正的 JSON 数组也不能仅靠换行拆分,因为字符串内部可以有转义和格式化换行。
缓冲区还需要大小限制。若服务端一直不发送分隔符,无限制拼接会占满内存。生产解析器应在超过合理帧大小时取消并报错。
用异步生成器把字节流变成文本行
可以把底层 reader 适配成按行产出的异步 iterable:
async function* lines(
response: Response,
signal?: AbortSignal,
): AsyncGenerator<string> {
if (!response.body) throw new Error('当前响应没有可读取的正文流')
const reader = response.body.getReader()
const decoder = new TextDecoder()
let buffer = ''
try {
while (true) {
if (signal?.aborted) throw signal.reason
const { value, done } = await reader.read()
buffer += decoder.decode(value, { stream: !done })
let newline = buffer.indexOf('\n')
while (newline !== -1) {
yield buffer.slice(0, newline)
buffer = buffer.slice(newline + 1)
newline = buffer.indexOf('\n')
}
if (done) {
if (buffer) yield buffer
return
}
}
} finally {
reader.releaseLock()
}
}消费侧只处理完整行:
const controller = new AbortController()
const response = await fetch('/api/events', {
signal: controller.signal,
})
for await (const line of lines(response, controller.signal)) {
if (!line.trim()) continue
const message = JSON.parse(line)
renderMessage(message)
}异步生成器把三种状态集中在一处:字节解码状态、尚未成行的文本缓冲、reader 生命周期。消费者通过 for await...of 顺序拉取完整行,提前 break 时生成器的 finally 会运行。
示例为了突出主线,只按 \n 拆分。生产版本需要决定是否移除行尾 \r、如何限制缓冲区、是否跳过空行、如何报告行号和 JSON 错误,以及协议是否允许最后一行没有换行。
一个最小限长判断可以放在没有找到换行之后:
const MAX_LINE_LENGTH = 1024 * 1024
if (!buffer.includes('\n') && buffer.length > MAX_LINE_LENGTH) {
await reader.cancel('单行数据超过限制')
throw new Error('流中的单行数据过大')
}这里按 JavaScript 字符串长度限制,不是按原始字节精确限长。安全协议若规定字节上限,应在解码前累计字节数,或用能识别帧边界的字节解析器。限长不是为了防正常中文,而是防止错误服务端或恶意响应让缓冲区无界增长。
Windows 风格行尾是 \r\n。按 \n 切完后,可以对每行末尾的单个 \r 做协议允许的移除;不能用 trim() 代替,因为它会同时删掉业务数据本来就有意义的首尾空白。
如果消息协议是 SSE,还要处理字段名、连续 data: 行、空行分帧、注释和重连信息;不能把 SSE 简化为“每行 JSON”。先明确协议,再写适配器,比拿一个万能 split('\n') 到处复用可靠。
提前退出、取消与 releaseLock
假设用户点击“停止生成”:
const controller = new AbortController()
stopButton.addEventListener('click', () => {
controller.abort(new Error('用户停止'))
})把同一个 signal 传给 fetch,可以让取消传播到网络请求:
const response = await fetch('/api/events', {
signal: controller.signal,
})若只在生成器循环里检查 signal.aborted,而 reader.read() 正等待数据,检查不会凭空唤醒这次等待。让 Fetch 本身接收 signal,或显式调用 reader 的取消能力,才能更及时地影响底层读取。
releaseLock() 只是解除 reader 与流的锁定。reader.cancel(reason) 表示消费者不再需要其余数据,并返回 Promise;取消是否以及怎样影响网络连接,由流的底层来源处理。两者用途不同。
清理通常写在 finally,但要防止清理错误覆盖原始错误。还要决定用户取消是否显示为错误、静默结束还是独立状态。AbortError 或自定义 abort reason 属于控制流的一部分,不应和服务器故障混为一谈。
流式到达不等于处理不会阻塞
每一块到达后,解码、JSON.parse、Markdown 转换和 DOM 更新仍是 JavaScript 工作:
for await (const line of lines(response)) {
const message = JSON.parse(line)
expensiveRender(message)
}如果 expensiveRender 每次运行 100 毫秒,主线程仍会卡。异步读取只让网络等待期间可以做别的事,不会让 CPU 处理自动进入 Worker。
逐 token 更新 DOM 还可能造成大量布局和渲染开销。可以把短时间内到达的内容缓冲起来,在 requestAnimationFrame 或合理批次中更新界面;但批次太大又会增加可见延迟。需要用 Performance 面板衡量,而不是固定相信“每个 chunk 立刻 render 最流畅”。
消费者处理较慢时,for await...of 默认不会请求下一项,形成一定背压。不过 Fetch 的底层网络与流队列可能已经缓冲了一些字节,背压不是“服务器立即停止发送”的绝对保证。
容易踩的坑:边下载不等于自动边渲染
第一种误解是“fetch resolve 就下载完了”。它通常只说明响应已经可用,正文仍需消费。
第二种是“一个 chunk 就是一条服务端消息”。chunk 由传输、缓冲和读取策略共同影响,应用必须自己恢复协议边界。
第三种是“用了 Stream 页面就不卡”。网络等待是异步的,chunk 处理仍可能形成长任务。
第四种是“退出循环就一定取消请求”。提前退出会触发 iterator 关闭和生成器 finally,但你的 finally 必须实际调用或传播底层取消。仅 releaseLock 不等于放弃下载。
最后,流式传输还受服务器、代理、压缩和平台缓冲影响。前端写了 reader,不代表中间链路一定及时把小块送达。部署后要观察真实响应,而不是只在本地开发服务器验证。
若同一响应既要记录原文又要流式解析,可以在消费前调用 response.clone(),或谨慎使用流分支。但两个分支消费速度不同会造成缓冲,慢分支仍可能提高内存占用。最简单的方案往往是在解析过程中按需要记录已经消费的业务消息,而不是复制整条大响应。
任何需要重试的流还要定义恢复位置。请求失败后重新连接,是从头读取、携带游标续传,还是接受重复消息,必须由应用协议决定,reader 本身不会替你去重。
九章之后,执行权这条线还在继续
第一章里,调用栈描述当前代码正在做什么,函数返回后回到哪里。第四章、第五章里,事件循环、任务和微任务决定暂时没有执行权的代码何时重新获得机会。
第六章先讲 Promise 的一次最终完成;第七章把“下一个值”变成协议;第八章让函数保存可恢复位置;第九章让下一项可以晚一点到;第十章再把 Promise 变成 async 函数的返回接口。到了流式响应,这些机制同时出现,却各管一件事。
遇到新的异步问题时,可以沿这条线问:
- 当前同步片段是否占住主线程?
- 后续通过任务、微任务还是渲染回调恢复?
- 数据是推送还是由消费者逐项拉取?
- 暂停期间谁保存状态?
- 提前退出如何通知上游并释放资源?
- 传输块和业务消息的边界在哪里?
能回答这些问题,就不必靠“异步很玄学”解释输出顺序,也不会因为代码里出现 await 就默认它不会阻塞页面。
若 chunk 处理本身仍是重 CPU 工作,主线程仍可能卡住。不过在那之前,还有一条常被跳过的地基:对象上的方法究竟存在哪里,new 与 class 背后是什么。下一章从家族菜谱抄本说起,拆开原型链;再往后才是 Worker 与模块。