目 录CONTENT

文章目录

Vite 的预构建功能

Hello!你好!我是村望~!
2022-12-11 / 0 评论 / 0 点赞 / 187 阅读 / 4,804 字
温馨提示:
我不想探寻任何东西的意义,我只享受当下思考的快乐~

Vite 的预构建功能

前言

Vite 是一个提倡 no-bundle 的构建工具,相比于传统的 Webpack,能做到开发时的模块按需编译,而不用先打包完再加载

需要注意的是,我们所说的模块代码其实分为两部分

  • 一部分是源代码,也就是业务代码
  • 另一部分是第三方依赖的代码,即node_modules中的代码。

所谓的no-bundle只是对于源代码而言

对于第三方依赖而言,Vite 还是选择 bundle(打包),并且使用速度极快的打包器 Esbuild 来完成这一过程,达到秒级的依赖编译速度。

为什么需要预构建?

为什么在开发阶段我们要对第三方依赖进行预构建? 如果不进行预构建会怎么样?

首先 Vite 是基于浏览器原生 ES 模块规范实现的 Dev Server,不论是应用代码,还是第三方依赖的代码,理应符合 ESM 规范才能够正常运行。

但可惜,我们没有办法控制第三方的打包规范。

就目前来看,还有相当多的第三方库仍然没有 ES 版本的产物,比如大名鼎鼎的 react:

// react 入口文件
// 只有 CommonJS 格式

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

这种 CommonJS 格式的代码在 Vite 当中无法直接运行,我们需要将它转换成 ESM 格式的产物。

此外,还有一个比较重要的问题-——————请求瀑布流问题

比如说,知名的loadsh-es库本身是有 ES 版本产物的,可以在 Vite 中直接运行。但实际上,它在加载时会发出特别多的请求,导致页面加载的前几秒几都乎处于卡顿状态,拿一个简单的 demo 项目举例,请求情况如下图所示:

image-20221208105349792

我在应用代码中调用了debounce方法,这个方法会依赖很多工具函数,如下图所示:

image-20221208105431642

每个import都会触发一次新的文件请求,因此在这种依赖层级深涉及模块数量多的情况下,会触发成百上千个网络请求

巨大的请求量加上 Chrome 对同一个域名下只能同时支持 6 个 HTTP 并发请求的限制,导致页面加载十分缓慢,与 Vite 主导性能优势的初衷背道而驰。

不过,在进行依赖的预构建之后,lodash-es这个库的代码被打包成了一个文件,这样请求的数量会骤然减少,页面加载也快了许多。下图是进行预构建之后的请求情况,你可以对照看看:

image-20221208105539544

总之,依赖预构建主要做了两件事情:

  • 一是将其他格式(如 UMD 和 CommonJS)的产物转换为 ESM 格式,使其在浏览器通过 <script type="module"><script>的方式正常加载。

  • 二是打包第三方库的代码,将各个第三方库分散的文件合并到一起,减少 HTTP 请求数量,避免页面加载性能劣化。

而这两件事情全部由性能优异的 Esbuild (基于 Golang 开发)完成,而不是传统的 Webpack/Rollup,所以也不会有明显的打包性能问题,反而是 Vite 项目启动飞快(秒级启动)的一个核心原因。

PS: Vite 1.x 使用了 Rollup 来进行依赖预构建,在 2.x 版本将 Rollup 换成了 Esbuild,编译速度提升了近 100 倍

如何开启预构建?

在项目启动成功后,你可以在根目录下的node_modules中发现.vite目录,这就是预构建产物文件存放的目录,内容如下:

image-20221210075823431

在浏览器访问页面后,打开 Dev Tools 中的网络调试面板,你可以发现第三方包的引入路径已经被重写:

image-20221210080110698

import __vite__cjsImport0_react_jsxDevRuntime from "/node_modules/.vite/deps/react_jsx-dev-runtime.js?v=e6f62b0e"; 
const jsxRuntime = __vite__cjsImport0_react_jsxDevRuntime
export const Fragment = jsxRuntime.Fragment
export const jsxDEV = jsxRuntime.jsxDEV

并且对于依赖的请求结果,Vite 的 Dev Server 会设置强缓存

缓存过期时间被设置为一年,表示缓存过期前浏览器对 react 预构建产物的请求不会再经过 Vite Dev Server,直接用缓存结果。

image-20221210080550698

一旦被缓存,这些请求将永远不会再到达开发服务器。如果安装了不同的版本(这反映在包管理器的 lockfile 中),则附加的版本 query 会自动使它们失效。如果你想通过本地编辑来调试依赖项,你可以:

  1. 通过浏览器调试工具的 Network 选项卡暂时禁用缓存;

  2. 删除node_modules/.vite目录。

  3. 重启 Vite dev server,并添加 --force 命令以重新构建依赖/或者npx vite optimize

    Vite 项目的启动可以分为两步,第一步是依赖预构建,第二步才是 Dev Server 的启动,npx vite optimize相比于其它的方案,仅仅完成第一步的功能。

  4. optimizeDeps.force 设为true

  5. 重新载入页面。

    vite缓存演示

自定义配置详解

前面说到了如何启动预构建的问题,现在我们来谈谈怎样通过 Vite 提供的配置项来定制预构建的过程。

Vite 将预构建相关的配置项都集中在optimizeDeps属性上,我们来一一拆解这些子配置项背后的含义和应用场景。

optimizeDeps文档

入口文件-optimizeDeps.entries

  • 类型: string | string[]

实际上,在项目第一次启动时,Vite 会默认抓取项目中所有的 HTML 文件(如当前脚手架项目中的index.html),将 HTML 文件作为应用入口,然后根据入口文件扫描出项目中用到的第三方依赖,最后对这些依赖逐个进行编译。

那么,当默认扫描 HTML 文件的行为无法满足需求的时候,比如项目入口为vue格式文件时,你可以通过 entries 参数来配置:

// vite.config.ts
{
  optimizeDeps: {
    // 为一个字符串数组
    entries: ["./src/main.vue"];
  }
}

当然,entries 配置也支持 fast-glob 模式 ,非常灵活,如:

// 将所有的 .vue 文件作为扫描入口
entries: ["**/*.vue"];

不光是.vue文件,Vite 同时还支持各种格式的入口,包括: htmlsvelteastrojsjsxtstsx

可以看到,只要可能存在import语句的地方,Vite 都可以解析,并通过内置的扫描机制搜集到项目中用到的依赖,通用性很强。

包括xxx-optimizeDeps.include

除了 entriesinclude 也是一个很常用的配置,它决定了可以强制预构建的依赖项,使用方式很简单:

// vite.config.ts
optimizeDeps: {
  // 配置为一个字符串数组,将 `lodash-es` 和 `vue`两个包强制进行预构建
  include: ["lodash-es", "vue"];
}

它在使用上并不难,真正难的地方在于,如何找到合适它的使用场景。

前面中我们提到,Vite 会根据应用入口(entries)自动搜集依赖,然后进行预构建,这是不是说明 Vite 可以百分百准确地搜集到所有的依赖呢?

事实上并不是,某些情况下 Vite 默认的扫描行为并不完全可靠,这就需要联合配置include来达到完美的预构建效果了。

接下来,我们好好梳理一下到底有哪些需要配置include的场景。

场景一: 动态 import

在某些动态 import 的场景下,由于 Vite 天然按需加载的特性,经常会导致某些依赖只能在运行时被识别出来。

下面我们随便建个ts文件,随便引入一些npm包~随便用一下!

│   ├── main.tsx
│   ├── packages
│   │   ├── p1.ts
│   │   └── p2.ts
// p1.ts
import oa from "object-assign"
console.log('p1');
console.log(oa);
export {}

// p2.ts
import  'ts-toolbelt';
export {};

然后我们使用一种动态引入的方式!

// main.tsx
const importModule = (m: string) => import(`./packages/${m}.ts`);
importModule('p1');
importModule('p2');
ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render(
  <App />
);

然后使用force的方式启动!

image-20221210093132849

这段 log 的意思是: Vite 运行时发现了新的依赖,随之重新进行依赖预构建,并刷新页面。这个过程也叫二次预构建

在一些比较复杂的项目中,这个过程会执行很多次,如下面的日志信息所示:

[vite] new dependencies found: @material-ui/icons/Dehaze, @material-ui/core/Box, @material-ui/core/Checkbox, updating...
[vite] ✨ dependencies updated, reloading page...
[vite] new dependencies found: @material-ui/core/Dialog, @material-ui/core/DialogActions, updating...
[vite] ✨ dependencies updated, reloading page...
[vite] new dependencies found: @material-ui/core/Accordion, @material-ui/core/AccordionSummary, updating...
[vite] ✨ dependencies updated, reloading page...

然而,二次预构建的成本也比较大。我们不仅需要把预构建的流程重新运行一遍,还得重新刷新页面,并且需要重新请求所有的模块。

尤其是在大型项目中,这个过程会严重拖慢应用的加载速度!因此,我们要尽力避免运行时的二次预构建

具体怎么做呢?你可以通过include参数提前声明需要按需加载的依赖:

{
    optimizeDeps:{
    	include:['ts-toolbelt', 'object-assign']
    },
}

image-20221210104013056

这一次就发现没有发现新的依赖并且重新加载页面!

场景二: 某些包被手动 exclude (并不常用,也不推荐使用)

excludeoptimizeDeps中的另一个配置项,与include相对,用于将某些依赖从预构建的过程中排除。

不过这个配置并不常用,也不推荐大家使用。如果真遇到了要在预构建中排除某个包的情况,需要注意它本身以及其所依赖的包是否具有 ESM 格式,

比如 @loadable/component 这个包~ 他本身是有esm格式的

image-20221210112435201

但是他的一个子依赖 hoist-non-react-statics! 并不支持ESM 格式

image-20221210115329916

所以如果你觉得某某包是支持ESM的那就不用vite去编译转换啦!把他exclude掉吧!那么可能就因为他的子依赖包不是支持ESM而报错!

image-20221210120053256

【官方描述】CommonJS 的依赖(非ESM)不应该排除在优化外。

如果一个 ESM 依赖被排除在优化外,但是却有一个嵌套的 CommonJS 依赖,则应该为该 CommonJS 依赖添加 optimizeDeps.include

export default defineConfig({
  optimizeDeps: {
    include: ['esm-dep > cjs-dep']
  }
})

那么我们也应该为我们案例中的非 ESM 子依赖做一些配置,让vite把他转换为ESM !

optimizeDeps: {
  include: [
    // 间接依赖的声明语法,通过`>`分开, 如`a > b`表示 a 中依赖的 b
    '@loadable/component > hoist-non-react-statics',
    '@loadable/component > hoist-non-react-statics  > react-is'
  ],
  exclude:['@loadable/component']
},

上面 hoist-non-react-staticshoist-non-react-statics 下面的 react-is 都不支持ESM格式!

include参数中,我们将所有不具备 ESM 格式产物包都声明一遍,这样再次启动项目就没有问题了。

image-20221211144725951

自定义 Esbuild 行为

Vite 提供了esbuildOptions 参数来让我们自定义 Esbuild 本身的配置,常用的场景是加入一些 Esbuild 插件:

// vite.config.ts
{
  optimizeDeps: {
    esbuildOptions: {
       plugins: [
        // 加入 Esbuild 插件
      ];
    }
  }
}

这个配置主要是处理一些特殊情况,如某个第三方包本身的代码出现问题了。接下来,我们就来讨论一下。

特殊情况: 第三方包出现问题怎么办?

由于我们无法保证第三方包的代码质量,在某些情况下我们会遇到莫名的第三方库报错。

举一个常见的案例——react-virtualized库。

这个库被许多组件库用到,但它的 ESM 格式产物有明显的问题,在 Vite 进行预构建的时候会直接抛出这个错误:

image-20221211145109713

原因是这个库的 ES 产物莫名其妙多出了一行无用的代码:

// WindowScroller.js 并没有导出这个模块
import { bpfrpt_proptype_WindowScroller } from "../WindowScroller.js";

其实我们并不需要这行代码,但它却导致 Esbuild 预构建的时候直接报错退出了。那这一类的问题如何解决呢?

改第三方库代码

首先,我们能想到的思路是直接修改第三方库的代码,不过这会带来团队协作的问题,你的改动需要同步到团队所有成员,比较麻烦。

pnpm patch(有趣)

首先我们先执行 pnpm patch <pkg> 去 patch 这个包!

  ╭─    ~/CunWangOwn/FE/vite-project  on   eslintPrettier !8 ?12 ────────────────────────────────────────────────────── ✔  took 14m 4s   at 14:57:39  ─╮
  ╰─ pnpm patch react-virtualized                                                                                                                                   
  You can now edit the following folder: /private/var/folders/wc/lq3tvptn19l7963dcqrznj7h0000gp/T/7ac53f1cb2a262be3e66cc3b63f21fc8/user

然后他就在这个地方/private/var/folders/wc/lq3tvptn19l7963dcqrznj7h0000gp/T/926607bb2ec5423174e11d839641f620/user给你生成了这个包的临时目录

然后你可以去这里修改任何你想去修改的代码!接着,我们进入第三方库的代码中去进行修改那个有问题的代码,先删掉无用的 import 语句,在随便加点其他的代码,再在命令行输入:

+ console.log(111111111111111111111111111);
- import { bpfrpt_proptype_WindowScroller } from "../WindowScroller.js";

修改完并且保存后!还需要提交补丁! patch-commit

╭─    ~/CunWangOwn/FE/vite-project  on   eslintPrettier !8 ?13 ────────────────────────────────────────────────────────────────────── ✔  at 15:04:24  ─╮
╰─ pnpm patch-commit /private/var/folders/wc/lq3tvptn19l7963dcqrznj7h0000gp/T/7ac53f1cb2a262be3e66cc3b63f21fc8/user                                               ─╯
 WARN  deprecated stable@0.1.8: Modern JS already guarantees Array#sort() is a stable sort, so this library is deprecated. See the compatibility table on MDN: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/sort#browser_compatibility
 WARN  deprecated source-map-resolve@0.5.3: See https://github.com/lydell/source-map-resolve#deprecated
 WARN  deprecated source-map-url@0.4.1: See https://github.com/lydell/source-map-url#deprecated
 WARN  deprecated resolve-url@0.2.1: https://github.com/lydell/resolve-url#deprecated
 WARN  deprecated urix@0.1.0: Please see https://github.com/lydell/urix#deprecated
Packages: -1
-
Progress: resolved 701, reused 680, downloaded 0, added 1, done

> vite-project@0.0.0 prepare /Users/codehope/CunWangOwn/FE/vite-project
> husky install

husky - Git hooks installed
 ERR_PNPM_PEER_DEP_ISSUES  Unmet peer dependencies

.
├─┬ @amatlash/vite-plugin-stylelint 1.2.0
│ ├── ✕ missing peer rollup@^2.60.0
│ └── ✕ unmet peer vite@^2.6.14: found 3.2.4
├─┬ vite-plugin-svgr 2.2.2
│ └─┬ @rollup/pluginutils 5.0.2
│   └── ✕ missing peer rollup@^1.20.0||^2.0.0||^3.0.0
├─┬ stylelint-config-standard-scss 6.1.0
│ ├── ✕ missing peer postcss@^8.3.3
│ └─┬ stylelint-config-recommended-scss 8.0.0
│   ├── ✕ missing peer postcss@^8.3.3
│   └─┬ postcss-scss 4.0.6
│     └── ✕ missing peer postcss@^8.4.19
└─┬ react-virtualized 9.22.3
  ├── ✕ unmet peer react@"^15.3.0 || ^16.0.0-alpha": found 18.2.0
  └── ✕ unmet peer react-dom@"^15.3.0 || ^16.0.0-alpha": found 18.2.0
Peer dependencies that should be installed:
  postcss@">=8.4.19 <9.0.0"  rollup@">=2.60.0 <3.0.0"   

hint: If you want peer dependencies to be automatically installed, add "auto-install-peers=true" to an .npmrc file at the root of your project.
hint: If you don't want pnpm to fail on peer dependency issues, add "strict-peer-dependencies=false" to an .npmrc file at the root of your project.

然后你再去运行一下你的项目!

image-20221211151644271

可以看到项目也不会报错了!然后也运行了我们自定义的代码!说明我们已经给有问题的第三方包成功的打了补丁,并且解决了项目的运行问题!

然后有个疑问,那么是不是安装依赖后,每次都要去做一次patch commit呢?答案肯定是不是的!首先当你commit 补丁之后,当前项目目录下会出现一个patches的文件夹!

├── patches
│   └── react-virtualized@9.22.3.patch

下面每一个文件记录者你对所有包的补丁操作记录

// react-virtualized@9.22.3.patch
diff --git a/dist/es/WindowScroller/utils/onScroll.js b/dist/es/WindowScroller/utils/onScroll.js
index d00f0f18c6596e4e57f4f762f91fed4282610c91..dc7fc078a8b578c8fbef74eb54e7e0b13c7208f9 100644
--- a/dist/es/WindowScroller/utils/onScroll.js
+++ b/dist/es/WindowScroller/utils/onScroll.js
@@ -71,4 +71,5 @@ export function unregisterScrollListener(component, element) {
     }
   }
 }
-import { bpfrpt_proptype_WindowScroller } from "../WindowScroller.js";
\ No newline at end of file
+console.log(111111111111111111111111111);
+// import { bpfrpt_proptype_WindowScroller } from "../WindowScroller.js";
\ No newline at end of file

然后package.json 也会多出这样的配置:

  "pnpm": {
    "patchedDependencies": {
      "react-virtualized@9.22.3": "patches/react-virtualized@9.22.3.patch"
    }
  }

这样我们在每次执行 pnpm install 的时候,pnpm 会根据 patches 的补丁记录 自动帮我们把第三方打上补丁!

加入 Esbuild 插件

第二种方式是通过 Esbuild 插件修改指定模块的内容,这里我给大家展示一下新增的配置内容:

// vite.config.ts
const esbuildPatchPlugin = {
  name: 'react-virtualized-patch',
  setup(build) {
    build.onLoad(
      {
        filter:
          /react-virtualized\/dist\/es\/WindowScroller\/utils\/onScroll.js$/
      },
      async (args) => {
        console.log(111);

        const text = await fs.promises.readFile(args.path, 'utf8');

        return {
          contents: text.replace(
            'import { bpfrpt_proptype_WindowScroller } from "../WindowScroller.js";',
            'console.log("Hello World !")'
          )
        };
      }
    );
  }
};

optimizeDeps: {
  esbuildOptions: {
    plugins: [esbuildPatchPlugin]
  }
},

运行项目! 可以看到esbuild插件也生效了!

image-20221211154225841

小结

Vite 中的依赖预构建技术主要解决了 2 个问题:

  • 即模块格式兼容问题

  • 和海量模块请求的问题。

而 Vite 中开启预构建有 2 种方式,并梳理了预构建产物的缓存策略,推荐了一些手动清除缓存的方法。

学习了预构建的相关配置——entriesincludeexcludeesbuldOptions,并且重点介绍了include配置的各种使用场景和使用姿势

还有第三方包出现了问题该怎么办,分别给你介绍了两个解决思路: 通过patch package修改库代码和编写 Esbuild 插件修改模块加载的内容。

0

评论区