浅谈 Vite 2.0 原理,依赖预编译,插件机制是如何兼容 Rollup 的?

2021-03-02 11:36:19 浏览数 (1)

.markdown-body{word-break:break-word;line-height:1.75;font-weight:400;font-size:15px;overflow-x:hidden;color:#333}.markdown-body h1,.markdown-body h2,.markdown-body h3,.markdown-body h4,.markdown-body h5,.markdown-body h6{line-height:1.5;margin-top:35px;margin-bottom:10px;padding-bottom:5px}.markdown-body h1{font-size:30px;margin-bottom:5px}.markdown-body h2{padding-bottom:12px;font-size:24px;border-bottom:1px solid #ececec}.markdown-body h3{font-size:18px;padding-bottom:0}.markdown-body h4{font-size:16px}.markdown-body h5{font-size:15px}.markdown-body h6{margin-top:5px}.markdown-body p{line-height:inherit;margin-top:22px;margin-bottom:22px}.markdown-body img{max-width:100%}.markdown-body hr{border:none;border-top:1px solid #ddd;margin-top:32px;margin-bottom:32px}.markdown-body code{word-break:break-word;border-radius:2px;overflow-x:auto;background-color:#fff5f5;color:#ff502c;font-size:.87em;padding:.065em .4em}.markdown-body code,.markdown-body pre{font-family:Menlo,Monaco,Consolas,Courier New,monospace}.markdown-body pre{overflow:auto;position:relative;line-height:1.75}.markdown-body pre>code{font-size:12px;padding:15px 12px;margin:0;word-break:normal;display:block;overflow-x:auto;color:#333;background:#f8f8f8}.markdown-body a{text-decoration:none;color:#0269c8;border-bottom:1px solid #d1e9ff}.markdown-body a:active,.markdown-body a:hover{color:#275b8c}.markdown-body table{display:inline-block!important;font-size:12px;width:auto;max-width:100%;overflow:auto;border:1px solid #f6f6f6}.markdown-body thead{background:#f6f6f6;color:#000;text-align:left}.markdown-body tr:nth-child(2n){background-color:#fcfcfc}.markdown-body td,.markdown-body th{padding:12px 7px;line-height:24px}.markdown-body td{min-width:120px}.markdown-body blockquote{color:#666;padding:1px 23px;margin:22px 0;border-left:4px solid #cbcbcb;background-color:#f8f8f8}.markdown-body blockquote:after{display:block;content:""}.markdown-body blockquote>p{margin:10px 0}.markdown-body ol,.markdown-body ul{padding-left:28px}.markdown-body ol li,.markdown-body ul li{margin-bottom:0;list-style:inherit}.markdown-body ol li .task-list-item,.markdown-body ul li .task-list-item{list-style:none}.markdown-body ol li .task-list-item ol,.markdown-body ol li .task-list-item ul,.markdown-body ul li .task-list-item ol,.markdown-body ul li .task-list-item ul{margin-top:0}.markdown-body ol ol,.markdown-body ol ul,.markdown-body ul ol,.markdown-body ul ul{margin-top:3px}.markdown-body ol li{padding-left:6px}.markdown-body .contains-task-list{padding-left:0}.markdown-body .task-list-item{list-style:none}@media (max-width:720px){.markdown-body h1{font-size:24px}.markdown-body h2{font-size:20px}.markdown-body h3{font-size:18px}}

前言

Hi,我是 ssh,春节过去了,躁动的心也该收一收,开始新一年的学习了。我目前就职于字节跳动的 Web Infra 团队,目前团队还很缺人(尤其是北京)。

为此我组建了一个氛围特别好的招聘社群,大家在里面尽情的讨论面试相关的想法和问题,也欢迎你加入,随时投递简历给我。

在字节跳动,大家都会自发的研究社区前沿的技术,尝试接入内部的项目并寻找提升开发效率的可能性,这种追求极致敢于创新也是被鼓励的,也有不少同学已经在尝试把新一代的构建工具 Vite 和 snowpack 引入使用。

这篇文章我想简单的聊聊,让尤老师如此专注的 Vite 2.0 到底有哪些亮点。

前几天,尤雨溪在各个社交平台宣布 Vite 2.0 发布了。

看得出他对 Vite 倾注了很多感情,甚至都冷落了 Vue3,停更了两个多月。

相关的中文公告已经有翻译了,可以在尤雨溪的知乎文章:Vite 2.0 发布了中查看。

这篇文章来谈谈 Vite 2.0 的发布中,几个让我比较感兴趣的技术点。

Vite 原理

为什么会出现 Vite?在过去的 Webpack、Rollup 等构建工具的时代,我们所写的代码一般都是基于 ES Module 规范,在文件之间通过 importexport 形成一个很大的依赖图。

这些构建工具在本地开发调试的时候,也都会提前把你的模块先打包成浏览器可读取的 js bundle,虽然有诸如路由懒加载等优化手段,但懒加载并不代表懒构建,Webpack 还是需要把你的异步路由用到的模块提前构建好。

当你的项目越来越大的时候,启动也难免变的越来越慢,甚至可能达到分钟级别。而 HMR 热更新也会达到好几秒的耗时。

Vite 则别出心裁的利用了浏览器的原生 ES Module 支持,直接在 html 文件里写诸如这样的代码:

代码语言:javascript复制
// index.html
<div id="app">div>
<script type="module">
  import { createApp } from 'vue'
  import Main from './Main.vue'

  createApp(Main).mount('#app')
script>
复制代码

Vite 会在本地帮你启动一个服务器,当浏览器读取到这个 html 文件之后,会在执行到 import 的时候才去向服务端发送 Main.vue 模块的请求,Vite 此时在利用内部的一系列黑魔法,包括 Vue 的 template 解析,代码的编译等等,解析成浏览器可以执行的 js 文件返回到浏览器端。

这就保证了只有在真正使用到这个模块的时候,浏览器才会请求并且解析这个模块,最大程度的做到了按需加载。

用 Vite 官网上的图来解释,传统的 bundle 模式是这样的:

而基于 ESM 的构建模式则是这样的:

灰色部分是暂时没有用到的路由,甚至完全不会参与构建过程,随着项目里的路由越来越多,构建速度也不会变慢。

依赖预编译

依赖预编译,其实是 Vite 2.0 在为用户启动开发服务器之前,先用 esbuild 把检测到的依赖预先构建了一遍。

也许你会疑惑,不是一直说好的 no-bundle 吗,怎么还是走启动时编译这条路线了?尤老师这么做当然是有理由的,我们先以导入 lodash-es 这个包为例。

当你用 import { debounce } from 'lodash' 导入一个命名函数的时候,可能你理想中的场景就是浏览器去下载只包含这个函数的文件。但其实没那么理想,debounce 函数的模块内部又依赖了很多其他函数,形成了一个依赖图。

当浏览器请求 debounce 的模块时,又会发现内部有 2 个 import,再这样延伸下去,这个函数内部竟然带来了 600 次请求,耗时会在 1s 左右。

这当然是不可接受的,于是尤老师想了个折中的办法,正好利用 Esbuild 接近无敌的构建速度,让你在没有感知的情况下在启动的时候预先帮你把 debounce 所用到的所有内部模块全部打包成一个传统的 js bundle

Esbuild 使用 Go 编写,并且比以 JavaScript 编写的打包器预构建依赖快 10-100 倍。

httpServer.listen 启动开发服务器之前,会先把这个函数劫持改写,放入依赖预构建的前置步骤,Vite 启动服务器相关代码。

代码语言:javascript复制
// server/index.ts
const listen = httpServer.listen.bind(httpServer)
httpServer.listen = (async (port: number, ...args: any[]) => {
  try {
    await container.buildStart({})
    // 这里会进行依赖的预构建
    await runOptimize()
  } catch (e) {
    httpServer.emit('error', e)
    return
  }
  return listen(port, ...args)
}) as any

runOptimize 相关的代码则在 Github optimizer 中。

首先会根据本次运行的入口,来扫描其中的依赖:

代码语言:javascript复制
let deps: Record<string, string>, missing: Record<string, string>
if (!newDeps) {
  ;({ deps, missing } = await scanImports(config))
}

scanImports 其实就是利用 Esbuild 构建时提供的钩子去扫描文件中的依赖,收集到 deps 变量里,在扫描到入口文件(比如 index.html)中依赖的模块后,形成类似这样的依赖路径数据结构:

代码语言:javascript复制
{
  "lodash-es": "node_modules/lodash-es"
}

之后再根据分析出来的依赖,使用 Esbuild 把它们提前打包成单文件的 bundle。

代码语言:javascript复制
const esbuildService = await ensureService()
await esbuildService.build({
  entryPoints: Object.keys(flatIdDeps),
  bundle: true,
  format: 'esm',
  external: config.optimizeDeps?.exclude,
  logLevel: 'error',
  splitting: true,
  sourcemap: true,
  outdir: cacheDir,
  treeShaking: 'ignore-annotations',
  metafile: esbuildMetaPath,
  define,
  plugins: [esbuildDepPlugin(flatIdDeps, flatIdToExports, config)]
})

在浏览器请求相关模块时,返回这个预构建好的模块。这样,当浏览器请求 lodash-es 中的 debounce 模块的时候,就可以保证只发生一次接口请求了。

你可以理解为,这一步和 Webpack 所做的构建一样,只不过速度快了几十倍。

在预构建这个步骤中,还会对 CommonJS 模块进行分析,方便后面需要统一处理成浏览器可以执行的 ES Module

插件机制

很多同学提到 Vite,第一反应就是生态不够成熟,其他构建工具有那么多的第三方插件,提供了各种各样开箱即用的便捷功能,Vite 需要多久才能赶上呢?

Vite 从 preact 的 WMR 中得到了启发,把插件机制做成兼容 Rollup 的格式。

于是便有了这个相亲相爱的 LOGO:

目前和 vite 兼容或者内置的插件,可以查看vite-rollup-plugins。

简单的介绍一下 Rollup 插件,其实插件这个东西,就是 Rollup 对外提供一些时机的钩子,还有一些工具方法,让用户去写一些配置代码,以此介入 Rollup 运行的各个时机之中。

比如在打包之前注入某些东西,或者改变某些产物结构,仅此而已。

而 Vite 需要做的就是基于 Rollup 设计的接口进行扩展,在保证 Rollup 插件兼容的可能性的同时,再加入一些 Vite 特有的钩子和属性来扩展。

举个简单的例子,@rollup/plugin-image 可以把图片模块解析成 base64 格式,它的源码其实很简单:

代码语言:javascript复制
export default function image(opts = {}) {
  const options = Object.assign({}, defaults, opts)
  const filter = createFilter(options.include, options.exclude)

  return {
    name: 'image',

    load(id) {
      if (!filter(id)) {
        return null
      }

      const mime = mimeTypes[extname(id)]
      if (!mime) {
        // not an image
        return null
      }

      const isSvg = mime === mimeTypes['.svg']
      const format = isSvg ? 'utf-8' : 'base64'
      const source = readFileSync(id, format).replace(/[rn] /gm, '')
      const dataUri = getDataUri({ format, isSvg, mime, source })
      const code = options.dom
        ? domTemplate({ dataUri })
        : constTemplate({ dataUri })

      return code.trim()
    }
  }
}
复制代码

其实就是在 load 这个钩子,读取模块时,把图片转换成相应格式的 data-uri,所以 Vite 只需要在读取模块的时候,也去兼容执行相关的钩子即可。

虽然 Vite 很多行为和 Rollup 构建不同,但他们内部有很多相似的行为和时机,只要确保 Rollup 插件只使用了这些共有的钩子,就很容易做到插件的通用。

可以参考 Vite 官网文档 —— 插件部分

一般来说,只要一个 Rollup 插件符合以下标准,那么它应该只是作为一个 Vite 插件:

  • 没有使用 moduleParsed 钩子。
  • 它在打包钩子和输出钩子之间没有很强的耦合。
  • 如果一个 Rollup 插件只在构建阶段有意义,则在 build.rollupOptions.plugins 下指定即可。

Vite 后面的目标应该也是尽可能和 Rollup 相关的插件生态打通,社区也会一起贡献力量,希望 Vite 的生态越来越好。

比较

和 Vite 同时期出现的现代化构建工具还有:

  • Snowpack - The faster frontend build tool
  • preactjs/wmr:

0 人点赞