|
| 1 | +# vite-plugin-native |
| 2 | + |
| 3 | +> 2024-06-11 |
| 4 | +
|
| 5 | +支持在 Node/Electron 中使用 C/C++ native addons. 这是一个基于 [Webpack](https://github.com/webpack/webpack) 的 bundle 方案。 |
| 6 | + |
| 7 | +> 感谢 [Erick Zhao](https://github.com/erickzhao) 提供灵感 :) |
| 8 | +
|
| 9 | +[](https://npmjs.org/package/vite-plugin-native) |
| 10 | +[](https://npmjs.org/package/vite-plugin-native) |
| 11 | + |
| 12 | +[English](./README.md) | 简体中文 |
| 13 | + |
| 14 | +## Install |
| 15 | + |
| 16 | +```bash |
| 17 | +npm i -D vite-plugin-native |
| 18 | +``` |
| 19 | + |
| 20 | +## Usage |
| 21 | + |
| 22 | +```javascript |
| 23 | +import native from 'vite-plugin-native' |
| 24 | + |
| 25 | +export default { |
| 26 | + plugins: [ |
| 27 | + native(/* options */) |
| 28 | + ] |
| 29 | +} |
| 30 | +``` |
| 31 | + |
| 32 | +## API |
| 33 | + |
| 34 | +```ts |
| 35 | +export interface NativeOptions { |
| 36 | + /** |
| 37 | + * Where we want to physically put the extracted `.node` files |
| 38 | + * @default 'dist-native' |
| 39 | + */ |
| 40 | + outDir?: string |
| 41 | + /** |
| 42 | + * - Modify the final filename for specific modules |
| 43 | + * - A function that receives a full path to the original file, and returns a desired filename |
| 44 | + * - Or a function that returns a desired file name and a specific destination to copy to |
| 45 | + * @experimental |
| 46 | + * @todo better calculation value of `id` automatically |
| 47 | + */ |
| 48 | + map?: (mapping: { |
| 49 | + /** `.node` file path */ |
| 50 | + native: string |
| 51 | + /** require id of `.node` file */ |
| 52 | + id: string |
| 53 | + /** `.node` file output location */ |
| 54 | + output: string |
| 55 | + }) => typeof mapping |
| 56 | + /** |
| 57 | + * - Use `dlopen` instead of `require`/`import` |
| 58 | + * - This must be set to true if using a different file extension that `.node` |
| 59 | + */ |
| 60 | + dlopen?: boolean |
| 61 | + /** |
| 62 | + * If the target is `esm`, so we can't use `require` (and `.node` is not supported in `import` anyway), we will need to use `createRequire` instead |
| 63 | + * @default 'cjs' |
| 64 | + */ |
| 65 | + target?: 'cjs' | 'esm' |
| 66 | +} |
| 67 | +``` |
| 68 | + |
| 69 | +## 工作原理 |
| 70 | + |
| 71 | +### esm 与 cjs |
| 72 | + |
| 73 | +esm 标准导入模块的方式,是使用写在 js 文件顶层作用域 import 语句,并且模块 id 必须是一个固定的字符串常量。这使得所有行为都是可预测的。这对于 bundler 十分的友好,甚至可以非常轻松的实现 tree-shake 功能。其次导出的模块成员必须是具名的,默认为名字为 default。 |
| 74 | + |
| 75 | +cjs 标准导入模块的方式,是使用 require 函数导入模块,require 函数与普通的 js 函数没有什么区别,这就决定了 require 函数可以写在任意的作用域且导入模块 id 可以是任何的 js 变量、字符串常量、表达式。这与 import 语句有着本质的区别,也就是说 cjs(require) 的模块规范要比 esm(import) 模块灵活的多,甚至导出模块成员可以是匿名的;这样的行为差异决定了很多时候一个 cjs 模块是无法有效的转换为 esm 模块的,它们的运行行为差异非常大。 |
| 76 | + |
| 77 | +esm 与 cjs 之间的相互导入/导出的转换是所有 bundler/transpiler 都会面临的一个问题,如 Vite、Webpack、esbuild、swc、tsc 等等。关于模块转换有个专有名词叫 interop。 |
| 78 | + |
| 79 | +> 我之前在 B 站讲过一个视频 👉🏻 [细讲⚡️vite-plugin-commonjs(上)](https://www.bilibili.com/video/BV1gm4y1e7zK/?vd_source=44b643ef038990a11abb9118def2ef80) |
| 80 | +
|
| 81 | +### 作用域 与 同步/异步 |
| 82 | + |
| 83 | +Vite(Rollup) 对于构建顶层作用域中使用 import 语句导入的模块是没有作用域概念的,代码的 bundle 规则是基于 export 导出成员的名字,它要求模块作者遵循 esm 规范。这样做理论上不会产生什么副作用,因为模块所有导出成员均在**顶层作用域**且为**同步行为**,所以这就确定了一切行为都是可预测的。 |
| 84 | + |
| 85 | +当然如果你使用 import() 函数导入模块,它更像一个普通的 js 函数,并且将不会有顶层作用域限制。导入语句可以写在**任意作用域**且模块导入永远为**异步行为**。Vite(Rollup) 默认是不会将 import() 函数导入的模块合并到 bundle.js 中,如果强行合并到 bundle.js 中可能会导致一些不可预测的副作用,例如用户在被 import() 导入的模块中的顶层作用域立即执行了一些逻辑,这可能会与用户想象中的行为不一致,因为模块会被立即执行!导致执行时机被提前,而不是 bundle.js 由上至下至执行到 import() 函数时候才被加载并执行 👉🏻 [output.inlineDynamicImports](https://github.com/rollup/rollup/blob/v4.17.1/docs/configuration-options/index.md#outputinlinedynamicimports)。 |
| 86 | + |
| 87 | +### require 与 import/import() |
| 88 | + |
| 89 | +我们知道了模块导入的作用域,同步/异步的概念后,这将决定了 Vite(Rollup) 能否能顺利的将 require 与 import/import() 转换成功,也即 interop 问题。 |
| 90 | + |
| 91 | +<table> |
| 92 | + <thead> |
| 93 | + <th>Statement</th> |
| 94 | + <th>Sync</th> |
| 95 | + <th>Async</th> |
| 96 | + <th>Scope</th> |
| 97 | + </thead> |
| 98 | + <tbody> |
| 99 | + <tr> |
| 100 | + <td>import</td> |
| 101 | + <td>✅</td> |
| 102 | + <td>❌</td> |
| 103 | + <td>Global</td> |
| 104 | + </tr> |
| 105 | + <tr> |
| 106 | + <td>import()</td> |
| 107 | + <td>❌</td> |
| 108 | + <td>✅</td> |
| 109 | + <td>Global / Anywhere</td> |
| 110 | + </tr> |
| 111 | + <tr> |
| 112 | + <td>require()</td> |
| 113 | + <td>✅</td> |
| 114 | + <td>✅ (Wrap with Promise.resolve())</td> |
| 115 | + <td>Global / Anywhere</td> |
| 116 | + </tr> |
| 117 | + </tbody> |
| 118 | +</table> |
| 119 | + |
| 120 | +### 模块互操作(interop) |
| 121 | + |
| 122 | +import 语句与 import() 函数很容易转换成 require 且无任何的副作用。 |
| 123 | + |
| 124 | +```js |
| 125 | +// Global scope |
| 126 | + |
| 127 | +import foo from './foo' |
| 128 | +↓↓↓↓ ✅ |
| 129 | +const { default: foo } = require('./foo') |
| 130 | + |
| 131 | +import * as foo from './foo' |
| 132 | +↓↓↓↓ ✅ |
| 133 | +const foo = require('./foo') |
| 134 | + |
| 135 | +import { bar, baz } from './foo' |
| 136 | +↓↓↓↓ ✅ |
| 137 | +const { bar, baz } = require('./foo') |
| 138 | + |
| 139 | +function func(name) { |
| 140 | + // Function scope |
| 141 | + |
| 142 | + const foo = import(`./${name}`) |
| 143 | + ↓↓↓↓ ✅ |
| 144 | + const foo = Promise.resolve(require(`./${name}`)) |
| 145 | +} |
| 146 | +``` |
| 147 | + |
| 148 | +require 函数换成 import 语句可能会失败! |
| 149 | + |
| 150 | +```js |
| 151 | +// Global scope |
| 152 | + |
| 153 | +const foo = require('./foo') // 需要配合 Object.defineProperty(exports, "__esModule", { value: true }); |
| 154 | +↓↓↓↓ 🚧 |
| 155 | +import foo from './foo' |
| 156 | + |
| 157 | +const foo = require(`./${name}`) |
| 158 | +↓↓↓↓ ❌ |
| 159 | +import foo from `./${name}` // 不支持使用变量 |
| 160 | + |
| 161 | +function func(name) { |
| 162 | + // Function scope |
| 163 | + |
| 164 | + const foo = require(`./${name}`) |
| 165 | + ↓↓↓↓ ❌ |
| 166 | + const foo = import(`./${name}`) // `require` 是同步 API |
| 167 | +} |
| 168 | +``` |
| 169 | + |
| 170 | +### 中心化模块管理 |
| 171 | + |
| 172 | +Webpack/esbuild 的 bundle 方案支持使用 require 函数导入模块并且可以**无视作用域**。他们能作到这点的主要原因都是采用了模块集中管理的策略;在 Webpack 中所有模块都被挂在到一个名为 `__webpack_modules__` 的变量上并且使用统一 `__webpack_require__` 加载函数加载,在 esbuild 中所有模块均被包裹在一个 `__commonJS` 模块管理函数中,并返回该模块的加载函数。 |
| 173 | + |
| 174 | +### Vite 的预处理 |
| 175 | + |
| 176 | +Vite 中有个概念是 [Pre-Bundling](https://vitejs.dev/guide/dep-pre-bundling.html#dependency-pre-bundling),它有一个很重要的作用就是将 cjs 模块提前构建成一个 bundle.js 然后通过 esm 的形式导出模块,这很好的解决了 interop 的问题。试想一下面有一段包含条件导入模块的代码段,让我们看看预构建是如何处理它的。 |
| 177 | + |
| 178 | +```js |
| 179 | +// add.js |
| 180 | +exports.add = (a, b) => a + b |
| 181 | + |
| 182 | +// minus.js |
| 183 | +exports.minus = (a, b) => a - b |
| 184 | + |
| 185 | +// math.js |
| 186 | +exports.calc = (a, b, operate) => { |
| 187 | + const calc = operate === '+' // condition require |
| 188 | + ? require('./add').add(a, b) |
| 189 | + : require('./minus').minus(a, b) |
| 190 | + return calc(a, b) |
| 191 | +} |
| 192 | +``` |
| 193 | + |
| 194 | +**Output a `bundle.js` file with Vite's Pre-Bundling** |
| 195 | + |
| 196 | +```js |
| 197 | +var __commonJS = (cb, mod = { exports: {} }) => function __require2() { |
| 198 | + const cjs_wrapper = Object.values(cb)[0] // wrapper function |
| 199 | + cjs_wrapper(mod.exports, mod); // inject exports, module |
| 200 | + return mod.exports; |
| 201 | +}; |
| 202 | + |
| 203 | +// add.js |
| 204 | +var require_add = __commonJS({ |
| 205 | + "add.js"(exports, module) { |
| 206 | + exports.add = (a, b) => a + b |
| 207 | + } |
| 208 | +}) |
| 209 | + |
| 210 | +// minus.js |
| 211 | +var require_minus = __commonJS({ |
| 212 | + "minus.js"(exports, module) { |
| 213 | + exports.minus = (a, b) => a - b |
| 214 | + } |
| 215 | +}) |
| 216 | + |
| 217 | +// math.js |
| 218 | +var require_math = __commonJS({ |
| 219 | + "math.js"(exports, module) { |
| 220 | + exports.calc = (a, b, operate) => { |
| 221 | + const calc = operate === '+' // condition require |
| 222 | + ? require_add().add(a, b) |
| 223 | + : require_minus().minus(a, b) |
| 224 | + return calc(a, b) |
| 225 | + } |
| 226 | + } |
| 227 | +}); |
| 228 | + |
| 229 | +// Finally export a esmodule ✅ |
| 230 | +export default require_math(); // { calc: Function } |
| 231 | +``` |
| 232 | + |
| 233 | +### Vite 与 Webpack 产物 |
| 234 | + |
| 235 | +假设我们有如下两个文件分别为 add.js 与 index.js: |
| 236 | + |
| 237 | +**add.js** |
| 238 | + |
| 239 | +```js |
| 240 | +export function add(a, b) { |
| 241 | + return a + b |
| 242 | +} |
| 243 | +``` |
| 244 | + |
| 245 | +**index.js** |
| 246 | + |
| 247 | +```js |
| 248 | +import { add } from './add' |
| 249 | + |
| 250 | +const sum = add(1, 2) |
| 251 | +``` |
| 252 | + |
| 253 | +Vite(Rollup) 默认只支持 esm 格式的文件,也就是模块引用需要使用 import 语句。这个设定很好,因为它是 ECMAScript 的模块标准;同样这会带来一些天然的好处比如很容易支持 tree-shake 这是因为 import/export 语句规定所有导入/导出模块必须写在 js 文件顶层作用域,也即它们的行为可预测的。Rollup 的构建产物给人最直观的感觉就是**所见即所得**,非常符合代码书写的顺序与直觉。 |
| 254 | + |
| 255 | +**Bundled with Vite** |
| 256 | + |
| 257 | +```js |
| 258 | +// add.js |
| 259 | +function add(a, b) { |
| 260 | + return a + b |
| 261 | +} |
| 262 | + |
| 263 | +// index.js |
| 264 | +const sum = add(1, 2) |
| 265 | +``` |
| 266 | + |
| 267 | +Webpack 的构建产物是以遵循 cjs 规范的方式组织代码,所以有 **模块中心(modules)、模块导出挂载点(module.exports)、模块加载函数(require)** 等概念。而这些概念在 Rollup 中通通没有;这是两者之间最大的区别,也即两者对模块导入/导出处理的不同。 |
| 268 | + |
| 269 | +**Bundled with Webpack** |
| 270 | + |
| 271 | +```js |
| 272 | +var __webpack_modules__ = { |
| 273 | + // index.js |
| 274 | + 0: (module, exports, __webpack_require__) => { |
| 275 | + const { add } = __webpack_require__(1) |
| 276 | + const sum = add(1 + 2) |
| 277 | + }, |
| 278 | + // add.js |
| 279 | + 1: (module, exports, __webpack_require__) => { |
| 280 | + function add(a, b) { |
| 281 | + return a + b |
| 282 | + } |
| 283 | + module.exports = { add }; |
| 284 | + }, |
| 285 | + ... |
| 286 | +} |
| 287 | + |
| 288 | +module.exports = __webpack_require__(0) |
| 289 | +``` |
| 290 | + |
| 291 | +Webpack 首先会为所有会为所有模块提供一个 cjs 外壳代码,并且注入 模块挂载点(module, exports),模块引入函数(require)。这与 node.js 的模块加载行为完全一致,可以说 Webpack 十分适合构建 Node/Electron 应用,这个场景天然优于使用 Vite(Rollup) 构建。 |
| 292 | + |
| 293 | +### 重头戏 C/C++ 模块 |
| 294 | + |
| 295 | +C/C++ 扩展是为了 Node/Electron 准备的高性能方案。社区对于构建 C/C++ 模块大部分情况下会使用 node-pre-gyp,node-gyp-build 等工具构建成为 .node 文件,然后放到固定的目录结构中,并且提供一个 bindings 工具用于加载 .node 文件,通常它们可能是这样的: |
| 296 | + |
| 297 | +```js |
| 298 | +// node_modules/sqlite3/build/Release/node_sqlite3.node |
| 299 | +require('bindings')('node_sqlite3.node'); |
| 300 | +// node_modules/better-sqlite3/build/Release/better_sqlite3.node |
| 301 | +require('bindings')('better_sqlite3.node'); |
| 302 | +``` |
| 303 | + |
| 304 | +C/C++ 构建的 .node 文件本质上是一个 cjs 模块,无论怎样它都不支持使用 import 语句或者 import() 函数加载,所以我们只能使用 require 函数加载它。<!-- 也就是说我们最好以 cjs 格式构建 bundle.js,但是如果我们使用 esm 格式构建 bundle.js 我们需要使用 createRequire 函数创建一个 require 函数并且加载 .node 文件。-->使用 Webpack 工具构建在兼容性上具有天然的优势,因为它们都基于 cjs 格式。且 Webpack 还具有丰富的插件系统。如果使用 Webpack 将 C/C++ 模块构建成一个 bundle.js 并且以一个 esm 模块格式导出,这既能享受 Webpack 强大成熟的生态系统又能做到兼容 Vite(Rollup)。你可以想到这个方法和上面讲到的 预构建(Pre-Bundling) 思路如出一辙,事实却是如此! |
| 305 | + |
| 306 | +## 为什么是 Webpack |
| 307 | + |
| 308 | +<!-- |
| 309 | +首先我们从上面的说明中关于 C/C++ 模块的支持能总结出两个点: |
| 310 | +
|
| 311 | +1. 必须是 bundle 方案,并且以 cjs 格式构建 |
| 312 | +2. 模块需要被一个模块中心管理,这样才能避免 作用域以及同步/异步 带来的副作用问题 |
| 313 | +--> |
| 314 | + |
| 315 | +你可能已经意识到 Vite 的 Pre-Bundling 的行为与 Webpack 的行为趋同,为什么我们不直接使用 Pre-Bundling 构建 C/C++ 模块,而且它也是基于 cjs 格式的 bundle 方案。主要是 Webpack 的生态优势,这能让我们少做很多事情且低风险。 |
0 commit comments