从零读 Vite 4.0 源码(三)———— vite 预构建
为什么需要预构建
我们先看一下官网的定义: 当首次启动 vite 时,Vite 在本地加载你的站点之前预构建了项目依赖。默认情况下,它是自动且透明地完成的。
执行预构建的目的是:
- CommonJS 和 UMD 兼容性: 在开发阶段中,Vite 的开发服务器将所有代码视为原生 ES 模块。因此,Vite 必须先将以 CommonJS 或 UMD 形式提供的依赖项转换为 ES 模块。
在转换 CommonJS 依赖项时,Vite 会进行智能导入分析,这样即使模块的导出是动态分配的(例如 React),具名导入(named imports)也能正常工作。
// 符合预期
import React, { useState } from 'react'
- 性能: 为了提高后续页面的加载性能,Vite将那些具有许多内部模块的 ESM 依赖项转换为单个模块。
有些包将它们的 ES 模块构建为许多单独的文件,彼此导入。例如,lodash-es 有超过 600 个内置模块!当我们执行 import { debounce } from ’lodash-es’ 时,浏览器同时发出 600 多个 HTTP 请求!即使服务器能够轻松处理它们,但大量请求会导致浏览器端的网络拥塞,使页面加载变得明显缓慢。
通过将 lodash-es 预构建成单个模块,现在我们只需要一个HTTP请求!
以上摘自:https://cn.vitejs.dev/guide/dep-pre-bundling.html
预构建的实现
官网上对预构建的定义的目的描述的十分明确,那么下面我们通过官网的说明,来看看预构建是如何实现的。
先整理下官网对实现细节的一些说明:
- 首次启动 vite 时进行构建。vite 会扫描源代码,自动寻找引入的依赖项。
- 将依赖项作为预构建的入口,使用 esbuild 执行。
- 将 CommonJS 或 UMD 模块转换为 ES 模块。
接下来,让我们深入源码,来看一下预构建具体是如何实现的。
首先,我们发现预构建的入口文件是:vite/packages/vite/src/node/optimizer/index.ts, 对应的函数是:optimizeDeps
vite 中执行预构建的时机有两处:
一个是执行 vite optimizer 命令时,会调用 optimizeDeps, 执行预构建。
一个是在启动 server 启动监听时,会初始化预构建:
const listen = httpServer.listen.bind(httpServer)
httpServer.listen = (async (port: number, ...args: any[]) => {
try {
createDepsOptimizer()
} catch (e) {
return
}
return listen(port, ...args)
})
vite optimize 触发的预构建流程较为完整,我们先以 optimizeDeps 函数来说明预构建的具体实现, 下面我们来看下具体的实现:
async function optimizeDeps(
config
): Promise<DepOptimizationMetadata> {
// 1.扫描源码,自动寻找依赖。
const deps = await discoverProjectDependencies(config).result;
// 2.将发现的依赖项转换为优化后的依赖项
const depsInfo = toDiscoveredDependencies(config, deps, true)
// 3. 预构建项目依赖
const result = await runOptimizeDeps(config, depsInfo).result
// 4. 执行构建成功后的一些处理,如将预构建从临时缓存目录拷贝到缓存目录中
await result.commit()
return result.metadata
}
可以看到预构建过程主要包括自动寻找依赖和预构建产物。
esbuild 来实现依赖收集与构建
我们先来看一下第一步:如何通过esbuild 来实现依赖收集与构建
import esbuild from 'esbuild';
export function discoverProjectDependencies(config) {
// 重要!请在以下代码中记得观察此变量的写入时机(esbuildScanPlugin 内)
const deps = {};
// 将根目录下的 html 后缀的文件作为依赖收集的入口
const entries = await globEntries('**/*.html');
// 自定义一个 esbuild 插件,用来扫描依赖
const plugin = esbuildScanPlugin(entries, deps);
// esbuild.context() 方法进行构建操作,具体详见 esbuild 官网:https://esbuild.github.io/api/#build
const esbuildContext = await esbuild.context({
// 指定工作目录的绝对路径:当前 node.js 进程的工作目录
absWorkingDir: process.cwd(),
// 禁止将构建结果写入硬盘,而是直接返回给调用者
write: false,
// 指定要构建的代码内容和加载器类型
stdin: {
// 是一个字符串,表示要构建的代码
contents: entries.map((e) => `import ${JSON.stringify(e)}`).join('\n'),
// 加载器是 js
loader: 'js',
},
// 指 esbuild 将所有模块打包成一个单独的文件
bundle: true,
// 指定构建结果的输出格式为 ESM
format: 'esm',
// 不输出日志信息
logLevel: 'silent',
// 指定使用的 plugin 插件
plugins: [plugin],
// 指定 ts 的配置文件路径
tsconfig,
// 解析 ts 的配置文件
tsconfigRaw: resolveTsconfigRaw(tsconfig, tsconfigRaw)
});
// 手动调用 esbuild 构建
await esbuildContext.rebuild();
return deps;
}
vite 首先会将 html 文件作为 entry ,之后借助 esbuild 来将依赖打包为 ESM 格式并输出。可以看到 vite 主要是借助自定义 esbuild 插件 esbuildScanPlugin 来实现依赖收集,我们接下来就来看一下 esbuildScanPlugin 插件的具体实现:
import fsp from 'node:fs/promises'
function esbuildScanPlugin(
config,
container,
deps,
) {
return {
name: 'vite:dep-scan',
setup(build) {
// 拦截 html 后缀的路径, 对路径进行处理,避免直接将其映射到文件系统。
build.onResolve({ filter: /\.html$/ }, async ({ path, importer }) => {
// ? 调用插件容器的 resoveId 接口
const resolved = await pluginContainer.resolveId(id, importer);
return {
path: resolved,
// 使用 "html" 命名空间标记,保留给插件使用
namespace: 'html',
}
})
// 拦截上一步 html 后缀,命名空间为 'html' 的对象,并提取 html 文件中的 script,将其按照 JS 模块处理
build.onLoad(
{ filter: /\.html$/, namespace: 'html' },
async ({ path }) => {
// 读取该文件的内容
let raw = await fsp.readFile(path, 'utf-8');
let match = null;
// 正则解释可参考:https://jex.im/regulex/#!flags=&re=%5E(a%7Cb)*%3F%24
const scriptRE = /(<script(?:\s+[a-z_:][-\w:]*(?:\s*=\s*(?:"[^"]*"|'[^']*'|[^"'<>=\s]+))?)*\s*>)(.*?)<\/script>/gis
// 匹配 script 内容
let js = '';
while ((match = scriptRE.exec(raw))) {
const [, openTag, content] = match;
// 正则可参考:https://jex.im/regulex/#!flags=i&re=%5Cbsrc%5Cs*%3D%5Cs*(%3F%3A%22(%5B%5E%22%5D%2B)%22%7C'(%5B%5E'%5D%2B)'%7C(%5B%5E%5Cs'%22%3E%5D%2B))
const srcRE = /\bsrc\s*=\s*(?:"([^"]+)"|'([^']+)'|([^\s'">]+))/i
// 匹配 src 属性的值
const srcMatch = openTag.match(srcRE)
if (srcMatch) {
const src = srcMatch[1] || srcMatch[2] || srcMatch[3]
// 将其转换为 import 语法
js += `import ${JSON.stringify(src)}\n`
}
if (!js.includes('export default')) {
js += '\nexport default {}';
}
}
return {
loader: 'js',
contents: js,
}
}
)
// 处理 bareImports(直接通过模块名称导入而未指定具体的模块名称或路径)
build.onResolve(
{
// avoid matching windows volume
filter: /^[\w@][^:]/,
},
async ({ path: id, importer, pluginData }) => {
if (id.includes('node_modules')) {
// 重要!收集依赖的所有三方库; resolvedId 为 id 的绝对路径;
// deps 变量会传递到上层,对 deps 依赖做进一步处理。
deps[id] = resolvedId;
return {
path,
external: !entries.includes(path),
}
}
},
)
}
}
}
esbuildScanPlugin 插件主要做的事情是将 html 文件内的 script 脚本转换为 js 的 Import 语法,便于 esbuild 进行依赖分析。之后收集所有依赖的三方库,用于进行后续的处理。
依赖项转换为优化后的依赖项
接下来我们来看一下第 2步,将发现的依赖项转换为优化后的依赖项,这一步主要是用于后续的 xx 处理:
const discovered: Record<string, OptimizedDepInfo> = {}
for (const id in deps) {
const src = deps[id]
discovered[id] = {
id,
file: getOptimizedDepPath(id, config),
src,
// 生成一个特定的哈希值来缓存和重用产物;
// 会基于以下几个来源来确定是否需要重新运行预构建:
// - 包管理器的锁文件内容,例如 package-lock.json,yarn.lock,pnpm-lock.yaml,或者 bun.lockb;
// - 补丁文件夹的修改时间;
// - vite.config.js 中的相关字段;
// - NODE_ENV 的值。
// - deps 的值
browserHash,
}
}
return discovered
这里提供一个转换后的示例:
deps:
{
"antd": "../../.pnpm/antd@4.16.13_react-dom@17.0.2_react@17.0.2/node_modules/antd/es/index.js",
}
discovered:
{
"antd": {
"src": "../../.pnpm/antd@4.16.13_react-dom@17.0.2_react@17.0.2/node_modules/antd/es/index.js",
"file": "antd.js",
"browserHash": "124f4611",
"id": "antd"
},
}
预构建项目依赖
上一步收集完三方库的所有依赖,并生成了对应的 discovered 对象后,开始进行三方库的依赖预构建,并将构建结果存至物理缓存(.vite/deps)目录下:
function runOptimizeDeps(depsInfo, config) {
const depsCacheDir = getDepsCacheDir(config)
// 可在你的 vite 项目下,寻找到该目录:/.vite/deps_temp_97303159
const processingCacheDir = getProcessingDepsCacheDir(resolvedConfig)
// 创建一个临时的文件夹用于存放依赖,避免出现错误时导致真正的缓存依赖目录不可用
// 这是 node 中比较通用的一个处理方式:
fs.mkdirSync(processingCacheDir, { recursive: true })
// esbuild 构建准备
const preparedRun = prepareEsbuildOptimizerRun(depsInfo);
const successfulResult = {
commit: async () => {
// 1. 在临时缓存目录中,写入 meta.json,内容是模块的一些基本信息
// 2. 将临时缓存目录的内容,写入到真正的缓存目录中(./vite/deps)
}
// 预构建的包相关的信息
metadata: {}
}
const runResult = preparedRun.then(({ context, idToExports }) => {
return context
.rebuild()
.then((result) => {
return successfulResult
})
});
}
我们主要看下 esbuild 构建准备那一步:prepareEsbuildOptimizerRun, 这一步不要和上述寻找依赖时使用的 esbuild 构建配置混淆。二者是不同的。
我们来看一下这个的具体实现:
function prepareEsbuildOptimizerRun(depsInfo) {
// 自定义一个 esbuild 插件:vite:dep-pre-bundle,
// 是为了解决某些依赖模块可能不支持原生 ES 模块,或者它们包含一些无法被动态导入的代码的情况。
const depPlugin = esbuildDepPlugin(flatIdDeps, external, config, ssr);
const context = await esbuild.context({
// 指定用于解析相对路径的绝对工作目录
absWorkingDir: process.cwd(),
// 构建入口:是上一步生成的 depsInfo 的 key 组成的数组,实际 vite 中对 key 进行了展平,便于处理
entryPoints: Object.keys(depsInfo),
// 创建单个打包文件
bundle: true,
// 指定输出的模块格式
format: 'esm',
// 指定要添加到输出捆绑包前面的字符串或对象。在这种情况下,如果platform为'node',则添加了一个用于Node.js的动态导入替代方案,使用了'module'包中的createRequire函数。
// See https://github.com/evanw/esbuild/issues/1921#issuecomment-1152991694
banner:
platform === 'node'
? {
js: `import { createRequire } from 'module';const require = createRequire(import.meta.url);`,
}
: undefined,
target: [
'es2020', // support import.meta.url
'edge88',
'firefox78',
'chrome87',
'safari14',
],
// 一个包含外部依赖项的数组,这些依赖项不应被打包,而应被视为外部导入。
external,
// 指定构建过程的日志级别
logLevel: 'error',
// 指示是否启用代码拆分
splitting: true,
// 指示是否生成源映射
sourcemap: true,
// 指定打包文件的输出目录
outdir: processingCacheDir,
// 是否忽略注释
ignoreAnnotations: !isBuild,
// 是否生成包含有关构建的其他信息的元文件。
metafile: true,
// 一个用于构建过程中使用的插件数组
plugins: [depPlugin],
charset: 'utf8',
// TypeScript配置文件的路径。
tsconfig,
// 配置文件的原始内容
tsconfigRaw: resolveTsconfigRaw(tsconfig, tsconfigRaw),
// 指定构建支持的功能的对象
supported: {
'dynamic-import': true,
'import-meta': true,
},
})
return context
}
根据以上配置,可以得出 esbuild 构建依赖是通过将三方库指定为入口文件,并打包为单个 esm 格式文件,产出会存至临时缓存目录中。
server 阶段的预构建流程
function createDepsOptimizer() {
// 创建 depsOptimizer 上下文对象
const depsOptimizer = {
metadata
// ... 省略一些公共函数
}
// 仅在 dev 模式下执行
depsOptimizer.scanProcessing = new Promise((resolve) => {
// 在后台运行,避免阻塞其他高优项目
;(async () => {
try {
const discover = discoverProjectDependencies(config)
const deps = await discover.result
discover = undefined
//
optimizationResult = runOptimizeDeps(config, deps)
} catch (e) {
} finally {
resolve()
depsOptimizer.scanProcessing = undefined
}
})()
})
// 在开发过程中,当服务器启动并所有的静态 import 都已经被爬取时,会调用 onCrawlEnd 回调。
async function onCrawlEnd() {
// 等待后台的扫描及生成预构建完毕
// 通常在爬取用户代码完毕时,此步骤也将完成
await depsOptimizer.scanProcessing
}
}
可以看到与使用 vite optimize 的实现有细微差别。在启动server监听阶段,会开始扫描项目中的静态 import,并生成预构建的依赖。但是预构建并不会阻塞其他任务的执行,而是当用户发起第一个请求且爬取所有import 完毕后,才会等待预构建生成完毕。
为三方库的依赖设置别名:
先来看一下编译后的源码示例:
import React from "react";
// 409e4d5f 对应 metadata.json 中的 browserHash
import __vite__cjsImport0_react from "/node_modules/.vite/deps/react.js?v=409e4d5f";
const React = __vite__cjsImport0_react.__esModule ? __vite__cjsImport0_react.default : __vite__cjsImport0_react;
// ...
可以看到 vite 对路径做了转换,这一步的转换是由 vite 的一个自定义 plugin 完成的:
function preAliasPlugin() {
return {
name: 'vite:pre-alias',
async resolveId(id, importer, options) {
// 如果是三方依赖的路径,则进行替换
if (resolved.id.startsWith('/.vite/deps')) {
return { id: `${depInfo.file}?v=${depInfo.browserHash}`}
}
}
}
}
浏览器缓存的处理
利用强缓存
对于已经预构建的依赖的请求使用 HTTP 头 max-age=31536000, immutable 进行强缓存,以提高开发期间页面重新加载的性能。一旦被缓存,这些请求将永远不会再次访问开发服务器。
这个处理是在我们熟悉的 viteTransformMiddleware 中间件中处理的:
return async function viteTransformMiddleware(req, res, next) {
// resolve, load and transform using the plugin container
const result = await transformRequest(url);
// 设置缓存相关请求头
res.setHeader('Etag', result.etag);
// 若 url 是在 "/node_modules/.vite" 路径下,表示是请求了三方依赖的情况下,则进行强缓存
res.setHeader('Cache-Control', 'max-age=31536000,immutable')
res.statusCode = 200
// 将转换后的内容返回
res.end(result.code)
}
缓存失效
当依赖的三方库的版本号被修改,或者对应的 patch 被修改,vite 并不会自动更新缓存,而是会继续使用旧的缓存。 只有重新启动 server 时,才会重新进行构建。
但在 .vite/deps/metadata.json 中,记录了很多 Hash 值,这些 hash 值的作用都是什么呢?
metadata.json 文件示例
{
"hash": "75d0a02b",
"browserHash": "409e4d5f",
"optimized": {
"react": {
"src": "../../.pnpm/react@17.0.2/node_modules/react/index.js",
"file": "react.js",
"fileHash": "3cc9f47d",
"needsInterop": true
},
}
}
hash 基于以下几个来源生成:
- package-lock.json/yarn.lock/pnpm-lock.yaml/bun.lockb 的内容
- patches 的时间戳(fs.stat.mtimeMs)
- 与预构建相关的配置项
其中:browserHash 基于以下几个来源生成:
- 上一步 hash 的内容
- 收集的所有依赖项的内容 (上述代码中对应的 deps 字段)
fileHash 基于以下几个来源生成:
- 上述的 hash 内容
- 文件路径
- 文件依赖的模块(esbuild metafile 中的 exports.outputs[‘filePath’].imports)
browserhash 的值被内联到了 import 语句中,这样浏览器就可以根据这个值进行缓存。当 lock 文件被修改,或者 patch 被修改时,或预构建相关的配置项被修改时,浏览器缓存会失效。并去加载最新的预构建产物。
hash 和 fileHash 未找到明确用途,找到我再补充。