侧边栏壁纸
博主头像
前端自习室博主等级

折腾是进步的阶梯

  • 累计撰写 30 篇文章
  • 累计创建 0 个标签
  • 累计收到 0 条评论

目 录CONTENT

文章目录

vite 5 源码分析(七): 预构建

lumozx
2024-04-23 / 0 评论 / 0 点赞 / 11 阅读 / 26499 字

本文使用vite 5.2.8版本

依赖预构建的入口是initDepsOptimizer函数,由initServer触发。

但在触发之前,通过isDepsOptimizerEnabled来判断,是否需要进行依赖预构建。

isDepsOptimizerEnabled的逻辑与文档保持一致。

如果你想完全禁用优化器,可以设置 optimizeDeps.noDiscovery: true 来禁止自动发现依赖项,并保持 optimizeDeps.include 未定义或为空。

在代码中也针对noDiscoveryoptimizeDeps.include进行了判断,并进行取反,如果不符合禁止依赖预构建的条件,那么就启用依赖预构建。

export function isDepsOptimizerEnabled(
  config: ResolvedConfig,
  ssr: boolean,
): boolean {
   // 获取预构建配置
  const optimizeDeps = getDepOptimizationConfig(config, ssr)
   // 如果不符合禁止预构建的条件
  return !(optimizeDeps.noDiscovery && !optimizeDeps.include?.length)
}

initDepsOptimizer实际是启用了一个单例的依赖预构建,如果当前的配置已经存在一个对应的预构建对象,那么就使用缓存,否则就创建一个。

export function getDepsOptimizer(
  config: ResolvedConfig,
  ssr?: boolean,
): DepsOptimizer | undefined {
   // 根据config从 WeakMap 获取预构建对象
  return (ssr ? devSsrDepsOptimizerMap : depsOptimizerMap).get(config)
}

export async function initDepsOptimizer(
  config: ResolvedConfig,
  server: ViteDevServer,
): Promise<void> {
   // 如果本地缓存 (WeakMap)获取不到预构建对象,使用createDepsOptimizer创建一个新的
  if (!getDepsOptimizer(config, false)) {
    await createDepsOptimizer(config, server)
  }
}

因此,我们的目光来到了createDepsOptimizer这个函数,我们分段理解这个函数。

createDepsOptimizer

首先,使用loadCachedDepOptimizationMetadata,通过配置中的config.cacheDir,并且拼接上deps_metadata.json,来获取本地存储的_metadata.json

一般情况下, config.cacheDir会被给予默认值node_modules/.vite,因此_metadata.json的默认地址是node_modules/.vite/deps/_metadata.json

const cachedMetadata = await loadCachedDepOptimizationMetadata(config, ssr)

如果cachedMetadata是空的,也就是loadCachedDepOptimizationMetadata获取不到硬盘上存储的_metadata.json,那么就使用initDepsOptimizerMetadata,来初始化一个metadata数据。

let metadata =
    cachedMetadata || initDepsOptimizerMetadata(config, ssr, sessionTimestamp)

initDepsOptimizerMetadata仅仅是初始化metadata,并不负责将它存储在硬盘上。

我们看看initDepsOptimizerMetadata初始化了什么数据。

export function initDepsOptimizerMetadata(
  config: ResolvedConfig,
  ssr: boolean,
  timestamp?: string,
): DepOptimizationMetadata {
  const { lockfileHash, configHash, hash } = getDepHash(config, ssr)
  return {
    hash,
    lockfileHash,
    configHash,
    browserHash: getOptimizedBrowserHash(hash, {}, timestamp),
    optimized: {},
    chunks: {},
    discovered: {},
    depInfoList: [],
  }
}
  • lockfileHash:枚举常见包管理工具的lock文件,优先使用npm_config_user_agent中定义的包,否则依照以下优先级: package-lock.json > yarn.lock > pnpm-lock.yaml > bun.lockb从项目文件或者父目录中寻找,计算找到文件的hash

  • configHash:从config摘取了几个关键配置组成了JSON,这个JSONhash

  • hashlockfileHashconfigHash拼接到一起的字符串,计算出来的hash

  • browserHash:上面的hash和空JSON字符串以及时间戳拼接在一起的字符串,计算出来的hash

  • optimized:每个预构建依赖的对照集合。

  • depInfoList:依赖列表。

  • chunkschunk集合。

  • discovered:新发现的依赖。

因此,变量metadata在经过以上逻辑后,总会有值的。

然后,连同预构建配置项,一起包装成预构建对象,并存入本地缓存中。

const depsOptimizer: DepsOptimizer = {
    metadata, // 上文读取的metadata
    registerMissingImport, // 发现新的依赖进行预构建
    run: () => debouncedProcessing(0), // 立即预构建
    isOptimizedDepFile: createIsOptimizedDepFile(config), //是否是经过预构建的文件
    isOptimizedDepUrl: createIsOptimizedDepUrl(config),//是否是经过预构建的
    getOptimizedDepId: (depInfo: OptimizedDepInfo) =>//预构建产物拼入hash
      `${depInfo.file}?v=${depInfo.browserHash}`,
    close, //结束预构建
    options, //预构建配置项
  }

  depsOptimizerMap.set(config, depsOptimizer)

接着,会判断如果本地硬盘没有找到_metadata.json,那么首先会处理optimizeDeps.include,通过跑一遍插件流水线resolveId钩子,计算出将它们的相关信息,然后推入metadatadiscovereddepInfoList字段。

这里需要注意的是,这些信息中的file字段,是这个依赖被预构建之后的地址,因为预构建还没开始,因此此时此刻,硬盘上并没有对应地址。

如果使用optimizeDeps.noDiscovery: true来禁止自动发现依赖项,那么调用runOptimizer,将只处理metadata中,optimizeddiscovered记录的依赖。

如果没有禁止自动发现依赖,那么就使用discoverProjectDependencies进行依赖扫描。

scanImports

虽然discoverProjectDependencies开启了依赖扫描,但实际核心函数在scanImports之中,discoverProjectDependencies是针对scanImports的进一步包装。

scanImports中,首先定义了两个空对象,收集的依赖deps和找不到的依赖missing,这俩相当重要!它们是依赖收集的主要容器。

我们知道,依赖扫描实际上是依靠esbuild实现的,所以,之后调用computeEntries计算出入口文件。在computeEntries中,进行了以下逻辑。

  1. 初始化entries数组。

  2. 检查配置中是否存在明确的入口(optimizeDeps.entries)。

    • 如果存在,使用 globEntries 根据这些模式解析匹配的文件路径,并将结果存储在 entries 数组中。

  3. 如果不存在明确的入口模式,检查配置中是否存在 build.rollupOptions.input

    • 如果存在,则根据 rollupOptions.input 的类型进行处理:

      • 如果是字符串,则将其解析为绝对路径,并将其添加到 entries 数组中。

      • 如果是数组,则对数组中的每个路径执行相同的操作。

      • 如果是对象,则对对象的每个值(路径)执行相同的操作。

      • 如果 rollupOptions.input 不是字符串、数组或对象,则抛出错误。

  4. 如果既没有明确的入口模式也没有 rollupOptions.input,则默认使用 **/*.html 作为入口模式,并使用 globEntries 函数解析匹配的文件路径。

  5. 这还没完事,还需要对确定的入口文件路径进行过滤:

    • 排除不支持的入口文件类型和虚拟文件。

    • 排除不存在的文件。

  6. 返回entries数组。

如果computeEntries计算出来的入口数组有值,那么使用prepareEsbuildScanner函数处理。

prepareEsbuildScanner中,定义了esbuild的扫描插件esbuildScanPlugin

const plugin = esbuildScanPlugin(config, container, deps, missing, entries)

注意,这个插件把插件流水线container、上文中把相当重要的depsmissing以及算出来的入口数组entries传入了。

扫描插件往depsmissing写入数据,因为内存不变的原因,是可以不用通过返回值就可以拿到的。

我们待会看这个插件逻辑。

在构造完扫描插件后,还会尝试取optimizeDeps.esbuildOptions的数据,并把其中的插件选项剥离出来。

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

然后针对esbuildtsconfigRaw进行兼容处理。

最后调用esbuild.context并使prepareEsbuildScanner返回它的上下文对象。

// prepareEsbuildScanner
return await esbuild.context({
    absWorkingDir: process.cwd(),
    write: false,
    stdin: {
      contents: entries.map((e) => `import ${JSON.stringify(e)}`).join('\n'),
      loader: 'js',
    },
    bundle: true,
    format: 'esm',
    logLevel: 'silent',
    plugins: [...plugins, plugin],
    ...esbuildOptions,
    tsconfigRaw,
  })

然后在scanImports触发这个上下文对象的rebuild函数,让esbuild进行真正的构建。当构建结束的时候,返回已经被填充好的、并且排好序的deps对象和missing对象。

const result = esbuildContext
    .then((context) => {
      return context
        .rebuild()
        .then(() => {
          return {
            deps: orderedDependencies(deps),
            missing,
          }
        })
    })

而这个result其实也作为scanImports的返回值返回给了discoverProjectDependencies

之前提到过,discoverProjectDependencies实际上是scanImports的包装,因此,它将result再次包装处理:

  • 如果missing是空对象,那么就抛出错误——找不到对应的依赖。

  • 只返回deps。也就是收集的依赖。

好了,我们现在通过esbuild得到了扫描到的依赖对象,接下来跟处理optimizeDeps.include一样,把它们对应的信息推入depInfoListdiscovered之中。

同样的,这些信息中的file字段,是这个依赖被预构建之后的地址,因为预构建还没开始,因此此时此刻,硬盘上并没有对应地址。

最后把已知依赖组合起来,传入runOptimizeDeps

runOptimizeDeps

首先runOptimizeDeps会往node_modules/.vite/创建一个deps_temp_[hash]的文件夹。

之所以不直接创建或者修改deps文件夹,是因为deps文件可能是存在的,并且此时还没进行依赖预构建,而是仅仅完成了依赖收集,因此如果依赖预构建出现了错误,就没有回退余地了。所以创立了临时目录,当预构建成功结束后,临时目录转正。

然后再次通过initDepsOptimizerMetadata计算出一份新的metadata模板。与之前相同,不同的是本次发现了依赖,因此browserHash不能以空对象作为基准计算hash了,而是前面组合的已知依赖。

然后使用prepareEsbuildOptimizerRun进行依赖预构建。

prepareEsbuildOptimizerRun

预构建部分逻辑很核心,但比较简单。

首先,跟prepareEsbuildScanner类似,尝试取optimizeDeps.esbuildOptions的数据,并把其中的插件剥离出来。

然后,它会处理传入的依赖,也就是上文的出来的已知依赖组合,将其扁平化为 flatIdDeps 对象,并提取每个依赖项的导出信息,将其存储在 idToExports 对象中。

接着,它准备一些构建配置,包括定义一些全局变量、选择平台、指定外部模块、设置插件等。

const context = await esbuild.context({
  absWorkingDir: process.cwd(),
  // 入口,上文扁平的flatIdDeps
  entryPoints: Object.keys(flatIdDeps),
  bundle: true,
  format: 'esm',
  // 构建输出的 JavaScript 版本
  target: ESBUILD_MODULES_TARGET,
  // 是否生成源映射文件
  sourcemap: true,
  // 输出目录
  outdir: processingCacheDir,
  // 是否生成元数据文件,用于后续分析依赖关系
  metafile: true,
  // 构建插件,用于在构建过程中对代码进行处理
  plugins,
  //略
})

我们省略了一些配置,剩下的配置需要我们重点关照:

  • entryPoints: 将每一个依赖作为入口文件。

  • format: 转为esm

  • outdir:输出目录,还记得我们之前新建了临时目录吗?就是那个临时目录。

  • metafile:元数据,我们之后要用。

最后,它使用这些配置调用 esbuild.context 方法,生成一个新的构建上下文,并返回该上下文以及导出信息对象 idToExports。当然,这个是esbuild.context 方法,因此并没有实际构建。

因此在runOptimizeDeps中,调用完prepareEsbuildOptimizerRun后,会调用返回的esbuild上下文的rebuild方法,触发构建,构建结束后,临时目录中已经有已知依赖的esm产物了。

注意:是已知依赖,不是所有依赖。以上逻辑是vite启动时候的逻辑。当项目运行中,新增依赖,页面也引用了,但没有访问对应页面,那么这个依赖就是未知依赖。

当构建结束后,我们可以从rebuildthen方法获取metafile。我们使用metafile.outputs,根据已知依赖,来填充前文刚建立起来的metadata.optimized

这里需要注意的是,虽然两次都是调用了esbuild

但第一次的入口是项目入口,所遍历出来的依赖是项目直接引用的依赖。

第二次入口是这些依赖,因此构建出来的产物不仅仅有这些依赖的esm格式,还有它们本身的依赖也会被打包进chunk里面。

同时,如果是异步依赖,会自动在名称后面拼入hash

因此,根据metafile.outputs,不存在已知依赖列表的产物,都会被填充到metadata.chunks里面——包括上面的异步依赖——它们被拼入一个hash字符串,并且依赖扫描不会收集异步依赖。

最后构建出一个对象successfulResult,作为预构建调用链的返回值,填充到runOptimizeDeps的返回值的result属性上。

这个successfulResult对象暴露三个属性。

  • 之前构建的metadata

  • 取消函数cancel

  • 更新deps文件的commit函数。

最重要的就是commit。但我们稍后再讲。

最后runOptimizeDeps的返回值挂载到createDepsOptimizeroptimizationResult属性上。

意味着,只要能访问到optimizationResult,那么就可以通过await optimizationResult.result获取上文定义的successfulResult。从而可以更新deps或者获取填充的metadata

但到目前为止,预构建已经结束了。

我们整理一下现状:

  • metadata有两份,一个是createDepsOptimizer初始化的,另一个是runOptimizeDeps返回值,挂载到optimizationResult上的。

  • 两个metadata已经填充完毕了,但都只在内存中,并没有写入硬盘。

  • createDepsOptimizermetadata数据比较基础,依赖最多收集到了依赖扫描到的依赖。discovered是有值的。

  • optimizationResultmetadata数据比较全,discovered没有值。

  • 依赖预构建产物已经生成了,但放入的是临时文件夹。

  • 创建、更新deps文件的函数commitmetadata挂载到了optimizationResult属性上。

那么什么时候才会将metadata填充到_metadata.json中呢?以及什么时候预构建产物所在文件夹才会转正呢?

onCrawlEnd

onCrawlEnd函数用于处理静态文件爬取结束后的依赖优化行为。主要负责处理临时文件转正和写入metadata的操作。

我们来看看它的逻辑。

首先它会使用await取出optimizationResult的内容。

然后清空optimizationResult

const afterScanResult = optimizationResult.result
optimizationResult = undefined
const result = await afterScanResult

如果createDepsOptimizer中的metadata.discovered所记录的依赖都能在optimizationResultmetadataoptimized找到。

那么就可以进行写入_metadata.json和临时文件转正了。

如果optimizeddiscovered少,说明存在遗漏依赖,或者虽然一一对应,但数据属性不一致。

那么optimizationResult中的数据作废,删除临时文件夹。并且将optimized中多了的依赖添加到discovered中,并且立刻重新开始一轮运行时的预构建。

注意,这里是将optimizeddiscovered多的依赖添加到discovered,确实存在这种情况,比如optimized的依赖是【A,B,D】而discovered是【A,B,C,E】,那么就会把D添加到discovered

最后执行runOptimizer(result)执行依赖预构建收尾,我们待会看看runOptimizer做了什么。

我们先看看onCrawlEnd是如何被调用的。

createDepsOptimizer中,将onCrawlEnd使用server._onCrawlEnd推入到了onCrawlEndCallbacks这个数组中。

这个数组最后是被谁消费呢?答案是setupOnCrawlEnd

setupOnCrawlEnd内部定义了一个逻辑,确保onCrawlEndCallbacks中的回调函数只会执行一次,它返回一个函数,叫做crawlEndFinder

这些不重要,重要的是crawlEndFinder最终被_registerRequestProcessing包装了下,并被doTransform调用。

doTransform是不是很眼熟,没错,就是模块依赖图的核心逻辑。

const result = loadAndTransform(...)

  if (!ssr) {
    const depsOptimizer = getDepsOptimizer(config, ssr)
    if (!depsOptimizer?.isOptimizedDepFile(id)) {
      server._registerRequestProcessing(id, () => result)
    }
  }

如果当前文件不是需要预构建的文件,那么就会调用_registerRequestProcessing。从而最终调用runOptimizer(result)

registerMissingImport

我们上文提到了运行时的预构建。什么意思?

比如现在项目完全启动起来,并且浏览器已经渲染出目标页面。当前情况已经超出了我们上文分析过的任何逻辑,是一个运行时的环境。

那么通过新增依赖包,并在页面进行引用,此时新增的包同样需要进行预构建。

那么需要一个对应的逻辑处理这种情况,那么就需要运行时的预构建。

当然,显而易见,增加一个运行时是不太划算的,那么目前vite有什么可以针对新引入的包做出反应的功能吗?

答案就是钩子。

更加具体点就是vite:resolve插件的resolveId钩子。

这个钩子如果发现引入的模块是一个并没有被预构建的钩子,那么就会调用registerMissingImport方法。

这个方法我们之前提到过,是一个专门注册未预构建依赖的方法。

这个方法会检测新发现的模块在不在待预构建的列表里面,也就是在不在discovered里面,如果在,就返回待预构建的信息。

否则就将此模块添加到discovered里面,并调用一次runOptimizer(),同时在控制台打印new dependencies found: xxx

runOptimizer

好了,现在两个方法最后都调用runOptimizer。不同的是,onCrawlEnd会传入optimizationResult.result,而registerMissingImport不会传入任何参数。

  • 如果传入参数的话,那么入参会赋值给processingResult

  • 否则,就会整理已知依赖,然后调用runOptimizeDeps,把它的返回值赋值给optimizationResult,并且把optimizationResult.result赋值给processingResult。换句话说,又走了遍初始化metadata-创建临时文件夹-将已知依赖构建输出到临时文件夹这个流程。

之后,会调用processingResult.commit(),并在调用结束后,将createDepsOptimizer中的metadata.discovered所记录的依赖赋值给optimizationResultmetadatadiscovered

也就是说,runOptimizer起到了收束的作用,让不同时态的预构建逻辑,收束为调用commit,也就是更新deps

commit

我们看看commit的逻辑,首先,会往临时目录下写入_metadata.json

fs.writeFileSync(
    dataPath,
    stringifyDepsOptimizerMetadata(metadata, depsCacheDir),
)

好了,现在临时目录什么都有了:预构建产物、_metadata.json。只差转正临门一脚了。

接着会判断现在当下有没有.vite/deps目录。如果有,将它重命名为一个临时文件。

然后将之前创建的临时文件夹重命名为.vite/deps,完成转正。

最后删除之前已经被重命名临时文件的原.vite/deps

为什么这么做呢?

这也做是为了最大程度地减少.vite/deps处于不一致状态的时间。因为重命名-重命名(然后在后台删除旧文件夹)比删除-重命名操作快。

同时我们也从侧面看出,如果进行依赖预构建,一直是针对所有已知依赖的预构建——即使仅仅新增了一个依赖包。

看到这里,我们应该了解依赖预构建的流程了,但是,构建是结束了,那么怎么将请求的包,指向构建结束后的产物呢?

当然还是vite:resolve插件的resolveId钩子。

当它第一次处理依赖的时候,就会执行tryOptimizedResolve函数。

tryOptimizedResolve

tryOptimizedResolve中,会使用await等待依赖扫描结束。

然后获取createDepsOptimizermetadata。查询当前依赖是否存在optimizeddiscoveredchunks之中,如果存在,那么就返回metadata记录的元数据。

然后拿这个元数据去调用预构建对象的getOptimizedDepId

getOptimizedDepId我们一开始就提到了,代码很简单,就是拿元数据的file拼上一个hash然后返回。

getOptimizedDepId: (depInfo: OptimizedDepInfo) => `${depInfo.file}?v=${depInfo.browserHash}`

file,正是预构建产物所在的deps目录。

可能有人问了,这是当前依赖可以在预构建metadata中找到,如果找不到呢?

如果找不到,就会尝试从package.jsonbrowser拿地址,当然,如果还拿不到,就会调用tryNodeResolve方法。

tryNodeResolve方法可能大家不熟,它里面有个情况就是发现当前包是一个新依赖,就会调用registerMissingImport——发现新依赖,并添加到预构建列表。

也就是tryNodeResolveregisterMissingImport前置逻辑。

当然,上面的逻辑并非重新请求(刷新页面)都会调用一次,而是项目启动后,只会调用一次。

因为处理完当前依赖后,之后依赖都被指向预构建的地址。

准确来说,会被vite:import-analysis插件的transform钩子中的interopNamedImports方法,将原来的依赖地址,使用MagicString替换为预构建产物的地址。

MagicString是一个用于处理字符串的JavaScript库。它可以让你在字符串中进行插入、删除、替换等操作。可以看这篇文章

替换完毕后,这个预构建产物就如同普通模块一样,在之后的逻辑中,被记录在模块依赖图里面。

之后再次刷新页面,由于地址已经被重写,并且模块依赖图存在对应数据,因此直接从模块依赖图拿数据,返回给浏览器。

好了,以上是预构建的所有逻辑,但我们还有个坑没有填,扫描插件是什么样的?

esbuildScanPlugin

还得提一下那两个比较重要的对象depsmissing,这两个分别记录收集到的依赖和找不到的依赖。

一开始,会定义以下规则:

  • data请求不处理。

  • httphttps开头的模块不处理。

  • worker不做处理。

  • 剔除virtual-module前缀,Svelte<script>Vue<script setup>会被增加这个虚拟前缀。

  • 如果是.html, .vue, .svelte, .astro, 或 .imba 结尾的文件,并且可被插件流水线的resolveId解析,那么返回解析后的链接,并归类到html类别。

  • css等样式文件,jsonwasm和其它静态文件,如果不被指定为入口文件,那么就不处理。

对于bare imports,会进行以下操作

  1. 首先会判断扫描到的依赖是否是被optimizeDeps.exclude排除的,并且不是@vite/client@vite/env

  2. 如果是的话,就return,不过return的时候还会判断是不是入口模块,如果不是入口模块,那么就返回忽略标记,让esbuild遇到相同的路径的时候进行忽略。

  3. 如果已经记录到deps里面,就return,同样的,如果不是入口模块,那么就返回忽略标记,让esbuild遇到相同的路径的时候进行忽略。

  4. 然后进入插件流水线的resolveId进行解析,如果解析不成功,那么就放到missing里面。【这里收集到了missing依赖】

  5. 如果解析成功还会判断解析出来的是否是绝对路径。如果不是绝对路径,或者是虚拟模块,那么就return,并根据是否是入口模块打上忽略标记。

  6. 如果是node_modules模块,或者记录在optimizeDeps.include,那么就将此模块收集到deps中。并根据是否是入口模块打上忽略标记。【这里收集到了deps依赖】

  7. 如果不是node_modules模块,会按需归为html类别。

那些归为html类别的文件,会进行以下操作

  1. 首先会读取源码,然后正则匹配所有的script标签。

  2. 针对每个匹配,会进行如下处理。

    1. 如果是.html结尾的文件,并且当前匹配的type不是module,那么跳过。

    2. 如果scripttype不是 javaScriptecmascriptmodule,则跳过。

    3. 确定loader,如果是 typeScripttsx,则使用对应的loader,否则默认使用 javaScriptloader

    4. 如果标签包含 src 属性,则将其作为模块导入。否则,如果内容不为空,则处理内容。

    5. 如果是typescript的话,那么需要把导入的模块再次全量引入,这样可以防止 esbuild 优化代码的时候把它们删除,比如在vue3setup中,使用import { ModuleA } from './modules',然后在<template>使用ModuleA,但esbuild不认<template>,会认为ModuleA是无效引入,从而删除,因此这里会在内容后面拼接\nimport './modules',从而避免esbuild删除。

    6. 根据内容是否包含 import.meta.glob 来确定是否需要转换 glob 导入路径,并将内容存储到 scripts 对象中,以供后续加载时使用。

    7. 生成virtual-module路径——还记得上文中剔除的virtual-module前缀吗,在这里添加的。

    8. 根据文件类型和上下文属性生成导出语句。进行引入或者重导出。

    9. 对于非 Vue 文件或者不包含默认导出的文件,在末尾添加默认导出。

    10. 最后返回loader和处理后的内容。

这样就可以让上面的bare imports逻辑捕获刚刚生成的内容。从而解析html类别文件中的依赖。

结束

至此,Vite开发环境下的源码已经分析了大致脉络,但需要学习的东西还有很多,因此我们依然在路上。

0
博主关闭了所有页面的评论