Electron Node端使用Vite API打包的坑

493 阅读7分钟

大家好,我是老纪。

标题不太好取。这里说的Vite API打包,指的不是Electron+前端使用Vite构建的模板工程,而是在Node.js端调用Vite官方的JavaScript API对第三方仓库进行打包。

背景是我们在开发一个编辑器,内部有个插件系统,自定义了一些规则,目前这些插件基本是使用Vue3+JavaScript开发,在编辑器加载插件时,调用RollupAPI将之编译打包为相应的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)

版本已经升无可升,去掉CommonJSPostCSS后,又是其它的错误,排列组合搞了半天,搞的脑壳都快爆了。想起Deno嘲讽Node.js的话,我们都在沦为配置工程师!

心烦意乱之际,突发奇想,为什么不直接使用Vite打包呢?我们现在的工程就是用的Electron+ViteVite将这些细节都屏蔽了,如果直接调用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");

在插件的代码中,使用了图片、SassTypeScriptVueCSS等,构建的产物(多入口文件生成多个编译后的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多半脱不了干系。

Electronasar是一种打包格式,类似于 ZIP 文件,用于将应用程序的资源文件(如 HTML、CSS、JavaScript 等)打包成一个单一的文件,以便于分发和加载。这样可以提高应用启动速度,并使资源管理更加方便。 上次遇到它,是在《抽丝剥茧:Electron与Node.js的奇葩Bug》这篇文章里,由于Electron重写了fs的相关方法,在遇到asar打包时,没有及时支持新增的递归参数recursive,使用了util.promisify而非fs/promises来替换异步的API,导致了一个蛋疼的Bug,虽然最终锅是Node.js的。

这次的问题依然是它引起的。EsbuildensureServiceIsRunning方法报出的错误,代码是这段:

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为例,右键显示包内容: image.png

就是传统的目录结构,进入Contentsimage.png

再到Resources

这里的app.asar就是asar压缩后的产物,本质上就是个zip包,它里面包含了前端的Web构建产物和手动添加的静态资源。

我们可以使用asar命令来解压:

npx asar extract app.asar app_unpacked

我们看到还有node_modules,它其实包含了dependencies的内容: image.png

对于我们这个功能而言,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

为什么ViteAPI会调用Esbuild呢?这里再简单科普下。

Vite整合了EsbuildRollup,在上层做了一次抽象。开发阶段使用Esbuild进行依赖预构建,对模块进行转换,产出ESMWeb端使用,得益于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,但遇到了各式各样的问题,一时搞不定,索性直接替换为ViteJavaScript API。尽管Vite成功简化了插件的构建过程,但在实际使用中遇到了Electron打包后的asar问题,导致了Esbuild的路径解析错误。最终手动设置环境变量解决了问题。

这次的经历也让我更加深刻地认识到现代前端工具链的复杂性。我们在不断追求简化和提升效率的同时,也得学会面对和解决这些复杂性带来的问题。