从esbuild开始,大幅提升Vue库构建速度

1,979 阅读2分钟

Vue3 来了,Vue3 + Vite这套组合拳大家也津津乐道。

提起 Vite 2.0,我们熟知就是快,而快基于 esbuild,数据表明要快百倍以上,我自己感受呢,还不止。

考虑 esbuild 比较新,Vite 基于 esbuild 也只用于开发时,构建生产环境还是比较熟悉的 Rollup 模式,所以大家平时也接触不到。

以下是重点,写组件库的家人们,在转为 esm 版本时,esbuild 是可用的。

以我在写的 Vue3 组件库为例子(最小颗粒度说明):

0. 回顾老版本

老版本是参考了当时 Element UI 库的,因为入口文件太多的问题,为了防止超时,以4个一组开子进程跑。构建方案用的 rollup。每跑一次的时间呢,我觉得喝个下午茶都够了,在 Github Action 上面大概是6分钟。

1. 从安装开始

npm i -D esbuild @vue/compiler-sfc

说一下 @vue/compiler-sfc 这个,这就是解析 .vue 文件的模块。

2. 写个 script

const { resolve } = require('path')
const { build } = require('esbuild')

const pkg = require('../package.json')
const tss = require('./ts-files.json')

const deps = Object.keys(pkg.dependencies)

const runBuild = async () => {
  const entryPoints = {}

  tss.forEach(name => {
    entryPoints[name] = resolve(__dirname, `../src/${name}.ts`)
  })

  await build({
    entryPoints: entryPoints,
    external: ['vue', '../*', './*', ...deps],
    outdir: `es/`,
    format: 'esm',
    bundle: true,
    target: ['es2018']
  })
}

runBuild()

其中 tss 就是所有要打包的文件列表,esm 模式需要全部单独打包,我这有100多个,性能差距可以特别明显。

3. 写个 vue 文件处理插件

3.1 了解 esbuild 插件原理

个人感觉 esbuild 插件比起 webpackrollup 还是简单不少,其中最核心的就是 onResolveonLoad 两个方法:

const vuePlugin = options => { 
  name: "vue",
  setup(build) { 
    // .... 
  }
}

其中 name 就是插件的名称,setup 则是写主要处理逻辑的,options 传入一些配置参数的,往下看两个核心方法:

// filter 可以过滤不需要的引用地址,如果下面就是只要 .vue 文件
build.onResolve({ filter: /\.vue$/ }, args => {
  return {
    path: args.path, // 重定向路径可用,这里不需要,原路径返回
    namespace: 'vue', // 告知 vue 插件也就是自身去处理
    pluginData: { resolveDir: args.resolveDir } // 插件传递参数
  }
})
// 对读取的内容
build.onLoad({ filter: /\.vue$/, namespace: 'vue' }, async args => {

  const filePath = path.resolve(args.pluginData.resolveDir, args.path)
  // 读取vue文件
  const content = await fs.promises.readFile(filePath, 'utf8')
  // 处理 .vue文件 具体逻辑可以往下看
  ...

  return {
    contents, // 返回处理后的代码
    resolveDir,
    loader: isTS ? 'ts' : 'js' // 告知 esbuild 用哪个loader 构建返回的代码
  }
})

3.2 处理 .vue 文件

举个简单的例子 hello.vue :

<template>
   <div>{{ hello }}</div>
</template>

<script lang="ts">
import { ref, defineComponent } from 'vue'

export default defineComponent({
  name: "hello",
  setup() {
     const hello = ref('hello world')
     
     return {
       hello
     }
  }
});
</script>

构建后的 hello.js :

import { ref, defineComponent } from 'vue'

var script = defineComponent({
  name: "hello",
  setup() {
     const hello = ref('hello world')
     
     return {
       hello
     }
  }
});

import { resolveComponent, openBlock ... } from "vue"

function render(_ctx, _cache) {
    return ...
}

script.render = render

export default script

原来是在 <script> 的默认导出中加个通过 <template> 内容转化后 render 函数。那 .vue 转 .ts 就可以这么做:

// 读取vue文件
const content = await fs.promises.readFile(filePath, 'utf8')
// 通过 @vue/compiler-sfc 解析 vue 文件区分 script 和 template,当然还有样式啥的本文不聊
const sfc = compiler.parse(content)
// 判断是否 lang="ts" 
const isTS = sfc.descriptor.script?.lang === 'ts'

// 开始组合成新ts
let contents = ``

// 读取 <script> 标签的内容 
if (sfc.descriptor.script) {
  // "export default" 改为 "var script = " 方便加入 render 函数
  contents += compiler.rewriteDefault(
    sfc.descriptor.script.content,
    'script'
  )
} else {
  contents += `var script = {};`
}

// 读取 <template> 标签的内容 
if (sfc.descriptor.template) {
  // 将我们熟知的模板转为 js 形式
  contents += compiler.compileTemplate({
    id: md5(filePath),
    source: sfc.descriptor.template.content,
    filename: filePath,
    isProd:
      process.env.NODE_ENV === 'production' ||
      process.env.BUILD === 'production',
    slotted: sfc.descriptor.slotted
  }).code

// 把 render 函数加入到 script 中
  contents += `
  script.render = render;
  `
}

// 上面把 export default 替换掉了,下面当然要加回来
contents += `
  export { script as default };
  `

这里没有写对 script setup 的处理,因为他依赖 @vue/compiler-sfc 一个比较核心的方法 compileScript ,需要多一层转化。

最终完整的插件代码可以 查看Github

4. 完整的 build

把上面写的插件引入之前写的 script :

// build.components.js

const { resolve } = require('path')
const { build } = require('esbuild')
const vuePlugin = require('./esbuild-plugin-vue')

const pkg = require('../package.json')
const tss = require('./ts-files.json')

const deps = Object.keys(pkg.dependencies)

const runBuild = async () => {
  const entryPoints = {}

  tss.forEach(name => {
    entryPoints[name] = resolve(__dirname, `../src/${name}.ts`)
  })

  await build({
    plugins: [vuePlugin()],
    entryPoints: entryPoints,
    external: ['vue', '../*', './*', ...deps],
    outdir: `es/`,
    format: 'esm',
    bundle: true,
    target: ['es2018']
  })
}

runBuild()

执行一下 node build.components.js,100多个文件2秒完成,欣慰啊。

5. 加入类型声明

esbuild 快的缺点就是构建没有类型声明,既然这么快了,就自己打包类型声明吧。

5.1 安装依赖

pnpm i -D vue-tsc vite

看到这两是不是很熟悉,没错就是使用 Vite 时看到那句熟悉的 vue-tsc --noEmit && vite build

vue-tsctsc 基础上加入对 .vue 文件的解析,所以使用方法可以参考 tsc

5.2 编写 tsconfig.json

再来写一个生成声明文件的 tsconfig.json。

# tsconfig.declaration.json

{
  "compilerOptions": {
    "target": "es2018",
    "useDefineForClassFields": true,
    "module": "esnext",
    "moduleResolution": "node",
    "strict": true,
    "jsx": "preserve",
    "sourceMap": true,
    "resolveJsonModule": true,
    "esModuleInterop": true, // 如果用到一些第三方库需要设置为 true
    "lib": ["esnext", "dom"],
    "types": ["vite/client"], // 这里就用到 vite 了,主要是用 vite 的全局声明来帮助 tsc 识别模板
    "declaration": true, // 输出声明文件
    "emitDeclarationOnly": true, // 只要声明文件
    "declarationDir": "./es", // 声明文件输出目录,跟上面构建对应
  },
  "files": ["./src/index.ts"], // 入口文件,因为我的index.ts有引入所有组件,所以没写一大列
  "include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue"] // 涵盖解析的范围
}

5.3 package.json 加个 script

{
  "scripts": {
    "build:declaration": "vue-tsc --project tsconfig.declaration.json",
  }
}

执行

npm run build:declaration

声明 Done。