大家好,我是老纪。
标题不太好取。这里说的Vite API
打包,指的不是Electron
+前端使用Vite
构建的模板工程,而是在Node.js
端调用Vite
官方的JavaScript API
对第三方仓库进行打包。
背景是我们在开发一个编辑器,内部有个插件系统,自定义了一些规则,目前这些插件基本是使用Vue3
+JavaScript
开发,在编辑器加载插件时,调用Rollup
的API
将之编译打包为相应的JavaScript
脚本,再在Web端加载使用。而在2024年的今天,居然不支持TypeScript
,简直是原罪,所以必须支持。
然后问题来了。
Rollup
的构建过程中加载了一大波插件:
import vue from 'rollup-plugin-vue'
import commonjs from '@rollup/plugin-commonjs'
import image from '@rollup/plugin-image'
import json from '@rollup/plugin-json'
import resolve from '@rollup/plugin-node-resolve'
import replace from '@rollup/plugin-replace'
import alias from '@rollup/plugin-alias'
import terser from '@rollup/plugin-terser'
import postcss from 'rollup-plugin-postcss'
import outputManifest from 'rollup-plugin-output-manifest'
按照正常操作,使用插件@rollup/plugin-typescript
就能搞定,但不太确定是哪个环节的问题,添加了插件后各种错误:
Error: [object Object] is not a PostCSS plugin
at Processor.normalize (/Users/jw/wk/gitlab/electron/editor/node_modules/.pnpm/postcss@5.2.18/node_modules/postcss/lib/processor.js:145:15)
at new Processor (/Users/jw/wk/gitlab/electron/editor/node_modules/.pnpm/postcss@5.2.18/node_modules/postcss/lib/processor.js:51:25)
at Object.postcss [as default] (/Users/jw/wk/gitlab/electron/editor/node_modules/.pnpm/postcss@5.2.18/node_modules/postcss/lib/postcss.js:73:10)
at /Users/jw/wk/gitlab/electron/editor/node_modules/.pnpm/rollup-plugin-postcss@4.0.2_postcss@5.2.18/node_modules/rollup-plugin-postcss/dist/index.js:322:55
at Generator.next (<anonymous>)
at asyncGeneratorStep (/Users/jw/wk/gitlab/electron/editor/node_modules/.pnpm/rollup-plugin-postcss@4.0.2_postcss@5.2.18/node_modules/rollup-plugin-postcss/dist/index.js:29:24)
at _next (/Users/jw/wk/gitlab/electron/editor/node_modules/.pnpm/rollup-plugin-postcss@4.0.2_postcss@5.2.18/node_modules/rollup-plugin-postcss/dist/index.js:51:9)
版本已经升无可升,去掉CommonJS
或PostCSS
后,又是其它的错误,排列组合搞了半天,搞的脑壳都快爆了。想起Deno
嘲讽Node.js
的话,我们都在沦为配置工程师!
心烦意乱之际,突发奇想,为什么不直接使用Vite
打包呢?我们现在的工程就是用的Electron
+Vite
,Vite
将这些细节都屏蔽了,如果直接调用Vite
来打包,就不用安装这一堆Rollup
插件了。
Vite API
说干就干。
我们来到Vite
官网,找到 JavasSript API 部分,关于构建有一个函数build
,这是一段示例:
import path from 'node:path'
import { fileURLToPath } from 'node:url'
import { build } from 'vite'
const __dirname = fileURLToPath(new URL('.', import.meta.url))
await build({
root: path.resolve(__dirname, './project'),
base: '/foo/',
build: {
rollupOptions: {
// ...
},
},
})
看起来平平无奇,参数基本上就是vite.config.ts
的内容。
我随便找了一个我们的插件,写了一段覆盖原来Rollup
构建功能的测试代码:
import { build } from "vite";
import path from "path";
import vue from "@vitejs/plugin-vue";
import cssInjectedByJsPlugin from "vite-plugin-css-injected-by-js";
import { fileURLToPath } from "node:url";
const __dirname = fileURLToPath(new URL(".", import.meta.url));
const isProduction = true;
const config = {
base: "./",
build: {
outDir: "./dist",
sourcemap: !isProduction,
lib: {
entry: {
index: path.resolve(__dirname, ".dynamic/index.js"),
main: path.resolve(__dirname, ".dynamic/main.js"),
},
formats: ["es"],
// fileName: (format, entry) => `${entry}.js`,
},
minify: isProduction,
rollupOptions: {
external: ["vue"],
output: {
entryFileNames: isProduction
? `[name].[hash].min.js`
: `[name].[hash].js`
},
},
manifest: true,
},
plugins: [vue(), cssInjectedByJsPlugin()],
esbuild: {
keepNames: true,
target: ["esnext"],
},
};
await build(config);
console.log("Build completed");
在插件的代码中,使用了图片、Sass
、TypeScript
、Vue
、CSS
等,构建的产物(多入口文件生成多个编译后的ESM
文件,没有合并)与原先基本一致,符合我的预期。
喜出望外的我,雷厉风行地开始了我的改造计划,直到遇到一个巨坑。
Electron的asar
在开发阶段,一切OK,而打包之后,报了这么一个错:
spawn ENOTDIR
at ChildProcess.spawn (node:internal/child_process:421:11)
at Object.spawn (node:child_process:776:9)
at ensureServiceIsRunning (/Users/jw/wk/gitlab/electron/editor/release/v0.0.43_darwin/mac-arm64/Editor.app/Contents/Resources/app.asar/node_modules/esbuild/lib/main.js:2145:29)
at transform (/Users/jw/wk/gitlab/electron/editor/release/v0.0.43_darwin/mac-arm64/Editor.app/Contents/Resources/app.asar/node_modules/esbuild/lib/main.js:2038:37)
at transformWithEsbuild (file:///Users/jw/wk/gitlab/electron/editor/release/v0.0.43_darwin/mac-arm64/Editor.app/Contents/Resources/app.asar/node_modules/vite/dist/node/chunks/dep-bb8a8339.js:13935:30)
at Object.renderChunk (file:///Users/jw/wk/gitlab/electron/editor/release/v0.0.43_darwin/mac-arm64/Editor.app/Contents/Resources/app.asar/node_modules/vite/dist/node/chunks/dep-bb8a8339.js:14052:31)
at file:///Users/jw/wk/gitlab/electron/editor/release/v0.0.43_darwin/mac-arm64/Editor.app/Contents/Resources/app.asar/node_modules/rollup/dist/es/shared/node-entry.js:18598:40
从错误名称(不是个文件夹)与堆栈路径上(app.asar/node_modules/esbuild/lib/main.js
)上看,与我们的老朋友asar
多半脱不了干系。
Electron
的asar
是一种打包格式,类似于 ZIP 文件,用于将应用程序的资源文件(如 HTML、CSS、JavaScript 等)打包成一个单一的文件,以便于分发和加载。这样可以提高应用启动速度,并使资源管理更加方便。
上次遇到它,是在《抽丝剥茧:Electron与Node.js的奇葩Bug》这篇文章里,由于Electron
重写了fs
的相关方法,在遇到asar
打包时,没有及时支持新增的递归参数recursive
,使用了util.promisify
而非fs/promises
来替换异步的API
,导致了一个蛋疼的Bug
,虽然最终锅是Node.js
的。
这次的问题依然是它引起的。Esbuild
的ensureServiceIsRunning
方法报出的错误,代码是这段:
var ensureServiceIsRunning = () => {
if (longLivedService)
return longLivedService;
let [command, args] = esbuildCommandAndArgs();
let child = child_process.spawn(command, args.concat(`--service=${"0.19.5"}`, "--ping"), {
windowsHide: true,
stdio: ["pipe", "pipe", "inherit"],
cwd: defaultWD
});
...
}
逻辑很简单,就是在子进程里调用esbuild
的二进制文件,执行一个命令。这里涉及到路径的有两处,一个是defaultWD
,一个是esbuild
二进制文件的存储地址,后来证明问题出在后者。
esbuildCommandAndArgs
中有不少细分逻辑,去除无效代码后大体是这样的:
var esbuildCommandAndArgs = () => {
if (false) {
return ["node", [path2.join(__dirname, "..", "bin", "esbuild")]];
} else {
const { binPath, isWASM } = generateBinPath();
if (isWASM) {
return ["node", [binPath]];
} else {
return [binPath, []];
}
}
};
function generateBinPath() {
if (isValidBinaryPath(ESBUILD_BINARY_PATH)) {
if (!fs.existsSync(ESBUILD_BINARY_PATH)) {
console.warn(`[esbuild] Ignoring bad configuration: ESBUILD_BINARY_PATH=${ESBUILD_BINARY_PATH}`);
} else {
return { binPath: ESBUILD_BINARY_PATH, isWASM: false };
}
}
const { pkg, subpath, isWASM } = pkgAndSubpathForCurrentPlatform();
let binPath;
try {
binPath = require.resolve(`${pkg}/${subpath}`);
} catch (e) {
binPath = downloadedBinPath(pkg, subpath);
...
}
...
return { binPath, isWASM };
}
这个bin
在哪里呢?找到打包后的文件,以MacOS
为例,右键显示包内容:
就是传统的目录结构,进入Contents
:
再到Resources
:
这里的app.asar
就是asar
压缩后的产物,本质上就是个zip
包,它里面包含了前端的Web构建产物和手动添加的静态资源。
我们可以使用asar
命令来解压:
npx asar extract app.asar app_unpacked
我们看到还有node_modules
,它其实包含了dependencies
的内容:
对于我们这个功能而言,esbuild
藏在app.asar.unpacked
这个目录下:
$ tree ./app.asar.unpacked
.
└── node_modules
├── @esbuild
│ └── darwin-arm64
│ ├── bin
│ │ └── esbuild
│ └── package.json
└── esbuild
├── LICENSE.md
├── bin
│ └── esbuild
├── install.js
├── lib
│ └── main.js
└── package.json
本来要设置electron-builder.config.cjs
的配置项:
asarUnpack: ['node_modules/esbuild'],
但现在本身就被放置在这里,就不处理了。具体原因我没有细查,安装Vite
是这样的,而单独安装Esbuild
则不然。
因为在generateBinPath
里会先判断ESBUILD_BINARY_PATH
是否存在:
function generateBinPath() {
if (isValidBinaryPath(ESBUILD_BINARY_PATH)) {
if (!fs.existsSync(ESBUILD_BINARY_PATH)) {
console.warn(`[esbuild] Ignoring bad configuration: ESBUILD_BINARY_PATH=${ESBUILD_BINARY_PATH}`);
} else {
return { binPath: ESBUILD_BINARY_PATH, isWASM: false };
}
}
// ...
}
而ESBUILD_BINARY_PATH
其实优先读取的是环境变量:
var ESBUILD_BINARY_PATH =
process.env.ESBUILD_BINARY_PATH || ESBUILD_BINARY_PATH;
值得注意的是,这行代码是随着esbuild
的代码立即执行的,所以,解决思路是,在加载Esbuild
,也就是加载Vite
之前,先手动将环境变量设置为这个目录下的bin
文件:
process.env.ESBUILD_BINARY_PATH = path.join(
app.getAppPath(),
'../app.asar.unpacked/node_modules',
'esbuild/bin/esbuild'
);
而我们这段代码,只能放在Node.js
加载Vite
之前,最简单是放在第一个文件。
我们新建一个init.ts
:
import { app } from 'electron'
import path from 'node:path'
import os from 'node:os'
if (app.isPackaged) {
const esbuildPath = os.type() === 'Windows_NT' ? '@esbuild/win32-x64/esbuild.exe' : 'esbuild/bin/esbuild'
process.env.ESBUILD_BINARY_PATH = path.join(app.getAppPath(), '../app.asar.unpacked/node_modules', esbuildPath)
}
在Node.js
端的入口文件index.ts
里第一个引入:
import './init' // 必须在最前面引入
// 其它引入
import WinApp from './WinApp'
打完收工!
后续
问题虽然解决了,但后续还有些小问题,也花了些时间处理。
报错1
编辑器打包之后,在运行过程中会报错:
[unhandledRejection] Error: EBADF: bad file descriptor, lstat '/dev/fd/114'
应该是某个文件的读取有问题(可能读取的时候刚好被删除了),但找了半天代码,没确定是哪段引起的,也没看出有什么影响,先把这错过滤掉了。未来不知道会不会有坑。
报错2
某个插件编译时报错:
[2024-08-06 17:23:27.801] [error]
build plugin error SyntaxError: At least one <template> or <script> is required in a single file component.
at Object.parse$2 [as parse] (/Users/jw/wk/gitlab/editor/MyProject01/plugins/asdsad/node_modules/.pnpm/@vue+compiler-sfc@3.4.32/node_modules/@vue/compiler-sfc/dist/compiler-sfc.cjs.js:1920:7)
at createDescriptor (/Users/jw/wk/test/editor/node_modules/.pnpm/@vitejs+plugin-vue@4.4.1_vite@4.5.0_@types+node@20.9.0_less@4.2.0_sass@1.69.5_terser@5.31.0___dc3b76mgj5h2cvzzpqiwzlipnq/node_modules/@vitejs/plugin-vue/dist/index.cjs:86:43)
at transformMain (/Users/jw/wk/test/editor/node_modules/.pnpm/@vitejs+plugin-vue@4.4.1_vite@4.5.0_@types+node@20.9.0_less@4.2.0_sass@1.69.5_terser@5.31.0___dc3b76mgj5h2cvzzpqiwzlipnq/node_modules/@vitejs/plugin-vue/dist/index.cjs:2302:34)
at Object.transform (/Users/jw/wk/test/editor/node_modules/.pnpm/@vitejs+plugin-vue@4.4.1_vite@4.5.0_@types+node@20.9.0_less@4.2.0_sass@1.69.5_terser@5.31.0___dc3b76mgj5h2cvzzpqiwzlipnq/node_modules/@vitejs/plugin-vue/dist/index.cjs:2848:16)
at file:///Users/jw/wk/test/editor/node_modules/.pnpm/rollup@3.29.4/node_modules/rollup/dist/es/shared/node-entry.js:25544:40
at process.processTicksAndRejections (node:internal/process/task_queues:95:5)
看错误是识别Vue
文件失败了,这就很奇怪,其它插件都没毛病,为何汝独秀?
断点追踪,发现原因是:我在这个工程做测试,添加了vite.config.ts
配置文件,使用Vite
构建打包;而现在编辑器的Vite
命令执行时读取到了插件工程的vite.config.ts
,与默认配置进行了合并处理,@vitejs/plugin-vue
插件重复了,导致第二次插件拿到的数据流已经是编译过的,不是合理的Vue
格式,所以报错了。
报错3
为了排查问题,我曾经搞了一个小的Electron
工程,内部调用Esbuild API
(就是上面那段ensureServiceIsRunning
的代码)。但这个工程仍然是ENOTDIR
错误:
[2024-07-31 10:48:18.343] [error]
Error: spawn ENOTDIR
at ChildProcess.spawn (node:internal/child_process:421:11)
at Object.spawn (node:child_process:776:9)
at ensureServiceIsRunning (/Users/jw/wk/github/electron/electron-vite-vue/release/28.1.0/mac-arm64/YourAppName.app/Contents/Resources/app.asar/node_modules/esbuild/lib/main.js:1976:29)
at build (/Users/jw/wk/github/electron/electron-vite-vue/release/28.1.0/mac-arm64/YourAppName.app/Contents/Resources/app.asar/node_modules/esbuild/lib/main.js:1874:26)
at T (file:///Users/jw/wk/github/electron/electron-vite-vue/release/28.1.0/mac-arm64/YourAppName.app/Contents/Resources/app.asar/dist-electron/main/index.js:117:19)
at Timeout._onTimeout (file:///Users/jw/wk/github/electron/electron-vite-vue/release/28.1.0/mac-arm64/YourAppName.app/Contents/Resources/app.asar/dist-electron/main/index.js:131:44)
at listOnTimeout (node:internal/timers:573:17)
at process.processTimers (node:internal/timers:514:7)
吊诡的是,将import
引入修改到下面就好了:
- import {build as esbuild} from 'esbuild'
async function build() {
try {
+ const {build: esbuild} = await import("esbuild");
let result = await esbuild({
entryPoints: [
"build/test.ts",
],
bundle: true,
outdir: "dist",
});
console.log(result);
mainLog.info(result);
} catch (error) {
mainLog.error(error);
}
}
我好奇,这么神奇吗?怎么做到的?
再一细看, 这个测试项目的打包,它的Node.js
端代码也使用Vite
合并成一个文件了,修改前的main/index.ts
编译后这段代码是这样的:
import { build as L } from "esbuild";
n.isPackaged && (process.env.ESBUILD_BINARY_PATH = i.join(
n.getAppPath(),
"../app.asar.unpacked/node_modules",
"esbuild/bin/esbuild"
));
我们注入的代码被放在了import
后面,原本可以通过import
立即执行的代码,顺序变了,这就意味着环境变量的设置晚了,没有起到作用。
所以,我一般不建议Node.js
端代码打包合并,可能会衍生一些边界问题。
TIPS
为什么Vite
的API
会调用Esbuild
呢?这里再简单科普下。
Vite
整合了Esbuild
和Rollup
,在上层做了一次抽象。开发阶段使用Esbuild
进行依赖预构建,对模块进行转换,产出ESM
供Web
端使用,得益于Esbuild
的高性能(底层使用Go
语言编写),大大提高了开发体验。生产环境构建则使用Rollup
,因为Rollup
有丰富的插件生态系统,又是个经过长期实践检验的成熟构建工具,而Esbuild
经过至少四五年的迭代,在代码切割、CSS处理方面还差点儿意思,到现在(2024年8月)还没有发布1.0
版本,真沉得住气啊。
正是这种开发和生产环境构建工具的不一致,不少人担心Vite
在复杂的场景下会有隐秘的Bug
,而针对大家的顾虑,尤雨溪表示用Rust
开发的统一这两个环节的Rolldown
已经在路上了……
对于我们这个项目而言,调用Vite API
进行打包,其实更合理是禁用Esbuild
,完全使用Rollup
打包,但那样又回到原来的老路,还得添加额外的插件支持TypeScript
,又有了各式各样的报错,为简单起见(实在不想再研究乱七八糟的配置了),我还是直接用默认的Esbuild
了。毕竟我们的插件会应用于生产环境的代码仅有JavaScript
,又不用考虑浏览器兼容性、代码拆分等问题,这正是Esbuild
的传统强项,用了也不怕。
总结
我们目前开发的Electron
项目针对插件打包,调用的Rollup
的API,由于需要支持TypeScript
,但遇到了各式各样的问题,一时搞不定,索性直接替换为Vite
的JavaScript API
。尽管Vite
成功简化了插件的构建过程,但在实际使用中遇到了Electron
打包后的asar
问题,导致了Esbuild
的路径解析错误。最终手动设置环境变量解决了问题。
这次的经历也让我更加深刻地认识到现代前端工具链的复杂性。我们在不断追求简化和提升效率的同时,也得学会面对和解决这些复杂性带来的问题。