本页目录

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

建立字符、编码和字节的分层模型,处理 UTF-8 跨块边界,理解 Buffer 的共享内存与二进制读写。

L2 · 能交付约 15 分钟阅读含示例、练习与验收

建议先读:从终端认识 Node:第一个程序与进程生命周期

本页内容

目标与前置#

团队助手读取中文文档、接收上传内容或向网络发送 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。本例不打印任何未初始化内存,只观察自己创建的数据。

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 时,第一块的末尾被视为不完整字符,第二块的开头也不是合法起始;后来再拼字符串,已经无法撤销替换过程。

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

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

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

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

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 交给异步写入后立刻复用并修改同一块内存,接收方到底看到什么取决于写入是否已经消费数据。接口文档若没有允许立即复用,就应等到相应完成通知,或在交界处复制。一个看起来没有对象字段的字节数组,依然有可变状态与所有权问题。

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

用可见字节排查乱码#

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

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

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

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

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

参考答案:完整单消息实验
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 可处理的约定,例如循环引用会抛错;真实业务要先验证结构,再进入编码层。

验收与自测#

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

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

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

本章实际验证范围#

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

官方参考#

字节创建、视图与二进制读写见 Buffer,跨块解码见 StringDecoder,严格文本解码见 TextDecoder。本章所有程序只操作自行创建的内存与输入,不读取真实用户文档。

原有课程整理于 2026-09-10;Node / Electron 扩充于 2026-09-11。示例环境与验证范围以正文为准。
原创中文学习手册,阅读结构参考 Vue 文档;非 Vue 官方教材。
下载本章 Markdown

支持中文和英文全文搜索 · ↑ ↓ 选择 · Enter 打开 · Esc 关闭