在事件循环与异步 I/O中,我们讨论了操作完成以后,结果怎样回到 JavaScript。接下来还有一个问题:读到的结果究竟是什么?一段文件内容为什么有时是字符串,有时却是 Buffer?
先看一句问候:Guang,你好👋。它同时包含英文、中文标点、汉字和 emoji。下面的程序对同一句话给出了三种长度;把字节切成两块后,甚至无法正确还原其中的“你”。
// opening.mjs
import { Buffer } from "node:buffer";
const text = "Guang,你好👋";
const bytes = Buffer.from(text, "utf8");
console.log(text.length);
console.log([...text].length);
console.log(bytes.length);
const first = bytes.subarray(0, 10);
const second = bytes.subarray(10);
console.log(first.toString("utf8") + second.toString("utf8"));
输出:
10
9
18
Guang,��好👋
字节没有丢,顺序也没有变,为什么还原出的文本却不对?本文沿着这段问候,解释文本怎样成为字节、Buffer 怎样持有这些字节,以及分块到达时怎样恢复文本。
本文以 Node.js v24.16.0 为验证环境,使用原生 ESM。每个带有 .mjs 文件名的代码块都是完整、独立的实验:保存到同名文件,再用 node 文件名.mjs 运行。实验无需第三方依赖;只有第六章的文件实验会创建并清理自己的临时目录,其余实验只处理内存数据。
1. 文件里保存的是什么
1.1 从文本退回字节
JavaScript 字符串让我们按文本语义操作数据,但文件内容也可能是图片、压缩包或加密结果。这些数据都可以表现为字节序列,却不一定能解释为文本。Node.js 因而需要一种直接读写字节的类型,Buffer 就承担这个职责。
一个字节有 8 位。按无符号整数解释时,取值范围是 0~255。为了方便观察,调试工具常用两个十六进制数字表示一个字节:
01000111(二进制) = 71(十进制) = 47(十六进制)
这三个写法表示同一个数。它是否代表字母 G,要由解释规则决定;UTF-8 对它作这样的解释,某个二进制协议也可以把它当作数值 71。
Buffer 保存字节,不保存“这些字节是什么文字”的结论。 因此,从文件中读到 Buffer 以后,应用还要根据文件格式或协议约定决定如何使用它。图片解码器、解压缩器和文本解码器,消费的都是字节,却采用不同规则。
1.2 Buffer 是怎样的字节容器
Buffer 是 Uint8Array 的子类,表示长度固定、内容可修改的字节序列,并提供 Node.js 常用的文本转换和数值读写方法。可以把一个 Buffer 理解为“底层存储中的某个字节区间”;这个区间不一定独占整块存储。
// bytes.mjs
import { Buffer } from "node:buffer";
const bytes = Buffer.from("Guang,你好👋", "utf8");
console.log(bytes instanceof Uint8Array);
console.log(bytes[0]);
console.log(bytes[0].toString(16));
console.log(bytes.toString("hex"));
输出:
true
71
47
4775616e67efbc8ce4bda0e5a5bdf09f918b
最后一行只是把字节打印成十六进制文本。Buffer 中并没有存放字符 4 和 7 来表达第一个字节。理解这个区别后,下面就可以分别追问:原来的文本由什么组成,以及编码为什么生成了这 18 个字节。
2. 一句话为什么有三种长度
2.1 码点、码元与用户看到的字符
Unicode 为文本元素分配码点,可以把码点理解为编码空间中的编号。例如 你 是 U+4F60,👋 是 U+1F44B;U+ 后面的数字使用十六进制。知道编号以后,还需要一种编码形式决定怎样表示它。
JavaScript 字符串在语言语义上是 UTF-16 码元序列。码元是编码形式使用的基本单位,UTF-16 的一个码元是 16 位。这里的英文、中文逗号和汉字各占一个码元,👋 则由 D83D DC4B 两个码元共同表示,这两个码元称为代理对。
因此,Guang 占 5 个码元,,你好 占 3 个,👋 占 2 个,总共是 10。字符串的 length 和索引按码元工作;字符串迭代器能够把合法代理对作为一个码点处理,因此展开后是 9 个元素。ECMAScript 的字符串定义规定了这种语义。
图中每一列表示同一个码点的不同表示。👋 在 UTF-16 行占两个码元,在 UTF-8 行占四个字节;两行的基本单位并不相同。图中每个英文字符各占一列,不能把整个 Guang 算作一个字符。
// unicode.mjs
const text = "Guang,你好👋";
console.log(text.charCodeAt(8).toString(16));
console.log(text.charCodeAt(9).toString(16));
console.log(text.codePointAt(8).toString(16));
const toned = "Guang,你好👋🏽";
const segmenter = new Intl.Segmenter("zh", { granularity: "grapheme" });
console.log(toned.length);
console.log([...toned].length);
console.log([...segmenter.segment(toned)].length);
输出:
d83d
dc4b
1f44b
12
10
9
这个变体给挥手表情增加了肤色修饰符。👋🏽 包含两个码点、四个 UTF-16 码元,通常显示为一个完整表情。Unicode 用字素簇描述这类接近用户感知字符的文本单位,Intl.Segmenter 可以按字素簇分段。限制昵称长度或截断界面文案时,需要先决定要限制哪一种长度。
UTF-16 语义也不等于 V8 总是为每个字符串码元分配两个物理字节。引擎可以采用不同的内部表示,text.length * 2 不能直接代表字符串对象的实际内存占用。
2.2 UTF-8 怎样把码点装进字节
UTF-8 使用 1~4 个字节表示一个 Unicode 标量值;标量值是排除了代理码点范围的码点。它的基本位模式如下,x 用来放入码点的二进制位:
1 字节:0xxxxxxx
2 字节:110xxxxx 10xxxxxx
3 字节:1110xxxx 10xxxxxx 10xxxxxx
4 字节:11110xxx 10xxxxxx 10xxxxxx 10xxxxxx
首字节的前缀说明序列长度,后续字节以 10 开头。合法 UTF-8 还要求码点范围正确、使用最短表示,并排除代理码点,不能只检查前缀数量。RFC 3629给出了完整规则。
以“你”为例,U+4F60 的二进制位可以填入三个字节的模板:
码点: 0100 1111 0110 0000
拆分: 0100 | 111101 | 100000
填入模板: 1110 0100 | 10 111101 | 10 100000
最终字节: e4 | bd | a0
回到问候语:五个英文字符各占一个字节,,、你、好 各占三个字节,👋 占四个字节,因此总长度是 5 + 3 + 3 + 3 + 4 = 18。这里的逗号是中文全角逗号 U+FF0C,不能换成英文逗号后继续沿用这个结果。
“中文占三个字节”只能描述这里这些汉字在 UTF-8 下的情况。某些扩展汉字需要四个字节,同一个字符换一种编码也可能改变字节数量。
2.3 编码与解码必须遵守同一个约定
Buffer.from(text, "utf8") 把文本编码为字节,bytes.toString("utf8") 则把字节按 UTF-8 解码为文本。Buffer 没有一个自动记住来源编码的属性,解码规则由调用方提供。
// encodings.mjs
import { Buffer } from "node:buffer";
const text = "Guang,你好👋";
const utf8 = Buffer.from(text, "utf8");
const utf16 = Buffer.from(text, "utf16le");
console.log(utf8.length, utf16.length);
console.log(utf16.toString("hex"));
console.log(utf16.toString("utf16le"));
const converted = Buffer.from(utf8.toString("utf8"), "utf16le");
console.log(converted.equals(utf16));
输出:
18 20
4700750061006e0067000cff604f7d593dd84bdc
Guang,你好👋
true
UTF-16LE 把每个 16 位码元分成两个字节,低位字节在前。例如 你 的码元 4F60 写为 60 4f,👋 的代理对写为 3d d8 4b dc。10 个码元因此得到 20 个字节。
示例中的转换经过“UTF-8 字节 → 字符串 → UTF-16LE 字节”,生成了另一份表示。目标编码如果无法表达某个字符,或者输入已经残缺,就不能假定转换无损。例如将“你”编码为 Buffer 的 latin1,会得到 60,原来的字符信息已经丢失。
对输入来源不明确的字节,成功解码也不证明猜对了编码。ASCII 范围内的字节可以同时符合多种编码;可靠依据应来自协议、文件格式或明确的来源约定。
2.4 把字节表示成文本:Base64 与 Hex
当接口只能携带文本时,可以把已经得到的字节表示为 Base64;调试报文时,则常把它们表示为 Hex。两者处理的是字节的文本表示,原始字节不必具有文本含义。
// representations.mjs
import { Buffer } from "node:buffer";
const bytes = Buffer.from("Guang,你好👋", "utf8");
const base64 = bytes.toString("base64");
const restored = Buffer.from(base64, "base64");
console.log(base64);
console.log(base64.length, bytes.toString("hex").length);
console.log(restored.equals(bytes));
console.log(restored.toString("utf8"));
输出:
R3VhbmfvvIzkvaDlpb3wn5GL
24 36
true
Guang,你好👋
Base64 将每三个字节的 24 位分成四组 6 位,再映射为四个字符。本例的 18 字节正好得到 24 个 Base64 字符;Hex 每字节需要两个字符,因此得到 36 个字符。带填充的标准 Base64 长度为 4 × ceil(字节数 / 3)。
这里 Buffer.from(base64, "base64") 执行 Base64 解码,而 restored.toString("utf8") 执行 UTF-8 解码。方法名相同或相似,不代表参数指定的是同一层转换。Base64URL 使用 -、_ 替代普通 Base64 的 +、/,填充规则还需遵守具体协议;这些表示都不提供加密保护。RFC 4648定义了这些形式。
2.5 不同 API 的编码标签与 BOM 边界
Buffer 的编码名称集合有限,TextEncoder 只生成 UTF-8;TextDecoder 可以解码更多传统编码,具体支持还受 Node.js 的 ICU 构建配置影响。需要读 GBK 文件时,应先确认解码器支持该标签;需要生成 GBK 时,也不能指望给 TextEncoder 传一个标签就切换输出。
一个容易忽视的差异是:Buffer 的 latin1 按单字节映射处理,而 WHATWG TextDecoder 将 latin1 标签映射到 Windows-1252。BOM(字节顺序标记)的处理也不完全相同。UTF-8 的开头标记为 ef bb bf,它没有在 UTF-8 中选择大小端的作用。
// decoder-options.mjs
import { Buffer } from "node:buffer";
const oneByte = Buffer.from([0x80]);
console.log(oneByte.toString("latin1").codePointAt(0).toString(16));
console.log(new TextDecoder("latin1").decode(oneByte));
console.log(Buffer.isEncoding("gbk"));
const marked = Buffer.concat([
Buffer.from([0xef, 0xbb, 0xbf]),
Buffer.from("Guang,你好👋", "utf8"),
]);
console.log(marked.toString("utf8").codePointAt(0).toString(16));
console.log(new TextDecoder("utf8").decode(marked));
console.log(
new TextDecoder("utf8", { ignoreBOM: true })
.decode(marked).codePointAt(0).toString(16),
);
输出:
80
€
false
feff
Guang,你好👋
feff
ignoreBOM: true 是忽略 BOM 的特殊处理,因此标记会留在结果里;默认行为则去掉开头的这个标记。选择解码器时,应核对这些语义,而不只比较方法名称。此版本 TextDecoder 文档说明了标签、BOM 和错误处理选项。
3. 一段字节由谁持有
3.1 分配空间以后,哪些字节已经有内容
Buffer.from() 根据输入创建内容;Buffer.alloc(size) 分配指定字节数并默认填零;Buffer.allocUnsafe(size) 不保证内容已经初始化。第三种方式适合调用方会在使用前完整覆盖相关区域的场景,不能读取或发送尚未写入的部分。
Buffer 的长度固定,写入不会自动扩容。为了观察这一点,我们故意只为问候语分配 10 字节:
// allocation.mjs
import { Buffer } from "node:buffer";
const target = Buffer.alloc(10);
const written = target.write("Guang,你好👋", "utf8");
console.log(written);
console.log(target.subarray(0, written).toString("utf8"));
const header = Buffer.allocUnsafe(4);
header.writeUInt32BE(18, 0); // 完整写入四个字节,之后才读取。
console.log(header.toString("hex"));
输出:
8
Guang,
00000012
Guang, 占了 8 字节,剩余 2 字节放不下需要 3 字节的“你”。UTF-8 字符串写入不会只写这个字符的一部分,因此返回实际写入的 8 字节。读取有效内容时,应使用返回的长度,而不是把整个容量都当成结果。
这种完整性只保证到编码字符边界,并不保证字素簇完整。例如带肤色的表情仍可能在修饰符之前被截断。根据字节上限截断协议字段,与根据可见字符截断文案,需要不同策略。
3.2 视图让两个对象看到同一段字节
假如只需要问候语中的名字,可以取前五个字节。但取到一个新 Buffer 对象,是否意味着复制了一份名字?
// ownership.mjs
import { Buffer } from "node:buffer";
const original = Buffer.from("Guang,你好👋", "utf8");
const view = original.subarray(0, 5);
const copy = Buffer.from(view);
view[0] = 0x67; // 将 G 改为 g。
console.log(original.toString("utf8"));
console.log(view.toString("utf8"));
console.log(copy.toString("utf8"));
const exactView = new Uint8Array(
original.buffer,
original.byteOffset,
original.byteLength,
);
console.log(exactView.length);
输出:
guang,你好👋
guang
Guang
18
subarray() 创建共享存储的视图;Buffer.from(view) 复制内容。图中复制区间与原区间互不重叠,因此后续修改互不影响,但这不要求它们一定属于不同的底层 ArrayBuffer:小 Buffer 也可能来自同一个内存池的不同位置。
读取 Node.js v24.16.0 的 Buffer 实现可以看到,subarray() 根据原来的底层存储、原偏移加起点以及新长度创建视图。它改变的是观察范围,没有逐字节复制内容。
3.3 底层存储、观察范围与存活时间
几个相关类型的职责可以放在一起理解:
| 类型 | 负责什么 |
|---|---|
ArrayBuffer |
提供底层字节存储 |
Uint8Array |
按无符号单字节整数读写某个区间 |
Buffer |
在字节视图基础上提供 Node.js 常用操作 |
DataView |
按偏移、数值类型和指定字节序访问存储 |
从 Buffer 建立对应视图时,要带上 buffer、byteOffset、byteLength。只写 new Uint8Array(original.buffer) 可能把整个底层存储都包含进来;这块存储可以比当前 Buffer 大得多,前后区域可能与当前数据无关。
也要留意方法之间的差别:Buffer.from(arrayBuffer, offset, length) 共享存储;Buffer.from(existingBuffer) 复制内容;Buffer.slice() 与 subarray() 一样共享字节,不能套用普通数组 slice() 的复制直觉。新代码用 subarray() 更能表达共享意图。
对于更宽的 TypedArray,还应区分“转换元素”和“复制原始字节”:Buffer.from(new Uint16Array([0x1234])) 得到一个值为 0x34 的字节;需要复制原始内存表示时使用 Buffer.copyBytesFrom() 等明确的字节操作,而协议数值应显式选择字节序。
共享视图省去了复制,也会延长底层存储的存活时间。如果从很大的 Buffer 中取出几个字节并长期保存,底层整块存储可能因此无法回收。确实只需要长期保存那几个字节时,可以复制成不再引用原大块存储的结果。共享还是复制,是所有权与成本之间的选择。
4. 字节切开后怎样还原文本
4.1 两次独立解码为什么损坏了“你”
现在回到开篇。18 个字节在偏移 10 处分成两块,subarray(0, 10) 的结束位置不包含索引 10:
第一块:47 75 61 6e 67 ef bc 8c e4 bd
第二块:a0 e5 a5 bd f0 9f 91 8b
第一块包含完整的 Guang,,以及“你”的前两个字节 e4 bd。第二块以“你”的最后一个字节 a0 开头。
每次 toString("utf8") 都把当前 Buffer 当作完整输入。第一次遇到末尾不完整的 e4 bd,用替换字符 U+FFFD,也就是 �,表示解码错误;第二次从 a0 开始,缺少前导字节,又产生一个 �。再拼接字符串时,无法知道它们原来共同属于一个“你”。
这里已经发生了信息丢失。原始字节顺序正确,并不能保证先分别解码再拼接的结果正确。
4.2 保存状态,等待字符剩余的字节
解码器需要区别两种状态:当前块已经结束,但后面还会有输入;整个输入已经结束,不会再有补充。StringDecoder 在两次调用之间保存状态,暂存末尾没有完成的字符。
// chunks.mjs
import { Buffer } from "node:buffer";
import { StringDecoder } from "node:string_decoder";
const bytes = Buffer.from("Guang,你好👋", "utf8");
const first = bytes.subarray(0, 10);
const second = bytes.subarray(10);
console.log(first.toString("utf8") + second.toString("utf8"));
const decoder = new StringDecoder("utf8");
console.log(decoder.write(first));
console.log(decoder.end(second));
输出:
Guang,��好👋
Guang,
你好👋
write(first) 输出已经完整的 Guang,,并暂存 e4 bd。第二块到来后,它先把 a0 接到暂存内容后面,解出“你”,再处理剩余的 好👋。两次返回的字符串按顺序拼接,就是原文。
这并不是修复乱码;解码器在信息尚未被替换之前,保留了继续处理所需的字节。对于这里的 UTF-8 字符,它只需要处理末尾少量尚未完成的字节,不必为了字符解码等待整份文件。
4.3 输入结束时,残缺与非法内容怎样处理
如果第一块以后就不再有输入,等待必须结束。下面同时观察替换策略和严格策略:
// incomplete.mjs
import { Buffer, isUtf8 } from "node:buffer";
import { StringDecoder } from "node:string_decoder";
const bytes = Buffer.from("Guang,你好👋", "utf8");
const first = bytes.subarray(0, 10);
const decoder = new StringDecoder("utf8");
console.log(decoder.write(first));
console.log(decoder.end());
const strict = new TextDecoder("utf-8", { fatal: true });
console.log(strict.decode(first, { stream: true }));
try {
strict.decode(); // 宣布输入结束;e4 bd 仍然缺一个字节。
} catch (error) {
console.log(error.name);
}
console.log(isUtf8(bytes), isUtf8(first));
输出:
Guang,
�
Guang,
TypeError
true false
StringDecoder.end() 将剩余残缺序列转换为替换字符。TextDecoder 的 fatal: true 选择严格失败;stream: true 允许保留末尾状态,最后不带流式标记的 decode() 才宣布输入结束。若收到的序列本身已经非法,严格解码也可能在中途调用时就抛错。
isUtf8(first) 为 false,只说明这块数据作为完整输入时不构成合法 UTF-8,并不证明整条数据流已经损坏。对任意块逐块校验,会把正常的跨块字符当成错误。反过来,isUtf8() 为 true 也只能证明符合 UTF-8 语法,不能确认发送方最初使用的编码。
还存在来自字符串一侧的残缺:JavaScript 字符串允许孤立代理码元,例如按码元截取 👋 的一半。将其编码为 UTF-8 时会产生替换字符。字节输入的完整性与字符串本身的完整性,需要分别考虑。
4.4 验证所有切分位置,再选择合适的接口
一个正确的增量解码方式不能只在偏移 10 时有效。下面对 18 个字节前后共 19 个单次切分位置逐一检查,同时验证两种解码器:
// all-splits.mjs
import assert from "node:assert/strict";
import { Buffer } from "node:buffer";
import { StringDecoder } from "node:string_decoder";
const text = "Guang,你好👋";
const bytes = Buffer.from(text, "utf8");
for (let cut = 0; cut <= bytes.length; cut += 1) {
const first = bytes.subarray(0, cut);
const second = bytes.subarray(cut);
const decoder = new StringDecoder("utf8");
assert.equal(decoder.write(first) + decoder.end(second), text);
const strict = new TextDecoder("utf-8", { fatal: true });
const result = strict.decode(first, { stream: true })
+ strict.decode(second, { stream: true })
+ strict.decode();
assert.equal(result, text);
}
console.log("19 个切分位置全部通过");
每个切分实验创建自己的解码器;同一条流中的连续块则复用同一个解码器。不能每来一块就重新创建,否则跨块状态仍会丢失;也不能让不同数据流混用一个状态实例。
| 数据入口 | 可以选择的方式 | 需要承担的责任 |
|---|---|---|
| 已经拿到完整 Buffer | toString("utf8"),或严格 TextDecoder |
编码已知,决定遇到非法输入时的策略 |
| 自己处理连续 Buffer 块 | StringDecoder |
按顺序 write(),结束时 end() |
| Node.js 可读字节流 | readable.setEncoding("utf8") |
让流处理跨块字符,后续收到字符串 |
| 标准字节视图与严格解码需求 | 流式 TextDecoder |
复用实例、保留状态、结束时刷新并处理异常 |
StringDecoder与可读流的 setEncoding分别提供了不同层次的入口。它们解决字符跨块的问题,接下来还要处理消息的边界。
5. 字符完整,消息就完整了吗
5.1 给这句问候加上一个长度头
假设一个应用要连续传递多句问候,我们约定每条消息由“四字节长度头+UTF-8 消息体”构成。长度字段使用大端序无符号整数,记录消息体的字节数。
// frame.mjs
import { Buffer } from "node:buffer";
const text = "Guang,你好👋";
const payload = Buffer.from(text, "utf8");
const frame = Buffer.alloc(4 + payload.length);
frame.writeUInt32BE(payload.length, 0);
payload.copy(frame, 4);
console.log(frame.length);
console.log(frame.subarray(0, 4).toString("hex"));
const size = frame.readUInt32BE(0);
console.log(size);
console.log(frame.subarray(4, 4 + size).toString("utf8"));
const number = Buffer.alloc(4);
number.writeUInt32BE(0x12345678, 0);
console.log(number.toString("hex"));
number.writeUInt32LE(0x12345678, 0);
console.log(number.toString("hex"));
输出:
22
00000012
18
Guang,你好👋
12345678
78563412
UInt32 表示占四字节的无符号整数,BE 表示高位字节在前,LE 表示低位字节在前。十进制 18 是十六进制 12,因此头部是 00 00 00 12。字节序规定数值的排列方式,字符编码规定文本的表示方式,这个协议同时使用了两者。
图中模拟在整条消息的偏移 14 处分块:第一块包含四字节头部和十字节消息体。这个接收边界恰好落在“你”的中间,也没有到达消息末尾。
同一组字节也可以按有符号整数或浮点数解释。需要解析此类字段时,选择对应的 readInt*()、readFloat*() 等方法;超出 Number 精确整数范围的 64 位整数,使用返回 BigInt 的方法。数值读写方法还会检查访问范围,不能在头部尚未收齐时提前读取。
5.2 字符边界与消息边界由不同规则决定
上面的实验已经拿到完整消息,所以可以直接读取。实际分块输入则需要保留协议状态:
- 不足四字节,先等待完整头部。
- 头部到齐后读取长度,并检查是否超过应用允许的上限。
- 消息体没有收齐,继续等待;还要有累计缓存和等待时间的限制。
- 收齐指定长度后,取出该消息体并解码。
- 多出来的字节属于后续消息,继续解析。
- 输入结束时仍有残缺头部或消息体,报告协议错误。
字符解码器不知道这个协议有四字节头部。它也不会自动判断一行、一个 JSON 文档或一条业务消息是否完整。TCP 提供有序字节流,一次写入和一次读取之间没有业务消息边界的对应保证;一条消息可以拆成多块,一块也可以包含多条消息。
如果把头部写成 text.length,记录的会是 10,而不是 18。接收端只取十个消息体字节,恰好又停在“你”的中间;余下字节还会被误当成下一条消息。这不仅影响显示,还会破坏后续协议解析。
到这里,Buffer、解码器和协议解析器的责任就可以分开:Buffer 提供字节访问,解码器维护字符状态,协议解析器维护消息状态。本文只建立这个边界,完整的流量控制和协议实现还需要处理更多资源与错误路径。
6. 怎样把这些判断用于实际代码
6.1 文件读到 Buffer 以后,什么时候解码
下面把问候语写入自己的临时文件,再分别按字节和文本读取。文件实验会在结束时清理这个临时目录:
// file-roundtrip.mjs
import { Buffer } from "node:buffer";
import { mkdtemp, writeFile, readFile, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
const directory = await mkdtemp(join(tmpdir(), "buffer-greeting-"));
try {
const path = join(directory, "greeting.txt");
await writeFile(path, Buffer.from("Guang,你好👋", "utf8"));
const raw = await readFile(path);
const text = await readFile(path, "utf8");
console.log(Buffer.isBuffer(raw), raw.length);
console.log(text, text.length);
} finally {
await rm(directory, { recursive: true, force: true });
}
输出:
true 18
Guang,你好👋 10
指定 utf8 是告诉读取 API 怎样解码,并不是让它自动识别文件编码。如果中间步骤只需要复制、计算摘要、压缩或转发,通常可以继续保留字节,到需要文本语义时再解码。
图片、压缩包等任意二进制数据尤其不能随意经过“UTF-8 字符串”这一层。非法字节被替换以后,再编码不会恢复原始内容。若外部接口要求以文本携带二进制,可以采用协议约定的 Base64 等表示。
HTTP 的 Content-Length 同样描述消息体字节数。若直接发送本例的 UTF-8 Buffer,应使用 18;如果发送前压缩了消息体,应根据实际发送的压缩后字节计算,不能继续沿用文本长度或压缩前长度。
6.2 复制、转换和引用各有什么成本
反复把“之前的累计结果”和“新块”做 Buffer.concat(),会一再复制旧内容。假设有 n 块,每块 m 字节,累计复制量约为:
m + 2m + 3m + ... + nm = m × n × (n + 1) / 2
总量有限而且确实需要完整数据时,可以先收集各块,最后拼接一次,减少重复复制。不过这仍要保存全部输入,合并时还要为结果分配空间。大文件或持续输入需要逐块消费,并通过背压协调生产与消费速度,这属于后续 Stream 的核心问题。
编解码也有扫描、转换和分配成本。Buffer.from(string) 与 toString() 是同步工作,处理大输入或频繁执行时会占用当前 JavaScript 线程。异步读取文件并不意味着随后进行的所有文本转换也自动成为后台任务。
Node.js 的部分小 Buffer 分配会使用内存池。它减少某些分配与管理成本,同时使得底层存储可能大于单个 Buffer。池大小和分配阈值属于具体版本的实现参数;判断代码时应关注实际字节范围与引用关系,不能把某个固定阈值当成 Buffer 的语义。
观察内存时,process.memoryUsage()中的 arrayBuffers 包含 Buffer 等底层存储分配,且已经包含在 external 中,两者不能简单相加。heapUsed 与进程驻留内存 rss 又各有不同范围。只看 JavaScript 堆大小,不能排除 Buffer 存储增长;对象可回收也不保证 rss 立刻下降。
6.3 从异常现象回到具体边界
| 现象 | 优先检查的边界 |
|---|---|
| 中文或 emoji 到某些位置就乱码 | 是否逐块独立解码,是否在字节或码元中间截断 |
| 换一个解码 API 后结果不同 | 编码标签、BOM 与非法输入策略是否一致 |
| 修改局部数据影响了原数据 | 是否使用共享视图,复制是否发生在修改之前 |
| 只有几个字节仍长期占用大块内存 | 视图是否仍持有原来的底层存储 |
| 字节数正确,协议解析却错位 | 头部含义、大小端和消息边界是否符合约定 |
| 数据越大,拼接越慢 | 是否反复复制累计结果,是否需要逐块处理 |
遇到乱码时,保留必要的原始字节证据,再比较发送时的编码约定、接收字节是否完整、解码状态是否跨块保存。已经变成替换字符以后,仅观察最终字符串往往无法还原丢失的内容。
对于 Guang,你好👋,10 个码元、9 个码点和 18 个 UTF-8 字节并不矛盾。它们属于不同的表示层。字节还会经历分配、共享、复制和分块;只有明确每一层的单位、所有权和结束条件,文本才能在整个处理过程中保持正确。继续研究文件、Stream 和 HTTP 时,可以沿着这些边界判断:当前拿到的是什么,谁在保存状态,以及什么时候才算完整。