前面文章说过(Vite如何实现秒级依赖预构建的能力),在 Vite 依赖预构建的底层实现中,大量地使用到了 Esbuild 这款构建工具,实现了比较复杂的 Esbuild 插件功能和技巧。接下来,我就来带你揭开 Vite 预构建神秘的面纱,从核心流程到依赖扫描、依赖打包的具体实现,带你彻底理解Esbuild预构建背后的技术。

一、预构建流程

关于预构建所有的实现代码都在optimizeDeps函数当中,对应的仓库源码:packages/vite/src/node/optimizer/index.ts,大家可以下载下来对照学习。

1.1 缓存判断

首先,是预构建缓存的判断。Vite 在每次预构建之后都将一些关键信息写入到了_metadata.json文件中,第二次启动项目时会通过这个文件中的 hash 值来进行缓存的判断,如果命中缓存则不会进行后续的预构建流程,代码如下所示。

// _metadata.json 文件所在的路径const dataPath = path.join(cacheDir, "_metadata.json");// 根据当前的配置计算出哈希值const mainHash = getDepHash(root, config);const data: DepOptimizationMetadata = {hash: mainHash,browserHash: mainHash,optimized: {},};// 默认走到里面的逻辑if (!force) {let prevData: DepOptimizationMetadata | undefined;try {// 读取元数据prevData = JSON.parse(fs.readFileSync(dataPath, "utf-8"));} catch (e) {}// 当前计算出的哈希值与 _metadata.json 中记录的哈希值一致,表示命中缓存,不用预构建if (prevData && prevData.hash === data.hash) {log("Hash is consistent. Skipping. Use --force to override.");return prevData;}}

值得注意的是哈希计算的策略,即决定哪些配置和文件有可能影响预构建的结果,然后根据这些信息来生成哈希值。这部分逻辑集中在getHash函数中,代码如下。

const lockfileFormats = ["package-lock.json", "yarn.lock", "pnpm-lock.yaml"];function getDepHash(root: string, config: ResolvedConfig): string {// 获取 lock 文件内容let content = lookupFile(root, lockfileFormats) || "";// 除了 lock 文件外,还需要考虑下面的一些配置信息content += JSON.stringify({// 开发/生产环境mode: config.mode,// 项目根路径root: config.root,// 路径解析配置resolve: config.resolve,// 自定义资源类型assetsInclude: config.assetsInclude,// 插件plugins: config.plugins.map((p) => p.name),// 预构建配置optimizeDeps: {include: config.optimizeDeps?.include,exclude: config.optimizeDeps?.exclude,},},// 特殊处理函数和正则类型(_, value) => {if (typeof value === "function" || value instanceof RegExp) {return value.toString();}return value;});// 最后调用 crypto 库中的 createHash 方法生成哈希return createHash("sha256").update(content).digest("hex").substring(0, 8);}

1.2 依赖扫描

如果没有命中缓存,则会正式地进入依赖预构建阶段。不过 Vite 不会直接进行依赖的预构建,而是在之前探测一下项目中存在哪些依赖,收集依赖列表,也就是进行依赖扫描的过程。并且,这个过程是必须的,因为 Esbuild 需要知道我们到底要打包哪些第三方依赖,关键代码如下:

({ deps, missing } = await scanImports(config));

在scanImports方法内部主要会调用 Esbuild 提供的 build 方法,如下所示。

const deps: Record = {};// 扫描用到的 Esbuild 插件const plugin = esbuildScanPlugin(config, container, deps, missing, entries);await Promise.all(// 应用项目入口entries.map((entry) =>build({absWorkingDir: process.cwd(),// 注意这个参数write: false,entryPoints: [entry],bundle: true,format: "esm",logLevel: "error",plugins: [...plugins, plugin],...esbuildOptions,})));

值得注意的是,其中传入的write参数被设为 false,表示产物不用写入磁盘,这就大大节省了磁盘 I/O 的时间,也是依赖扫描为什么往往比依赖打包快很多的原因之一。接下来,会输出预打包信息:

if (!asCommand) {if (!newDeps) {logger.info(chalk.greenBright(`Pre-bundling dependencies:\n${depsString}`));logger.info(`(this will be run only when your dependencies or config have changed)`);}} else {logger.info(chalk.greenBright(`Optimizing dependencies:\n${depsString}`));}

到此,你可能已经明白,为什么第一次启动时会输出预构建相关的 log 信息了,其实这些信息都是通过依赖扫描阶段来搜集的,而此时还并未开始真正的依赖打包过程。

可能大家有个疑问,为什么项目入口打包一次就能够收集到所有的依赖信息呢?事实上,日志的收集都是使用esbuildScanPlugin这个函数创建 scan 插件来实现的,在 scan 插件里面就是解析各种 import 语句,最终通过它来记录依赖信息。

1.3 依赖打包

收集完依赖之后,就正式地进入到依赖打包的阶段了,主要是调用 Esbuild 进行打包并写入产物到磁盘中,关键代码如下:

const result = await build({absWorkingDir: process.cwd(),// 所有依赖的 id 数组,在插件中会转换为真实的路径entryPoints: Object.keys(flatIdDeps),bundle: true,format: "esm",target: config.build.target || undefined,external: config.optimizeDeps?.exclude,logLevel: "error",splitting: true,sourcemap: true,outdir: cacheDir,ignoreAnnotations: true,metafile: true,define,plugins: [...plugins,// 预构建专用的插件esbuildDepPlugin(flatIdDeps, flatIdToExports, config, ssr),],...esbuildOptions,});// 打包元信息,后续会根据这份信息生成 _metadata.jsonconst meta = result.metafile!;

1.4 元信息写入磁盘

在打包过程完成之后,Vite 会拿到 Esbuild 构建的元信息,也就是上面代码中的meta对象,然后将元信息保存到_metadata.json文件中。

const data: DepOptimizationMetadata = {hash: mainHash,browserHash: mainHash,optimized: {},};// 省略中间的代码for (const id in deps) {const entry = deps[id];data.optimized[id] = {file: normalizePath(path.resolve(cacheDir, flattenId(id) + ".js")),src: entry,// 判断是否需要转换成 ESM 格式,后面会介绍needsInterop: needsInterop(id,idToExports[id],meta.outputs,cacheDirOutputPath),};}// 元信息写磁盘writeFile(dataPath, JSON.stringify(data, null, 2));

到此,预构建的核心流程就梳理完了。可以看到,总体的流程上面并不复杂,具体参考依赖扫描和依赖打包这两个部分的代码。

二、依赖扫描分析

2.1 获取入口

现在让我们把目光聚焦在scanImports这个函数的实现上。在正式扫描之前,需要先找到入口文件。不过,入口文件可能存在于多个配置当中,比如optimizeDeps.entries和build.rollupOptions.input,同时还需要考虑数组和对象的情况,当然也可能用户没有配置需要自动探测入口文件。那么,scanImports是如何做到这么多场景的处理的呢?首先来看一段代码:

const explicitEntryPatterns = config.optimizeDeps.entries;const buildInput = config.build.rollupOptions?.input;if (explicitEntryPatterns) {// 先从 optimizeDeps.entries 寻找入口,支持 glob 语法entries = await globEntries(explicitEntryPatterns, config);} else if (buildInput) {// 其次从 build.rollupOptions.input 配置中寻找,注意需要考虑数组和对象的情况const resolvePath = (p: string) => path.resolve(config.root, p);if (typeof buildInput === "string") {entries = [resolvePath(buildInput)];} else if (Array.isArray(buildInput)) {entries = buildInput.map(resolvePath);} else if (isObject(buildInput)) {entries = Object.values(buildInput).map(resolvePath);} else {throw new Error("invalid rollupOptions.input value.");}} else {// 兜底逻辑,如果用户没有进行上述配置,则自动从根目录开始寻找entries = await globEntries("**/*.html", config);}

其中,globEntries 方法即通过 fast-glob 库来从项目根目录扫描文件。接下来,还需要考虑入口文件的类型,一般情况下入口需要是js/ts文件,但实际上像 html、vue 单文件组件这种类型我们也是需要支持的。

在源码当中,同时对 html、vue、svelte、astro(一种新兴的类 html 语法)四种后缀的入口文件进行了解析,当然,具体的解析过程在依赖扫描阶段的 Esbuild 插件中得以实现,接着就让我们在插件的实现中一探究竟。

const htmlTypesRE = /.(html|vue|svelte|astro)$/;function esbuildScanPlugin(/* 一些入参 */): Plugin {// 初始化一些变量// 返回一个 Esbuild 插件return {name: "vite:dep-scan",setup(build) {// 标记「类 HTML」文件的 namespacebuild.onResolve({ filter: htmlTypesRE }, async ({ path, importer }) => {return {path: await resolve(path, importer),namespace: "html",};});build.onLoad({ filter: htmlTypesRE, namespace: "html" },async ({ path }) => {// 解析「类 HTML」文件});},};}

这里来我们以html文件的解析为例来讲解,原理如下图所示。

此插件中会扫描出所有带有 type=module 的 script 标签,对于含有 src 的 script 改写为一个 import 语句,对于含有具体内容的 script,则抽离出其中的脚本内容,最后将所有的 script 内容拼接成一段 js 代码。接下来我们来看具体的代码,其中会以上图中的html为示例来拆解中间过程。

const scriptModuleRE =/(]*type\s*=\s*(" />2.2 记录依赖 

入口的问题解决了,接下来还有一个问题: 如何在 Esbuild 编译的时候记录依赖呢?Vite 中会把 bare import的路径当做依赖路径,关于bare import,你可以理解为直接引入一个包名。而以.开头的相对路径或者以/开头的绝对路径都不能算bare import,比如下面这样就不算是bare import:

// 以下都不是 bare importimport React from "../node_modules/react/index.js";import React from "/User/sanyuan/vite-project/node_modules/react/index.js";

对于解析 bare import、记录依赖的逻辑依然实现在 scan 插件当中,对应的代码如下:

build.onResolve({// avoid matching windows volumefilter: /^[\w@][^:]/,},async ({ path: id, importer }) => {// 如果在 optimizeDeps.exclude 列表或者已经记录过了,则将其 externalize (排除),直接 return// 接下来解析路径,内部调用各个插件的 resolveId 方法进行解析const resolved = await resolve(id, importer);if (resolved) {// 判断是否应该 externalize,下个部分详细拆解if (shouldExternalizeDep(resolved, id)) {return externalUnlessEntry({ path: id });}if (resolved.includes("node_modules") || include?.includes(id)) {// 如果 resolved 为 js 或 ts 文件if (OPTIMIZABLE_ENTRY_RE.test(resolved)) {// 注意了! 现在将其正式地记录在依赖表中depImports[id] = resolved;}// 进行 externalize,因为这里只用扫描出依赖即可,不需要进行打包,具体实现后面的部分会讲到return externalUnlessEntry({ path: id });} else {// resolved 为 「类 html」 文件,则标记上 'html' 的 namespaceconst namespace = htmlTypesRE.test(resolved) ? "html" : undefined;// linked package, keep crawlingreturn {path: path.resolve(resolved),namespace,};}} else {// 没有解析到路径,记录到 missing 表中,后续会检测这张表,显示相关路径未找到的报错missing[id] = normalizePath(importer);}});

2.3 external 规则制定

上面我们分析了在 Esbuild 插件中如何针对 bare import 记录依赖,那么在记录的过程中还有一件非常重要的事情,就是决定哪些路径应该被排除,不应该被记录或者不应该被 Esbuild 来解析。这就是 external 规则的概念。在这里,需要 external 的路径分为两类: 资源型和模块型。

首先,对于资源型的路径,一般是直接排除,在插件中的处理方式如下:

// data url,直接标记 external: true,不让 esbuild 继续处理build.onResolve({ filter: dataUrlRE }, ({ path }) => ({path,external: true,}));// 加了 ?worker 或者 ?raw 这种 query 的资源路径,直接 externalbuild.onResolve({ filter: SPECIAL_QUERY_RE }, ({ path }) => ({path,external: true,}));// css & jsonbuild.onResolve({filter: /.(css|less|sass|scss|styl|stylus|pcss|postcss|json)$/,},// 非 entry 则直接标记 externalexternalUnlessEntry);// Vite 内置的一些资源类型,比如 .png、.wasm 等等build.onResolve({filter: new RegExp(`.(${KNOWN_ASSET_TYPES.join("|")})$`),},// 非 entry 则直接标记 externalexternalUnlessEntry);

其中,externalUnlessEntry的实现相对简单:

const externalUnlessEntry = ({ path }: { path: string }) => ({path,// 非 entry 则标记 externalexternal: !entries.includes(path),});

其次,对于模块型的路径,也就是当我们通过 resolve 函数解析出了一个 JS 模块的路径,如何判断是否应该被 externalize 呢?这部分实现主要在shouldExternalizeDep 函数中,核心代码如下:

export function shouldExternalizeDep(resolvedId: string,rawId: string): boolean {// 解析之后不是一个绝对路径,不在 esbuild 中进行加载if (!path.isAbsolute(resolvedId)) {return true;}// 1. import 路径本身就是一个绝对路径// 2. 虚拟模块(Rollup 插件中约定虚拟模块以`\0`开头)// 都不在 esbuild 中进行加载if (resolvedId === rawId || resolvedId.includes("\0")) {return true;}// 不是 JS 或者 类 HTML 文件,不在 esbuild 中进行加载if (!JS_TYPES_RE.test(resolvedId) && !htmlTypesRE.test(resolvedId)) {return true;}return false;}

三、依赖打包分析

3.1 产物文件结构

一般情况下,esbuild 会输出嵌套的产物目录结构,比如对于Vue 来说,其产物在dist/vue.runtime.esm-bundler.js中,那么经过 esbuild 正常打包之后,预构建的产物目录如下:

node_modules/.vite├── _metadata.json├── vue│ └── dist│ └── vue.runtime.esm-bundler.js

由于各个第三方包的产物目录结构不一致,这种深层次的嵌套目录对于 Vite 路径解析来说,其实是增加了不少的麻烦的,带来了一些不可控的因素。为了解决嵌套目录带来的问题,Vite 做了两件事情来达到扁平化的预构建产物输出。

  • 嵌套路径扁平化,/被换成下划线,如 react/jsx-dev-runtime,被重写为react_jsx-dev-runtime;
  • 用虚拟模块来代替真实模块,作为预打包的入口。

回到optimizeDeps函数中,在执行完依赖扫描的步骤后,就会执行路径的扁平化操作,代码如下。

const flatIdDeps: Record = {};const idToExports: Record = {};const flatIdToExports: Record = {};// deps 即为扫描后的依赖表// 形如: {//react :/Users/sanyuan/vite-project/react/index.js}//react/jsx-dev-runtime :/Users/sanyuan/vite-project/react/jsx-dev-runtime.js// }for (const id in deps) {// 扁平化路径,`react/jsx-dev-runtime`,被重写为`react_jsx-dev-runtime`;const flatId = flattenId(id);// 填入 flatIdDeps 表,记录 flatId -> 真实路径的映射关系const filePath = (flatIdDeps[flatId] = deps[id]);const entryContent = fs.readFileSync(filePath, "utf-8");// 后续代码省略}

对于虚拟模块的处理,大家可以把目光放到 esbuildDepPlugin 函数上面,它的逻辑实现大致如下:

export function esbuildDepPlugin(/* 一些传参 */) {// 定义路径解析的方法// 返回 Esbuild 插件return {name: 'vite:dep-pre-bundle',set(build) {// bare import 的路径build.onResolve({ filter: /^[\w@][^:]/ },async ({ path: id, importer, kind }) => {// 判断是否为入口模块,如果是,则标记上`dep`的 namespace,成为一个虚拟模块}}build.onLoad({ filter: /.*/, namespace: 'dep' }, ({ path: id }) => {// 加载虚拟模块}}}

如此一来,Esbuild 会将虚拟模块作为入口来进行打包,最后的产物目录会变成如下的扁平结构:

node_modules/.vite├── _metadata.json├── vue.js├── react.js├── react_jsx-dev-runtime.js

不过,需要说明的是,虚拟模块加载部分的代码在 Vite 3.0 中已被移除,原因是 Esbuild 输出扁平化产物路径已不再需要使用虚拟模块。

3.2 代理模块加载

虚拟模块代替了真实模块作为打包入口,因此也可以理解为代理模块,后面也统一称之为代理模块。我们首先来分析一下代理模块究竟是如何被加载出来的,换句话说,它到底了包含了哪些内容。

拿import React from "react"来举例,Vite 会把react标记为 namespace 为 dep 的虚拟模块,然后控制 Esbuild 的加载流程,然后使用真实模块的内容进行重新导出。所以,第一步就是确定真实模块的路径:

// 真实模块所在的路径,拿 react 来说,即`node_modules/react/index.js`const entryFile = qualified[id];// 确定相对路径let relativePath = normalizePath(path.relative(root, entryFile));if (!relativePath.startsWith("./") &&!relativePath.startsWith("../") &&relativePath !== ".") {relativePath = `./${relativePath}`;}

确定了路径之后,接下来就是对模块的内容进行重新导出。这里需要分以下两种情况:

  • CommonJS 模块
  • ES 模块

那么,如何来识别这两种模块规范呢?我们可以暂时把目光转移到optimizeDeps中,实际上在进行真正的依赖打包之前,Vite 会读取各个依赖的入口文件,通过es-module-lexer这种工具来解析入口文件的内容。这里稍微解释一下es-module-lexer,这是一个在 Vite 被经常使用到的工具库,主要是为了解析 ES 导入导出的语法,大致用法如下:

import { init, parse } from "es-module-lexer";// 等待`es-module-lexer`初始化完成await init;const sourceStr = `import moduleA from './a';export * from 'b';export const count = 1;export default count;`;// 开始解析const exportsData = parse(sourceStr);// 结果为一个数组,分别保存 import 和 export 的信息const [imports, exports] = exportsData;// 返回 `import module from './a'`sourceStr.substring(imports[0].ss, imports[0].se);// 返回 ['count', 'default']console.log(exports);

值得注意的是, export * from 导出语法会被记录在 import 信息中。接下来,我们来看看 optimizeDeps 中如何利用 es-module-lexer工具来解析入口文件的,实现代码如下:

import { init, parse } from "es-module-lexer";// 省略中间的代码await init;for (const id in deps) {// 省略前面的路径扁平化逻辑// 读取入口内容const entryContent = fs.readFileSync(filePath, "utf-8");try {exportsData = parse(entryContent) as ExportsData;} catch {// 省略对 jsx 的处理}for (const { ss, se } of exportsData[0]) {const exp = entryContent.slice(ss, se);// 标记存在 `export * from` 语法if (/export\s+*\s+from/.test(exp)) {exportsData.hasReExports = true;}}// 将 import 和 export 信息记录下来idToExports[id] = exportsData;flatIdToExports[flatId] = exportsData;}

在代码的最后,会有两张表记录下 ES 模块导入和导出的相关信息,而flatIdToExports表会作为入参传给 Esbuild 插件:

// 第二个入参esbuildDepPlugin(flatIdDeps, flatIdToExports, config, ssr);

如此,我们就能根据真实模块的路径获取到导入和导出的信息,通过这份信息来甄别 CommonJS 和 ES 两种模块规范。现在,回到 Esbuild 打包插件中加载代理模块的代码:

let contents = "";// 下面的 exportsData 即外部传入的模块导入导出相关的信息表// 根据模块 id 拿到对应的导入导出信息const data = exportsData[id];const [imports, exports] = data;if (!imports.length && !exports.length) {// 处理 CommonJS 模块} else {// 处理 ES模块}

如果是 CommonJS 模块,则执行如下的代码:

let contents = "";contents += `export default require( ${relativePath} );`;

如果是ES 模块,则分默认导出和非默认导出这两种情况来处理:

// 默认导出,即存在 export default 语法if (exports.includes("default")) {contents += `import d from${relativePath} ;export default d;`;}// 非默认导出if (// 1. 存在 `export * from` 语法,前文分析过data.hasReExports ||// 2. 多个导出内容exports.length > 1 ||// 3. 只有一个导出内容,但这个导出不是 export defaultexports[0] !== "default") {// 凡是命中上述三种情况中的一种,则添加下面的重导出语句contents += `\nexport * from${relativePath} `;}

现在,已经组装好了代理模块 的内容,接下来就可以放心地交给 Esbuild 加载了:

let ext = path.extname(entryFile).slice(1);if (ext === "mjs") ext = "js";return {loader: ext as Loader,// 虚拟模块内容contents,resolveDir: root,};

3.3 代理模块为何要与真实模块分离

现在,我们已经清楚了 Vite 是如何组装代理模块,以此作为 Esbuild 打包入口的,整体的思路就是先分析一遍模块真实入口文件的import和export语法,然后在代理模块中进行重导出。那为什么代理模块要与真实模块进行区分呢?

对于这个问题,官方的回答是:这种重导出的做法是必要的,它可以分离虚拟模块和真实模块,因为真实模块可以通过相对地址来引入。如果不这么做,Esbuild 将会对打包输出两个一样的模块。

刚开始看的确不太容易理解,接下来我会通过对比的方式来告诉你这种设计到底解决了什么问题。假设我不像源码中这么做,在虚拟模块中直接将真实入口的内容作为传给 Esbuild,代码如下:

build.onLoad({ filter: /.*/, namespace: 'dep' }, ({ path: id }) => {// 拿到查表拿到真实入口模块路径const entryFile = qualified[id];return {loader: 'js',contents: fs.readFileSync(entryFile, 'utf8');}}

那么,此时会产生什么问题呢?我们可以先看看正常的预打包流程:

可以看到,Vite 会使用 dep:react这个代理模块来作为入口内容在 Esbuild 中进行加载,与此同时,其他库的预打包也有可能会引入 React,比如@emotion/react这个库里面会有require(‘react’)的行为。那么在 Esbuild 打包之后,react.js与@emotion_react.js的代码中会引用同一份 Chunk 的内容,这份 Chunk 也就对应 React 入口文件(node_modules/react/index.js)。

当然,这是理想情况下的打包结果,接下来我们来看看上述有问题的版本是如何工作的:

现在如果代理模块通过文件系统直接读取真实模块的内容,而不是进行重导出,因此由于此时代理模块跟真实模块并没有任何的引用关系,这就导致最后的react.js和@emotion/react.js两份产物并不会引用同一份 Chunk,Esbuild 最后打包出了内容完全相同的两个 Chunk。

这也就能解释为什么 Vite 中要在代理模块中对真实模块的内容进行重导出了,主要是为了避免 Esbuild 产生重复的打包内容。