聊一聊 Vite 的预构建和二次预构建

7,477 阅读10分钟

本文使用「署名 4.0 国际 (CC BY 4.0)」 许可协议,欢迎转载、或重新修改使用,但需要注明来源。

作者: 百应前端团队 @0o华仔o0 首发于 聊一聊 Vite 的预构建和二次预构建

前言

之前在使用 Vite 的时候,经常会遇到这种情况:项目启动以后,不管是首屏还是页面懒加载,如果发现有未进行预构建的第三方依赖,那么 Vite 就会重新预构建,然后触发页面的 reload。重复的页面load 操作,给开发人员带来了很不友好的体验。

最新发布的 3.0 版本对此问题做了部分优化,即首屏期间,即使有未进行预构建的第三方依赖,也不会发生页面 reload

3.0 版本是怎么做到的呢?今天我们就通过本文,和大家一起聊一聊 3.0 版本针对二次预构建做了什么优化。

本文的目录结构如下:

初探 Vite 预构建

使用过 Vite 的同学都知道,开发阶段 Vite 会对项目中使用的第三方依赖如 reactreact-domlodash-es 等做预构建操作。

之所以要做预构建,是因为 Vite 是基于浏览器原生的 ESM 规范来实现 dev server 的,这就要求整个项目中涉及的所有源代码必须符合 ESM 规范。

而在实际开发过程中,业务代码我们可以严格按照 ESM 规范来编写,但第三方依赖就无法保证了,比如 react。这就需要我们通过 Vite预构建功能将非 ESM 规范的代码转换为符合 ESM 规范的代码。

另外,尽管有些第三方依赖已经符合 ESM 规范,但它是由多个子文件组成的,如 lodash-es。如果不做处理就直接使用,那么就会引发请求瀑布流,这对页面性能来说,简直就是一场灾难。同样的,我们可以通过 Vite预构建功能,将第三方依赖内部的多个文件合并为一个,减少 http 请求数量,优化页面加载性能。

综上,预构建,主要做了两件事情:

  • 将非 ESM 规范的代码转换为符合 ESM 规范的代码;

  • 将第三方依赖内部的多个文件合并为一个,减少 http 请求数量;

关于上面提到的几种情形,我们可以通过几个简单的 demo 来为大家演示一下:

  • not-esm-demo

    react 的入口文件代码如下:

    'use strict';
    
    if (process.env.NODE_ENV === 'production') {
      module.exports = require('./cjs/react.production.min.js');
    } else {
      module.exports = require('./cjs/react.development.js');
    }
    

    我们可以通过配置 optimizeDeps.exclude["react"], 使得 dev server 在工作过程中不对 react 进行预构建

    gif-1.gif

    当浏览器请求 react 时,拿到的是 commonjs 类型的代码,无法执行,直接报错。

    image.png

  • not-merge-demo

    lodash-es 的入口文件如下:

    export { default as add } from './add.js';
    export { default as after } from './after.js';
    export { default as ary } from './ary.js';
    ...
    export { default as zipWith } from './zipWith.js';
    export { default } from './lodash.default.js';
    
    

    我们通过配置 optimizeDeps.exclude["lodash-es"], 使得 dev server 在工作过程中不对 lodash-es 进行预构建

    gif-3.gif

    当我们打开浏览器的 network 面板时,我们发现首屏渲染时居然发起了多达 659http 请求,导致页面性能非常差。

  • normal-demo

    gif-4.gif

    在本示例中,我们不对 optimizeDeps.exclude 做任何配置,Vite 会在 dev server 启动以后,默认对项目 reactlodash-es 等第三方依赖做预构建

    image.png

    默认情况下,预构建结果会保存到 node_modules.vite/deps 目录下。

    当我们再次启动 dev server 时,如果项目的第三方依赖(.lock 文件内容没有变)和 vite.config 没有改变,那么 Vite 会复用上一次预构建的结果。如果不想让 Vite 复用上一次预构建的结构,我们可以配置 optimizeDeps.forcetrue,使得 dev server 每次启动的时候都强制进行预构建

二次预构建

预构建,最关键的一步就是找到项目中所有的第三方依赖。 那 Vite 是怎么做到快速获取项目中所有的第三方依赖呢?

在解释这个问题之前,我们先来聊一聊 WebpackRollupParcel 这一类静态打包器是如何打包代码的。以 Webpack 为例,整个打包过程可以分为构建模块依赖图 - module graph 和将 module graph 分离为多个 bundles 两个步骤。其中,构建 module graph 是重中之重。

Webpack 构建 module graph 的过程,可以拆解成下面几个步骤:

  1. 找到入口文件 entry 对应的 url, 这个 url 一般为相对路径;
  2. url 解析为绝对路径,找到源文件在本地磁盘的位置,并构建一个 module 对象;
  3. 读取源文件的内容;
  4. 将源文件内容解析为 AST 对象,分析 AST 对象,找到源文件中的静态依赖(import xxx from 'xxx') 和动态依赖(import('xx'))对应的 url, 并收集到 module 对象中;
  5. 遍历第 4 步收集到的静态依赖动态依赖对应的 url,重复 2 - 5 步骤,直到项目中所有的源文件都遍历完成。

在构建 module graph 过程中,我们就可以知道整个项目涉及的所有源文件对应的 url,然后就可以从这些 url 中过滤出第三方依赖。

同样的,Vite预构建的时候也是基于类似的机制去找到项目中所有的第三方依赖的。和 Webpack 不同, Vite 另辟蹊径,借助了 EsbuildWebpack 更快的打包能力,对整个项目做一个全量打包。打包的时候,通过分析依赖关系,得到项目中所有的源文件的 url,然后分离出第三方依赖。

这样,Vite 就可以对找到的第三方依赖做转化、合并操作了。

预构建功能非常棒,但在实际的项目中,并不能保证所有的第三方依赖都可以被找到。如果出现下面的这两种情况, Esbuild 也无能为力:

  • plugin 在运行过程中,动态给源码注入了新的第三方依赖;

  • 动态依赖在代码运行时,才可以确定最终的 url

当出现这两种情况时,Vite 会触发二次预构建

我们可以通过几个 demo,为大家演示一下上面提到的两种情况:

  • plugin-inject-demo-2.9

    首先是 demo 演示,使用的 Vite 版本是 2.9:

    gif-5.gif

    在控制台上,我们可以清楚的看到打印信息:new dependencies optimized: lodash-es, optimized dependencies changed. reloading。打开 performance 面板,也能清晰的看到 reload 操作。

    image.png

    在这个 demo 中,我们通过一个自定义插件 - customePlugin,在加载 util.1.ts 文件时,手动注入了 lodash-es 依赖。

    const customePlugin = {
        name: 'custome-plugin',
        async transform(code, id) {
            if (id.includes('util.1')) {
                code = `import { throttle } from 'lodash-es'; console.log(throttle); ${code}`;
            }
            return code;
    
        }
    }
    
    export { customePlugin };
    

    lodash-es 在初次预构建的时候,无法被获取,也就无法进行预构建优化。dev server 启动后,浏览器向 dev server 发送 util.1.ts 请求,dev server 收到请求以后,需要对 util.1.ts 做下面几个处理:

    1. url 解析为的绝对路径;

    2. 根据文件绝对路径去加载源代码;

    3. 对源代码做转换(如 ts -> js, tsx -> js 等);

    4. 分析转换以后的源代码,收集源文件依赖的其他模块的 url

    5. 对收集到的 url 重复步骤 1 到步骤 5,直到没有需要处理的 url

    customePlugin 在步骤 3 起作用,给源文件添加 lodash-es 依赖。在步骤 4, lodash-es 会被收集到 util.1.ts 的依赖列表中。之后 lodash-es 开始步骤 1 的时候, dev server 会判断出该 url 属于未进行预构建的第三方依赖,然后触发二次预构建二次预构建完成以后,通知浏览器去 reload 页面。

  • plugin-inject-demo-3.0

    plugin-inject-demo-2.9Vite 版本换成 3.0,效果如下:

    git-6.gif

    控制台上没有打印发现新的依赖、重新 load 页面的信息。打开 preformance 面板,我们可以看到只发生了一次 load 操作。

    image.png

    相比 2.9 版本, 3.0 版本对首屏期间的二次预构建做了优化,不再需要浏览器进行 reload 操作。这一块儿我们将在第三节 Vite 3.0 对预构建的优化 中重点讲解。

  • dynamic-url-demo

    demo 演示,使用的 Vite 版本是 3.0:

    gif-7.gif

    在演示 demo 中,当我们切换到 page3 时,可以很明显的看到页面发生了 reload 操作。回到 Vscode,我们也可以看到控制台上打印了 new dependencies optimized: lodash-esoptimized dependencies changed. reloading 信息。

    demo 中,我们给 Page3 组件添加了下述代码:

    // Page3.tsx
    import React from 'react';
    
    const importModule = (m: string) => import(`../../utils/${m}.ts`);
    importModule("util.1").then(res => {
        res.func1();
    });
    
    const Page = () => {
        return <div>页面3</div>
    }
    
    export default Page;
    
    // util.1.ts
    import { throttle } from 'lodash-es'; 
    export const func1 = () => {
        console.log(throttle);
        console.log('func1');
    }
    

    Page3 进行懒加载时,会动态加载 util.1.tsutil.1.ts 中包含第三方依赖 lodash-es

    在首次预构建时, util.1.ts 无法被解析,导致 lodash-es 也无法被 esbuild 扫描到,没有被预构建优化。

    当我们切换到 Page3 时, 先懒加载 Page3.tsx,再懒加载 util.1.ts, 然后解析 lodash-es。此时,由于 lodash-es 属于未进行预构建的第三方依赖,dev server 会触发二次预构建,然后通知浏览器做 reload 操作。

Vite 3.0 对预构建的优化

在开发过程中,如果频繁的触发二次预构建,导致重复的 reload 操作,对开发人员来说简直就是一种折磨。

针对这个问题, Vite 3.0 做了相关优化。在 plugin-inject-demo-3.0 中我们可以看到, 首屏期间,即使有未进行预构建的第三方依赖,也不会发生页面 reload。那它是怎么做到的呢?

其实,原理也非常简单。

首先需要声明一点,首屏期间,如果发现有未预构建的第三方依赖,还是会触发二次预构建

3.0 版本对第三方依赖的请求和业务代码的请求有不同的处理逻辑。当浏览器请求业务代码时,dev server 只要完成源代码转换并收集到依赖模块的 url,就会给浏览器发送 response。而第三方依赖请求则不同,dev server 会等首屏期间涉及的所有模块的依赖关系全部解析完毕以后,才会给浏览器发送 response。这就导致了,如果发现有未预构建的第三方依赖,第三方依赖的请求会一直被阻塞,直到二次预构建完成为止。

有了这种操作,当然就不需要 reload 操作了。

这么一说,是不是非常简单呢!😄。

通过 plugin-inject-demo-2.9plugin-inject-demo-3.0 两个 demo 首屏期间的 performance 面板,我们可以看到 react 在请求过程中被阻塞的情况。

image.png

plugin-inject-demo-2.9 中,在初次预构建时,react 已经完成了优化,首屏期间关于 react 的请求会快速响应。

image.png

plugin-inject-demo-3.0 中,尽管 react 已经在初次预构建的时候被优化,但在首屏期间解析模块依赖关系时,发现有未预构建lodash-es,触发二次预构建react 请求一直被阻塞,直到二次预构建完成以后才被响应,耗时较长。

总结一下, 3.0二次预构建的优化,其实是以消耗首屏性能来优化 reload 交互体验。只能说鱼与熊掌,二者不可兼得吧。

遗憾的是,Vite 3.0 并没有解决懒加载二次预构建导致的 reload 的问题。这个问题,目前只能通过社区提供的 vite-plugin-optimize-persistvite-plugin-package-config 来解决了。

vite-plugin-package-config & vite-plugin-optimize-persist

老规矩,我们还是先通过一个 demo 为大家做一下演示:

gif-9.gif

在本 demo 中,我们做了两次 dev server 的启动。第一次,由于懒加载 Page3 时发现有未进行预构建的第三方依赖 lodash-es,触发二次预构建,导致页面 reload。第二次启动时,懒加载 Page3 时没有发生二次预构建,也没有发生 reload

如果仔细看演示 demo,你会发现第一次启动 dev server 并发生二次预构建时,package.json 文件会新增一个 vite.optimizeDeps.include 字段,里面包含项目中用到的所有第三方依赖。正是有了这个字段,第二次启动 dev server 以后,就不在发生二次预构建了。

package.json 文件中这个新增的 vite.optimizeDeps.include 字段,是 vite-plugin-package-configvite-plugin-optimize-persist 这两个插件的功劳。

其中 vite-plugin-package-config 提供 config hook,在 dev server 初始化 vite.config 时提供 vite.optimizeDeps.include 配置项,告诉 dev server预构建指定的第三方依赖。

vite-plugin-optimize-persist 提供 configureServer hook,在发生二次预构建时将新的第三方依赖写入 package.jsonvite.optimizeDeps.include 字段中永久保存起来,供下一次启动 dev server 时使用。

由于 vite-plugin-package-configvite-plugin-optimize-persist 还没有出匹配 Vite 3.0 的版本,我们在本 demo 中使用的是 Vite 2.4 版本。虽然版本不是最新的,但原理是相通的。

总的来说,使用 vite-plugin-package-configvite-plugin-optimize-persist 依旧无法解决第一次启动 dev server 时发生二次预构建导致页面 reload 的问题。但如果项目中用到的第三方依赖没有变化的话,之后再启动 dev server 时就不会再发生二次预构建了。这也是一种很好的优化策略了,👍🏻。

结束语

到这里关于预构建二次预构建的话题就结束了。

写这篇文章的初衷,是看到 3.0二次预构建的优化后,想深入了解一下内部原理,并把自己搞清楚的东西分享给同样对 Vite 感兴趣的小伙伴。整个研究和整理的过程,让自己学到了很多,对 Vite 的认识也更加深入了,成就感满满。另外,要特别感谢一下神三元大佬,他的掘金小册 - <<深入浅出 Vite>> 写的非常棒,给了我不少帮助,还没有看的小伙伴可以赶紧去看看了,点赞 👍🏻。

后续作者还会陆续推出一些 Vite 的学习所得,感兴趣的小伙伴可以时常关注哦。