logologo

极速DX Vite + Electron + esbuild

ViteElectronesbuild技术分享

Jul 9, 2021 · 20min

Vite 问世后,让我们体验到了极速的开发体验,由于我的好多项目需要用到 Electron,于是我就在想如何将 Vite 和 Electron 集成起来...

# Vite 的背后

Vite 的基本原理大家应该都已经清楚了,基于原生 ESM 的开发服务,只需要在浏览器请求源码时进行转换并按需提供源码。除了这一点,Vite 还依赖于一个非常快速的 js bundler — esbuild。 native-esm-based-dev-server

如果仅使用原生 ESM,实际上我们的许多依赖是没法使用的,它们大多数时候是按照 CommonJS 的格式开发,另外,当一个模块依赖另外一个模块时,浏览器会进行过多的请求,比如你引用 lodash 中的一个方法,结果它由引用了其他的文件,如此反复,浏览器为了获取这个方法可能要进行上百次的请求,所以 Vite 会通过 esbuild 对这些依赖进行整体的转换和打包,而 esbuild 是由 Go 编写,比传统的 js 编写的打包器要快很多。(大概如下图这么快) esbuild

通过 esbuild 的快速预构建,让这些模块符合 ESM 格式,并且进行了捆绑,这样浏览器只需要少量的请求就可以获取到源码。

# 让 Electron 的构建也快起来

在开发模式下,前端部分我们可以用 Vite 快速启动服务,然后让 Electron 的主进程去加载对应的 localhost 地址。这里有一个问题,我们用什么来构建 Electron 呢?

首先要说明一点,我们在这里全部使用 Typescript 进行开发,无论是主进程还是渲染进程。所以 Electron 主进程代码的转换打包也是必不可少的。 正常情况下,主进程构建我们会用 webpack 来完成,然后在 node 环境下让主进程跑起来即可。就像 vue-cli 的项目可以用 vue-cli-plugin-electron-builder 来完成 Electron 的快速集成,其背后也是基于 webpack.

实际上,当你打开 awesome-vite 时确实有几个 Vite + Electron 的项目模板,Electron 部分基本上是通过 rollup 进行转换打包,rollup 倒是也不错,整体速度也有所提升,甚至你还可以用 rollup-plugin-esbuild 这样的插件集成 esbuild 的极速优势。但是我这里却遇到了一些麻烦,因为我的项目中在主进程部分使用了不少装饰器和元数据反射,所以在 tsconfig.json 中必须开启 experimentalDecoratorsemitDecoratorMetadata,不幸的是,esbuild 不支持 emitDecoratorMetadata[不支持],所以 rollup-plugin-esbuild 插件没法用了。我也尝试着不使用 esbuild,而使用 @rollup/plugin-typescript, 可是速度却降低了很多,特别是在项目大了以后。

# 抛弃 rollup

尽管 esbuild 暂时不支持 emitDecoratorMetadata,但是作者在 issues#915 给出了解决办法,即先判断一下 Typescript 文件中是否包含装饰器(利用正则表达式),如果包含装饰器,则使用 tsc 对这些文件处理,至于其他不包含装饰器的文件依然由 esbuild 来出来,这样可以说是目前最快速的方法。这确实是一个好思路,也许我也可以在 rollup 里这样做,写一个 rollup 插件,预先处理一下这些包含装饰器的文件…

等等 😓,为什么我一定要使用 rollup 来构建主进程?虽然 esbuild 在构建打包的功能还有待加强,特别是代码分割,CSS 处理方面[待加强],灵活性也没有 rollup 强,可是用来构建 Electron 主进程,这些东西其实都没有必要,我需要的只是将 Typescript 编写的代码进行转换,解析依赖,打包成 cjs 格式的代码即可,esbuild 完全可以胜任。现在只要解决 emitDecoratorMetadata 问题就行了,幸运的是,沿着上面的 issues 查找,我找到了对应的插件 esbuild-decorators,需要说明一下,目前 esbuild 的插件只能在 build 中使用,transform 中不支持。

# 代码细节

现在整体的思路已经理清,可以开始编写构建脚本了。

# Vite 部分

前端部分其实不需要太大的修改,毕竟 Vite 都已经帮我们封装好了,唯一要做的也许只是目录结构的调整(你也可以不调整,看你的规范程度 😝),将前端的代码都放到 src/render 目录下,然后修改一下 vite.config.ts:

import { join } from 'path'
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import dotenv from 'dotenv'
dotenv.config({ path: join(__dirname, '.env') })

export default defineConfig({
  root: join(__dirname, 'src/render'), // 现在的 root 在 src/render 下了
  plugins: [vue()],
  resolve: {
    alias: {
      /* ...路径调整了,alias也需要对应的修改 */
    },
  },
  base: './',
  build: {
    outDir: join(__dirname, 'dist/render'), // 输出路径
    emptyOutDir: true,
  },
  server: {
    port: +process.env.PORT,
  },
})

# Electron 部分

重点是编写 Electron 主进程的构建脚本,这里我设置了 src/main 作为主进程代码的目录,src/main/index.ts 作为入口。构建脚本放置在 script 目录下。

以下是 esbuild 的一些配置选项:

// esbuild.options.ts
import { join } from 'path'
import { builtinModules } from 'module'
import { esbuildDecorators } from '@anatine/esbuild-decorators'
import type { BuildOptions } from 'esbuild'

export function createOptions(): BuildOptions {
  return {
    entryPoints: [join(__dirname, '../src/main/index.ts')],
    outfile: join(__dirname, '../dist/main/index.js'),
    format: 'cjs',
    bundle: true,
    platform: 'node',
    plugins: [
      esbuildDecorators({
        tsconfig: join(__dirname, '../tsconfig.json'),
      }),
    ],
    external: [...builtinModules.filter(x => !/^_|^(internal|v8|node-inspect)\/|\//.test(x)), 'electron'],
  }
}

构建脚本build.ts:

import { join } from 'path'
import type { ChildProcess } from 'child_process'
import { spawn } from 'child_process'
import dotenv from 'dotenv'
import electron from 'electron'
import minimist from 'minimist'
import waitOn from 'wait-on'
import { build } from 'esbuild'
import { main } from '../package.json'
import { createOptions } from './esbuild.options'
dotenv.config({ path: join(__dirname, '../.env') })

const argv = minimist(process.argv.slice(2))
const options = createOptions()

const runApp = () => {
  return spawn(electron as any, [join(__dirname, `../${main}`)], { stdio: 'inherit' })
}

if (argv.watch) {
  waitOn(
    {
      resources: [`http://localhost:${process.env.PORT}/index.html`],
      timeout: 5000,
    },
    (err: any) => {
      if (err) {
        console.log(err)
        process.exit(1)
      }
      else {
        let child: ChildProcess
        build({
          ...options,
          watch: {
            onRebuild(error) {
              if (error) {
                console.error('Rebuild Failed:', error)
              }
              else {
                console.log('Rebuild Succeeded')
                if (child)
                  child.kill()
                child = runApp()
              }
            },
          },
        }).then(() => {
          if (child)
            child.kill()
          child = runApp()
        })
      }
    }
  )
}
else {
  build(options)
    .then(() => {
      console.log('Electron Build Succeeded.')
    })
    .catch((error) => {
      console.log('Electron Build Failed\n', error, '\n')
    })
}

更多的代码细节可以查看我新建的模板项目 fast-vite-electron

# ⚡ 极速 DX

随着前端工程化的进一步推进,我们不仅仅要提升 UX,对于自身来说 DX 也非常重要。项目越来越大,越来越复杂,整个构建过程的时间也越来越长。开玩笑地说,或许在上班时,还可以为我们提供一些摸鱼时间,但是当开发自己的项目时,我依然想进一步的压缩这些时间。第一次体验到 Vite 的时候,我真的惊呆了,居然可以做到如此迅速的开发启动。随着 esbuild, swc 这样的高效率语言开发的工具加入,这种极速的追求还将继续。

CC BY-NC-SA 4.0 2021 © Archer Gu