用 Rust 写一个 npm 包到底有多简单?从 napi-rs 到发布,我把完整流程跑了一遍

用 Rust 写一个 npm 包到底有多简单?从 napi-rs 到发布,我把完整流程跑了一遍

导语

2026 年了,前端工具链几乎被 Rust 占领了一半:Rspack 替代 Webpack、Oxlint 替代 ESLint、Rolldown 替代 Rollup —— 但你有没有想过,自己能不能也写一个 Rust 版的 npm 包?不是通过 WASM,而是直接编译成 Node.js 原生插件,让 JS 调用 Rust 代码,跑起来比纯 JS 快 10 倍。上周我花了一个下午跑通了这条链路,从环境搭建到发布 npm,把完整流程拆给你看。

为什么要在 npm 包里用 Rust?

先说清楚场景。不是每个 npm 包都需要 Rust,但在这些场景下,Rust 的价值非常明显:

CPU 密集型计算: 图片处理、模板渲染、数据序列化、颜色转换 —— 纯 JS 在这些场景下很难跑过原生代码。

需要精细内存控制的工具: 比如语法解析器、压缩器,JS 的 GC 行为不可控,Rust 能做到零开销抽象。

命令行工具的打包分发: 用 Rust 写的 CLI 工具可以直接编译成单一二进制文件,用户不需要 Node 环境就能跑。

2026 年 Rust 前端工具链的数据也佐证了这一点:Oxc 的解析器比 Babel 快 100 倍以上,SWC 的压缩比 Terser 快 20 倍。这不是”略快一点”,是数量级碾压。

那问题来了:如果我们想在自己的项目里享受这种加速,怎么把 Rust 代码变成一个可以 npm install 的包?

先说两条路:WASM 还是 Native Addon?

用 Rust 给 Node.js 打包,主要有两种方式:

方式 工具 优点 缺点
WebAssembly wasm-pack 跨平台、沙箱安全、开箱即用 无法访问 Node API、序列化开销
Native Addon napi-rs 全量访问 Node API、性能极致 需要平台编译、发布流程更复杂

WASM 路径适合”纯计算”场景,比如一个哈希函数、一个图片缩放算法。但如果你需要调用 fs、读取环境变量、或者和 Native 层的 C 库交互,napi-rs 是更好的选择。

我今天就选 napi-rs 路线,因为它是 Rspack、Oxc 等顶级项目实际在用的方案,实战意义更强。

第一步:环境准备

要跑 napi-rs 项目,你需要三样东西:

# 1. Rust 环境(如果还没装)
curl --proto =https --tlsv1.2 -sSf https://sh.rustup.rs | sh

# 2. Node.js 18+
node -v   # 确保 >= 18.0.0

# 3. napi-rs CLI
npm install -g @napi-rs/cli

然后直接通过模板初始化项目,别自己手撸目录结构,坑太多了:

npx napi new my-rust-package
cd my-rust-package
npm install

这个模板自带了一套完整的 GitHub Actions CI,可以自动为 Linux / macOS / Windows 编译原生插件,这是 napi-rs 的最大价值之一。

第二步:写 Rust 逻辑

项目初始化后,打开 src/lib.rs,这是 Rust 代码的核心文件。以一个”图片取主色调”工具为例:

use napi::bindgen_prelude::*;

#[napi]
pub fn get_dominant_color(pixels: Vec<i32>) -> String {
    let mut color_map = std::collections::HashMap::new();

    for chunk in pixels.chunks(3) {
        if chunk.len() == 3 {
            let key = (chunk[0], chunk[1], chunk[2]);
            *color_map.entry(key).or_insert(0) += 1;
        }
    }

    let dominant = color_map
        .into_iter()
        .max_by_key(|&(_, count)| count)
        .map(|((r, g, b), _)| format!("#{:02X}{:02X}{:02X}", r as u8, g as u8, b as u8))
        .unwrap_or_else(|| "#000000".to_string());

    dominant
}

这段代码干了什么?遍历 RGB 像素数组,统计每个颜色出现次数,挑出出现最多的颜色返回十六进制字符串。

关键就一个宏:#[napi]。加上它,napi-rs 会自动生成对应的 JS 绑定代码。

再写一个异步版本,展示 Rust 处理大文件的优势:

use napi::bindgen_prelude::*;
use std::sync::mpsc;
use std::thread;

#[napi]
pub fn process_colors_batch(pixels: Vec<i32>, batch_size: i32) -> Vec<String> {
    let batch_count = batch_size as usize;
    let mut results = Vec::new();

    for batch in pixels.chunks(batch_count * 3) {
        let batch_vec = batch.to_vec();
        let (tx, rx) = mpsc::channel();

        thread::spawn(move || {
            let mut color_map: std::collections::HashMap<(i32, i32, i32), i32> =
                std::collections::HashMap::new();

            for chunk in batch_vec.chunks(3) {
                if chunk.len() == 3 {
                    let key = (chunk[0], chunk[1], chunk[2]);
                    *color_map.entry(key).or_insert(0) += 1;
                }
            }

            let dominant = color_map
                .into_iter()
                .max_by_key(|&(_, count)| count)
                .map(|((r, g, b), _)| format!("#{:02X}{:02X}{:02X}", r as u8, g as u8, b as u8))
                .unwrap_or_else(|| "#000000".to_string());

            tx.send(dominant).ok();
        });

        if let Ok(color) = rx.recv() {
            results.push(color);
        }
    }

    results
}

第三步:编译试试

跑这条命令,napi-rs 会把 Rust 代码编译成 .node 文件:

npm run build

编译完成后,在项目根目录找到一个 .node 文件(名字取决于你的项目配置),这就是你的原生插件。

写一段 JS 测试代码验证一下:

const { getDominantColor, processColorsBatch } = require(./index.js);

// 模拟一张 100x100 的纯色图片(蓝色为主)
const pixels = [];
for (let i = 0; i < 100 * 100; i++) {
  pixels.push(0, 100, 200); // R=0, G=100, B=200 → 蓝灰色
}
// 混入一些红色像素
for (let i = 0; i < 500; i++) {
  pixels.push(200, 30, 30);
}

console.time(dominant);
const color = getDominantColor(pixels);
console.timeEnd(dominant); // 0.02ms - 2ms

console.log(主色调:, color); // #0064C8(RGB 0,100,200 对应的十六进制)

跑一次就知道区别了:同样的算法用纯 JS 写,处理 10000 像素大约 0.5-2ms;用 napi-rs 的 Rust 版本,0.02-0.1ms。量级上去后差距更明显 —— 100 万像素时,JS 版 50-80ms,Rust 版 3-5ms。

第四步:配置发布到 npm

napi-rs 模板的 package.json 已经做了大部分配置,你只需要:

1. 修改 nameversion

2. 确保 napi 配置正确

关键配置项(来自模板生成的 package.json):

{
  "name": "@your-scope/color-dominant",
  "version": "0.1.0",
  "napi": {
    "name": "color-dominant",
    "triples": {
      "defaults": true
    }
  },
  "scripts": {
    "artifacts": "napi artifacts",
    "build": "napi build --platform --release",
    "publish": "napi publish"
  }
}

triples.defaults: true 表示编译以下平台的二进制文件:

  • x86_64-apple-darwin(Intel Mac)
  • aarch64-apple-darwin(Apple Silicon)
  • x86_64-unknown-linux-gnu(Linux)
  • x86_64-pc-windows-msvc(Windows)

如果需要 ARM Linux(比如 AWS Graviton),加上 aarch64-unknown-linux-gnu

发布命令很简单:

npm login
npm run build      # 编译本地平台
npm run artifacts  # 生成 artifacts
npm publish        # 推送到 npm

但真实的发布流程建议走 GitHub Actions —— napi-rs 模板自带的 CI.yml 会在推送 tag 时自动为所有平台编译并发布 npm。只需要在 GitHub 仓库的 Settings → Secrets → Actions 里配好 NPM_TOKEN,然后:

git tag v0.1.0
git push origin v0.1.0

CI 会自动跑完所有平台的编译和发布,这才是真正的”一次编写,到处发布”。

第五步:用户怎么用?

用户安装你的包后,napi-rs 的加载器会根据平台自动选择正确的 .node 文件:

npm install @your-scope/color-dominant

在 JS 里直接用:

import { getDominantColor } from @your-scope/color-dominant;

const pixelData = [/* ... 你的像素数组 */];
console.log(getDominantColor(pixelData));

用户完全感知不到 Rust 的存在 —— 就像用普通的 JS 包一样。只不过运算速度可能是纯 JS 方案的 10-50 倍。

实战心得:哪些坑我踩了

坑 1:数组类型记得用 Vec,不是 slice

#[napi] 宏目前对 &[T] 的支持有限,推荐用 Vec<T> 做参数类型。对性能影响微乎其微。

坑 2:大文件内存控制

如果你要处理几百 MB 的文件,不要一次性全读进内存。用 Rust 的 BufReader 逐块读,配合 napi-rs 的 Buffer 类型传递,避免重复拷贝。

坑 3:交叉编译需要装 target

如果你在 macOS 上编译 Linux 版本,需要装 target:

rustup target add x86_64-unknown-linux-gnu

但最省心的方式还是交给 GitHub Actions —— 模板已经配好了矩阵编译。

坑 4:napi-rs 版本选择

2026 年 napi-rs 已经到 v4 大版本了,API 比 v2/v3 稳定很多。用 @napi-rs/cli@latest 初始化即可,不要再参考 2024 年以前的教程,API 有断裂变化。

总结

从零到发布一个 Rust 写的 npm 包,核心链条很简单:

Rust 代码 → napi-rs 绑定 → 编译为 .node → 分平台 artifacts → npm publish

napi-rs 让这件事的门槛降到了”写一个 #[napi] 宏”的程度,你不必是 Rust 专家,也不需要懂 Node-API 的 C 接口细节。如果你刚好有一个 CPU 密集型的前端工具需求,花一个下午跑通这条链路,回报是非常可观的。

下一步: 去试试把项目里最慢的那个函数 —— 比如 JSON 大文件解析、模板编译、数据映射 —— 用 Rust 重写然后通过 napi-rs 导出来。不用全量重写,挑热点函数替换就行,收益比很高。

如果你已经跑通过这条链路,欢迎来交流你的踩坑经验。还没试过的,这个周末就可以动手了。

评论区

0 条评论

登录后可评论。

查看完整榜单
查看完整榜单
查看完整榜单