秒启动的基石,vite 依赖预构建的原理

vite 在开发环境能够做到秒启动的原因有两个

  • No Bundle:即跳过打包,通过浏览器 ESModule 解析源文件
  • 依赖预构建:将常用依赖提前编译和处理,从而在启动阶段大大减少了开销

依赖预构建不仅能实现 vite 的秒启动,还能够兼容 CommonJS 的依赖产物、合并 ESModule 多个模块,下面就展开讲讲 vite 依赖预构建的实现原理

实现原理

依赖预构建的核心方法是 optimizeDeps ,在 packages/vite/src/node/optimizer/index.ts 文件下,主要有 4 个实现步骤

  1. 缓存判断,命中缓存直接返回
  2. 依赖扫描
  3. 添加依赖到优化列表
  4. 执行依赖打包

可以看到实现步骤非常清晰,下面就具体分析每一步的实现原理

ts 复制代码
export async function optimizeDeps(
  config: ResolvedConfig,
  force = config.optimizeDeps.force,
  asCommand = false,
): Promise<DepOptimizationMetadata> {
  // 第一步:缓存判断,命中缓存直接返回
  const cachedMetadata = await loadCachedDepOptimizationMetadata(config, force, asCommand)
  if (cachedMetadata) {
    return cachedMetadata
  }

  // 第二步:依赖扫描
  const deps = await discoverProjectDependencies(config).result

  // 第三步:添加依赖到优化列表
  await addManuallyIncludedOptimizeDeps(deps, config)

  const depsInfo = toDiscoveredDependencies(config, deps)

  // 第四步:执行依赖打包
  const result = await runOptimizeDeps(config, depsInfo).result
  await result.commit()

  // 返回打包 meta 信息,后续写入 _metadata.json
  return result.metadata
}

第一步:缓存判断

通过 loadCachedDepOptimizationMetadata 方法判断预构建的缓存 meta 信息是否存在,meta 信息都统一保存在 _meta.json 文件中

缓存判断的实现步骤如下

  1. 通过 cleanupDepsCacheStaleDirs 方法,清理异常退出或执行中断的缓存目录,放在 setTimeout 中异步执行是为了确保加载缓存数据之前,所有残留的缓存目录都已经被清理,保证缓存的一致性和正确性
  2. 通过 getDepsCacheDir 获取缓存依赖路径,也就是 _metadata.json 文件所在的文件路径
  3. 通过 parseDepsOptimizerMetadata 方法解析 meta 数据和依赖预构建缓存 optimized 相关的数据
  4. 将 meta 数据中的 hash 和 getDepHash 依赖获取的 hash 值做对比,如果相同说明命中缓存,直接返回 meta 信息,如果不相同,则移除掉 _metadata.json 文件,准备刷新缓存
ts 复制代码
export async function loadCachedDepOptimizationMetadata(
  config: ResolvedConfig,
  ssr: boolean,
  force = config.optimizeDeps.force,
  asCommand = false,
): Promise<DepOptimizationMetadata | undefined> {
  if (firstLoadCachedDepOptimizationMetadata) {
    firstLoadCachedDepOptimizationMetadata = false
    // 清理异常退出残留的依赖处理目录
    setTimeout(() => cleanupDepsCacheStaleDirs(config), 0)
  }

  // 获取缓存依赖路径
  const depsCacheDir = getDepsCacheDir(config, ssr)

  if (!force) {
    let cachedMetadata: DepOptimizationMetadata | undefined
    try {
      // 获取 _metadata.json 文件所在路径
      const cachedMetadataPath = path.join(depsCacheDir, '_metadata.json')
      // 解析 meta 数据
      cachedMetadata = parseDepsOptimizerMetadata(
        await fsp.readFile(cachedMetadataPath, 'utf-8'),
        depsCacheDir,
      )
    } catch (e) {}
    // 命中缓存,直接读取缓存 mata 信息
    if (cachedMetadata && cachedMetadata.hash === getDepHash(config, ssr)) {
      return cachedMetadata
    }
  }

  // 移除文件,准备刷新缓存
  await fsp.rm(depsCacheDir, { recursive: true, force: true })
}

getDepHash 方法需要展开讲讲,影响缓存 hash 变化的主要有两个方面:lock 文件和配置文件,lock 文件内部记录着依赖的具体信息,如果发生变化自然需要重新构建。另一方面配置文件中的一些参数会影响依赖预构建的方式,如果变化的话同样也需要重新构建

目前 vite 支持的 npm、yarn、pnpm、bun 作为依赖管理工具,bun 是一个更快速的依赖编译和解析工具,这里提供一篇文章作为参考

影响预构建的配置有以下几个配置

  • mode:开发 / 生产环境
  • root:项目根路径
  • resolve:路径解析配置
  • buildTarget:最终构建的浏览器兼容目标,比如 es2020,edge88 等等
  • assetsInclude:自定义资源类型
  • plugins:插件配置
  • optimizeDeps:预构建配置

将 lock 文件中的依赖和配置参数合并之后,通过 crypto 的 createHash 方法生成当前依赖和配置的最终 hash

ts 复制代码
export function getDepHash(config: ResolvedConfig, ssr: boolean): string {
  const lockfilePath = lookupFile(config.root, lockfileNames)
  // 获取 lock 文件内容
  let content = lockfilePath ? fs.readFileSync(lockfilePath, 'utf-8') : ''
  if (lockfilePath) {
    const lockfileName = path.basename(lockfilePath)
    const { checkPatches } = lockfileFormats.find(
      (f) => f.name === lockfileName,
    )!
    if (checkPatches) {
      const fullPath = path.join(path.dirname(lockfilePath), 'patches')
      const stat = tryStatSync(fullPath)
      if (stat?.isDirectory()) {
        content += stat.mtimeMs.toString()
      }
    }
  }
  
  const optimizeDeps = getDepOptimizationConfig(config, ssr)
  // 增加会影响依赖预构建的配置
  content += JSON.stringify(
    {
      // 开发 / 生产环境
      mode: process.env.NODE_ENV || config.mode,
      // 项目根路径
      root: config.root,
      // 路径解析配置
      resolve: config.resolve,
      // 最终构建的浏览器兼容目标
      buildTarget: config.build.target,
      // 自定义资源类型
      assetsInclude: config.assetsInclude,
      // 插件
      plugins: config.plugins.map((p) => p.name),
      // 预构建配置
      optimizeDeps: {
        include: optimizeDeps?.include,
        exclude: optimizeDeps?.exclude,
        esbuildOptions: {
          ...optimizeDeps?.esbuildOptions,
          plugins: optimizeDeps?.esbuildOptions?.plugins?.map((p) => p.name),
        },
      },
    },
    // 特殊正则和函数类型
    (_, value) => {
      if (typeof value === 'function' || value instanceof RegExp) {
        return value.toString()
      }
      return value
    },
  )
  // 通过调用 crypto 的 createHash 方法生成哈希
  return getHash(content)
}

第二步:依赖扫描

在第一步没有命中缓存之后,接下来这一步就要开始扫描有哪些依赖,discoverProjectDependencies 方法比较简单,核心在于通过 scanImports 方法获取依赖扫描的结果

ts 复制代码
export function discoverProjectDependencies(config: ResolvedConfig): {
  cancel: () => Promise<void>
  result: Promise<Record<string, string>>
} {
  // 获取依赖扫描结果
  const { cancel, result } = scanImports(config)
  
  return {
    cancel,
    result: result.then(({ deps, missing }) => {
      return deps
    }),
  }
}

scanImports 方法主要分为三步

  1. 通过 computeEntries 方法寻找入口
  2. 通过 prepareEsbuildScanner 方法执行依赖扫描,建立 esbuild 上下文
  3. 执行 esbuild 上下文的 rebuild 方法,获取依赖扫描结果
ts 复制代码
export function scanImports(config: ResolvedConfig): {
  cancel: () => Promise<void>
  result: Promise<{
    deps: Record<string, string>
    missing: Record<string, string>
  }>
} {
  const deps: Record<string, string> = {}
  const missing: Record<string, string> = {}
  let entries: string[]

  const scanContext = { cancelled: false }

  // 第一步:寻找入口
  const esbuildContext: Promise<BuildContext | undefined> = computeEntries(
    config,
  ).then((computedEntries) => {
    entries = computedEntries

    if (scanContext.cancelled) return

    // 第二步:使用 Esbuild 执行依赖扫描
    return prepareEsbuildScanner(config, entries, deps, missing, scanContext)
  })
  
  const result = esbuildContext
    .then((context) => {
      return context
        .rebuild()
        .then(() => {
          return {
            deps: orderedDependencies(deps),
            missing,
          }
        })
    })

  return {
    cancel: async () => {
      scanContext.cancelled = true
      return esbuildContext.then((context) => context?.cancel())
    },
    result,
  }
}

第一步 computeEntries 方法用于寻找依赖扫描的入口,按照以下三个顺序寻找

  • 首先从 optimizeDeps.entries 中获取入口,支持 glob 语法
  • 其次从 build.rollupOptions.input 中获取入口,同时兼容字符串、数组、对象配置方式
  • 最后是兜底逻辑,没有配置入口,默认从根目录寻找
ts 复制代码
async function computeEntries(config: ResolvedConfig) {
  let entries: string[] = []

  const explicitEntryPatterns = config.optimizeDeps.entries
  const buildInput = config.build.rollupOptions?.input

  // 先从 optimizeDeps.entries 中获取入口,支持 glob 语法
  if (explicitEntryPatterns) {
    entries = await globEntries(explicitEntryPatterns, config)
  }
  // 其次从 build.rollupOptions?.input 中获取入口,兼容数组和对象
  else if (buildInput) {
    const resolvePath = (p: string) => path.resolve(config.root, p)
    if (typeof buildInput === 'string') {
      entries = [resolvePath(buildInput)]
    } else if (Array.isArray(buildInput)) {
      entries = buildInput.map(resolvePath)
    } else if (isObject(buildInput)) {
      entries = Object.values(buildInput).map(resolvePath)
    }
  } else {
    // 兜底逻辑,如果没有配置,自动从根目录寻找
    entries = await globEntries('**/*.html', config)
  }

  entries = entries.filter(
    (entry) => isScannable(entry) && fs.existsSync(entry),
  )

  return entries
}

第二步 prepareEsbuildScanner 中,通过 esbuildScanPlugin 方法通过定义 esbuild 插件的形式,定义在扫描过程中需要的文件处理,比如

  • 支持 对 html、vue、svelte、astro(一种新兴的类 html 语法) 四种后缀的入口文件进行了解析
  • 支持对 bare import 场景的处理逻辑
  • external 规则处理,排除不需要扫描的依赖

最后利用 esbuild 的 context 方法,将相对路径解析为决定路径的上下文环境,此时的产物是不写入磁盘的,能够节省 IO 的时间

ts 复制代码
async function prepareEsbuildScanner(
  config: ResolvedConfig,
  entries: string[],
  deps: Record<string, string>,
  missing: Record<string, string>,
  scanContext?: { cancelled: boolean },
): Promise<BuildContext | undefined> {
  const container = await createPluginContainer(config)

  if (scanContext?.cancelled) return

  // 扫描需要用到的 Esbuild 插件
  const plugin = esbuildScanPlugin(config, container, deps, missing, entries)

  const { plugins = [], ...esbuildOptions } =
    config.optimizeDeps?.esbuildOptions ?? {}

  return await esbuild.context({
    absWorkingDir: process.cwd(),
    write: false, // ! 产物不写入磁盘,节省 IO 时间
    stdin: {
      contents: entries.map((e) => `import ${JSON.stringify(e)}`).join('\n'),
      loader: 'js',
    },
    bundle: true,
    format: 'esm',
    logLevel: 'silent',
    plugins: [...plugins, plugin],
    ...esbuildOptions,
  })
}

最后通过 scanImports 方法扫描依赖的结果 result 作为整个方法的返回,至此第二步扫描依赖就结束了

第三步:添加依赖到优化列表

在扫描了依赖之后,接下来就需要将依赖添加到优化列表

首先会通过 addManuallyIncludedOptimizeDeps 方法处理在配置文件自定义添加到优化列表的依赖,也就是配置文件中的 optimizeDeps 配置,主要实现步骤如下

  1. 获取配置文件中的 optimizeDeps 配置
  2. 创建路径解析函数
  3. 遍历需要添加的依赖数组
    1. 对依赖的 id 进行标准化处理
    2. 解析依赖路径,得到依赖入口文件路径
    3. 将满足条件的依赖放入 deps 对象
ts 复制代码
export async function addManuallyIncludedOptimizeDeps(
  deps: Record<string, string>,
  config: ResolvedConfig,
  ssr: boolean,
  extra: string[] = [],
  filter?: (id: string) => boolean,
): Promise<void> {
  const { logger } = config
  // 获取配置文件中的 optimizeDeps 配置
  const optimizeDeps = getDepOptimizationConfig(config, ssr)
  const optimizeDepsInclude = optimizeDeps?.include ?? []
  
  if (optimizeDepsInclude.length || extra.length) {
    // 定义需要添加的依赖,排除不需要添加的依赖数组
    const includes = [...optimizeDepsInclude, ...extra]
    for (let i = 0; i < includes.length; i++) {
      const id = includes[i]
      if (glob.isDynamicPattern(id)) {
        const globIds = expandGlobIds(id, config)
        includes.splice(i, 1, ...globIds)
        i += globIds.length - 1
      }
    }

    // 创建路径解析函数
    const resolve = createOptimizeDepsIncludeResolver(config, ssr)
    
    // 遍历需要添加的依赖数组
    for (const id of includes) {
      // 对依赖的 id 进行标准化处理
      const normalizedId = normalizeId(id)
      
      if (!deps[normalizedId] && filter?.(normalizedId) !== false) {
        // 解析依赖路径,得到依赖入口文件路径
        const entry = await resolve(id)
				// 将满足条件的依赖放入 deps 对象
        if (entry) {
          if (isOptimizable(entry, optimizeDeps)) {
            if (!entry.endsWith('?__vite_skip_optimization')) {
              deps[normalizedId] = entry
            }
          } 
        }
      }
    }
  }
}

接下来将 esbuild 扫描的依赖和配置中自定义添加的依赖通过 toDiscoveredDependencies 方法转化一个标准的优化列表

ts 复制代码
export function toDiscoveredDependencies(
  config: ResolvedConfig,
  deps: Record<string, string>,
  ssr: boolean,
  timestamp?: string,
): Record<string, OptimizedDepInfo> {
  
  const browserHash = getOptimizedBrowserHash(
    getDepHash(config, ssr),
    deps,
    timestamp,
  )
  
  const discovered: Record<string, OptimizedDepInfo> = {}
  
  // 遍历依赖列表,标准化为统一的对象
  for (const id in deps) {
    const src = deps[id]
    discovered[id] = {
      id,
      file: getOptimizedDepPath(id, config, ssr),
      src,
      browserHash: browserHash,
      exportsData: extractExportsData(src, config, ssr),
    }
  }
  return discovered
}

至此第三步将依赖添加到优化列表就结束了

第四步:执行依赖打包

接下来就到了最后一步,将上一步获取的依赖优化列表,通过 runOptimizeDeps 方法进行打包

打包过程首先通过 prepareEsbuildOptimizerRun 方法,准备 esbuild 的运行环境,主要步骤会遍历所有依赖,将扁平化依赖记录到 flatIdDeps。再通过 esbuild 的 context 方法,创建上下文,入口就是所有扁平化后的依赖路径

这里的扁平化路径的目的是用作对象的唯一 key,比如 react/jsx-dev-runtime,被重写为react_jsx-dev-runtime

ts 复制代码
async function prepareEsbuildOptimizerRun(
  resolvedConfig: ResolvedConfig,
  depsInfo: Record<string, OptimizedDepInfo>,
  ssr: boolean,
  processingCacheDir: string,
  optimizerContext: { cancelled: boolean },
): Promise<{
  context?: BuildContext
  idToExports: Record<string, ExportsData>
}> {

  // 扁平化路径依赖记录
  const flatIdDeps: Record<string, string> = {}

  // ...

  // 遍历所有依赖,将扁平化路径依赖记录到 flatIdDeps
  await Promise.all(
    Object.keys(depsInfo).map(async (id) => {
      const src = depsInfo[id].src!
      const exportsData = await(
        depsInfo[id].exportsData ?? extractExportsData(src, config, ssr),
      )
      if (exportsData.jsxLoader && !esbuildOptions.loader?.['.js']) {
        esbuildOptions.loader = {
          '.js': 'jsx',
          ...esbuildOptions.loader,
        }
      }
      // 扁平化路径,`react/jsx-dev-runtime`,被重写为`react_jsx-dev-runtime`
      const flatId = flattenId(id)
      flatIdDeps[flatId] = src
      idToExports[id] = exportsData
      flatIdToExports[flatId] = exportsData
    }),
  )

  // ...

  // 创建 esbuild 上下文,入口为所有扁平化的依赖路径
  const context = await esbuild.context({
    absWorkingDir: process.cwd(),
    entryPoints: Object.keys(flatIdDeps), // 入口
    bundle: true,
    platform,
    define,
    format: 'esm',
    target: isBuild ? config.build.target || undefined : ESBUILD_MODULES_TARGET,
    external,
    logLevel: 'error',
    splitting: true,
    sourcemap: true,
    outdir: processingCacheDir,
    ignoreAnnotations: !isBuild,
    metafile: true,
    plugins,
    charset: 'utf8',
    ...esbuildOptions,
    supported: {
      'dynamic-import': true,
      'import-meta': true,
      ...esbuildOptions.supported,
    },
  })
  return { context, idToExports }
}

在创建好 esbuild 上下文之后,会调用 rebuild 方法开始执行预构建过程,然后会经历两次遍历过程

  • 首先会遍历 depsInfo 依赖信息,将重新构建的依赖信息添加到 metadata 的 optimized 部分
  • 然后遍历 metadata 的文件输出路径,将非 js 文件添加到 metadata 的 chunk 部分
ts 复制代码
const runResult = preparedRun.then(({ context, idToExports }) => {
  return context
    .rebuild()
    .then((result) => {
    // metadata
    const meta = result.metafile!

    // 遍历依赖信息,添加到 metadata 的 optimized 部分
    for (const id in depsInfo) {
      const output = esbuildOutputFromId(
        meta.outputs,
        id,
        processingCacheDir,
      )

      const { exportsData, ...info } = depsInfo[id]
      addOptimizedDepInfo(metadata, 'optimized', {
        ...info,
        fileHash: getHash(
          metadata.hash +
          depsInfo[id].file +
          JSON.stringify(output.imports),
        ),
        browserHash: metadata.browserHash,
        // 判断是否有要转换为 ESM 格式
        needsInterop: needsInterop(
          config,
          ssr,
          id,
          idToExports[id],
          output,
        ),
      })
    }

    // 遍历 metadata 的输出文件路径
    for (const o of Object.keys(meta.outputs)) {
      // 如果不是 js 文件
      if (!o.match(jsMapExtensionRE)) {
        const id = path
          .relative(processingCacheDirOutputPath, o)
          .replace(jsExtensionRE, '')
        // 根据 id 获取构建依赖的文件路径
        const file = getOptimizedDepPath(id, resolvedConfig, ssr)
        // 如果不存在相同的文件,则将输出的文件信息放入 metadata 的 chunk 部分
        if (
          !findOptimizedDepInfoInRecord(
            metadata.optimized,
            (depInfo) => depInfo.file === file,
          )
        ) {
          addOptimizedDepInfo(metadata, 'chunks', {
            id,
            file,
            needsInterop: false,
            browserHash: metadata.browserHash,
          })
        }
      }
    }
    
    // ! 注意到此时返回的是 succesfulResult
    return succesfulResult
  })
})

在执行完成 rebuild 操作之后,会返回一个 succesfulResult 对象,主要包含三个属性

  • metadata:优化后的依赖信息,包括每个依赖的文件路径、导出信息、构建输出文件
  • cancel:取消依赖预构建操作
  • commit:提交预构建的优化结果,将最后结果写入 _metadata.json 文件
ts 复制代码
const succesfulResult: DepOptimizationResult = {
  metadata,
  cancel: cleanUp,
  commit: async () => {
    committed = true
    // meta 信息写入 _metadata.json 文件
    const dataPath = path.join(processingCacheDir, '_metadata.json')
    fs.writeFileSync(
      dataPath,
      stringifyDepsOptimizerMetadata(metadata, depsCacheDir),
    )

    // 将临时文件夹中的优化结果,重命名为全局的依赖缓存文件夹
    const temporalPath = depsCacheDir + getTempSuffix()
    const depsCacheDirPresent = fs.existsSync(depsCacheDir)
    if (isWindows) {
      if (depsCacheDirPresent) await safeRename(depsCacheDir, temporalPath)
      await safeRename(processingCacheDir, depsCacheDir)
    } else {
      if (depsCacheDirPresent) fs.renameSync(depsCacheDir, temporalPath)
      fs.renameSync(processingCacheDir, depsCacheDir)
    }

    // 删除临时路径(旧的全局依赖缓存文件夹),确保临时文件夹的清理工作在后台进行
    if (depsCacheDirPresent)
      fsp.rm(temporalPath, { recursive: true, force: true })
  },
}

最后会执行 commit 方法,完成依赖预构建的全部过程

ts 复制代码
// 第四步:执行依赖打包
const result = await runOptimizeDeps(config, depsInfo).result

await result.commit()

// 返回打包 meta 信息,后续写入 _metadata.json
return result.metadata

总结

最后总结一下 vite 依赖预构建的实现步骤

  1. 缓存判断:根据文件生成的 hash 和 _metadata.json 中的记录的缓存文件 hash 进行对比,如果相同说明命中缓存
  2. 依赖扫描:获取扫描入口,使用 esbuild 进行扫描
  3. 添加依赖到优化列表:将配置文件自定义的优化依赖和扫描依赖结果添加到优化列表
  4. 依赖打包:使用 esbuild 进行依赖打包,产物写入 _metadata.json 文件

vite 原理系列文章导航

相关推荐
天下无贼!1 小时前
2024年最新版Vue3学习笔记
前端·vue.js·笔记·学习·vue
Jiaberrr1 小时前
JS实现树形结构数据中特定节点及其子节点显示属性设置的技巧(可用于树形节点过滤筛选)
前端·javascript·tree·树形·过滤筛选
赵啸林1 小时前
npm发布插件超级简单版
前端·npm·node.js
罔闻_spider2 小时前
爬虫----webpack
前端·爬虫·webpack
吱吱鼠叔2 小时前
MATLAB数据文件读写:1.格式化读写文件
前端·数据库·matlab
爱喝水的小鼠2 小时前
Vue3(一) Vite创建Vue3工程,选项式API与组合式API;setup的使用;Vue中的响应式ref,reactive
前端·javascript·vue.js
盏灯2 小时前
前端开发,场景题:讲一下如何实现 ✍电子签名、🎨你画我猜?
前端
WeiShuai2 小时前
vue-cli3使用DllPlugin优化webpack打包性能
前端·javascript
Wandra2 小时前
很全但是超级易懂的border-radius讲解,让你快速回忆和上手
前端
ice___Cpu2 小时前
Linux 基本使用和 web 程序部署 ( 8000 字 Linux 入门 )
linux·运维·前端