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 之所以需要进行依赖预构建,主要有两个原因:

  1. 支持 CommonJS 和 UMD 规范的模块。由于 Vite 视所有模块为 ESM 模块,所以 Vite 在打包构建之前,需要先将所有的其他规范的模块,转换为 ESM 模块。
  2. 有些模块的依赖实在太多了,如果不进行打包,浏览器会请求成百上千个模块,意味着浏览器需要发送成百上千次请求。所以 Vite 会先对这些依赖打包,让他们变成单个模块。

构建完的产物放在了哪里?

Vite 在预构建完成后,将构建结果缓存至 node_modules/.vite 中。只有在发生以下三点时,会重新进行预构建:

  1. package.json 中的 dependencies 列表发生变化。
  2. 包管理器的 lockfile 发生变化,例如 package-lock.json, yarn.lock,或者 pnpm-lock.yaml
  3. 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 就完成了依赖预构建。