Vite 打包流程
0. 开始
所有的技术,最终的形态是否都会趋于简单易用?我认为是的。
Webpack 有什么问题?
Vite 之于 Webpack,我认为其优秀地解决了两个方面的问题,一是打包速度慢,二是配置繁琐复杂。
Webpack 在处理大体积的项目时,是真的慢的可以,即使有 HMR,开发体验是真的谁难受谁知道。
至于配置项繁琐,这需要分两方面说。单说配置复杂,这个是没有任何问题的。entry、context、module、plugin 等等等等,仅仅是想跑起来一个 Vue 项目,你至少需要配置 vue-loader、css-loader、style-loader,还需要处理公共路径、开发和生产的配置分离、环境变量注入。。仅仅是一个能运行的 demo,不包含任何优化分包手段,我认为一个 Webpack 初学者就很难将其配置完善。
不过每个硬币都有另一面,复杂的配置意味着功能强大,你可以更细粒度的控制打包和产出物的行为,这点是毋庸置疑的。
Vite 怎么解决?
反观 Vite,就开发速度来说,Vite 的体验确实很棒。Vite 在启动时不需要像 Webpack 一样处理所有依赖的 module。它在一开始会扫描项目的依赖图,然后使用 esbuild 对第三方依赖进行依赖预构建,简言之,就是将项目中所有来自 node_modules 的依赖打包成少数几个包(因为这里很可能有成千上万个包,如果不将他们整合起来会极大地拖慢效率)。然后 Vite 会处理我们项目的 entry 入口,此时,Vite 就已经准备好了!当浏览器从入口开始发起请求时,Vite 再处理对应的源码模块,这本质上是让浏览器接管了一部分依赖解析工作。
关于配置项,如果你打开一个 Vue 3 + Vite 的模板项目,你会发现其配置文件简洁的令人发指,只有一个 Vue 插件的配置。如果你想向其添加特性,不需要像 Webpack 一样配置 loader,只需要引入合适的 plugin 即可。
1. 依赖预构建
当 Vite 冷启动的时候(第一次构建),会先对依赖进行预构建。默认情况下,它是自动且透明的完成的。
依赖预构建仅适用于开发模式。生产模式直接把所有模块打成一个/几个包了。
为什么要进行依赖预构建?
Vite 之所以需要进行依赖预构建,主要有两个原因:
- 支持 CommonJS 和 UMD 规范的模块。由于 Vite 视所有模块为 ESM 模块,所以 Vite 在打包构建之前,需要先将所有的其他规范的模块,转换为 ESM 模块。
- 有些模块的依赖实在太多了,如果不进行打包,浏览器会请求成百上千个模块,意味着浏览器需要发送成百上千次请求。所以 Vite 会先对这些依赖打包,让他们变成单个模块。
构建完的产物放在了哪里?
Vite 在预构建完成后,将构建结果缓存至 node_modules/.vite
中。只有在发生以下三点时,会重新进行预构建:
package.json
中的dependencies
列表发生变化。- 包管理器的 lockfile 发生变化,例如
package-lock.json
,yarn.lock
,或者pnpm-lock.yaml
。 - 在
vite.config.js
相关字段中配置过的配置项发生变化。
如果我们想强制 Vite 重新进行预构建,可以直接删掉 .vite
缓存目录,重新打包。但是可能会因为 Vite 配置了浏览器的强缓存,导致请求无法到达开发服务器,这时可以先禁用浏览器缓存。
源码
当我们执行 vite 命令时,其实执行的是 vite/bin/vite.js
这个脚本,这个脚本引入并执行了 vite/src/cli.ts
,随后,cli.ts
使用 cac
配置了命令行参数和不同的启动命令,vite
命令就是其中的 [root]
命令。
我们接着深入,可以看到这个命令执行了 createServer
函数,这个函数一看就是核心:
// dev cli .command('[root]', 'start dev server') // ... .action(async (root: string, options: ServerOptions & GlobalCLIOptions) => { // ... 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), }) // ... } catch (e) { // ... } })
那我们就可以接着往里找,看看 createServer
做了什么,然后就会发现这个函数调用的是下面的 _createServer
:
export async function _createServer( inlineConfig: InlineConfig = {}, options: { hotListen: boolean }, ): Promise<ViteDevServer> { // ... // 初始化server const initServer = async () => { if (serverInited) return if (initingServer) return initingServer initingServer = (async function () { await container.buildStart({}) // start deps optimizer after all container plugins are ready if (isDepsOptimizerEnabled(config, false)) { // 注意这里,这里的initDepsOptimizer表示进行依赖预构建 await initDepsOptimizer(config, server) } warmupFiles(server) initingServer = undefined serverInited = true })() return initingServer } if (!middlewareMode && httpServer) { // overwrite listen to init optimizer before server start const listen = httpServer.listen.bind(httpServer) httpServer.listen = (async (port: number, ...args: any[]) => { try { // ensure ws server started hot.listen() // server 启动监听后,调用了 initServer await initServer() } catch (e) { httpServer.emit('error', e) return } return listen(port, ...args) }) as any } else { if (options.hotListen) { hot.listen() } await initServer() } return server }
我们在 _createServer
里一顿好找,最后在结束的部分找到了看起来正确的代码 initServer
,在 initServer
中,有一个 initDepsOptimizer
调用!我们接着往里找,发现它在调用 createDepsOptimizer
,又是一个很长的函数:
async function createDepsOptimizer( config: ResolvedConfig, server: ViteDevServer, ): Promise<void> { // ... if (noDiscovery) { // We don't need to scan for dependencies or wait for the static crawl to end // Run the first optimization run immediately runOptimizer() } else { // Important, the scanner is dev only depsOptimizer.scanProcessing = new Promise((resolve) => { // Runs in the background in case blocking high priority tasks ;(async () => { try { debug?.(colors.green(`scanning for dependencies...`)) // 这里在查找项目的依赖 discover = discoverProjectDependencies(config) const deps = await discover.result discover = undefined const manuallyIncluded = Object.keys(manuallyIncludedDepsInfo) discoveredDepsWhileScanning.push( ...Object.keys(metadata.discovered).filter( (dep) => !deps[dep] && !manuallyIncluded.includes(dep), ), ) // Add these dependencies to the discovered list, as these are currently // used by the preAliasPlugin to support aliased and optimized deps. // This is also used by the CJS externalization heuristics in legacy mode for (const id of Object.keys(deps)) { if (!metadata.discovered[id]) { addMissingDep(id, deps[id]) } } const knownDeps = prepareKnownDeps() startNextDiscoveredBatch() // For dev, we run the scanner and the first optimization // run on the background // 对于 dev,在后台运行 scanner 和 首次优化 optimizationResult = runOptimizeDeps(config, knownDeps, ssr) // If the holdUntilCrawlEnd stratey is used, we wait until crawling has // ended to decide if we send this result to the browser or we need to // do another optimize step if (!holdUntilCrawlEnd) { // If not, we release the result to the browser as soon as the scanner // is done. If the scanner missed any dependency, and a new dependency // is discovered while crawling static imports, then there will be a // full-page reload if new common chunks are generated between the old // and new optimized deps. optimizationResult.result.then((result) => { // Check if the crawling of static imports has already finished. In that // case, the result is handled by the onCrawlEnd callback if (!waitingForCrawlEnd) return optimizationResult = undefined // signal that we'll be using the result runOptimizer(result) }) } } catch (e) { logger.error(e.stack || e.message) } finally { resolve() depsOptimizer.scanProcessing = undefined } })() }) } }
到这里,我们发现 Vite 使用了 discoverProjectDependencies
函数来查找项目的三方依赖,然后通过 runOptimizeDeps
来进行依赖预构建,我们接着深入 runOptimizeDeps
:
/** * Internally, Vite uses this function to prepare a optimizeDeps run. When Vite starts, we can get * the metadata and start the server without waiting for the optimizeDeps processing to be completed */ export function runOptimizeDeps( resolvedConfig: ResolvedConfig, depsInfo: Record<string, OptimizedDepInfo>, ssr: boolean, ): { cancel: () => Promise<void> result: Promise<DepOptimizationResult> } { // ... const preparedRun = prepareEsbuildOptimizerRun( resolvedConfig, depsInfo, ssr, processingCacheDir, optimizerContext, ) const runResult = preparedRun.then(({ context, idToExports }) => { // ... return context .rebuild() // ... }) // ... }
这里 Vite 使用 prepareEsbuildOptimizerRun
调用 esbuild.context()
构建生产了 context
,然后使用 context.rebuild
来生成预构建产物。
为什么使用了 esbuild.context
来构建?我认为应该是这个函数提供了取消构建的操作,如果使用 esbuild.build
则无法完成取消构建的操作,而 context
函数提供了 cancel
方法。
至此,Vite 就完成了依赖预构建。