跳转到内容

移植到 pi-natives (N-API) — 实践笔记

这是一份将热路径迁移到 crates/pi-natives 并通过 JS 绑定进行连接的实践指南。它的存在是为了避免同样的错误重复发生。

当以下任一条件成立时进行移植:

  • 热路径运行在渲染循环、频繁的 UI 更新或大批量处理中。
  • JS 分配占主导地位(字符串频繁创建销毁、正则回溯、大数组)。
  • 你已经有 JS 基准线,可以并排对比两个版本的基准测试。
  • 工作是 CPU 密集型或可以在 libuv 线程池上运行的阻塞 I/O。
  • 工作是可以在 Tokio 运行时上运行的异步 I/O(例如 shell 执行)。

避免移植依赖于仅 JS 状态或动态导入的代码。N-API 导出应该是纯粹的、数据输入/数据输出。长时间运行的工作应通过 task::blocking(CPU 密集型/阻塞 I/O)或 task::future(异步 I/O)并支持取消。

Rust 端:

  • 实现代码位于 crates/pi-natives/src/<module>.rs。如果添加新模块,需在 crates/pi-natives/src/lib.rs 中注册。
  • 使用 #[napi] 导出;snake_case 导出会自动转换为 camelCase。仅在真正需要别名/非默认名称时使用显式 js_name。结构体使用 #[napi(object)]
  • 对于 CPU 密集型或阻塞工作,使用 task::blocking(tag, cancel_token, work)(参见 crates/pi-natives/src/task.rs)。对于需要 Tokio 的异步工作(例如 shell 会话),使用 task::future(env, tag, work)。当你暴露 timeoutMsAbortSignal 时传递 CancelToken

JS 端:

  • packages/natives/src/bindings.ts 包含基础 NativeBindings 接口。
  • packages/natives/src/<module>/types.ts 定义 TS 类型并通过声明合并扩展 NativeBindings
  • packages/natives/src/native.ts 导入每个 <module>/types.ts 文件以激活声明。
  • packages/natives/src/<module>/index.ts 封装来自 packages/natives/src/native.tsnative 绑定。
  • packages/natives/src/native.ts 加载插件,validateNative 强制检查必需的导出。
  • packages/natives/src/index.tspackages/* 中的调用者重新导出封装。
  1. 添加 Rust 实现
  • 将核心逻辑放在一个普通的 Rust 函数中。
  • 如果是新模块,将其添加到 crates/pi-natives/src/lib.rs
  • 使用 #[napi] 导出,保持默认的 snake_case -> camelCase 映射一致性。
  • 保持签名为拥有所有权且简单的类型:StringVec<String>Uint8Array,或对于大字符串/字节输入使用 Either<JsString, Uint8Array>
  • 对于 CPU 密集型或阻塞工作,使用 task::blocking;对于异步工作,使用 task::future。传递 CancelToken 并在长循环中调用 heartbeat()
  1. 连接 JS 绑定
  • packages/natives/src/<module>/types.ts 中添加类型和 NativeBindings 扩展。
  • packages/natives/src/native.ts 中导入 ./<module>/types 以触发声明合并。
  • packages/natives/src/<module>/index.ts 中添加调用 native 的封装。
  • packages/natives/src/index.ts 重新导出。
  1. 更新原生验证
  • validateNativepackages/natives/src/native.ts)中添加 checkFn("newExport")
  1. 添加基准测试
  • 将基准测试放在所属包旁边(packages/tui/benchpackages/natives/benchpackages/coding-agent/bench)。
  • 在同一次运行中包含 JS 基准线和原生版本。
  • 使用 Bun.nanoseconds() 和固定的迭代次数。
  • 保持基准测试输入小而真实(热路径中实际看到的数据)。
  1. 构建原生二进制文件
  • bun --cwd=packages/natives run build
  • 使用 bun --cwd=packages/natives run build,如果想在测试时获取加载器诊断信息,设置 PI_DEV=1
  1. 运行基准测试
  • bun run packages/<pkg>/bench/<bench>.ts(或 bun --cwd=packages/natives run bench
  1. 决定使用方式
  • 如果原生更慢,保留 JS,让原生导出暂不使用。
  • 如果原生更快,将调用点切换到原生封装。

1) 过期的 pi_natives.node 阻止新导出

Section titled “1) 过期的 pi_natives.node 阻止新导出”

加载器优先使用 packages/natives/native 中带平台标签的二进制文件(pi_natives.<platform>-<arch>.node)。PI_DEV=1 现在仅启用加载器诊断信息;它不再切换到单独的开发插件文件名。还有一个回退的 pi_natives.node。编译后的二进制文件会提取到 ~/.xcsh/natives/<version>/pi_natives.<platform>-<arch>.node。如果其中任何一个过期,导出都不会更新。

修复方法: 在重新构建之前删除过期文件。

Terminal window
rm packages/natives/native/pi_natives.linux-x64.node
rm packages/natives/native/pi_natives.node
bun --cwd=packages/natives run build

如果你正在运行编译后的二进制文件,删除缓存的插件目录:

Terminal window
rm -rf ~/.xcsh/natives/<version>

然后验证二进制文件中是否存在该导出:

Terminal window
bun -e 'const tag = `${process.platform}-${process.arch}`; const mod = require(`./packages/natives/native/pi_natives.${tag}.node`); console.log(Object.keys(mod).includes("newExport"));'

2) 来自 validateNative 的 “Missing exports” 错误

Section titled “2) 来自 validateNative 的 “Missing exports” 错误”

这是好事 — 它防止了静默的不匹配。当你看到:

Native addon missing exports ... Missing: visibleWidth

这意味着你的二进制文件过期了,Rust 导出名称(或使用时的显式别名)与 JS 名称不匹配,或者导出根本没有编译进去。修复构建和命名不匹配问题,不要削弱验证。

保持简单且拥有所有权。StringVec<String>Uint8Array 可以正常工作。在公共导出中避免使用 &str 等引用。如果需要结构化数据,将其包装在 #[napi(object)] 结构体中。

  • 不要比较不同的输入或分配。
  • 保持 JS 和原生使用完全相同的输入数组。
  • 在同一个基准测试文件中运行两者以避免偏差。
const ITERATIONS = 2000;
function bench(name: string, fn: () => void): number {
const start = Bun.nanoseconds();
for (let i = 0; i < ITERATIONS; i++) fn();
const elapsed = (Bun.nanoseconds() - start) / 1e6;
console.log(`${name}: ${elapsed.toFixed(2)}ms total (${(elapsed / ITERATIONS).toFixed(6)}ms/op)`);
return elapsed;
}
bench("feature/js", () => {
jsImpl(sample);
});
bench("feature/native", () => {
nativeImpl(sample);
});
  • validateNative 通过(没有缺失的导出)。
  • NativeBindingspackages/natives/src/<module>/types.ts 中已扩展,封装已在 packages/natives/src/index.ts 中重新导出。
  • Object.keys(require(...)) 包含你的新导出。
  • 基准测试数据已记录在 PR/笔记中。
  • 仅在原生更快或持平时才更新调用点。
  • 如果原生更慢,不要切换。保留导出供将来使用,但 TUI 应该保持在更快的路径上。
  • 如果原生更快,切换调用点并保留基准测试以捕获性能回退。