都说 Vite 快,快在哪里?又是怎么个快法呢?
一个对比实验引发的思考
这里有两个项目,通过对比,我们可以发现 Vite 在启动项目前都干了什么。
项目一:自建项目
使用 npm init -y
新建项目,安装 lodash-es
,之所以不用 lodash
是因为 lodash
不是通过 ES Module
形式开发的,直接通过相对路径引入会报错,需要通过 Webpack
打包构建。
再新建一个 main.js
和 index.html
,并在 index.html
中引入 main.js
。
// main.js 随便导入一个方法
import uniq from './node_modules/lodash-es/uniq.js'
const arr = [1, 2, 3, 3, 4]
console.log(uniq(arr))
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Document</title>
<script src="./main.js" type="module"></script>
</head>
<body></body>
</html>
注意:在使用 src
引入时,必须添加 type="module"
属性,浏览器才能正确识别 ES Module
语法。
使用 VS Code 的 Live Server 拓展,快速启动项目,在浏览器中打开,此时我们可以看下 Network
情况:
可以看到刚刚写的 main.js
脚本加载成功了 ↓↓↓
接着往下翻,你会发现,底下还有一堆脚本也被加载了进来,腚眼儿一看,总共58个请求,加载总耗时555ms ↓↓↓
??? 底下这些是从哪里冒出来的,我们可以点开 uniq.js
,不难看出,uniq 方法又依赖到了 _baseUniq.js
,所以紧接着 _baseUniq.js
也被加载了进来。于是乎,套娃就这样产生了。 ↓↓↓
点开 initiator
同样也能看到对应的依赖关系。↓↓↓
这就是不做任何处理,单纯地拉取一个的方法所需要的消耗。可想而知,一个大型项目的开发会带来怎样的效果······
项目二:Vite 脚手架
创建项目就不赘述了,直接两条命令跑一下:
# 创建一个 Vue 的项目
npm create vite@latest my-vue-app -- --template vue
# 启动
npm run dev
看下效果,请求直接来到8条,只有原来的零头,减少了6倍之多,而耗时则降低到 200 多毫秒,效率提高了一倍不止。↓↓↓
究其原因,我们点开这个被改了名字的 lodash-ed_uniq.js
就可以看出:Vite 将需要用到的资源提前整理到一个模块中,这样,我们只需要请求一个 HTTP 请求就可以了。↓↓↓
因此,我们可以得出以下结论:Vite 会在 DevServer 启动前对需要预构建的依赖进行构建,然后在分析模块的导入("bare import" 即裸依赖,表示从 node_modules 中解析)时会动态地应用构建过的依赖。
执行流程
- 在项目根目录下运行
npm run dev
,也就是运行 vite 命令后,会执行node_modules/.bin/vite
脚本:#!/bin/sh basedir=$(dirname "$(echo "$0" | sed -e 's,\\,/,g')") case `uname` in *CYGWIN*|*MINGW*|*MSYS*) basedir=`cygpath -w "$basedir"`;; esac if [ -x "$basedir/node" ]; then exec "$basedir/node" "$basedir/../vite/bin/vite.js" "$@" else exec node "$basedir/../vite/bin/vite.js" "$@" fi
- 上述脚本会执行
node_modules/vite/bin/vite.js
脚本,其中有一个start
函数:#!/usr/bin/env node function start() { return import('../dist/node/cli.js') } if (profileIndex > 0) { // 略.... } else { start() }
- 最终会执行 cli 文件中的
command('[root]')
,这也是默认执行的脚本。对应到 Vite 源码位置:// packages\vite\src\node\cli.ts 102 // dev cli .command('[root]', 'start dev server') // default command .alias('serve') // the command is called 'serve' in Vite's API .alias('dev') // alias to align with the script name .option('--host [host]', `[string] specify hostname`) .option('--port <port>', `[number] specify port`) .option('--https', `[boolean] use TLS + HTTP/2`) .option('--open [path]', `[boolean | string] open browser on startup`) .option('--cors', `[boolean] enable CORS`) .option('--strictPort', `[boolean] exit if specified port is already in use`) .option( '--force', `[boolean] force the optimizer to ignore the cache and re-bundle`, ) .action(async (root: string, options: ServerOptions & GlobalCLIOptions) => { filterDuplicateOptions(options) // output structure is preserved even after bundling so require() // is ok here const { createServer } = await import('./server') try { const server = await createServer({ root, base: options.base, mode: options.mode, configFile: options.config, logLevel: options.logLevel, clearScreen: options.clearScreen, optimizeDeps: { force: options.force }, server: cleanOptions(options), }) if (!server.httpServer) { throw new Error('HTTP server not available') } await server.listen() // 略...... })
源码浅析
接下来我们主要看一看 createServer
以及其中依赖预构建的过程。
createServer 函数
一开始的createServer 做了很多工作,如配置文件初始化、构建 plugin 运行容器、初始化模块依赖、创建 server、添加比较重要的 transformMiddleware 等。源码 →
// packages/vite/src/node/server/index.ts 316
export async function createServer(
inlineConfig: InlineConfig = {}
): Promise<ViteDevServer> {
// 初始化 config、创建 connect 服务、hmr 热更新、创建插件运行容器等......略
// 通常情况下我们会命中这个逻辑
if (!middlewareMode && httpServer) {
// overwrite listen to init optimizer before server start
// (重写 DevServer 的 listen,保证在 DevServer 启动前进行依赖预构建)
const listen = httpServer.listen.bind(httpServer)
httpServer.listen = (async (port: number, ...args: any[]) => {
try {
await initServer()
} catch (e) {
httpServer.emit('error', e)
return
}
return listen(port, ...args)
}) as any
} else {
await initServer()
}
return server
}
补充:
- 在历次版本中,
createServer
进行了多次重构,很多参考资料中都有提到runOptimize
这个函数,但在v2.9.0-beta.7
的版本中被移除,具体改动可以查看这个PR - 从
v3.0.0-beta.7
至目前的版本中,createServer
使用initServer
来初始化 Server,具体改动可以查看这个PR
预构建
_metadata.json 和 package.json
_metadata.json
和 package.json
都是预构建的产物,打开 node_modules/.vite/deps
目录,可以看到以下结构:
// _metadata.json
{
"hash": "3b5a1d21",
"browserHash": "a45b2510",
"optimized": {
"lodash-es/uniq.js": {
"src": "../../lodash-es/uniq.js",
"file": "lodash-es_uniq__js.js",
"fileHash": "f42ffe15",
"needsInterop": false
}
},
"chunks": {}
}
属性 | 说明 | 作用 |
---|---|---|
hash | 由需要进行预构建的文件内容生成的 | 用于防止 DevServer 启动时重复构建相同的依赖,即依赖并没有发生变化,不需要重新构建 |
browserHash | 由 hash 与 deps 生成的8位 hash 值 | 浏览器文件请求时会携带,用于使预构建的依赖的浏览器请求无效 |
optimized | 包含每个进行过预构建的依赖 | |
src | 源码的相对路径 | |
file | 预构建生成的地址 | |
fileHash | 由 hash 和 file 生成,作者预留属性 | |
needsInterop | 是否是 CommonJS 模块转成的 ESM 模块 | 会对需要预构建且为 CommonJS 的依赖导入代码进行重写 |
// package.json
{"type":"module"}
添加 package.json
文件是因为所有的缓存文件都应被识别为 ES Module 模块。
相关函数
预构建的过程会依次经过以下几个函数的调用,篇幅有限,这里就不把所有代码都贴出来了,名字和路径都附在下面,有兴趣的同学可以去对应的源码中查看:
initServer
(packages/vite/src/node/server/index.ts 615)initDepsOptimizer
(packages/vite/src/node/optimizer/optimizer.ts 53)createDepsOptimizer
(packages/vite/src/node/optimizer/optimizer.ts 92)runOptimizeDeps
(packages/vite/src/node/optimizer/index.ts 449)
这里列出部分相关的函数,做个简单的分析:
createDepsOptimizer 函数
async function createDepsOptimizer(
config: ResolvedConfig,
server?: ViteDevServer,
): Promise<void> {
// ......
// step1. 根据 config 中的 cacheDir 读取缓存的 metadata 信息
const cachedMetadata = loadCachedDepOptimizationMetadata(config, ssr)
// 如果之前有缓存的 metadata,将不再创建依赖
// 比如之前已经 run 过 dev了,即使关掉再跑也不会再走这里,除非加上"--force"
if (!cachedMetadata) {
//......
// step2. 给 metadata 添加 discovered 对象,里面包含 browserHash、hash、id、file 等属性
const deps = {};
await addManuallyIncludedOptimizeDeps(deps, config, ssr);
const discovered = await toDiscoveredDependencies(config, deps, ssr, sessionTimestamp);
for (const depInfo of Object.values(discovered)) {
addOptimizedDepInfo(metadata, 'discovered', {
...depInfo,
processing: depOptimizationProcessing.promise,
});
newDepsDiscovered = true;
}
if (!isBuild) {
depsOptimizer.scanProcessing = new Promise((resolve) => {
setTimeout(async () => {
try {
// step3. 扫描并获取依赖
const deps = await discoverProjectDependencies(config)
// step4. 创建依赖(主要是runOptimizeDeps)
const knownDeps = prepareKnownDeps()
postScanOptimizationResult = runOptimizeDeps(config, knownDeps)
}
// ......
}, 0)
})
}
}
}
该函数在确认没有读取到缓存的 cacheMetadata
后,会生成一份新的 metaData
,里面包含本次依赖的各种信息。
开发模式下调用 discoverProjectDependencies
扫描函数[4]分析项目中依赖的第三方模块,然后将模块名作为 key,绝对地址作为 value 得到 deps
对象。而依赖扫描的核心思路就是先将代码解析成 AST 抽象语法树,然后找到 Import 节点。而 Vite 也是借助了打包工具 esbuild
进行这步处理,具体可以查看 scanImports
函数。地址:
// packages/vite/src/node/optimizer/scan.ts 50
而 prepareKnownDeps
主要是拷贝了一份 deps 对象,并将 metadata
中的依赖信息合并,以供 runOptimizeDeps
需要。最终得到的 deps
和 knownDeps
结构如下:
// deps:
{
"lodash-es/uniq.js": "E:/vite-demo/vite-lodash/node_modules/lodash-es/uniq.js",
}
// knownDeps:
{
"lodash-es/uniq.js": {
id: "lodash-es/uniq.js",
file: "E:/vite-demo/vite-lodash/node_modules/.vite/deps/lodash-es_uniq__js.js",
src: "E:/vite-demo/vite-lodash/node_modules/lodash-es/uniq.js",
browserHash: "bff80963",
exportsData: {},
},
}
runOptimizeDeps 函数
这个函数是依赖预构建的核心。
// packages/vite/src/node/optimizer/index.ts 449
import { build } from 'esbuild'
// vite 启动就会调用这个函数,并处理 metadata 信息,而不需要等 optimizeDeps 完成。
// 说这句话是因为以前的版本在 createServer 时,需要调用 optimizeDeps(),
// 而现在优化了延时加载 deps 的逻辑,避免在抓取完成之前全页面加载,不再调用上述函数。
export async function runOptimizeDeps(
resolvedConfig: ResolvedConfig,
depsInfo: Record<string, OptimizedDepInfo>,
ssr: boolean = resolvedConfig.command === 'build' &&
!!resolvedConfig.build.ssr,
): Promise<DepOptimizationResult> {
const isBuild = resolvedConfig.command === 'build'
const config: ResolvedConfig = {
...resolvedConfig,
command: 'build',
}
// 依赖的文件目录 'E:/xx/xx/node_modules/.vite/deps'
const depsCacheDir = getDepsCacheDir(resolvedConfig, ssr)
// 临时缓存目录 'E:/xx/xx/node_modules/.vite/deps_temp'
const processingCacheDir = getProcessingDepsCacheDir(resolvedConfig, ssr)
// Create a temporal directory so we don't need to delete optimized deps
// until they have been processed. This also avoids leaving the deps cache
// directory in a corrupted state if there is an error
// 清空之前错误状态下的deps_temp缓存
if (fs.existsSync(processingCacheDir)) {
emptyDir(processingCacheDir)
} else {
fs.mkdirSync(processingCacheDir, { recursive: true })
}
// a hint for Node.js
// all files in the cache directory should be recognized as ES modules
// 给 .vite/deps 缓存目录添加 package.json,因为所有的缓存文件都应被识别为ES模块。
writeFile(
path.resolve(processingCacheDir, 'package.json'),
JSON.stringify({ type: 'module' }),
)
// 初始化依赖的 metadata 信息
const metadata = initDepsOptimizerMetadata(config, ssr)
// 生成由 hash 与 deps 组成的8位 browserHash(浏览器文件的请求会携带该 browserHash)
metadata.browserHash = getOptimizedBrowserHash(
metadata.hash,
depsFromOptimizedDepInfo(depsInfo),
)
// 略......
// 遍历收集所有预构建的模块列表
// flatIdDeps: { flatId: 对应模块的绝对路径 }
const flatIdDeps: Record<string, string> = {}
// idToExports: { id: 对应模块的AST,是一个数组 }
const idToExports: Record<string, ExportsData> = {}
// flatIdToExports: { flatId: 对应模块的AST,是一个数组 }
const flatIdToExports: Record<string, ExportsData> = {}
const optimizeDeps = getDepOptimizationConfig(config, ssr)
const { plugins: pluginsFromConfig = [], ...esbuildOptions } =
optimizeDeps?.esbuildOptions ?? {}
// 遍历收集,通过 `es-module-lexer` 将模块转换成 AST,并赋值给 exportsData
for (const id in depsInfo) {
const src = depsInfo[id].src!
const exportsData = await (depsInfo[id].exportsData ??
extractExportsData(src, config, ssr))
if (exportsData.jsxLoader) {
// Ensure that optimization won't fail by defaulting '.js' to the JSX parser.
// This is useful for packages such as Gatsby.
esbuildOptions.loader = {
'.js': 'jsx',
...esbuildOptions.loader,
}
}
const flatId = flattenId(id)
flatIdDeps[flatId] = src
idToExports[id] = exportsData
flatIdToExports[flatId] = exportsData
}
// build 打包
const plugins = [...pluginsFromConfig]
if (external.length) {
plugins.push(esbuildCjsExternalPlugin(external, platform))
}
plugins.push(esbuildDepPlugin(flatIdDeps, external, config, ssr))
const start = performance.now()
const result = await build({
absWorkingDir: process.cwd(),
entryPoints: Object.keys(flatIdDeps), // ['lodash-es']
bundle: true,
// We can't use platform 'neutral', as esbuild has custom handling
// when the platform is 'node' or 'browser' that can't be emulated
// by using mainFields and conditions
platform,
define,
format: 'esm',
// 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: isBuild ? config.build.target || undefined : ESBUILD_MODULES_TARGET,
external,
splitting: true, // 自动进行代码分割
sourcemap: true,
plugins,
// 其他配置略...
})
// 略...
// 生成新的_metadata.json
const dataPath = path.join(processingCacheDir, '_metadata.json')
writeFile(dataPath, stringifyDepsOptimizerMetadata(metadata, depsCacheDir))
debug(`deps bundled in ${(performance.now() - start).toFixed(2)}ms`)
return processingResult
}
在 runOptimizeDeps
函数中,首先创建了临时的 deps_temp
目录,等最终依赖构建完成再换成 deps
。
在使用 for 循环遍历收集所有预构建的模块列表时,会将对应模块的绝对路径添加到 flatIdDeps
中;读取模块代码,通过 es-module-lexer
将模块转换成 AST[5],并赋值给 exportsData
。查找有没有 export * from xxx
形式的代码,如果有 exportsData.hasReExports
设置成 true。最后将AST赋值给 idToExports
和 flatIdToExports
。
而对于 esbuild build 的执行过程,入口文件为每一个 deps。具体的构建过程是使用 esbuildDepPlugin
函数定义的,这个插件会创建一个函数,这个函数的作用是根据不同模块类型返回不同的路径查找函数,并返回一个插件对象。
esbuildDepPlugin 函数[5]:
// packages/vite/src/node/optimizer/esbuildDepPlugin.ts 47
export function esbuildDepPlugin(
qualified: Record<string, string>,
external: string[],
config: ResolvedConfig,
ssr: boolean,
): Plugin {
// 略...
// default resolver which prefers ESM
// 创建 ESM 的路径查找函数
const _resolve = config.createResolver({ asSrc: false, scan: true })
// cjs resolver that prefers Node
// 创建 CommonJS 的路径查找函数
const _resolveRequire = config.createResolver({
asSrc: false,
isRequire: true,
scan: true,
})
// 返回不同的路径查找函数
const resolve = (
id: string,
importer: string,
kind: ImportKind,
resolveDir?: string,
): Promise<string | undefined> => {
let _importer: string
// explicit resolveDir - this is passed only during yarn pnp resolve for
// entries
if (resolveDir) {
_importer = normalizePath(path.join(resolveDir, '*'))
} else {
// map importer ids to file paths for correct resolution
_importer = importer in qualified ? qualified[importer] : importer
}
const resolver = kind.startsWith('require') ? _resolveRequire : _resolve
return resolver(id, _importer, undefined, ssr)
}
// 返回插件对象
return {
name: 'vite:dep-pre-bundle',
setup(build) {
// 拦截裸模块
build.onResolve() {/* ... */}
// 构建一个虚拟模块,并导入预构建的入口模块
build.onLoad() {/* ... */}
build.onResolve(/* 参数略 */)
build.onLoad(/* 参数略 */)
}
}
}
build.onLoad
的虚拟模块如下:
- CommonJS 类型的文件,导出的虚拟模块内容是
export default require("模块路径")
; - export default 的文件,导出的虚拟模块内容是
import d from "模块路径"; export default d;
- 其他 ESM 类型的文件,导出的虚拟模块内容是
export * from "模块路径"
之后就通过这个虚拟模块开始打包所有预渲染模块。
等到 esbuild 进行打包完毕,生成最终的 metadata 文件,写入到缓存目录 .vite 中。
小结
Vite 执行依赖预构建处于两个目的:
- Vite 的开发服务器将所有代码视为原生 ES 模块,所以必须考虑到 CommonJS 和 UMD 的兼容性;
- Vite 将有许多内部模块的 ESM 依赖关系转换为单个模块,以提高后续页面加载性能。
Vite的预构建流程:
- 先通过
discoverProjectDependencies
扫描出项目代码中的裸依赖; - 再通过遍历 AST 获取对应的依赖模块列表;
- 拿到所有需要预构建的依赖信息后,通过 ESbuild 打包;
- 最终将资源写入
.vite/deps
中,依赖描述optimized
以及browserHash
等写入到.vite/deps/metadata.json
中。
涉及到源码和程序执行步骤,小伙伴可能会云里雾里,不知文章所云。所以最好能自己运行项目源码调试一下,这样理解起来印象会更深刻。不知道如何调试源码的,可以 猛戳这里哈→
最后,文章存在表达不当或错误的地方,欢迎大家留言一起讨论~