PAYATHON 2026

为什么 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 中同样可能出现这一现象。

备注:内容仅供参考。