# Buffer 与编码：字符串之外的字节世界

## 目标与前置

团队助手读取中文文档、接收上传内容或向网络发送 JSON 时，最终传递的是字节。你已经会使用 JavaScript 字符串，但字符串长度不等于文件大小，一次数据块也不一定包含完整字符。本章的目标是能解释中文乱码从哪里出现，正确限制字节长度，并在需要时读写简单二进制结构。前置只需要能够运行 Node 文件。

浏览器经常把编码处理藏在响应解析与页面渲染后面，Node 文件和网络 API 则更容易直接把 Buffer 交给你。看到 Buffer 不必先学习底层语言：先把它理解为可以按下标访问的一段字节序列，再逐步理解这段字节如何解释、由谁拥有、是否与其他视图共享。数据和解释方式分离，是本章最重要的模型。

## 字符、码点、代码单元与字节

人看到的一个符号、Unicode 码点、JavaScript 字符串中的 UTF-16 代码单元，以及 UTF-8 文件中的字节，并不总是一一对应。普通中文字符在 UTF-8 中通常占三个字节，某些表情会占四个字节，在 JavaScript length 中还可能计为两个代码单元。组合字符和视觉上的字素又是更高一层的问题。

因此标题长度限制必须先明确产品含义。限制用户看到的字符数量，与限制数据库字段或请求体字节数是不同规则。HTTP 内容长度和 Buffer 长度使用字节计数；不能直接用 JavaScript 字符串的 length 生成二进制协议头。反过来，用户输入框也不应该简单把字节上限当成可见字符上限展示。

编码是一套字符与字节之间的映射规则。将同一段字节按错误编码解释，会得到错误字符；对已经乱码的文本再次用 UTF-8 保存，并不能恢复最初丢失的信息。排查乱码时应追问每个边界原来是什么字节、使用什么解码方式，而不是对字符串反复调用转换函数碰运气。

## Buffer 的创建与长度 API

`Buffer.from(text, encoding)` 根据编码创建字节，字符串输入的默认编码是 utf8；返回新的 Buffer。`Buffer.byteLength(text, encoding)` 计算指定编码下的字节数量，适合在不需要完整副本时检查大小。`buf.length` 是该 Buffer 包含的字节数。它们接收的编码名称必须是受支持值，不要把响应头中的任意文本未经判断就传入。

`Buffer.alloc(size, fill)` 创建指定长度并初始化的内存，默认填零；size 应为合法非负整数。`Buffer.allocUnsafe(size)` 为性能场景提供未初始化内存，内容可能包含之前的数据，必须在对外暴露前完整写入。基础业务优先使用 alloc 或 from，让初始化边界明确。不要把 unsafe 理解为仅仅命名夸张，它确实改变了数据初始化保证。

`buf.toString(encoding, start, end)` 按指定编码解释字节区间，默认使用 utf8、从开头到末尾。无效或不完整的 UTF-8 通常会以替换字符体现，而不是总抛异常。因此“解码没抛错”不能证明上传内容是严格合法文本。需要拒绝无效编码时，可使用带 fatal 选项的 TextDecoder，并明确接受哪种编码。

## 实验一：长度、视图与副本

环境 Node 22.22.0，无依赖，保存为 `buffer-basics.mjs`。本例不打印任何未初始化内存，只观察自己创建的数据。

```js buffer-basics.mjs
import { Buffer } from 'node:buffer';
import assert from 'node:assert/strict';

const title = '文档🙂';
const bytes = Buffer.from(title, 'utf8');
assert.equal(title.length, 4);
assert.equal(bytes.length, 10);
assert.equal(Buffer.byteLength(title, 'utf8'), 10);
console.log(`代码单元=${title.length}, UTF-8字节=${bytes.length}`);

const original = Buffer.from([10, 20, 30]);
const view = original.subarray(1, 3);
const copy = Buffer.from(view);
view[0] = 99;
assert.equal(original[1], 99);
assert.equal(copy[0], 20);
console.log(`原始=${original.join(',')}, 副本=${copy.join(',')}`);
```

执行 `node buffer-basics.mjs`，预期第一行 `代码单元=4, UTF-8字节=10`，第二行 `原始=10,99,30, 副本=20,30`。字符串中两个中文字符贡献六个 UTF-8 字节，表情贡献四个；JavaScript length 的四来自两个中文代码单元和表情的两个代码单元。这里不是在计算视觉字素数量。

第二段展示 `subarray(start, end)` 返回共享底层内存的视图，不复制字节。修改视图会改变原始 Buffer。`Buffer.from(existingBuffer)` 则复制内容，后续修改互不影响。选择视图可以减少复制，但会让小视图继续保留较大的底层内存，也要求双方遵守修改约定。性能与所有权不能分开讨论。

不要把 Buffer 的行为直接等同于所有数组方法。历史上的 Buffer.slice 也具有共享视图行为，而普通数组 slice 常被用来复制元素。为了表达意图，本教材使用 subarray 表示视图、from 表示字节副本。阅读旧代码时，应该检查具体对象类型，而不是仅凭方法名字判断是否复制。

## 为什么逐块 toString 会把中文拆坏

文件和网络按块提供数据，块边界由缓冲和调度决定，不会为了中文字符完整而停在某个位置。假设一个三字节字符的前两个字节出现在第一块，最后一个字节出现在第二块。分别对每块调用 toString 时，第一块的末尾被视为不完整字符，第二块的开头也不是合法起始；后来再拼字符串，已经无法撤销替换过程。

解决办法是让解码器保留跨块状态。`StringDecoder` 的 `write(buffer)` 返回目前可以确定的完整文本，并暂存尚未完整的尾部字节；`end(buffer)` 表示输入结束，处理最后数据并冲刷剩余状态。默认编码为 utf8。结束时仍不完整的序列可能产生替换字符，所以这是一种增量解码工具，不是严格输入验证器。

如果用流设置编码，Node 也会在相应读取路径维护解码状态；如果自己手动处理 Buffer，就要自己维持解码器。不要同时在上游解码成字符串、下游又把它当原始字节累计，否则字节限制与协议边界可能失真。明确哪一层负责字节、哪一层负责文本，会让流处理更容易组合。

## 实验二：故意把中文拆在字节中间

保存为 `buffer-decode.mjs`。程序把一个中文字符的三个字节拆成两块，同时验证普通逐块解码与有状态解码的差异。

```js buffer-decode.mjs
import { Buffer } from 'node:buffer';
import { StringDecoder } from 'node:string_decoder';
import assert from 'node:assert/strict';

const bytes = Buffer.from('中', 'utf8');
const parts = [bytes.subarray(0, 2), bytes.subarray(2)];
const broken = parts.map((part) => part.toString('utf8')).join('');
assert.notEqual(broken, '中');
const decoder = new StringDecoder('utf8');
const first = decoder.write(parts[0]);
const second = decoder.write(parts[1]);
const tail = decoder.end();
assert.equal(first, '');
assert.equal(first + second + tail, '中');
assert.throws(() => new TextDecoder('utf-8', { fatal: true }).decode(Buffer.from([0xff])));
console.log(`逐块解码=${broken}, 增量解码=${first + second + tail}`);
```

执行 `node buffer-decode.mjs`，预期逐块结果出现替换字符，增量结果是 `中`。不要把替换字符的具体个数当成跨所有无效输入的通用断言，本例只断言它不等于原文。第一块解码结果为空，说明解码器选择等待完整字符，而不是急着输出一个错误替代。

最后一条断言验证严格解码会拒绝明确无效的字节。TextDecoder 的 fatal 默认是 false，本例主动设为 true；decode 返回字符串，非法输入在严格模式下抛错。若采用它处理分块数据，还要正确使用 stream 选项与最后一次冲刷，不能每一块都创建一个新解码器而失去状态。

## 二进制数字与字节序

二进制协议经常把一个整数拆成多个字节保存。大端序把高位字节放前面，小端序把低位字节放前面。发送方与接收方必须约定一致，否则同样四个字节会被解释成完全不同的长度。不要因为当前电脑使用某种 CPU 架构，就让网络协议依赖本机内存布局。

`writeUInt32BE(value, offset)` 把无符号三十二位整数写入指定偏移，返回写入位置之后的偏移；`readUInt32BE(offset)` 读取相应整数。偏移默认从零开始，范围必须保证四个字节都在 Buffer 中，数值也必须落在可表示范围。非法偏移或数值会触发范围类错误。这里 BE 明确表示大端序，LE 则表示小端序。

解析长度头时，先确认头部完整，再读取长度，再验证长度上限，最后检查负载是否完整。顺序不能反过来，否则攻击者可以通过一个巨大长度值诱导程序分配大量内存。长度头只是对方的声明，不是可信事实；它与实际收到的字节数还要核对。

## 文本表示不是加密，也不是文件类型验证

十六进制和 Base64 都可以把字节表示成文本，方便嵌入某些协议，但不会隐藏内容。拿到字符串的人可以解码，所以不能把 Base64 当作保存密码或隐藏凭据的方式。编码后的长度还会增加，上传限额应明确限制原始大小还是编码大小，避免产品提示与实际限制不一致。

某些 Buffer 解码方式对输入较宽容。例如十六进制遇到不完整或无效部分时可能截断解析，而不是替你严格校验整个字符串。需要严格格式时，先验证原始输入规则，再解码，并根据必要的长度或重新编码结果核对。不要把构造成功直接等同于整个输入都符合协议。

文件名与 MIME 声明也不能证明字节内容。用户可以把任意内容命名为文本文件；严格文本解析、文件签名识别和业务格式校验属于不同层次。团队助手至少应先限制字节规模，再验证接受的编码和内容结构，避免在一开始就把整份未知数据当成可信字符串使用。

## 把编码边界放进实际导入流程

团队助手收到一个文件时，最早能够可靠统计的是原始字节数。先在字节层设置总量上限，避免过大的输入占满内存；确定这是允许的文本类型之后，再进入解码；解码后才验证 JSON、行结构或文档字段。每一层都只处理自己知道的信息，错误也能准确说明发生在大小、编码还是业务格式上。

例如同样是“导入失败”，超过十兆字节与正文缺少标题是不同问题。前者在尚未完整读取文件时就可以拒绝，后者需要先得到足够的结构信息。如果把所有字节先转成字符串再统一校验，程序可能已经付出了大量内存与解析成本，也失去了提前终止的机会。限制位置因此会影响系统承受异常输入的能力。

对于需要保留原件的文档，保存字节原文与保存解码后的文本也有区别。解码可能处理字节顺序标记、替换无效序列或采用特定换行解释；再编码后的文件不一定与原始上传逐字节一致。若业务需要校验原件完整性，应对原始字节计算摘要，并明确正文提取是另一个派生产物。

## 内存视图的收益与代价如何判断

视图适合短期解析，例如从一个消息帧中读出头部和负载，不必复制整份数据。可是如果把很小的标题字节视图长期存进缓存，就可能继续保留整个大文件的底层内存。此时复制那几百个字节反而能减少长期占用。不要用“零复制更快”覆盖所有生命周期，短期 CPU 成本和长期内存成本可能指向不同选择。

共享视图还可能产生时序错误。发送方把 Buffer 交给异步写入后立刻复用并修改同一块内存，接收方到底看到什么取决于写入是否已经消费数据。接口文档若没有允许立即复用，就应等到相应完成通知，或在交界处复制。一个看起来没有对象字段的字节数组，依然有可变状态与所有权问题。

`byteOffset` 与 `byteLength` 在 TypedArray 和 ArrayBuffer 互操作时尤其重要。一个 Buffer 可能只是更大底层 ArrayBuffer 的一段视图，直接传出整个 `.buffer` 可能带上视图之外的字节。需要跨线程或交给其他 API 时，应同时保留偏移与长度，或创建明确范围的副本。后面的工作线程章节会再次遇到这个边界。

## 用可见字节排查乱码

调试时选一段最短的失败文本，例如一个中文字符或一个表情，打印自己控制的样本对应十六进制字节，再记录每块的长度。若合并原始字节后解码正常，而逐块解码异常，问题就很可能出在增量状态；若原始字节本身已经错误，则应继续往上游查编码或截断。这样能把“乱码”拆成可定位的具体阶段。

真实文档可能包含敏感信息，不应为诊断把整份 Buffer 输出到共享日志。记录总长度、有限且脱敏的样本、摘要以及失败偏移通常更合适。二进制日志也可能被控制台自动截断，看到的预览不一定是全部数据；需要严格验证时使用断言或受控文件，而不是凭终端显示猜测。

早期 Node 资料强调 Buffer，是因为服务器必须直接面对字节。现代环境增加了 TextEncoder、TextDecoder 和 Web Streams，这个基础仍然成立：工具可以变化，字符与字节的区分不会消失。选择 Web 风格 API 有利于共享部分浏览器逻辑，选择 Buffer 能更直接使用 Node 的文件与二进制接口，关键是不要在两套表示之间无目的地反复复制。

## 练习：编码并解析带长度头的文档消息

设计一个只处理完整消息的格式：四字节大端长度头，后面是 UTF-8 JSON，负载最多一千零二十四字节。解析器必须拒绝过短头部、超限声明、实际长度不符和无效 JSON。它不是流解析器，不能直接逐块调用；下一章与网络章节会继续处理分块。

<details><summary>参考答案：完整单消息实验</summary>

```js buffer-frame.mjs
import { Buffer } from 'node:buffer';
import assert from 'node:assert/strict';

function encode(value) {
  const payload = Buffer.from(JSON.stringify(value), 'utf8');
  if (payload.length > 1024) throw new RangeError('负载过大');
  const header = Buffer.alloc(4);
  header.writeUInt32BE(payload.length, 0);
  return Buffer.concat([header, payload]);
}
function decode(frame) {
  if (!Buffer.isBuffer(frame) || frame.length < 4) throw new TypeError('头部不完整');
  const size = frame.readUInt32BE(0);
  if (size > 1024) throw new RangeError('声明长度超限');
  if (frame.length !== 4 + size) throw new RangeError('消息长度不符');
  const text = new TextDecoder('utf-8', { fatal: true }).decode(frame.subarray(4));
  return JSON.parse(text);
}
const frame = encode({ title: '文档' });
assert.deepEqual(decode(frame), { title: '文档' });
assert.throws(() => decode(frame.subarray(0, 3)), TypeError);
assert.throws(() => decode(frame.subarray(0, frame.length - 1)), RangeError);
const oversized = Buffer.alloc(4);
oversized.writeUInt32BE(1025);
assert.throws(() => decode(oversized), RangeError);
assert.throws(() => decode(Buffer.from([0, 0, 0, 1, 123])), SyntaxError);
console.log('长度头、字节上限与格式边界通过');
```

执行 `node buffer-frame.mjs`，预期通过。Buffer.concat 接收 Buffer 列表并返回拼接后的新 Buffer，这里只拼两个小块，成本可控；若每收到一小块就把全部历史重新拼接，累计复制成本可能明显增加。编码函数的输入还应遵守 JSON.stringify 可处理的约定，例如循环引用会抛错；真实业务要先验证结构，再进入编码层。

</details>

## 验收与自测

验收要求能预测中文与表情的字节数，运行视图实验解释共享修改，运行跨块实验恢复完整字符，并让消息解析器拒绝四类非法输入。自测一：字符串 length 能否直接作为 HTTP 内容长度？答案：不能，必须计算实际编码后的字节数。自测二：subarray 是否一定减少被保留的底层内存？答案：不一定，小视图仍可能持有整个底层分配。自测三：无效 UTF-8 的 toString 没抛错是否说明输入合法？答案：不是，它可能用替换字符表示错误。

实际协议还应规定空负载是否允许、文本是否允许字节顺序标记、字段的最大数量以及未知字段如何处理。长度校验只能证明外壳一致，不能证明内部业务合理。把二进制外壳与业务对象分层校验，可以让解析器保持可复用，也让团队助手的标题、租户和资源归属规则继续由业务层负责。

字节正确只是导入成功的前提。

## 本章实际验证范围

三个文件实际通过，验证字符串四个代码单元对应十个 UTF-8 字节、共享视图与副本差异、中文跨块恢复、严格无效编码拒绝，以及完整消息的长度与格式错误。

## 官方参考

字节创建、视图与二进制读写见 [Buffer](https://nodejs.org/docs/latest-v24.x/api/buffer.html)，跨块解码见 [StringDecoder](https://nodejs.org/docs/latest-v24.x/api/string_decoder.html)，严格文本解码见 [TextDecoder](https://nodejs.org/docs/latest-v24.x/api/util.html#class-utiltextdecoder)。本章所有程序只操作自行创建的内存与输入，不读取真实用户文档。
