文章目录
- 前言
-
- [一 、@node-rs/jieba 模块分析](#一 、@node-rs/jieba 模块分析)
- [二、`.node` 文件是什么:N-API 原生模块](#二、
.node文件是什么:N-API 原生模块) - [三、Webpack 为什么会炸](#三、Webpack 为什么会炸)
- [四、`serverExternalPackages` 的作用原理](#四、
serverExternalPackages的作用原理) - [五、和 `external` 的关系](#五、和
external的关系) - [六、为什么不能用 bundler 默认的"asset modules"方案](#六、为什么不能用 bundler 默认的"asset modules"方案)
- [七、Vercel 上是这么工作的](#七、Vercel 上是这么工作的)
- [八、对比:和 nodejieba 有什么不同](#八、对比:和 nodejieba 有什么不同)
- 一句话总结
前言
一 、@node-rs/jieba 模块分析
二、.node 文件是什么:N-API 原生模块
Node.js 本身是 C++ 写的,提供了一套叫 N-API (Node-API)的 ABI 接口,允许 C/C++/Rust 代码编译成 .node 文件被 require() 直接加载。
工作流程:
1. 编译阶段:用 C++ 编译器把 C++ 源码编成 .node 文件(动态库)
Rust 的话用 NAPI-RS 这个工具链,效果一样
2. 加载阶段:require('./xxx.node')
↓
Node.js 内部调用 process.dlopen()
↓
调用系统 API:macOS 用 dlopen(),Linux 也用 dlopen(),Windows 用 LoadLibrary()
↓
操作系统把二进制代码 mmap 到内存,跳到入口函数
3. 调用阶段:JS 调原生函数,实际上是跨语言 FFI 调用
@node-rs/jieba 用 Rust + NAPI-RS 写的,所以 index.js 里的关键代码是
js
} else if (process.arch === 'arm64') {
try {
return require('./jieba.darwin-arm64.node') // 关键:动态加载二进制
} catch (e) {
loadErrors.push(e)
}
try {
return require('@node-rs/jieba-darwin-arm64') // 备选:走子包
} catch (e) {
loadErrors.push(e)
}
}
它还会根据平台选不同的二进制(macOS / Linux / Windows × x64 / arm64 / musl / gnu),这就是为什么 Vercel 的 Linux x64 gnu 环境能跑。
三、Webpack 为什么会炸
Next.js 15 用 Turbopack(开发)/ Webpack(构建)做模块打包。它们默认假设 node_modules 里的文件都是 JS / WASM / JSON,有一套默认的 loader 规则:
js
// Webpack 默认的 module.rules 大致是
{
test: /\.js$/, use: 'babel-loader',
test: /\.ts$/, use: 'ts-loader',
test: /\.json$/, use: 'json-loader',
// 遇到 .node → 不知道怎么办
}
Turbopack 看到 import { Jieba } from '@node-rs/jieba',会沿着 import 链追踪依赖:
src/lib/rag.ts
→ node_modules/@node-rs/jieba/index.js
→ require('./jieba.darwin-arm64.node') ← 它尝试把这行 require 当成普通 JS 模块处理
然后它打开 jieba.darwin-arm64.node,头四个字节是 cf fa ed fe(Mach-O 魔数),不是合法 JS 语法,就报:
Module parse failed: Unexpected character '�' (1:0)
You may need an appropriate loader to handle this file type
那个 � 就是 cf 字节被 UTF-8 强行解码后的产物。
四、serverExternalPackages 的作用原理
这个配置的本质是告诉打包器:"别动这个包,把它当作运行时依赖,部署时从 node_modules 单独 require"。
技术细节:
ts
// 没配置时,Turbopack 的处理:
1. 扫描 import/require 链
2. 找到 @node-rs/jieba
3. 尝试把它和它的所有依赖(包括 .node 二进制)一起塞进 server bundle
4. .node 文件无法被解析 → 报错
// 配置 serverExternalPackages: ['@node-rs/jieba'] 后:
1. 扫描到 @node-rs/jieba
2. 跳过它!把 import 改成等价的 require 形式保留下来
3. 部署产物里 .next/server/ 不会包含 jieba 的代码
4. 运行时,Vercel 把整个 node_modules 上传到 Lambda
5. Node.js 进程启动后,真正执行 require('@node-rs/jieba') 时
6. 走 N-API 的 process.dlopen 路径,操作系统加载 .node 文件
对打包器来说,它只看到:
js
// 编译产物里
const _jieba = require('@node-rs/jieba') // 告诉打包器:别解析这个 require
而不是把它解析为内联代码。
五、和 external 的关系
你可能也见过 webpack.externals 或 Turbopack 的 experimental.serverComponentsExternalPackages(旧版),它们是同一回事的不同名字:
| 版本 | 配置名 | 作用 |
|---|---|---|
| Next.js 13- | experimental.serverComponentsExternalPackages |
旧 API |
| Next.js 15 | serverExternalPackages(顶层) |
新 API,统一了 RSC 和 Route Handler |
不需要放到 experimental 下了。
六、为什么不能用 bundler 默认的"asset modules"方案
理论上 Webpack 也可以处理 .node 文件,配置一个 file-loader / asset modules 把它当作资源拷贝到输出目录。但这样会有两个问题:
- 平台二进制不通用:你 macOS 开发时打的 arm64 二进制,部署到 Vercel Linux x64 跑不起来
- 冷启动慢:每个 Lambda 函数启动都要重新加载 2.6MB 二进制
N-API 的设计哲学是:二进制文件由 Node.js 运行时直接从 node_modules 原位置 dlopen,操作系统会缓存它,跨函数实例共享内存。
七、Vercel 上是这么工作的
js
1. pnpm install
→ 触发可选依赖钩子(@node-rs/jieba-darwin-arm64 等)
→ 只安装匹配当前平台的二进制子包
→ 你的部署环境是 Linux x64 GNU,所以装的是 jieba.linux-x64-gnu.node
2. pnpm build (next build)
→ serverExternalPackages 让 webpack 跳过 jieba
→ .next/server/ 不包含 jieba 代码
3. Vercel 部署
→ 把 .next + node_modules + package.json 上传到 Lambda 镜像
→ 注意:只装 Linux 二进制的子包,体积更小
4. 用户请求 /api/chat
→ Lambda 冷启动 → 加载函数
→ require('@node-rs/jieba') → dlopen jieba.linux-x64-gnu.node
→ ~50ms 加载完成,进程内缓存
→ 后续请求直接复用
八、对比:和 nodejieba 有什么不同
nodejieba 用 node-pre-gyp 机制:安装时下载预编译的 .node 文件 或本地 cmake 编译。本质上 .node 文件是同一种东西。
两者在打包阶段都需要 serverExternalPackages,没有本质区别。区别在于:
| 维度 | nodejieba | @node-rs/jieba |
|---|---|---|
| 编译/下载 | 需要 cmake 编译,失败率高 | 纯预编译,安装即用 |
| pnpm 兼容 | 经常需要 .npmrc 配置 |
完美兼容 |
| 跨平台二进制 | 不全(你 RAG_OPTIMIZATION 里就吐槽了 darwin-arm64) | 全平台覆盖 |
| Vercel 部署 | 经常踩坑 | 直接能用 |
一句话总结
serverExternalPackages 不是为了修 bug,而是让打包器把"原生模块"这个"运行时由操作系统加载的二进制"和"普通 JS 代码"区分开 。.node 文件是 N-API 标准的 Node.js 原生扩展,必须在运行时由 Node.js 通过 process.dlopen 加载,不能在编译期被 Webpack 解析或打包。
这就是为什么我前面在 next.config.ts 里加 serverExternalPackages: ['@node-rs/jieba'] ------ 它和 nodejieba 一样需要这个配置,技术原理完全一致。