Rust 在最近几年在前端基建这一块有很大的发展。比如用于 lint 的 oxlint, 用于构建的 rspack 等等。这些工具都是用 Rust 写的,然后通过 Node.js 的 binding 连接到前端项目中。
很多前端小伙伴看这些项目的源码的时候也会一头雾水,不知道怎么编译,怎么运行。这里我就以 rolldown 为例简单介绍一下如何用 Rust 开发前端基建工具。
我这里将 rust 写的工具分为两个大部分。
rust 实现的部分,以及如何编译成 node binding。
js 中如何使用前面编译好的 node binding。
这两个环节打通了,那一个 rust 写的工具就可以在前端项目中使用了。
什么是 node binding node binding 是一个用于连接 Node.js 与其他语言的工具。比如你可以用 cpp 编写一个模块,然后通过 node binding 将这个模块连接到 Node.js 中。
有了 node binding,你就可以在 Node.js 中使用其他语言编写的模块了,只要你能够将你的语言写的包编译成符合规范的 node binding 就好了。
什么是 rolldown rolldown 是一个用 Rust 编写的前端基建工具,它可以将 JavaScript 代码转换为 AST,然后通过 AST 进行一系列的操作,比如优化、压缩等等。不同 rspack api 对标的是 webpack,rolldown 开发的时候 api 对标的是 Rollup API。
rolldown 的作者是尤雨溪,rolldown 会被 Vite 2.0 用作默认的构建工具。
node binding 的编译 rolldown 通过 napi 与 Node.js 进行交互。
通过 rolldown 源码可以发现,它在 package.json 中定义了如下 napi 的配置:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 "napi": { "binaryName": "rolldown-binding", "packageName": "@rolldown/binding", "targets": [ "x86_64-apple-darwin", "x86_64-pc-windows-msvc", "x86_64-unknown-linux-gnu", "x86_64-unknown-linux-musl", "i686-pc-windows-msvc", "armv7-unknown-linux-gnueabihf", "aarch64-unknown-linux-gnu", "aarch64-apple-darwin", "aarch64-unknown-linux-musl", "aarch64-pc-windows-msvc" ] },
有了这个配置,就可以通过 napi build
命令来编译 napi 模块。
1 2 3 4 5 6 7 { "scripts" : { "build-binding" : "napi build -o=./src --manifest-path ../../crates/rolldown_binding/Cargo.toml --platform -p rolldown_binding --js binding.js --dts binding.d.ts --dts-header \"type MaybePromise<T> = T | Promise<T>\ntype Nullable<T> = T | null | undefined\ntype VoidNullable<T = void> = T | null | undefined | void\"" , "build-binding:release" : "napi build -o=./src --release --manifest-path ../../crates/rolldown_binding/Cargo.toml --platform -p rolldown_binding --js binding.js --dts binding.d.ts --dts-header \"type MaybePromise<T> = T | Promise<T>\ntype Nullable<T> = T | null | undefined\ntype VoidNullable<T = void> = T | null | undefined | void\"" , } }
这样执行 npm run build-binding
就可以编译出 binding.js 和 binding.d.ts 文件了。后面我们会看到这两个文件是如何使用的。
大家可能对 node-saa 比较眼熟,资深的前端或多或少都遇到过 node-saas 安装报错,因此对它还是比较眼熟的。rolldown 与 node-saa 类似。node-saas 底层是 cpp 实现的,通过 node-gyp 来编译的,而 rolldown 底层是 rust 写的,通过 napi 来编译的。
node-saas 的工作原理大概是:
node-gyp 首先读取用于描述插件的 binding.gyp 文件,这个文件是用 Python 的 GYP(Generate Your Projects)语言编写的。
然后,node-gyp 会生成对应平台和编译器的项目构建文件。例如,在 Windows 上,它会生成 .vcxproj 文件,这个文件可以被 MSBuild 使用;在 Unix 系统上,它会生成 Makefile 文件。
最后,node-gyp 调用相应的构建工具(如 make 或 msbuild)来编译源代码,生成 Node.js 可以加载的二进制插件。
需要注意的是,node-gyp 需要 Python 和 C++ 编译器的支持,因此在使用之前需要确保这些依赖已经正确安装在系统上。
如果你对 node-gyp 也不太了解,可以看下死月的 《Node.js:来一打 C++ 扩展》,这里附上这本书的配套源码,方便大家学习,地址:https://github.com/XadillaX/nyaa-nodejs-demo
node binding 的使用 前面讲了 rolldown 的编译过程,接下来我们来看看编译好的 node binding 是怎么样的,以及如何使用。
rolldown 的核心 rust 代码在这里实现:https://github.com/rolldown/rolldown/blob/main/crates/rolldown_binding/src/bundler.rs
可以看出这里也是通过 napi 来实现的。
前面讲到:执行 npm run build-binding
就可以编译出 binding.js 和 binding.d.ts 文件了。
那么 binding.js 是什么样?以及如何使用呢?
回答这两个问题前,我们来看下 rolldown 是如何使用这两个文件的。
1 2 3 4 5 6 7 8 9 10 import { Bundler } from '../binding' export async function createBundler ( inputOptions: InputOptions, outputOptions: OutputOptions, ): Promise <Bundler > { return new Bundler(bindingInputOptions, normalizeOutputOptions(outputOptions)) }
代码截取自 https://github.com/rolldown/rolldown/blob/main/packages/rolldown/src/utils/create-bundler.ts
可以看出 binding.js 是一个模块,它对外暴露了 rust 实现的 Bundler 类,使得 JS 代码可以直接调用 rust 实现的 Bundler 类。
那我们进一步来看看 binding.js 是什么样的。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 const { readFileSync } = require ('fs' )let nativeBinding = null const loadErrors = []const isMusl = () => { let musl = false if (process.platform === 'linux' ) { musl = isMuslFromFilesystem() if (musl === null ) { musl = isMuslFromReport() } if (musl === null ) { musl = isMuslFromChildProcess() } } return musl } const isFileMusl = (f ) => f.includes('libc.musl-' ) || f.includes('ld-musl-' )const isMuslFromFilesystem = () => { try { return readFileSync('/usr/bin/ldd' , 'utf-8' ).includes('musl' ) } catch { return null } } const isMuslFromReport = () => { const report = typeof process.report.getReport === 'function' ? process.report.getReport() : null if (!report) { return null } if (report.header && report.header.glibcVersionRuntime) { return false } if (Array .isArray(report.sharedObjects)) { if (report.sharedObjects.some(isFileMusl)) { return true } } return false } const isMuslFromChildProcess = () => { try { return require ('child_process' ) .execSync('ldd --version' , { encoding : 'utf8' }) .includes('musl' ) } catch (e) { return false } } function requireNative ( ) { if (process.platform === 'android' ) { if (process.arch === 'arm64' ) { try { return require ('./rolldown-binding.android-arm64.node' ) } catch (e) { loadErrors.push(e) } try { return require ('@rolldown/binding-android-arm64' ) } catch (e) { loadErrors.push(e) } } else if (process.arch === 'arm' ) { try { return require ('./rolldown-binding.android-arm-eabi.node' ) } catch (e) { loadErrors.push(e) } try { return require ('@rolldown/binding-android-arm-eabi' ) } catch (e) { loadErrors.push(e) } } else { loadErrors.push( new Error (`Unsupported architecture on Android ${process.arch} ` ), ) } } else if (process.platform === 'win32' ) { if (process.arch === 'x64' ) { try { return require ('./rolldown-binding.win32-x64-msvc.node' ) } catch (e) { loadErrors.push(e) } try { return require ('@rolldown/binding-win32-x64-msvc' ) } catch (e) { loadErrors.push(e) } } else if (process.arch === 'ia32' ) { try { return require ('./rolldown-binding.win32-ia32-msvc.node' ) } catch (e) { loadErrors.push(e) } try { return require ('@rolldown/binding-win32-ia32-msvc' ) } catch (e) { loadErrors.push(e) } } else if (process.arch === 'arm64' ) { try { return require ('./rolldown-binding.win32-arm64-msvc.node' ) } catch (e) { loadErrors.push(e) } try { return require ('@rolldown/binding-win32-arm64-msvc' ) } catch (e) { loadErrors.push(e) } } else { loadErrors.push( new Error (`Unsupported architecture on Windows: ${process.arch} ` ), ) } } else if (process.platform === 'darwin' ) { try { return require ('./rolldown-binding.darwin-universal.node' ) } catch (e) { loadErrors.push(e) } try { return require ('@rolldown/binding-darwin-universal' ) } catch (e) { loadErrors.push(e) } if (process.arch === 'x64' ) { try { return require ('./rolldown-binding.darwin-x64.node' ) } catch (e) { loadErrors.push(e) } try { return require ('@rolldown/binding-darwin-x64' ) } catch (e) { loadErrors.push(e) } } else if (process.arch === 'arm64' ) { try { return require ('./rolldown-binding.darwin-arm64.node' ) } catch (e) { loadErrors.push(e) } try { return require ('@rolldown/binding-darwin-arm64' ) } catch (e) { loadErrors.push(e) } } else { loadErrors.push( new Error (`Unsupported architecture on macOS: ${process.arch} ` ), ) } } else if (process.platform === 'freebsd' ) { if (process.arch === 'x64' ) { try { return require ('./rolldown-binding.freebsd-x64.node' ) } catch (e) { loadErrors.push(e) } try { return require ('@rolldown/binding-freebsd-x64' ) } catch (e) { loadErrors.push(e) } } else if (process.arch === 'arm64' ) { try { return require ('./rolldown-binding.freebsd-arm64.node' ) } catch (e) { loadErrors.push(e) } try { return require ('@rolldown/binding-freebsd-arm64' ) } catch (e) { loadErrors.push(e) } } else { loadErrors.push( new Error (`Unsupported architecture on FreeBSD: ${process.arch} ` ), ) } } else if (process.platform === 'linux' ) { if (process.arch === 'x64' ) { if (isMusl()) { try { return require ('./rolldown-binding.linux-x64-musl.node' ) } catch (e) { loadErrors.push(e) } try { return require ('@rolldown/binding-linux-x64-musl' ) } catch (e) { loadErrors.push(e) } } else { try { return require ('./rolldown-binding.linux-x64-gnu.node' ) } catch (e) { loadErrors.push(e) } try { return require ('@rolldown/binding-linux-x64-gnu' ) } catch (e) { loadErrors.push(e) } } } else if (process.arch === 'arm64' ) { if (isMusl()) { try { return require ('./rolldown-binding.linux-arm64-musl.node' ) } catch (e) { loadErrors.push(e) } try { return require ('@rolldown/binding-linux-arm64-musl' ) } catch (e) { loadErrors.push(e) } } else { try { return require ('./rolldown-binding.linux-arm64-gnu.node' ) } catch (e) { loadErrors.push(e) } try { return require ('@rolldown/binding-linux-arm64-gnu' ) } catch (e) { loadErrors.push(e) } } } else if (process.arch === 'arm' ) { try { return require ('./rolldown-binding.linux-arm-gnueabihf.node' ) } catch (e) { loadErrors.push(e) } try { return require ('@rolldown/binding-linux-arm-gnueabihf' ) } catch (e) { loadErrors.push(e) } } else if (process.arch === 'riscv64' ) { if (isMusl()) { try { return require ('./rolldown-binding.linux-riscv64-musl.node' ) } catch (e) { loadErrors.push(e) } try { return require ('@rolldown/binding-linux-riscv64-musl' ) } catch (e) { loadErrors.push(e) } } else { try { return require ('./rolldown-binding.linux-riscv64-gnu.node' ) } catch (e) { loadErrors.push(e) } try { return require ('@rolldown/binding-linux-riscv64-gnu' ) } catch (e) { loadErrors.push(e) } } } else if (process.arch === 's390x' ) { try { return require ('./rolldown-binding.linux-s390x-gnu.node' ) } catch (e) { loadErrors.push(e) } try { return require ('@rolldown/binding-linux-s390x-gnu' ) } catch (e) { loadErrors.push(e) } } else { loadErrors.push( new Error (`Unsupported architecture on Linux: ${process.arch} ` ), ) } } else { loadErrors.push( new Error ( `Unsupported OS: ${process.platform} , architecture: ${process.arch} ` , ), ) } } nativeBinding = requireNative() if (!nativeBinding || process.env.NAPI_RS_FORCE_WASI) { try { nativeBinding = require ('./rolldown-binding.wasi.cjs' ) } catch (err) { if (process.env.NAPI_RS_FORCE_WASI) { console .error(err) } } if (!nativeBinding) { try { nativeBinding = require ('@rolldown/binding-wasm32-wasi' ) } catch (err) { if (process.env.NAPI_RS_FORCE_WASI) { console .error(err) } } } } if (!nativeBinding) { if (loadErrors.length > 0 ) { throw new Error ('Failed to load native binding' , { cause : loadErrors }) } throw new Error (`Failed to load native binding` ) } module .exports.BindingPluginContext = nativeBinding.BindingPluginContextmodule .exports.Bundler = nativeBinding.Bundler
这里有几个关键信息:
这个文件是 NAPI-RS 自动生成的,即前面说的 napi 构建的结果。
这个文件会根据不同的平台和架构加载不同的 binding 文件。这是因为不同平台和架构的二进制文件是不同的。了解汇编的同学都知道。
会尝试加载本地的 .node 文件,如果没有找到,会尝试加载对应发布在 npm 上 @rolldown 命名空间 下的包。
关于发布 npm 包,我的猜测是 napi 自动发布的。
总结 目前 rust 来写前端基建工具已经是一个比较成熟的方案了。比如 unplugin-parcel-macros 也是用 rust 实现的的构建插件,同样是基于 napi 构建,支持 webpack ,vite, rollup 等。功能是将一些构建的时候运行代码,然后将运行结果插入到代码中,从而节省运行时的性能。
通过这篇文章,我们了解了 rolldown 是如何用 Rust 编写的前端基建工具,以及如何通过 napi 构建 node binding,以及如何在 JS 中使用 node binding。
具体来说,如果你想用 rust 开发前端基建工具,一般可以采用如下的步骤:
用 rust 实现你的工具。
通过 napi 构建 node binding。
在 JS 中使用 node binding。
对于其他语言,比如 cpp 基本流程是一样的,只是构建 node binding 的工具不同。
比如使用 cpp 开发前端基建工具,步骤可能是:
用 cpp 实现你的工具。
通过 node-gyp 构建 node binding。
在 JS 中使用 node binding。
你也可以直接使用一些基于 napi-rs 的工具,这样就可以节省一些时间,比如 neon-rs 。
如下脚本就可以快速创建一个脚手架项目。
1 2 3 npm init neon cpu-count cd cpu-countnpm install
修改代码,写一些 rust 代码实现功能,然后执行 npm run build
就可以构建 node binding 了。
1 2 3 4 5 6 cargo clean npm run build -- --release node > const cpuCount = require('.' ) > cpuCount.get() 4