为什么 readFileSync().buffer 包含文件内容之外的 JavaScript 源码?
结论
readFileSync() 返回的 Buffer 包含读取到的文件字节。它的 .buffer 属性指向底层 ArrayBuffer,这块存储可能比文件大,也可能由多个 Buffer 共享。
下面两行代码只取出了底层 ArrayBuffer,没有保留文件数据的起点和长度:
const buffer = readFileSync("testfile.txt").buffer;
const view = new DataView(buffer);
应保留原始 Buffer,并将它的 byteOffset 和 byteLength 一起传给 DataView:
const file = readFileSync("testfile.txt");
const view = new DataView(file.buffer, file.byteOffset, file.byteLength);
这样,view 的索引 0 就对应文件的第一个字节。
为什么会看到源码或 source map?
先看这三个属性:
| 属性 | 含义 |
|---|---|
file.buffer | 支撑这个 Buffer 的底层 ArrayBuffer |
file.byteOffset | 文件数据在底层 ArrayBuffer 中的起始偏移 |
file.byteLength | 这个 Buffer 包含的有效字节数 |
文件数据在底层存储中对应的区间是:
[file.byteOffset, file.byteOffset + file.byteLength)
Node.js 的部分小型 Buffer 分配会使用共享内存池。因此,即使文件很小,底层 ArrayBuffer 也可能较大,其他分配使用的字节可能位于同一块存储中。
new DataView(file.buffer) 默认覆盖整个底层 ArrayBuffer,因此也会将文件范围之外的字节纳入读取范围。这可以解释示例中出现的源码或 source map;仅凭打印结果,无法确定那些字节具体来自哪一次分配。
示例中的 8192 是底层存储的大小,不能据此认定文件有 8192 字节,也不应把这个数值写死。具体大小以及存储是否共享,取决于分配路径和运行环境。
修正最小复现代码
让 readHeader() 接收原始 Buffer,在函数内部创建范围正确的视图:
import { readFileSync } from "node:fs";
function readHeader(buf) {
const view = new DataView(
buf.buffer,
buf.byteOffset,
buf.byteLength
);
console.log("printing out the buffer char by char:");
for (let i = 0; i < view.byteLength; i++) {
process.stdout.write(String.fromCharCode(view.getUint8(i)));
}
}
const file = readFileSync("testfile.txt");
readHeader(file);
也可以打印下面几个值,检查文件范围与底层存储范围是否一致:
console.log({
fileBytes: file.byteLength,
byteOffset: file.byteOffset,
backingBytes: file.buffer.byteLength
});
即使某次运行中 byteOffset 恰好为 0,仍需要传入 byteLength,否则视图可能读到文件末尾之后的字节。
逐字节调用 String.fromCharCode() 适合观察这个示例中的 ASCII 内容。如果只是读取 UTF-8 文本,可以直接使用:
const text = readFileSync("testfile.txt", "utf8");
console.log(text);
解析 .npy 等二进制文件时,应继续按字节读取。
如果解析器只接受 ArrayBuffer
有些解析器只接受一个 ArrayBuffer,没有偏移和长度参数。这时可以复制出恰好覆盖文件内容的存储:
import { readFileSync } from "node:fs";
const file = readFileSync("data.npy");
const arrayBuffer = file.buffer.slice(
file.byteOffset,
file.byteOffset + file.byteLength
);
const view = new DataView(arrayBuffer);
这里的 ArrayBuffer.prototype.slice() 会复制指定范围。新对象从偏移 0 开始,长度等于文件字节数。
file.slice() 和 file.subarray() 返回的仍是共享底层存储的视图。如果随后直接取 .buffer,仍可能遇到相同的范围问题。
解析 .npy 文件头时先检查魔数
原问题中的 \X93NUMPY 表示二进制签名:第一个字节为 0x93,后面是 ASCII 字节 NUMPY,并非包含反斜杠的普通文本。可以在修正后的视图上直接比较:
import { readFileSync } from "node:fs";
const file = readFileSync("data.npy");
const view = new DataView(
file.buffer,
file.byteOffset,
file.byteLength
);
const magic = [0x93, 0x4e, 0x55, 0x4d, 0x50, 0x59];
if (view.byteLength < magic.length) {
throw new Error("File is too short to contain the .npy signature");
}
for (let i = 0; i < magic.length; i++) {
if (view.getUint8(i) !== magic[i]) {
throw new Error("Invalid .npy signature");
}
}
这段代码只检查签名,不是完整的 .npy 解析器。后续还需要根据文件中的格式版本读取头部长度,并检查边界。对于多字节字段,也要按格式要求显式指定字节序。
修正视图范围即可解决这里的偏移问题,无须调整 --max-old-space-size;纯 JavaScript 中同样可能出现这一现象。
备注:内容仅供参考。
