
处理二进制数据的代码里,最贵的一类 bug 是改副本的时候改到了原件。Node 里 Buffer 的视图与副本只差一个方法名,行为正好相反,小 Buffer 还会从内存池里切一段出来,byteOffset 不是零。下面九条按踩中频率排,每条一句结论加一行最小代码,输出全部来自本机脚本 tools/verify-buffer-views.mjs,Node v26.8.1。
-
slice与subarray返回视图,不是副本。两者拿到的都是指向同一块内存的 Buffer,改其中一个字节源数据就跟着变。源码里src.slice(0,5)与src.subarray(0,5)判「用同一块内存」为 true,写第一个字节之后源串从 hello world 变成 Jello world,而通过Buffer.from拿到的那份没变。 -
想要副本只有三条路:
Buffer.from(buf)、Buffer.alloc加copy、Uint8Array的构造。实测里这三份数据的源串都保持 hello world,第一份是浅拷贝里最省事的一条,性能上也是一次全量拷贝,大 Buffer 别在循环里反复调。 -
小于 8KB 的 Buffer 是池化分配的,
byteOffset不为零。Buffer.allocUnsafe(8)实测byteOffset是 5480,底层那个 ArrayBuffer 长度是 65599 字节,比这个 Buffer 大了三个数量级。 -
直接
new Uint8Array(buf.buffer)读到的是整个池子,从池子开头算起。上面那个 8 字节 Buffer 这么包出来有 65599 字节长,开头 24 字节是上一个用池子的代码留下的内容,实测里正好是本脚本的源码字符。要拿到真正属于这个 Buffer 的那一段,必须把 offset 和 length 一起传进去。 -
Buffer.from(arrayBuffer)反过来是视图,不是副本。同一块 ArrayBuffer 包两次,得到的是两个不同对象,但底层内存共享,写v1[0]=0xff之后v2[0]也是 0xff。这一条和第二条看着矛盾,区别在于入参是 Buffer 还是 ArrayBuffer。 -
多字节字符被切开时
toString会给替换字符。中文字符串的字节按 [0,4) 与 [4,) 切一刀,前一段末尾多出一个 U+FFFD,后一段开头多出两个,两边都解不出原字符。网络分片和文件分块都在这一类问题上栽过,要拼回原文得用TextDecoder的 stream 模式,逐块喂进去。 -
JSON.stringify与structuredClone都不给 Buffer。前者输出带type与data两个字段的普通对象,JSON.parse回来之后 instanceof 判定为假;后者给出的是 Uint8Array,Buffer.isBuffer同样为假。跨进程传 Buffer 时这两种走法都会静默降级。 -
视图长度越界会抛 RangeError,但写入越界没人拦。给 4 字节的 Buffer 包一个长度 8 的视图会当场抛异常,然而用这个视图往后写 4 个字节不会报错,相邻内存被改掉,实测里那个 8 字节数组被写成了四个零加四个九。
-
Buffer.concat是副本,copy返回写进去的字节数。拼接之后改结果的第一字节,两个源 Buffer 都不动。copy的返回值是实际写入长度,用来确认没被目标长度截断。
node v26.8.1
=== 1. 共享还是复制 ===
src = "hello world"
src.slice(0,5) === src.subarray(0,5) 用同一块内存? true
改 src.slice(0,5)[0] 之后 src = "Jello world"
改完 viaSubarray.toString() = "Jello"
改完 viaFrom.toString() = "hello world"
改完 viaAllocCopy.toString() = "hello world"
=== 2. 池化带来的 byteOffset 陷阱 ===
Buffer.allocUnsafe(8): byteOffset = 5480, byteLength = 8, underlying buffer.byteLength = 65599
new Uint8Array(buf.buffer) 长度 = 65599,前 12 字节 2f 2f 20 4e 6f 64 65 20 42 75 66 66
new Uint8Array(buf.buffer, off, len) 长度 = 8,前 12 字节 d8 bd cb 5c 07 00 00 00
往 buf 写 ABCDEFGH 之后:
new Uint8Array(buf.buffer) 在 off=5480 处开始读 8 字节 -> "ABCDEFGH"
new Uint8Array(buf.buffer,off,len) -> "ABCDEFGH"
池子里 off 之前的内容(即 new Uint8Array(buf.buffer) 读到的开头)前 24 字节 -> "// Node Buffer / TypedAr"
=== 3. Buffer.from(ArrayBuffer) 是视图还是副本 ===
Buffer.from(ab) 两次 === 同一个对象? false,共享底层内存? true
写 v1[0]=0xff 之后 v2[0] = 0xff
=== 4. 多字节字符被切开 ===
'中文测试' 的字节: e4 b8 ad e6 96 87 e6 b5 8b e8 af 95
切成 [0,4) 与 [4,) 之后:
part1.toString() -> "中�"
part2.toString() -> "��测试"
用 TextDecoder(stream) 拼回来 -> "中文测试"
=== 5. JSON / structuredClone / toString 的边界 ===
JSON.stringify(Buffer.from([1,2,3])) -> "{\"type\":\"Buffer\",\"data\":[1,2,3]}"
JSON.parse(JSON.stringify(buf)) instanceof Buffer -> false
JSON.parse(JSON.stringify(buf)) 实际是 -> [object Object]
JSON.stringify(Buffer.from('中文')) -> "{\"type\":\"Buffer\",\"data\":[228,184,173,230,150,135]}"
structuredClone(Buffer) 的类型 -> [object Uint8Array],值 [1,2,3]
Buffer.isBuffer(structuredClone(buf)) -> false
=== 6. 视图长度越界与写入越界 ===
new Uint8Array(buf.buffer, 0, 8) 对 4 字节 buf -> 抛 RangeError: Invalid typed array length: 8
通过 8 字节视图写后 4 字节 -> big = [0,0,0,0,9,9,9,9]
=== 7. concat 与 write 的连接语义 ===
Buffer.concat 之后再改 joined[0] -> a = "abc", joined = "Zbcdef"
Buffer.copy 返回值 = 3,target = "abc\u0000\u0000\u0000"
落到写法上
给出一条可执行的判据:拿到一个 Buffer 却不确定它是视图还是副本时,比较 buf.buffer 与 buf.byteOffset,两者相同才说明和来源共用同一块内存。做协议解析、文件切块、WebSocket 分片这些活,视图比副本省内存,代价是任何一次写入都会穿透到调用方;跨函数传递一律给副本,这一条我没有找到兼顾两头的写法。
另外提醒一句,第 3 条和第 4 条只在池化分配时出现,Buffer.alloc(8192) 以上走的是独立 ArrayBuffer,byteOffset 是零,同一段代码换个大 Buffer 跑就正常了,排查时容易被这种尺寸差异带偏。