先记住这个答案
因为网络数据是按字节分块的,不管字符边界。UTF-8 里一个汉字占 3 个字节,完全可能前 2 个字节在这一块、第 3 个在下一块。不传 stream: true,解码器会认为数据到此为止,把那 2 个字节当成坏数据替换成 �。传了之后,它会把这 2 个字节先存在实例内部,等下一块来了拼成完整的字再输出。所以整条流要复用同一个解码器,最后再调一次不带参数的 decode() 收尾。
- chunk 边界不等于字符边界,汉字可能被切成两半
stream: true让解码器缓存末尾不完整的字节- 整条流必须复用同一个
TextDecoder实例 - 结束时调一次
decode()把残留字节清掉
一个字被切成两块会怎样
欧元符号 € 的 UTF-8 是 E2 82 AC 三个字节。模拟网络把它切成两块,分别用两种方式解码。
stream 和传 stream 的对比JavaScriptconst first = new Uint8Array([0xe2, 0x82]) // € 的前两个字节
const second = new Uint8Array([0xac]) // € 的最后一个字节
// 错误写法:每块独立解码
const bad = new TextDecoder()
console.log(bad.decode(first) + bad.decode(second)) // '��',拼不回来了
// 正确写法:同一个实例,传 stream: true
const decoder = new TextDecoder()
let text = decoder.decode(first, { stream: true }) // '',先存着不输出
text += decoder.decode(second, { stream: true }) // '€',凑齐了
text += decoder.decode() // '',流结束,收尾
console.log(text) // '€'错误写法里,第一块的 E2 82 凑不成字符,解码器直接输出一个替换字符 �;第二块的 AC 单独出现也不合法,又输出一个。字符串层面已经坏了,事后怎么拼都恢复不了原文。
正确写法里,第一次调用返回空字符串,因为两个字节还不够一个字。第二次调用时解码器把缓存的 E2 82 和新来的 AC 拼在一起,输出 €。
真实场景:流式读取 fetch 响应
AI 对话的打字机效果、大文件边下边显示,都是用 response.body.getReader() 一块块读字节。这时候解码器要放在循环外面创建,整条流共用一个。
- 读循环里怎么写
- 循环外
const decoder = new TextDecoder(),循环里每读到一块value就decoder.decode(value, { stream: true })追加到结果上,done为true跳出后再补一句decoder.decode()。中文内容多的接口,如果把解码器写进了循环里,平时可能看不出问题,只有碰巧一个字跨了块边界才出现一个乱码,很难复现,排查起来也费劲。 也可以不自己写:response.body.pipeThrough(new TextDecoderStream())得到的就是文本流,它内部做的就是同一件事。 - 最后那次
decode()为什么不能省 stream: true的意思是「后面还有数据」。如果最后一块也这样传,解码器会一直等下去,末尾要是残留了不完整的字节,就永远不会被处理。不带参数调一次decode()等于告诉它「没有了」。这时残留的字节如果还凑不成字符,默认输出�,开了fatal: true就抛TypeError,之后解码器回到初始状态,可以复用来解码下一条流。
容易答错的地方
- 每个 chunk 都新建一个解码器
- 缓存存在实例里。每块新建一个实例,前一块留下的半个字符就跟着旧实例一起丢了,这时传
stream: true也救不回来。 - 先把字节转成字符串再拼接
- 有人想「先各自解码,最后把字符串拼起来」。问题在于解码那一步已经把半个字符替换成
�了,信息丢了。要么流式解码,要么先把所有字节拼成一个Uint8Array再一次性解码。 - 以为字符完整了,一行数据就完整了
stream: true只保证不会切坏一个字符,不保证一块刚好是一行 JSON 或一条 SSE 事件。上层协议的拆分要自己再缓冲一次。
面试官还会怎么问?
SSE 流为什么解码之后还要自己缓存?
一块数据可能包含半条事件,也可能包含好几条。常见做法是维护一个字符串 buffer,每次把解码后的文本追加进去,按空行 \n\n 切分,最后一段不完整的留在 buffer 里等下一块。解码器处理的是字节到字符,这一步处理的是字符到消息。
TextDecoderStream 和手动 decode 有什么区别?
效果一样。TextDecoderStream 是一个 TransformStream,内部就是带 stream: true 的解码器,结束时会自动收尾,适合直接 pipeThrough 串起来。手动写的好处是控制更细,比如边解码边统计字节数。
GBK 编码的流能这样解码吗?
可以。浏览器的 TextDecoder 支持 GBK、Big5、Shift_JIS 等编码,写成 new TextDecoder('gbk') 即可,流式用法完全一样,GBK 的汉字是 2 个字节,同样会跨块。反过来 TextEncoder 只能输出 UTF-8,这是两者不对称的地方。
参考资料
示例用于理解所注明的运行环境与边界;延伸学习可结合原文中的更多案例。