TextDecoderStream 读取 NDJSON:跨块中文、行边界与失败处理
一个中文字符可能分成两次网络读取,一条 JSON 记录也可能分成十个块。把每次 reader.read() 得到的字节直接转字符串,再对每个块调用 JSON.parse(),输入稍微换一种分块方式就会失败。
流的块只是传输和调度单位。NDJSON 的记录边界由换行确定,UTF-8 的字符边界则由编码确定,这两层必须分别恢复。TextDecoderStream 负责字节到文本的连续解码,应用继续负责把文本片段拼成完整记录。
下面做一个可以直接打开的浏览器实验页。它故意每两字节发送一次输入,因此中文经常跨块;页面逐行显示结果,同时限制一条记录的文本长度。需要支持 Streams 与 TextDecoderStream 的现代浏览器,启动前会检测这两项能力。
从两字节分块开始,别先依赖真实网络
保存为 ndjson.html 后用浏览器打开。页面没有访问外部服务,只将输入框里的文本变成字节流,方便稳定复现各种分块。
<!doctype html>
<html lang="zh-CN">
<meta charset="utf-8">
<title>NDJSON 分块读取实验</title>
<label for="source">每行一个 JSON 值</label><br>
<textarea id="source" rows="6" cols="60">{"name":"中文","count":1}
{"name":"最后一条没有换行","count":2}</textarea><br>
<button id="run" type="button">按两字节分块读取</button>
<p id="status" role="status"></p>
<pre id="result"></pre>
<script>
function sourceStream(text, step = 2) {
const bytes = new TextEncoder().encode(text);
let offset = 0;
return new ReadableStream({
pull(controller) {
if (offset >= bytes.length) {
controller.close();
return;
}
controller.enqueue(bytes.slice(offset, offset + step));
offset += step;
}
});
}
async function* records(byteStream, maxLineUnits = 1000) {
const reader = byteStream.pipeThrough(
new TextDecoderStream('utf-8', { fatal: true })
).getReader();
let pending = '', number = 0;
function parseLine(raw) {
number += 1;
const line = raw.endsWith('\r') ? raw.slice(0, -1) : raw;
if (line.length > maxLineUnits) throw new Error(`第 ${number} 行过长`);
if (line.trim() === '') return null;
try {
return { number, value: JSON.parse(line) };
} catch {
throw new Error(`第 ${number} 行不是完整 JSON`);
}
}
try {
while (true) {
const { value, done } = await reader.read();
if (done) break;
pending += value;
let end;
while ((end = pending.indexOf('\n')) !== -1) {
const record = parseLine(pending.slice(0, end));
pending = pending.slice(end + 1);
if (record !== null) yield record;
}
const pendingUnits = pending.endsWith('\r') ? pending.length - 1 : pending.length;
if (pendingUnits > maxLineUnits) {
throw new Error(`第 ${number + 1} 行过长`);
}
}
if (pending !== '') {
const record = parseLine(pending);
if (record !== null) yield record;
}
} finally {
await reader.cancel().catch(() => {});
reader.releaseLock();
}
}
const input = document.querySelector('#source');
const status = document.querySelector('#status');
const result = document.querySelector('#result');
const button = document.querySelector('#run');
button.addEventListener('click', async () => {
result.textContent = '';
if (!('TextDecoderStream' in globalThis) || !('ReadableStream' in globalThis)) {
status.textContent = '当前浏览器不支持本实验所需的流 API';
return;
}
button.disabled = true;
let count = 0;
try {
for await (const record of records(sourceStream(input.value))) {
result.textContent += `${record.number}: ${JSON.stringify(record.value)}\n`;
count += 1;
}
status.textContent = `完成,共 ${count} 条记录`;
} catch (error) {
status.textContent = `已读取 ${count} 条后停止:${error.message}`;
} finally {
button.disabled = false;
}
});
</script>
</html>
正常输入得到两条记录,中文保持完整。把分块大小从 2 改成 1,结果仍应一致;把它改成一大块,解析规则也不变。这才是记录层不依赖传输分块的证据。
fatal: true 让非法 UTF-8 导致流失败,而不是默默替换字符。它还会在流结束时检查未完成的多字节序列。逐块创建新的 TextDecoder 会失去前一块的解码状态,不能靠加上相同编码名字修复。
尾行、空行与 CRLF 都是协议选择
例子接受最后一条没有换行的记录,并且忽略空白行。行号仍包含空行,便于定位原始输入。CRLF 被识别为一个换行,解析前去掉行末的 CR;没有试图把任意位置的回车都当成记录分隔符。
这些规则不应藏在解析器偶然的行为里。某些服务要求每条记录都以 LF 结尾,便于判断末条是否完整,此时应在结束时拒绝非空尾缓冲,而不是继续解析。客户端与服务端必须选同一份约定。
JSON 字符串中的换行必须按 JSON 语法转义为 \n,转义后的两个字符不会被当前分隔器当作真实换行。若服务端直接把未转义换行写进字符串,客户端看到的已经不是有效的逐行 JSON,不能凭猜测把相邻行粘回去。
将第二行改成 {bad},页面保留第一条已经读取的结果并报告第二行失败。它没有把之前展示的记录撤销,也没有声称整批成功。流式界面若要提供全有或全无体验,需要先暂存到结束,再统一发布结果;“逐条显示”和“整体原子提交”不是免费兼得的两个开关。
行上限要在切完完整行之后检查
一个文本块里可能同时含有很多短记录。若收到块后立刻判断 pending.length > 上限,还没切行就报错,会误伤合法数据。示例先把完整行逐条取走,每条独立检查,最后再检查未完成的尾缓冲。
这里的 length 单位是 JavaScript UTF-16 代码单元,不是 UTF-8 字节,也不是用户眼中的字形簇。它限制的是解析器保留的文本长度。真实接口还要限制总输入字节、记录数量与处理时长;不能把一个行长度参数包装成完整资源防护。
页面为了直观,使用 textContent += 累积结果。长时间接收大量记录时,这个展示层本身会不断增加内存,并产生字符串复制;即使流解析是增量的,界面也不因此变成无限容量。实际列表可以只保留最近若干条,或将完整结果写入单独的受控存储。
记录解析成功也不代表字段满足业务要求。当前页面接受任意合法JSON值,因此数字、数组和null都可能成为一条记录。如果接口要求每行必须是带编号的对象,应在JSON.parse之后添加对应的形状检查,并保持行号继续随错误传递。不要在展示层临时访问value.name,等遇到null才抛一个失去行号的异常。
长度边界还要覆盖CR与LF恰好分在不同块的情况。代码在检查尾缓冲时允许最后一个CR作为潜在行结束符,不把它算进正文长度;否则恰好达到上限的合法CRLF记录会因为多出那一个字符被误拒绝。分块大小越小,越容易稳定发现这种边界错误。
接入 fetch 时,把读取责任继续往下传
真实网络入口先检查 HTTP 状态与 response.body,再把字节流交给同一个 records()。错误页往往是 HTML,不能仅凭请求地址以 .jsonl 结尾就开始解析。服务器若约定特定媒体类型,也应核对实际响应头。
用户中途停止浏览时,应取消 fetch 或停止迭代,让管线有机会取消上游。示例的 finally 会取消 reader 并释放锁;它并不能撤销服务端已经执行的工作,更不会把已经消费的流变回可读。再次读取需要新的响应或提前决定的复制策略。
解析器也没有无限重试坏行。没有清楚的恢复协议时,第一条畸形记录就停止,比跳过错误以后悄悄展示“全部成功”诚实。业务允许部分成功时,应把坏行数量、原行号和完成状态放进正式结果,而不是只在控制台留下异常。
先在合成字节流里固定字符、行和错误边界,再接真实网络,能够把编码错误、服务端格式错误和代理中断分开定位。只要相同字节换一种分块仍能得到相同记录,流式读取的基础合同才算站住。