Node.js 26 深度拆解:当运行时决定把 node_modules、fs 和 C ABI 一起收进内核——node:ffi、node:vfs、package maps 与 Perfetto 的四条暗线
一、开篇:这不是一次常规的小版本升级
如果你只看 Node.js 的 CHANGELOG 标题,2026 年 6 月到 8 月这段时间显得平平无奇:v26.4.0、v26.5.0、v26.5.1(安全版)、v26.6.0、v26.7.0,六周里发了五个版本,条目密密麻麻,大部分是 buffer: optimize ...、benchmark: add ... 这种日常缝补。
但如果你把 Notable Changes 单独抽出来排成一列,会看到一个非常不一样的图景:
v26.4.0——vfs: add minimal node:vfs subsystem、vfs: dispatch node:fs/promises to mounted VFS instances、loader: implement package mapsv26.5.0——perf_hooks: sample delay per event loop iteration、stream: expose ReadableStreamTee、tls: report negotiated TLS groupsv26.6.0——ffi: add getCurrentEventLoopv26.7.0——lib: add perfetto support、crypto: support loading private keys through STORE loaders、module: implement Symbol.dispose in ModuleHooks
四个东西:一个虚拟文件系统、一套绕开 node_modules 的模块解析、一个原生 FFI、一套 Perfetto 追踪管线。
这四件事表面上八竿子打不着,但如果你把它们放在一条线上看,会发现一个共同的主题:
Node.js 正在把过去二十年「外包给 npm 生态」的基础能力,一件一件收回运行时内核。
- 想调 C 库?以前你得会 node-gyp、会 N-API、会
binding.gyp,或者装ffi-napi/koffi。现在有node:ffi。 - 想 mock 文件系统?以前你得用
mock-fs、memfs加上一堆 require 拦截黑魔法。现在有node:vfs。 - 想干掉幻影依赖、想让 monorepo 的 peer dependency 真正解析对?以前你得上 Yarn PnP,忍受一半生态不兼容。现在有
--experimental-package-map。 - 想做生产级性能追踪?以前你导出一堆 Chrome Trace JSON,几百兆的文件把
chrome://tracing卡死。现在有 Perfetto protobuf。
这是一次战略转向,而且是一次几乎没被中文技术圈认真讨论过的转向。这篇文章会把这四条暗线一条一条拆开:讲清楚它解决什么历史问题、架构怎么设计、代码怎么写、性能边界在哪里、哪些坑会让你在生产上翻车。
顺带说一句版本节奏:Node.js 26 是偶数版本,按官方发布策略会在当年 10 月进入 LTS。也就是说,现在你在 26.x 上踩的所有坑,两个月后就会变成大量团队的默认环境。现在读这篇文章,比十月读要划算得多。
二、暗线一:node:ffi —— Node 终于承认 node-gyp 这条路走不通了
2.1 历史包袱:为什么「调个 C 函数」在 Node 里这么难
先说清楚痛点的量级。在 Node.js 里调用一个已经编译好的 .so / .dylib / .dll,历史上你有四条路,每一条都有毒:
路线一:写 C++ Addon(node-gyp + NAN)。 需要在用户机器上装 Python、装 C++ 工具链、下载对应版本的 node headers。V8 API 每个大版本都在变,NAN 就是为了抹平这些变化而生的一层膏药。安装一个包要编译五分钟,CI 里挂掉是家常便饭。
路线二:N-API / Node-API。 这是官方给出的正解,ABI 稳定,编译一次跨版本可用。但它解决的是「ABI 稳定性」,没有解决「你必须写 C++、必须有构建系统」这个根本问题。想调用 libsqlite3 里一个函数,你还是得写胶水层、写 binding.gyp、配 prebuild、传 CI 矩阵。
路线三:napi-rs / neon。 用 Rust 写 addon,开发体验大幅提升,但依然是「编译产物」模型,依然需要为每个平台出 prebuilt binary,依然要面对 glibc 版本、musl、Windows MSVC 运行时这些发行地狱。
路线四:ffi-napi / koffi 等纯 FFI 库。 思路对了——运行时动态 dlopen,不需要编译。但它们本身是 native addon,于是问题递归了:为了不编译,你得先编译一个东西。 而且 ffi-napi 长期处于半维护状态,V8 一升级就要跟着修。
与此同时,Bun 从第一天就内置了 bun:ffi,Deno 有 Deno.dlopen。当竞争对手把「零编译调用 C 函数」当作基础设施,Node.js 还在让开发者跟 node-gyp 搏斗,这个差距已经不是「生态丰富度」能掩盖的了。
node:ffi 就是这个问题的答案。
2.2 开启方式与构建约束
node:ffi 是实验性的,而且有双重门槛:
# 运行时开关
node --experimental-ffi app.js
# 如果同时开了权限模型,还需要显式授予 FFI 权限
node --permission --experimental-ffi --allow-ffi app.js
不加 --allow-ffi 时的报错长这样,很有辨识度:
Error: Access to this API has been restricted. Use --allow-ffi to manage permissions.
at node:internal/main/run_main_module:17:47 {
code: 'ERR_ACCESS_DENIED',
permission: 'FFI'
}
构建层面的约束更值得注意,这部分很多人会忽略:
node:ffi只在「带 FFI 支持编译」的 Node 构建里可用。官方发行版走的是 bundled libffi;也可以用--shared-ffi这个 configure flag 链接系统 libffi。- 非官方的 GN 构建不支持
node:ffi。 - bundled libffi 明确不支持的目标平台包括:
s390x;mips/mipsel/mips64el(FreeBSD、Linux、OpenBSD 之外的目标);ppc64(Android、CloudABI、iOS、OpenHarmony、OS/400、Solaris、Windows)。
工程含义:如果你的产品要跑在 IBM Z(s390x)这类金融机构常见的架构上,别把 node:ffi 写进架构设计里。这不是「暂时没测」,是 bundled libffi 的后端根本没覆盖。
2.3 API 全景:两组能力
node:ffi 的 API 面积不大,但设计得很克制,分成两组:
第一组:动态库 API。 dlopen / DynamicLibrary / dlsym / dlclose,负责加载库、解析符号、生成可调用的 JS 包装函数。
第二组:裸内存助手。 getInt32 / setFloat64 / toString / toBuffer / toArrayBuffer / exportString 等,负责在 JS 和原生指针之间搬数据。
类型系统用字符串名表达,简单粗暴但足够:
void
char
i8, int8 | u8, uint8, bool
i16, int16 | u16, uint16
i32, int32 | u32, uint32
i64, int64 | u64, uint64
f32, float, float32 | f64, double, float64
pointer, ptr | string, str | buffer | arraybuffer | function
同时挂在 ffi.types 上作为常量(ffi.types.INT_32 === 'int32'),方便做类型提示和拼装。
函数签名是一个对象,两个字段都可选:
const signature = {
return: 'i32', // 默认 'void'
arguments: ['i32', 'i32'], // 默认 []
};
2.4 实战一:从零调一个 C 函数
先写一个最小的 C 库,覆盖几个典型场景:整数运算、字符串处理、指针输出参数、回调。
// mylib.c
#include <stdint.h>
#include <string.h>
#include <stdlib.h>
int32_t add_i32(int32_t a, int32_t b) {
return a + b;
}
uint64_t string_length(const char *s) {
if (s == NULL) return 0;
return (uint64_t)strlen(s);
}
// 把结果写进调用方提供的缓冲区,典型的「out param」模式
void fill_pattern(uint8_t *buf, uint64_t len, uint8_t value) {
for (uint64_t i = 0; i < len; i++) buf[i] = value;
}
// 由 C 侧分配、需要调用方释放的字符串——这是最容易泄漏的一类 API
char *make_greeting(const char *name) {
size_t n = strlen(name) + 16;
char *out = (char *)malloc(n);
snprintf(out, n, "hello, %s!", name);
return out;
}
void free_greeting(char *p) { free(p); }
// 接受回调的 API
typedef void (*progress_cb)(int32_t percent);
void run_job(progress_cb cb) {
for (int32_t i = 0; i <= 100; i += 25) cb(i);
}
编译(macOS / Linux 通用写法):
# Linux
gcc -shared -fPIC -O2 -o mylib.so mylib.c
# macOS
clang -dynamiclib -O2 -o mylib.dylib mylib.c
JS 侧调用,注意 ffi.suffix 会自动给出当前平台的动态库后缀,别自己拼 .so:
// app.mjs
import { dlopen, suffix, toString as ptrToString } from 'node:ffi';
const { lib, functions } = dlopen(`./mylib.${suffix}`, {
add_i32: { arguments: ['i32', 'i32'], return: 'i32' },
string_length: { arguments: ['pointer'], return: 'u64' },
fill_pattern: { arguments: ['pointer', 'u64', 'u8'], return: 'void' },
make_greeting: { arguments: ['string'], return: 'pointer' },
free_greeting: { arguments: ['pointer'], return: 'void' },
});
// 1. 最简单的整数调用
console.log(functions.add_i32(20, 22)); // 42
// 2. 传字符串:JS string 会被复制成临时的 NUL 结尾 UTF-8 缓冲区,调用结束即失效
console.log(functions.string_length('程序员茄子')); // 15n(UTF-8 字节数,注意是 bigint)
// 3. out param:把 Buffer 的底层内存借给 C
const buf = Buffer.alloc(8);
functions.fill_pattern(buf, 8n, 0x41);
console.log(buf.toString()); // AAAAAAAA
// 4. C 侧分配的内存:读出来之后必须还回去
const p = functions.make_greeting('茄子');
console.log(ptrToString(p)); // hello, 茄子!
functions.free_greeting(p); // 忘了这行 = 稳定泄漏
lib.close();
这里有四个必须记住的语义细节,每一个都对应一类线上事故:
- 64 位整数用 bigint。
i64/u64的参数要传bigint,返回值也是bigint。string_length('abc')返回的是3n不是3。跟Number直接比较会静默出错。 bool不接受 JS 的true/false。 它被当作 8 位无符号整数 marshal,你得传0/1。这个设计很反直觉,但它避免了「哪种 falsy 算 false」的歧义。- 指针返回值是 bigint 地址,不代表所有权。 文档里写得很直白:返回一个指针不意味着你需要(或不需要)free 它,这完全取决于那个 native API 的约定。
make_greeting需要配对free_greeting,而strerror返回的静态字符串你去 free 就是崩溃。 char跟随平台 C ABI。 在char有符号的平台上行为等同i8,否则等同u8。写跨平台代码时,显式用i8/u8而不是char。
2.5 实战二:显式资源管理(using)与回调生命周期
dlopen 返回的对象和 DynamicLibrary 都实现了显式资源管理协议,可以配合 using 声明自动关闭:
import { dlopen, suffix } from 'node:ffi';
{
using handle = dlopen(`./mylib.${suffix}`, {
add_i32: { arguments: ['i32', 'i32'], return: 'i32' },
});
console.log(handle.functions.add_i32(20, 22));
} // 离开作用域,handle.lib.close() 自动被调用
这是一个被低估的设计。FFI 最常见的泄漏不是内存,而是库句柄和回调指针的生命周期跟 JS 作用域对不上。using 把「关闭」这件事从「你记得写」变成「语言帮你写」。
回调更危险,值得单独展开:
import { DynamicLibrary, suffix } from 'node:ffi';
using lib = new DynamicLibrary(`./mylib.${suffix}`);
const runJob = lib.getFunction('run_job', {
arguments: ['function'],
return: 'void',
});
// 注册一个 JS 函数,拿到它的 native 指针(bigint)
const cbPtr = lib.registerCallback(
{ arguments: ['i32'], return: 'void' },
(percent) => {
console.log(`progress: ${percent}%`);
},
);
runJob(cbPtr);
// 确认 native 侧不会再调用之后,才能注销
lib.unregisterCallback(cbPtr);
文档对回调的警告写得极其严厉,我把它翻译成人话:
- 不要在 native 还可能调用它的时候
unregisterCallback。 已经交给 native 的回调指针,Node 不会去追踪、也不会撤销。 - 不要在库的某个 active callback 里面调
library.close()。 回调必须先返回。这个反模式在「回调里出错 → 想赶紧清理」的代码里特别容易出现。 close()之后,已解析的函数包装器全部失效,再解析符号会抛错,已注册的回调也被作废。但这不代表 native 手里那个指针变安全了——它变成了悬垂指针,调用它是未定义行为。
配套的 refCallback / unrefCallback 用来控制回调对事件循环的引用计数,这是长驻回调(比如 C 侧的事件监听器)能不能让进程正常退出的关键。
2.6 性能:Fast FFI 路径与那个「8 个参数」的硬限制
node:ffi 有两条调用路径:优化过的 Fast FFI 调用和通用 FFI 调用。这个区别直接决定你的热路径性能。
文档给的约束非常具体,而且明显是从 ABI 层面倒推出来的:
Optimized Fast FFI calls support at most 8 function arguments, but the exact limit depends on the architecture and on the argument types... Integer and pointer arguments are limited to 7 on AArch64 and to 6 on x86-64, while floating-point arguments can use up to 8 on both. Functions that exceed these limits, including any function with more than 8 arguments, use the generic FFI call path instead.
为什么是 7 和 6?这是寄存器传参约定的直接体现:
- x86-64 System V ABI:整数/指针参数走
rdi, rsi, rdx, rcx, r8, r9六个寄存器,浮点走xmm0-xmm7八个。所以整数上限 6,浮点上限 8。 - AArch64 AAPCS64:整数/指针走
x0-x7八个寄存器,但 trampoline 自己要占用一个(通常用来传上下文),所以留给用户的是 7 个;浮点走v0-v7,八个。
这意味着一条非常实用的优化规则:
如果一个 native 函数在你的热路径上每秒被调用几十万次,想尽办法把它的参数个数压到 x86-64 上 ≤6 个整数/指针参数。多出来的参数打包成一个 struct 指针传进去,比多传两个参数要快得多——因为前者走 Fast 路径,后者掉进 generic 路径。
这是一个典型的「知道了就能白赚性能,不知道就永远慢一截」的知识点。
改写示例:
// 慢:9 个参数,必然走 generic 路径
int32_t render_slow(int32_t x, int32_t y, int32_t w, int32_t h,
int32_t r, int32_t g, int32_t b, int32_t a, int32_t flags);
// 快:1 个指针参数,稳稳落在 Fast 路径
typedef struct {
int32_t x, y, w, h, r, g, b, a, flags;
} render_args;
int32_t render_fast(const render_args *args);
JS 侧配合 DataView 或者 ffi.setInt32 系列助手填结构体:
import { getRawPointer, setInt32 } from 'node:ffi';
const argsBuf = Buffer.alloc(9 * 4);
const argsPtr = getRawPointer(argsBuf);
function render(x, y, w, h, r, g, b, a, flags) {
const v = [x, y, w, h, r, g, b, a, flags];
for (let i = 0; i < 9; i++) setInt32(argsPtr, i * 4, v[i]);
return renderFast(argsPtr);
}
注意:getRawPointer 返回的地址在底层 buffer 被 detach / resize / transfer 之后会失效。热路径上复用同一个 Buffer 是对的,但绝不能让它被 GC 回收或者被 Buffer.concat 之类的操作替换掉。把它挂在模块级变量上,不要放在闭包里靠运气。
2.7 零拷贝的三个安全前提
toBuffer(pointer, length, copy) 和 toArrayBuffer 的 copy: false 是零拷贝逃生舱:返回的 Buffer 直接是 native 内存的可写视图,JS 里改它就是改 native 内存。
文档列出的四个前提,我认为应该背下来:
pointer在返回的 Buffer 的整个生命周期内保持有效;length不越过实际分配的区域;- JS 还在用这块 Buffer 的时候,native 侧不能 free 或复用它;
- 内存保护属性要被尊重——只读页不能写(写了就是 SIGSEGV,不是抛异常)。
第 4 点尤其阴险。你 mmap 一段 PROT_READ 的文件映射,toBuffer(ptr, len, false) 拿到一个「看起来完全正常」的 Buffer,然后某个日志中间件顺手 buf[0] = 0 —— 进程直接段错误,没有栈,没有异常,只有一个 core dump。
默认建议:除非你在做大块数据(视频帧、模型权重、大文件切片)的传递,否则一律用 copy: true。零拷贝省的那点 memcpy,通常远不如一次生产事故贵。
2.8 getCurrentEventLoop():一个小 API,一扇大门
v26.6.0 加的 ffi.getCurrentEventLoop() 只有一行说明:返回当前线程 uv_loop_t 的地址(bigint)。主线程返回主事件循环,worker 线程返回该 worker 的循环。
看起来平平无奇,但它和 dlopen(null, ...) 组合起来,含义就不同了:你可以在纯 JS 里,把 Node 的事件循环交给一个原生库去注册 handle。
import { dlopen, getCurrentEventLoop, suffix } from 'node:ffi';
// dlopen(null) 打开当前进程的符号表(Windows 不支持传 null)
const { functions } = dlopen(null, {
uv_backend_fd: { arguments: ['pointer'], return: 'i32' },
});
const loop = getCurrentEventLoop();
console.log('backend fd =', functions.uv_backend_fd(loop));
过去要做这件事,你必须写 C++ addon 才能拿到 Environment::GetCurrent(env)->event_loop()。现在一行 JS 就够了。这为「用 FFI 集成异步原生库」(而不只是调用同步函数)打开了大门——虽然目前还非常硬核,需要你对 libuv 有真实理解。
同样,风险也是对等的:这个指针只在当前 environment 的生命周期内有效。worker 退出后还有 native 代码持有它,就是崩溃。
三、暗线二:node:vfs —— 文件系统终于变成了可替换接口
3.1 动机:那些年我们为了 mock fs 干过的脏活
node:fs 是 Node 里被 mock 得最频繁、也最难 mock 的模块。历史上的方案,每一个都是妥协:
mock-fs:猴子补丁fs的所有导出。跟任何在补丁生效前就缓存了fs.readFileSync引用的库都会打架,而且几乎每个 Node 大版本都要跟着修。memfs:实现了一套内存 fs,API 兼容度很高,但它是「另一个 fs」——你得让被测代码支持依赖注入,否则用不上。proxyquire/jest.mock:从模块加载层拦截。能用,但你 mock 的是「模块」,不是「文件系统」,语义层次错了。- 临时目录 +
fs.mkdtemp:最诚实的方案,但慢(真实磁盘 I/O)、脏(CI 上清理不干净)、并发不友好。
除了测试,还有一批场景长期没有好答案:SEA(单文件可执行程序)里的内嵌资源、把 zip/tar 当作只读文件系统挂载、给插件系统提供一个受限的文件视图。
node:vfs 是官方给出的统一答案。
3.2 架构:三层,职责分得很干净
┌─────────────────────────────────────────────┐
│ 用户代码:fs.readFileSync('/virtual/a.txt') │
└───────────────────┬─────────────────────────┘
│ (mount 后) vfsState.handlers 路由
┌───────────────────▼─────────────────────────┐
│ VirtualFileSystem │
│ - node:fs 形状的 API(sync/callback/promise)│
│ - mount() / unmount() │
│ - .provider / .readonly │
└───────────────────┬─────────────────────────┘
│ 调用 primitive
┌───────────────────▼─────────────────────────┐
│ VirtualProvider(抽象基类) │
│ ├─ MemoryProvider(默认,Map 树) │
│ ├─ RealFSProvider(映射真实目录) │
│ └─ 你自己的 Provider(tar / zip / 远程...) │
└─────────────────────────────────────────────┘
三层的划分很讲究:
VirtualFileSystem负责「长得像 fs」。它把readFileSync、promises.readFile、createReadStream、watch、opendir这些三套 API 风格全部实现一遍,你的业务代码完全不需要改写法。VirtualProvider负责「数据从哪来」。它只要求子类实现核心原语(openSync/statSync/readdirSync/mkdir/rmdir/unlink/rename等),派生方法(readFile/writeFile/exists/copyFile/access)由基类用原语拼出来。没实现的原语会抛ERR_METHOD_NOT_IMPLEMENTED。- 能力标志决定行为边界:
provider.readonly(默认 false)、provider.supportsSymlinks(默认 false)、provider.supportsWatch(默认 false)。
这是一个教科书级的 Template Method 模式:把「必须自己实现的」和「可以复用的」用能力标志切开,让写一个新 Provider 的成本从「实现 60 个方法」降到「实现 8 个原语」。
3.3 基础用法与 mount
node --experimental-vfs app.js
const vfs = require('node:vfs');
const myVfs = vfs.create(); // 默认 MemoryProvider
myVfs.mkdirSync('/dir', { recursive: true });
myVfs.writeFileSync('/dir/hello.txt', 'Hello, VFS!');
console.log(myVfs.readFileSync('/dir/hello.txt', 'utf8')); // 'Hello, VFS!'
注意路径规则:VFS 内部一律是 POSIX 风格的绝对路径,以 / 开头。Windows 上也是这样,不存在盘符。这个统一化对跨平台测试是件好事。
真正的杀手锏是 v26.4.0 那个 vfs: dispatch node:fs/promises to mounted VFS instances。它在 lib/fs.js、lib/internal/fs/promises.js、lib/internal/fs/dir.js 里插入了一个 handler registry:mount 之后,公共的 fs.* API 会按绝对路径归属,把请求路由到对应的 VFS,不属于任何挂载点的路径则落回真实文件系统。
const vfs = require('node:vfs');
const fs = require('node:fs');
const assets = vfs.create();
assets.mkdirSync('/config', { recursive: true });
assets.writeFileSync('/config/app.json', JSON.stringify({ env: 'test' }));
assets.mount('/virtual'); // 挂到 /virtual
// 下面这行是普通的 node:fs 调用,被测代码完全无感
const cfg = JSON.parse(fs.readFileSync('/virtual/config/app.json', 'utf8'));
console.log(cfg.env); // 'test'
// 不在挂载点下的路径,照常走真实磁盘
fs.readFileSync('/etc/hostname');
assets.unmount();
这才是 node:vfs 真正的价值:你不需要改被测代码,不需要依赖注入,不需要猴子补丁。你只是在进程里造了一个挂载点。
mount 失败时的状态清理,在 #64335 / #64336 里被专门修过(vfs: reset state after failed mount),说明这块的状态机在早期是有坑的。升级到尽可能新的 26.x 再用 mount。
3.4 一个真实有用的自定义 Provider:只读 tar
写一个 Provider 的成本比想象中低。下面是一个「把已解析的 tar 索引暴露成只读文件系统」的骨架,展示核心原语怎么实现:
const { VirtualProvider } = require('node:vfs');
/**
* entries: Map<string, { type: 'file'|'dir', size: number, read(): Buffer }>
* 路径均为 POSIX 绝对路径
*/
class TarProvider extends VirtualProvider {
#entries;
#fds = new Map();
#nextFd = 3;
constructor(entries) {
super();
this.#entries = entries;
}
get readonly() { return true; }
get supportsSymlinks() { return false; }
get supportsWatch() { return false; }
statSync(path) {
const e = this.#entries.get(path);
if (!e) {
const err = new Error(`ENOENT: no such file or directory, stat '${path}'`);
err.code = 'ENOENT';
throw err;
}
return {
isFile: () => e.type === 'file',
isDirectory: () => e.type === 'dir',
size: e.size,
mode: e.type === 'dir' ? 0o040555 : 0o100444,
mtimeMs: 0,
};
}
readdirSync(path) {
const prefix = path.endsWith('/') ? path : path + '/';
const out = new Set();
for (const key of this.#entries.keys()) {
if (!key.startsWith(prefix) || key === path) continue;
out.add(key.slice(prefix.length).split('/')[0]);
}
return [...out];
}
openSync(path, flags) {
// 只读 Provider:任何写标志直接拒绝
if (typeof flags === 'string' && !/^rs?\+?$/.test(flags) && flags !== 'r') {
const err = new Error(`EROFS: read-only file system, open '${path}'`);
err.code = 'EROFS';
throw err;
}
const e = this.#entries.get(path);
if (!e || e.type !== 'file') {
const err = new Error(`ENOENT: no such file or directory, open '${path}'`);
err.code = 'ENOENT';
throw err;
}
const fd = this.#nextFd++;
this.#fds.set(fd, { buf: e.read(), pos: 0 });
return fd;
}
readSync(fd, buffer, offset, length, position) {
const h = this.#fds.get(fd);
const start = position == null ? h.pos : position;
const n = h.buf.copy(buffer, offset, start, Math.min(start + length, h.buf.length));
if (position == null) h.pos += n;
return n;
}
closeSync(fd) { this.#fds.delete(fd); }
}
module.exports = { TarProvider };
用起来:
const vfs = require('node:vfs');
const { TarProvider } = require('./tar-provider');
const bundle = vfs.create(new TarProvider(entries));
bundle.mount('/bundle');
// 全局 fs 现在能读 tar 里的东西了,零解压落盘
require('node:fs').readFileSync('/bundle/app/index.js', 'utf8');
这个模式的适用面比看上去广得多:内嵌资源、插件包、CDN 拉下来的 zip、甚至一个远程对象存储的只读视图(配合本地缓存)。以前这些都要求上层代码用专门的 API 读取;现在它们可以直接冒充文件系统。
3.5 Stats 的合成值:一个容易被忽略的兼容性细节
VFS 返回的 Stats 是真正的 fs.Stats 实例({ bigint: true } 时是 fs.BigIntStats),但字段是合成的:
dev固定为 4085(VFS 的设备号)ino每个进程内单调递增blksize固定 4096blocks为Math.ceil(size / 512)- 时间戳默认取创建/最后修改的那一刻
这几个数字看起来是实现细节,实际上是兼容性契约。很多库(比如各种缓存、构建工具、文件监听器)会用 dev + ino 组合作为文件的唯一标识,用 blocks 估算磁盘占用。合成值保证了这些库不会因为拿到 undefined 而崩溃,同时 dev = 4085 这个固定值也给了你一个判断「这个文件来自 VFS」的可靠手段:
const VFS_DEV = 4085;
function isVirtual(stats) {
return stats.dev === VFS_DEV || stats.dev === 4085n;
}
3.6 「它不是沙箱」——这句话必须重复三遍
官方文档在 VFS 章节的第二节就是 Security,措辞极其罕见地强硬:
The VFS API is not a sandbox, permission system, or access-control mechanism. It does not isolate untrusted code from the host file system or from other Node.js capabilities.
而且专门点名了 RealFSProvider:它确实会把路径映射到 root 下、拒绝解析到 root 之外的符号链接,但**「that check is not a security boundary」**。
为什么要写得这么重?因为这个 API 的形状太像沙箱了。任何一个产品经理看到「虚拟文件系统 + 只读 Provider + 根目录限制」,第一反应都会是「那我们可以用它跑用户上传的脚本了」。
答案是不能。 原因很简单:
- VFS 只拦截「解析后落在挂载点下的、受支持的
node:fs调用」。用别的路径、别的 API(child_process、net、原生 addon、node:ffi)照样能碰到宿主资源。 - 能拿到
VirtualFileSystem实例、能 mount、能选 Provider 的代码,本身就是被信任的应用代码。 - 路径规范化这件事历史上出过太多 CVE,把它当安全边界是拿信誉赌
..的所有变体。
要真隔离,用操作系统级手段:独立用户、容器、平台沙箱。想要进程内的能力约束,用 Node 自己的 Permission Model(--permission + --allow-fs-read / --allow-fs-write),那才是设计目标就是安全边界的东西。
四、暗线三:package maps —— 把模块解析从文件系统里拔出来
4.1 node_modules 算法的三宗罪
--experimental-package-map 这个 flag 背后的动机,PR 里写得比任何第三方分析都清楚。我把它整理成三条:
罪一:幻影依赖(phantom dependencies)。 因为提升(hoisting),一个包能 require 到它 package.json 里根本没声明的东西。本地跑得好好的,换个包管理器、换个安装顺序,扁平化结构变了,线上就 MODULE_NOT_FOUND。运行时无法区分一次 import 是合法的还是意外的。
罪二:monorepo 里的 peer dependency 无解。 这是最狠的一条。设想:website-v1 用 React 18,website-v2 用 React 19,两者共用一个 component-lib,而 component-lib 把 React 声明为 peer dependency。不存在任何一种 node_modules 目录布局能让这个场景正确解析——共享的库永远拿到被提升上去的那个 React。你只能靠复制、靠 alias、靠打包器 hack。
罪三:解析需要 I/O。 每一次裸标识符解析,都要沿着目录树往上找 node_modules,stat 一堆不存在的路径。单次很便宜,规模一大就是实打实的启动开销。PR 里特别提了一句我觉得非常有前瞻性的话:
Even more so in agentic worlds where working with multiple git trees become a common pattern.
翻译过来:当 AI Agent 同时在五个 worktree 里跑测试,每个 worktree 一份 node_modules,解析 I/O 的浪费会被线性放大。这是一个2026 年才成立的论据,而且它成立得很扎实。
历史上 Yarn PnP 尝试过解决同样的问题,思路几乎一致——用静态数据表代替目录遍历。它失败(或者说只取得局部成功)的原因不是设计错,是它是包管理器的私有方案,运行时不认。你得 patch Node 的 resolver,得让每个工具都支持 PnP API,生态兼容性长期是个坑。
package maps 的关键差别:它在运行时里。
4.2 配置格式与解析算法
配置是一个 JSON 文件,只有一个顶层 packages 对象:
{
"packages": {
"app": {
"url": "./packages/app",
"dependencies": {
"@myorg/utils": "utils",
"@myorg/ui-lib": "ui-lib"
}
},
"utils": {
"url": "./packages/utils"
},
"ui-lib": {
"url": "./packages/ui-lib",
"dependencies": {
"@myorg/utils": "utils"
}
}
}
}
字段语义:
url(必填):绝对或相对 URL,用 WHATWG URL API 以配置文件 URL 为 base 解析,只支持file:协议。多个 package 允许共享同一个 url——这时消费方必须用「module url + package ID」的组合来区分模块实例。dependencies(可选):把源码里写的裸标识符映射到 packages 对象里的 key。默认空对象。
注意这里有个双层命名的设计:key 是 package ID(内部标识),dependencies 的 value 也是 package ID。这就是为什么上面那个 peer dependency 死局能解开——同一个物理目录可以有多个 ID,不同的 ID 挂不同的依赖表。
解析算法(裸标识符命中时):
- 确定是哪个 package 发起了这次解析。能提供 importer 的 package ID 最好;否则按文件路径反查它落在哪个 package 的
url范围内。 - 如果没有 package ID 且文件不属于任何映射的 package → 抛
ERR_PACKAGE_MAP_EXTERNAL_FILE。 - 在发起方的
dependencies里查这个包名,拿到目标 package key。 - 找到目标 package 的
url。 - 把这个位置交给常规 Node 解析算法收尾(
index.js、exports字段等等)。 - 如果
dependencies里没有 →MODULE_NOT_FOUND。
第 6 步就是幻影依赖的死刑判决:没声明就是找不到,没有例外,没有提升,没有运气成分。
不受影响的情况:相对路径(./、../)、绝对路径/URL、Node 内置模块(node:fs 等)。这三类继续走标准解析。
4.3 实战:给一个 monorepo 生成 package map
手写这个文件显然不现实。下面是一个从 pnpm workspace 生成 package map 的脚本,思路可以套到任何包管理器:
#!/usr/bin/env node
// scripts/gen-package-map.mjs
import { readFile, writeFile } from 'node:fs/promises';
import { globSync } from 'node:fs';
import { dirname, relative, join } from 'node:path';
const roots = globSync('packages/*/package.json');
const byName = new Map(); // name -> { id, dir, pkg }
for (const p of roots) {
const pkg = JSON.parse(await readFile(p, 'utf8'));
const dir = dirname(p);
const id = pkg.name.replace(/^@/, '').replace(/\//g, '__');
byName.set(pkg.name, { id, dir, pkg });
}
const packages = {};
for (const { id, dir, pkg } of byName.values()) {
const deps = {};
const all = {
...(pkg.dependencies ?? {}),
...(pkg.peerDependencies ?? {}),
...(pkg.devDependencies ?? {}),
};
for (const name of Object.keys(all)) {
const target = byName.get(name);
if (target) {
// workspace 内部包:直连
deps[name] = target.id;
} else {
// 外部依赖:指向它在 node_modules 里的实际落点
// 生产环境建议用包管理器的 lockfile 精确定位版本目录
deps[name] = `ext:${name}`;
packages[`ext:${name}`] ??= { url: `./node_modules/${name}` };
}
}
packages[id] = {
url: './' + relative('.', dir).split('\\').join('/'),
...(Object.keys(deps).length ? { dependencies: deps } : {}),
};
}
await writeFile('package-map.json', JSON.stringify({ packages }, null, 2));
console.log(`generated ${Object.keys(packages).length} entries`);
跑起来:
node scripts/gen-package-map.mjs
node --experimental-package-map=./package-map.json packages/app/index.js
验证幻影依赖是否真的被堵住,这是最有价值的一步。写个冒烟测试:
// test/phantom.test.mjs
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { execFile } from 'node:child_process';
import { promisify } from 'node:util';
const run = promisify(execFile);
test('未声明的依赖必须解析失败', async () => {
await assert.rejects(
run(process.execPath, [
'--experimental-package-map=./package-map.json',
'-e',
// utils 没有声明 lodash
"import('lodash').then(() => process.exit(0), () => process.exit(1))",
], { cwd: 'packages/utils' }),
);
});
峰回路转的地方在于:一旦你有了这个测试,CI 就能拦住「谁又偷偷用了没声明的包」,这在大型 monorepo 里是个长期治理难题。package map 把它从「靠 lint 规则和人肉 review」变成了「运行时物理上不可能」。
4.4 三条明确的限制
文档给了三条限制,每一条都有工程含义:
- 必须是单个静态文件,不支持动态配置。 意味着你需要一个生成步骤,并且要把它接进
postinstall或者构建流程。忘了重新生成 = 神秘的 MODULE_NOT_FOUND。 - 不做循环依赖检测。 package map 只管解析,环由你自己管。原来靠 node_modules 结构隐式暴露出来的环,现在会静悄悄通过。
- 启动时同步加载。 文件越大,启动成本越高。超大 monorepo 要关注这个文件的体积——它是每次进程启动都要付的固定成本,跟你实际 import 了几个包无关。
第 3 点值得展开:package map 用「一次性同步读一个大 JSON」换掉了「按需做很多次目录 stat」。对于启动即 import 大量模块的服务,这笔账稳赚;对于启动只 import 三个模块的 CLI 工具,如果你的 map 有几兆,反而可能变慢。先量,再上。
量的方法很简单:
# 基线
hyperfine --warmup 3 'node packages/app/index.js'
# 开 package map
hyperfine --warmup 3 'node --experimental-package-map=./package-map.json packages/app/index.js'
同时观察解析相关的系统调用数量:
# Linux
strace -f -c -e trace=stat,lstat,openat node packages/app/index.js 2>&1 | tail -20
# macOS
sudo dtruss -c node packages/app/index.js 2>&1 | tail -20
看 stat / openat 的调用次数下降幅度——这是 package map 收益最直观的证据。
五、暗线四:Perfetto —— 诊断从「JSON 日志」升级成「工业级时间线」
5.1 trace_events 的历史包袱
Node.js 早就有 node:trace_events 和 --trace-event-categories,能采 node.async_hooks、node.bootstrap、node.console、v8 等类别。问题不在采集,在输出格式:Chrome Trace Event JSON。
这个格式的毛病是结构性的:
- 体积爆炸。 每个事件是一个带完整字段名的 JSON 对象。跑一个中等负载的服务几十秒,文件轻松上百兆。
- 解析慢。 前端要把整个 JSON 读进内存再建索引。
chrome://tracing打开一个 500MB 的 trace,基本等于让浏览器去世。 - 无法流式处理。 JSON 数组不闭合就不是合法 JSON,长时间采集要么截断要么冒险。
- 没有 schema。 字段靠约定,工具链之间互相猜。
Perfetto 是 Google 为 Android/Chrome 性能分析做的下一代方案,用 protobuf 编码,天然解决上面四条:二进制紧凑、可流式追加、有正式 schema、前端(ui.perfetto.dev)能处理 GB 级 trace 并支持 SQL 查询。
5.2 Node.js 26.7.0 的实现方式
v26.7.0 的 lib: add perfetto support(PR #64565,src: add perfetto trace agent)做了三件事:
- 构建期开关:
--with-perfetto这个 configure flag 决定是否编译进 Perfetto 支持。同期还有build: run perfetto build and test on GHA、build: fix v8_use_perfetto source scraping两个配套提交,说明它已经进了官方 CI 矩阵。 - 输出格式:trace 开启后,默认写到
node_trace.1.pftrace这样的文件(protobuf 编码),直接拖进https://ui.perfetto.dev/就能看。 - JSON 被故意砍掉。原文是
JSON output is intentionally not supported.
第 3 点是整件事里最有信息量的:这不是「还没做」,是明确的产品决策。Node.js 团队不想维护两套输出路径,选择了一刀切。
还有一个必须知道的互斥关系:
As of this PR, when perfetto is enabled, inspector trace support is disabled.
原因是 inspector 协议目前只能传递协议对象值,没法直接投递原始字节;要在 inspector 里支持 Perfetto,得先改协议。所以现在是二选一:要么走 DevTools 的 inspector trace,要么走 Perfetto 文件。
5.3 实战流程
# 1. 确认你的 Node 构建带 Perfetto(官方发行版视构建配置而定;自建时加 --with-perfetto)
./configure --with-perfetto && make -j$(nproc)
# 2. 启用追踪,挑你关心的类别
./node --trace-events-enabled \
--trace-event-categories v8,node,node.async_hooks,node.perf \
server.js
# 3. 产出 node_trace.1.pftrace,拖到 https://ui.perfetto.dev/
类别的选择直接决定 trace 的信噪比,给几条经验:
- 查事件循环卡顿 →
node,node.async_hooks。async_hooks会给出每个异步资源的asyncId/triggerAsyncId,在时间线上能直接看到「哪个 Promise 链把 tick 拖长了」。代价是开销不小,别在生产常开。 - 查 GC / 编译停顿 →
v8。能看到 Scavenge、Mark-Compact、以及 Ignition/Sparkplug/Maglev/TurboFan 的编译事件。 - 查启动慢 →
node.bootstrap。它给出 Node 自身的启动里程碑,配合--cpu-prof交叉验证。
配合 v26.5.0 加的 perf_hooks: sample delay per event loop iteration(PR #62935),你现在能在每次事件循环迭代的粒度上采样延迟,而不是过去那种粗糙的定时器采样。这两个东西是天生一对:perf_hooks 告诉你「哪一段时间窗口有问题」,Perfetto trace 告诉你「那段时间里具体发生了什么」。
import { monitorEventLoopDelay } from 'node:perf_hooks';
const h = monitorEventLoopDelay({ resolution: 10 });
h.enable();
setInterval(() => {
// 纳秒单位
if (h.max > 100e6) { // 单次 tick 超过 100ms
console.warn('event loop stall', {
p50: h.percentile(50) / 1e6,
p99: h.percentile(99) / 1e6,
max: h.max / 1e6,
});
h.reset();
}
}, 1000).unref();
生产上的正确用法:默认只跑 monitorEventLoopDelay 这种低开销监控,当 p99 越过阈值时,再由运维触发一段短时间的 Perfetto 采集。常开 trace 在高 QPS 服务上是自杀行为。
六、横切线索:Permission Model 在悄悄长大
把上面四条线串起来看,你会发现 Permission Model 是那个隐形的连接件。Node 26 的权限清单现在长这样:
File System → --allow-fs-read / --allow-fs-write
Network → --allow-net
Child Process → --allow-child-process
Worker → --allow-worker
WASI → --allow-wasi
Addons → --allow-addons
FFI → --allow-ffi
OpenSSL STORE → --allow-openssl-store
最后两项是新来的,而且都不是巧合:
--allow-ffi对应node:ffi。运行时新增了一个「能任意调用原生代码」的能力,权限模型必须同步长出一个开关,否则整个模型就破了。--allow-openssl-store对应v26.7.0的crypto: support loading private keys through STORE loaders(PR #63949)。
第二个值得单独说两句。OpenSSL 的 STORE API 是一套统一的「从各种后端加载密钥/证书」的抽象——PKCS#11 的 HSM、TPM、操作系统密钥链、各种硬件令牌,都可以通过 STORE loader 暴露成一个 URI。以前在 Node 里用 HSM 里的私钥做 TLS,你得写 addon 或者起一个外部代理;现在这条路被打通了。
这是一个专门给金融、政务、医疗这类合规场景准备的能力,虽然它在 CHANGELOG 里只占一行。它同时也解释了为什么要加 --allow-openssl-store:能让进程去加载任意 STORE URI,等于能让它去摸 HSM,这必须是显式授权的。
还有一个信号是 v26.5.1 的安全版:一次修了 11 个 CVE,其中三个 High(http2 的 rst stream 处理、http2 header 内存计量、permission 模型的 radix split node 授权问题),还有两个 Low 直接是 permission 模型本身的漏洞(enforce fs write permission for trace events、check final report output path)。
Permission Model 正在从「实验性玩具」变成「被认真攻击、也被认真修复的安全边界」。 这跟 VFS 文档里那句「我不是沙箱,要隔离请用权限模型」是同一件事的两面。
顺带提一句,--permission-audit 这个只审计不拒绝的模式非常适合迁移期:先跑一段时间收集违规日志,把 allow 列表配全了,再切成强制模式。
七、被忽略的第五条线:Rust 进入了 Node.js 的构建依赖
翻 v26.6.0 和 v26.7.0 的 commit 列表,有两条不起眼但含义重大:
build: extract temporal_capi crate directory name into gyp variable (René) #64482
build: bump rustc requirement to >=1.86 (Renegade334) #64543
temporal_capi 是 Temporal 提案的参考实现(用 Rust 写的日期时间库)的 C ABI 封装。也就是说:
构建 Node.js 现在需要 Rust 工具链,且最低版本要求是 rustc 1.86。
对绝大多数开发者这没影响——你下载的是二进制。但对三类人影响巨大:
- 发行版打包者(Debian、Alpine、Fedora)。他们的构建环境必须提供足够新的 rustc。老旧发行版的 Rust 版本落后是常态,这会直接影响 Node 26 在这些平台上的可得性和时间线。
- 自建 Node 的公司。内部构建镜像要加 Rust,构建时间和镜像体积都会涨。
- 异构架构用户。 Rust 的目标平台支持矩阵和 Node 传统支持的平台矩阵不完全重合,冷门架构上可能出现「以前能编,现在编不了」。
把这条线和 node:ffi 那条线放在一起看,图景就清晰了:Node.js 的实现语言正在从「C++ 独大」变成「C++ + Rust」,同时它对外提供的扩展方式正在从「编译 C++ addon」变成「运行时 dlopen」。 一进一出,两个方向都在降低对 C++ 生态的耦合。
八、还有一批值得知道的中小改动
除了四条主线,26.x 这几个版本里还有一批「不上头条但每天都用得着」的改动,按主题归一下类:
模块与加载
module: implement Symbol.dispose in ModuleHooks(#63928)—— 自定义 loader hook 现在能用using自动清理,跟node:ffi的资源管理是同一套哲学。esm: add --experimental-import-text(#62300)—— 直接 import 文本文件,不用再写readFileSync胶水。
性能相关
buffer: add fast api for isUtf8 and isAscii(#64169)buffer: use simdutf for two-byte utf8 byteLength(#63639)buffer: optimize Buffer.prototype.copy(#63828)fs: support caller-supplied readFile() buffers(#63634)—— 这个很实用:高频读小文件的场景可以复用缓冲区,直接砍掉每次读都要分配一块新内存的开销。
网络与 TLS
net: support TCP_KEEPINTVL and TCP_KEEPCNT in setKeepAlive(#63825)—— 终于能在 JS 里完整配置 TCP keepalive 三件套(idle/intvl/cnt),而不是只能设 idle。对长连接服务(gRPC、WebSocket 网关)意义很大。tls: add certificateCompression option(#62217)+tls: report negotiated TLS groups(#64119)—— 前者减小握手体积,后者让你能确认实际协商到的椭圆曲线/PQC group,在后量子迁移期是刚需可观测性。
测试
test_runner: add context.log() and test:log event(#64389)test_runner: report entryFile in TestStream events(#64309)test_runner: add support for --test-coverage-include-all(#64830)—— 这个是覆盖率统计的老痛点:以前没被任何测试 require 过的文件根本不出现在报告里,覆盖率虚高。现在可以把它们全算进来。
流
stream: expose ReadableStreamTee(#64195)—— 需要把一个流分发给多个消费者时,不用再自己实现 tee 逻辑。
配一个 setKeepAlive 的完整示例,因为这个 API 的三个参数搭配很容易搞错:
import net from 'node:net';
const sock = net.connect({ host: 'example.com', port: 443 });
// 老写法:只能设「空闲多久后开始探测」
// sock.setKeepAlive(true, 30_000);
// Node 26 起:完整三件套
sock.setKeepAlive(true, 30_000, {
interval: 5_000, // TCP_KEEPINTVL:探测包间隔
count: 3, // TCP_KEEPCNT:连续失败几次判定断开
});
// 语义:空闲 30s 开始探测,每 5s 一次,连丢 3 次 → 45s 内感知断连
对比一下默认值:Linux 默认 tcp_keepalive_time=7200(两小时)、intvl=75、probes=9,也就是说默认配置下你要两个多小时才能发现一条死连接。在 K8s + 各种中间 NAT/LB 的环境里,这个默认值基本等于没有。能在应用层设置这三个参数,是实打实的可用性提升。
九、性能优化清单:怎么把这些新能力用出正收益
按「投入产出比」排序,给一份可执行的清单:
1. FFI 热路径参数压缩(高收益、低风险)
把每秒调用上万次的 native 函数参数压到 x86-64 上 ≤6 个整数/指针,或用 struct 指针打包。区别是 Fast 路径 vs generic 路径,量级差异明显。
2. FFI 优先 copy: true(高价值、防事故)
只在大块数据(≥ 数十 KB)传递时才用零拷贝。小数据零拷贝省的时间可以忽略,风险却是段错误级别的。
3. package map 先量后上(中收益、需验证)
用 strace -c 数 stat/openat 调用,用 hyperfine 量启动时间。启动即加载大量模块的服务收益明显;小 CLI 可能反向。
4. VFS 替换测试临时目录(中收益、零风险)
把测试里的 fs.mkdtemp + 真实读写换成 mount 一个 MemoryProvider。省掉磁盘 I/O 和清理逻辑,并发跑测试也不会互相踩。这是四条线里最容易立刻落地的一条。
5. readFile 复用缓冲区(中收益、局部)
高频读固定大小小文件(配置热加载、/proc 采样)时,用调用方提供的 buffer,避免每次分配。
6. Perfetto 按需采集(可观测性收益)
默认只开 monitorEventLoopDelay,越阈值再触发短时 trace。别常开。
7. TCP keepalive 三件套(可用性收益)
所有长连接客户端补上 interval 和 count,把死连接感知时间从小时级压到秒级。
8. --test-coverage-include-all(质量收益)
接进 CI,把虚高的覆盖率打回原形。第一次跑通常会很难看,但那才是真实数字。
十、十四条踩坑清单
按被坑概率从高到低排:
u64/i64返回值是 bigint。ffi返回3n,你=== 3判断永远 false。所有 64 位整数交互处都要显式转换。- FFI 的
bool不吃true/false。 传0/1。传布尔值会抛错或行为异常。 - 指针返回值不含所有权信息。 该 free 的不 free 就泄漏,不该 free 的 free 了就崩溃。查清楚那个 C API 的约定,并在 JS 侧用
using或try/finally配对。 getRawPointer拿到的地址会失效。 底层 buffer 被 detach/resize/transfer 后就是悬垂指针。热路径上要保证 Buffer 稳定持有。- 零拷贝 Buffer 写只读页 = 段错误。 不抛异常,直接死。mmap 只读区域千万别用
copy: false后往里写。 - 回调注销时机。 native 还可能回调时
unregisterCallback,或在回调内部library.close(),都是未定义行为。 node:ffi平台矩阵有洞。 s390x、部分 mips/ppc64 组合不支持 bundled libffi。GN 构建完全不支持。架构选型阶段就要确认。--experimental-ffi和--allow-ffi是两件事。 开了权限模型只给--allow-ffi不加--experimental-ffi,模块根本不存在;反过来则是ERR_ACCESS_DENIED。- VFS 不是沙箱。 说三遍。
RealFSProvider的 root 限制不是安全边界。要隔离用容器/独立用户/Permission Model。 - VFS 路径永远是 POSIX 绝对路径。 Windows 上也没有盘符。跨平台代码里别用
path.join拼 VFS 路径,会在 Windows 上拼出反斜杠。 - 自定义 Provider 未实现的原语会抛
ERR_METHOD_NOT_IMPLEMENTED。 上线前把目标场景会用到的原语过一遍,别等到某个库调了readlinkSync才发现。别忘了设置supportsSymlinks/supportsWatch能力标志——声明不支持,比实现一个半吊子版本安全。 MemoryProvider.setReadOnly()不可逆。 没有反向操作。写测试 fixture 时想清楚再锁。- package map 不做循环检测,且必须重新生成。 改了依赖关系忘了跑生成脚本 = 神秘的 MODULE_NOT_FOUND。把生成步骤接进
postinstall和 CI。 - Perfetto 和 inspector trace 互斥。 开了 Perfetto,DevTools 那条 trace 通路就没了。同时 JSON 输出永久不支持,别指望回退到
chrome://tracing的工作流。
十一、选型建议:现在该不该用
分场景给结论,避免「都很好,看情况」这种废话:
node:ffi
- ✅ 现在就该试:需要调用已经存在的、稳定的 C 库(图像编解码、加密硬件、行业 SDK),且目标平台在支持矩阵内。相比写 addon,开发和分发成本是数量级下降。
- ❌ 别用:需要复杂的 C++ 对象生命周期管理、需要高频跨语言回调、目标平台包含 s390x 等不支持架构。这些场景老老实实写 N-API addon。
- ⚠️ 实验性:API 可能变。生产使用要把 FFI 层封装成一个薄适配模块,方便将来一次性改。
node:vfs
- ✅ 测试场景立刻可用。替换临时目录方案,收益直接,风险接近零。
- ✅ 内嵌资源 / 只读包挂载,写个自定义 Provider 就能拿到「全局 fs 能读」的能力,很香。
- ❌ 绝不要当安全边界。
- ⚠️ mount 的状态清理在早期版本有 bug,用尽可能新的 26.x。
package maps
- ✅ 大型 monorepo 值得投入,尤其是被幻影依赖和 peer dependency 折磨过的团队。它是目前唯一一个运行时原生的解法。
- ⚠️ 需要配套的生成脚本和 CI 集成,是一个工程项目不是一个 flag。
- ❌ 小项目、单包项目没必要,纯粹增加复杂度。
Perfetto
- ✅ 有性能问题要深挖时,比 JSON trace 好用一个档次。
- ⚠️ 需要确认你的 Node 构建带
--with-perfetto,且要接受失去 inspector trace。 - ❌ 别常开。
十二、总结:Node.js 正在重新定义自己的边界
回到开头那个观察。四条暗线看起来分散,但它们指向同一个方向:
过去二十年,Node.js 的策略是「内核尽量小,能力交给 npm」。 这个策略造就了世界上最大的包生态,也造就了 node_modules 这个人类历史上最大的文件夹笑话、node-gyp 这个安装地狱、以及「装一个包引入四百个传递依赖」的供应链噩梦。
从 Node 26 开始,策略明显变了:把基础设施级的能力收回内核。 不是因为生态不好,而是因为有些东西只有运行时能做对:
- 模块解析只有运行时能强制执行(PnP 证明了外挂方案的天花板)。
- 文件系统抽象只有运行时能让
node:fs无感透传(memfs 证明了旁路方案的局限)。 - FFI 只有运行时能做到零编译(ffi-napi 证明了「用 addon 实现免 addon」的递归荒诞)。
- 权限边界只有运行时能守(任何库级方案都能被绕过)。
这背后还有一个更大的推力,PR 里那句「in agentic worlds where working with multiple git trees become a common pattern」已经说破了:当写代码、跑测试的主力从人变成 Agent,规模假设全变了。 一个人开三个 worktree 已经算多,一个 Agent 集群开三十个是常态。解析 I/O、文件系统隔离、可复现的依赖图,从「优化项」变成了「必需项」。
Node.js 26 十月会进 LTS。到那时,这四个东西大概率还都带着 --experimental- 前缀——但这不妨碍你现在就在测试环境里把它们跑一遍。因为等它们稳定的那天,先熟悉的人和临时抱佛脚的人,效率差距会非常明显。
具体到行动,我的建议就三条:
- 今天就能做:把测试里的临时目录换成
node:vfs的 MemoryProvider,感受一下测试速度。 - 这个季度可以做:如果你有 monorepo,写个 package map 生成脚本,先在 CI 里跑幻影依赖检测,不切生产。
- 有明确需求再做:
node:ffi和 Perfetto 都属于「有具体痛点才值得投入」的能力,别为了新而新。
最后留个开放问题,我自己也还没想清楚:当 node:ffi 足够成熟,npm 上那几万个 native addon 会怎么演化? 是集体重写成「纯 JS 包装 + 预编译动态库」的形态,还是因为 FFI 的不安全性(没有类型检查、没有生命周期管理)而分化成两个阵营?
从工程角度看,FFI 把「编译期错误」变成了「运行期段错误」,这是一次实打实的安全性倒退——它用开发效率换了正确性保障。Rust 社区大概率会给出一个中间方案:用宏从 C header 自动生成 FFI 签名和安全包装。
这件事值得盯着看。