大家好,我是老纪。
标题不太好取。这里说的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的路径解析错误。最终手动设置环境变量解决了问题。
这次的经历也让我更加深刻地认识到现代前端工具链的复杂性。我们在不断追求简化和提升效率的同时,也得学会面对和解决这些复杂性带来的问题。