Vite4.0 依赖预构建源码浅析

3,331 阅读11分钟

都说 Vite 快,快在哪里?又是怎么个快法呢?

一个对比实验引发的思考

这里有两个项目,通过对比,我们可以发现 Vite 在启动项目前都干了什么。

项目一:自建项目

使用 npm init -y 新建项目,安装 lodash-es,之所以不用 lodash 是因为 lodash 不是通过 ES Module 形式开发的,直接通过相对路径引入会报错,需要通过 Webpack 打包构建。

再新建一个 main.jsindex.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 脚本加载成功了 ↓↓↓ image.png

接着往下翻,你会发现,底下还有一堆脚本也被加载了进来,腚眼儿一看,总共58个请求,加载总耗时555ms ↓↓↓ lodash-loading.gif

??? 底下这些是从哪里冒出来的,我们可以点开 uniq.js,不难看出,uniq 方法又依赖到了 _baseUniq.js,所以紧接着 _baseUniq.js 也被加载了进来。于是乎,套娃就这样产生了。 ↓↓↓ image.png

点开 initiator 同样也能看到对应的依赖关系。↓↓↓ image.png

这就是不做任何处理,单纯地拉取一个的方法所需要的消耗。可想而知,一个大型项目的开发会带来怎样的效果······

项目二:Vite 脚手架

创建项目就不赘述了,直接两条命令跑一下:

# 创建一个 Vue 的项目
npm create vite@latest my-vue-app -- --template vue
# 启动
npm run dev

看下效果,请求直接来到8条,只有原来的零头,减少了6倍之多,而耗时则降低到 200 多毫秒,效率提高了一倍不止。↓↓↓ image.png

究其原因,我们点开这个被改了名字的 lodash-ed_uniq.js 就可以看出:Vite 将需要用到的资源提前整理到一个模块中,这样,我们只需要请求一个 HTTP 请求就可以了。↓↓↓ image.png

因此,我们可以得出以下结论:Vite 会在 DevServer 启动前对需要预构建的依赖进行构建,然后在分析模块的导入("bare import" 即裸依赖,表示从 node_modules 中解析)时会动态地应用构建过的依赖。

执行流程

  1. 在项目根目录下运行 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
    
  2. 上述脚本会执行 node_modules/vite/bin/vite.js脚本,其中有一个start 函数:
    #!/usr/bin/env node
    function start() {
      return import('../dist/node/cli.js')
    }
    if (profileIndex > 0) {
    // 略....
    } else {
      start()
    }
    
  3. 最终会执行 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.jsonpackage.json 都是预构建的产物,打开 node_modules/.vite/deps目录,可以看到以下结构:

image.png

// _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 模块。

相关函数

预构建的过程会依次经过以下几个函数的调用,篇幅有限,这里就不把所有代码都贴出来了,名字和路径都附在下面,有兴趣的同学可以去对应的源码中查看:

  1. initServer (packages/vite/src/node/server/index.ts 615)
  2. initDepsOptimizer (packages/vite/src/node/optimizer/optimizer.ts 53)
  3. createDepsOptimizer (packages/vite/src/node/optimizer/optimizer.ts 92)
  4. 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 需要。最终得到的 depsknownDeps 结构如下:

// 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赋值给 idToExportsflatIdToExports

而对于 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 执行依赖预构建处于两个目的:

  1. Vite 的开发服务器将所有代码视为原生 ES 模块,所以必须考虑到 CommonJS 和 UMD 的兼容性;
  2. Vite 将有许多内部模块的 ESM 依赖关系转换为单个模块,以提高后续页面加载性能。

Vite的预构建流程:

  1. 先通过 discoverProjectDependencies 扫描出项目代码中的裸依赖;
  2. 再通过遍历 AST 获取对应的依赖模块列表;
  3. 拿到所有需要预构建的依赖信息后,通过 ESbuild 打包;
  4. 最终将资源写入.vite/deps中,依赖描述 optimized 以及 browserHash 等写入到 .vite/deps/metadata.json 中。

涉及到源码和程序执行步骤,小伙伴可能会云里雾里,不知文章所云。所以最好能自己运行项目源码调试一下,这样理解起来印象会更深刻。不知道如何调试源码的,可以 猛戳这里哈→

最后,文章存在表达不当或错误的地方,欢迎大家留言一起讨论~

参考资料

  1. vitejs-github
  2. Vite官网-依赖预构建
  3. Vite官网-依赖优化选项
  4. 快速理解 Vite 的依赖预构建
  5. Vite 源码(八)Vite 的预构建原理
  6. vite2 源码分析(一) — 启动 vite
  7. Vite 依赖预构建,缩短数倍的冷启动时间