vite预构建源码梳理

2,332 阅读5分钟

对于“为什么要进行依赖预构建?"这个问题vite文档已经解释的很清楚了,那么预构建大概的流程是什么样的呢?

启动预构建

从文档中我们知道在服务启动前会进行预构建,对应源码位置在src/node/server/index.ts,预构建的函数名是optimizeDeps

...
const runOptimize = async () => {
    if (config.cacheDir) {
      server._isRunningOptimizer = true;
      try {
        server._optimizeDepsMetadata = await optimizeDeps(config);
      } finally {
        server._isRunningOptimizer = false;
      }
      server._registerMissingImport = createMissingImporterRegisterFn(server);
    }
  };
  
...
await runOptimize();
...

开始预构建

函数optimizeDeps定义在src/node/optimizer/index.ts,其主要流程可分为以下几步:

  1. 判断是否需要预构建,如果之前预构建的内容还可以用,那么直接return,反之继续往下执行。需要说明的是判断预构建的内容是否可用的依据是package.lock.json和部分vite配置的内容,具体实现在getDepHash函数中。

  if (!force) {
    let prevData;
    try {
      prevData = JSON.parse(fs.readFileSync(dataPath, "utf-8"));
    } catch (e) {}
    // hash is consistent, no need to re-bundle
    if (prevData && prevData.hash === data.hash) {
      log("Hash is consistent. Skipping. Use --force to override.");
      return prevData;
    }
  }
  1. 使用esbuild解析整个项目,获取本次需要进行预构建的依赖解析出问题的依赖,分别赋值给depsmissing,其主要执行过程在函数scanImports中(这部分的代码实现过程梳理放在本部分最后)。
  let deps: Record<string, string>, missing: Record<string, string>;
  if (!newDeps) {
    ({ deps, missing } = await scanImports(config));
  } else {
    deps = newDeps;
    missing = {};
  }
  1. 正式预构建前的一系列的处理
    1. 如果missing有值,则报错,就是我们在控制台看到的The following dependencies are imported but could not be resolved.... Are they installed
    2. 把配置项config.optimizeDeps?.include里的依赖加入到deps中,如果处理失败的话也会在控制台报错
    3. 如果deps为空的话,说明不需要预构建,更新预构建内容的hash值后直接return
    4. 执行到这说明本次需要进行预构建,在控制台提示本次预构建的依赖,如下图所示

screenshot-20210815-121549.png 4. 进一步处理deps得到flatIdDeps,主要是因为默认esbuild打包的话对于依赖的分析、映射的处理可能比较麻烦,这里主要做了两方面的工作

  1. 扁平化目录结构。举个例子,引入lib-flexible/flexible,而预构建的依赖为lib-flexible_flexible.js

carbon.png

2. 在插件中,把入口文件当作虚拟文件(这一步应该是esbuild插件的需要,不是特别理解)

// esbuild generates nested directory output with lowest common ancestor base
 // this is unpredictable and makes it difficult to analyze entry / output
 // mapping. So what we do here is:
 // 1. flatten all ids to eliminate slash
 // 2. in the plugin, read the entry ourselves as virtual files to retain the
 //    path.
 const flatIdDeps: Record<string, string> = {};
 const idToExports: Record<string, ExportsData> = {};
 const flatIdToExports: Record<string, ExportsData> = {};

 await init;
 for (const id in deps) {
   const flatId = flattenId(id);
   flatIdDeps[flatId] = deps[id];
   const entryContent = fs.readFileSync(deps[id], "utf-8");
   const exportsData = parse(entryContent) as ExportsData;
   for (const { ss, se } of exportsData[0]) {
     const exp = entryContent.slice(ss, se);
     if (/export\s+\*\s+from/.test(exp)) {
       exportsData.hasReExports = true;
     }
   }
   idToExports[id] = exportsData;
   flatIdToExports[flatId] = exportsData;
 }
  1. 使用esbuild对deps每个依赖进行构建并默认输出到node_modules/.vite
 const result = await build({
   entryPoints: Object.keys(flatIdDeps),
   bundle: true,
   format: "esm",
   external: config.optimizeDeps?.exclude,
   logLevel: "error",
   splitting: true,
   sourcemap: true,
   outdir: cacheDir,
   treeShaking: "ignore-annotations",
   metafile: true,
   define,
   plugins: [
     ...plugins,
     esbuildDepPlugin(flatIdDeps, flatIdToExports, config),
   ],
   ...esbuildOptions,
 });
  1. 把此次预构建的信息更新并写入文件node_modules/.vite/_metadata.json,完成预构建!
for (const id in deps) {
    const entry = deps[id];
    data.optimized[id] = {
      file: normalizePath(path.resolve(cacheDir, flattenId(id) + ".js")),
      src: entry,
      needsInterop: needsInterop(
        id,
        idToExports[id],
        meta.outputs,
        cacheDirOutputPath
      ),
    };
  }

  writeFile(dataPath, JSON.stringify(data, null, 2));

scanImports

”具体哪些依赖是需要预构建的?“是函数scanImports处理的,在src/node/optimizer/scan.ts中,其过程比较简单,大概分为两步:

  1. 找到入口文件(一般是index.html
  2. 使用esbuild进行一次打包,打包过程中就找到了depsmissing,最后返回depsmissing
// step 1
 let entries: string[] = []
 ...
 entries = await globEntries('**/*.html', config)
 ...
 
// step 2
const plugin = esbuildScanPlugin(config, container, deps, missing, entries)

const { plugins = [], ...esbuildOptions } =
    config.optimizeDeps?.esbuildOptions ?? {}
  await Promise.all(
    entries.map((entry) =>
      build({
        write: false,
        entryPoints: [entry],
        bundle: true,
        format: 'esm',
        logLevel: 'error',
        plugins: [...plugins, plugin],
        ...esbuildOptions
      })
    )
  )
  
return {
    deps,
    missing
}

由上可以看出,depsmissing是在esbuild插件esbuildScanPlugin中得到的,那么这个插件是怎么做的呢?

esbuildScanPlugin

还是在src/node/optimizer/scan.ts中,该插件主要做了以下两件事:

  1. 处理导入模块(依赖),在build.onResolve中,具体:
  1. 设置external属性(external代表该模块是否需要打包)
  2. 判断是否应该加入deps或者missing,代码如下:
...
export const OPTIMIZABLE_ENTRY_RE = /\.(?:m?js|ts)$/
...
const resolved = await resolve(id, importer)
if (resolved) {
    if (shouldExternalizeDep(resolved, id)) {
      return externalUnlessEntry({ path: id })
    }
    if (resolved.includes('node_modules') || include?.includes(id)) {
      // dependency or forced included, externalize and stop crawling
      if (OPTIMIZABLE_ENTRY_RE.test(resolved)) {
        depImports[id] = resolved
      }
      return externalUnlessEntry({ path: id })
    } else {
      // linked package, keep crawling
      return {
        path: path.resolve(resolved)
      }
    }
} else {
    missing[id] = normalizePath(importer)
}

由上可知模块(依赖)是否放在deps、missing里、放的话放在哪一个都是由函数resolve决定的,从代码中可以看到resolve的执行逻辑如下:

  1. 执行rollup的hook resolveId(),
  2. 执行vite的插件pluginContainer resolveId()
  3. 最后是这里的resolve() 由于我对于这一段的处理逻辑不是很清楚,这里只能简单的理解为:
  4. resolve失败的话就会放到missing
  5. resolve里包含node_modules(我理解为放在node_modules目录下)或者在vite的配置项include里且是OPTIMIZABLE_ENTRY_RE的会直接放进deps等待打包,不再进一步向下crawling。 这里就把预构建需要的depsmissing收集到了。
  1. 处理文件内容,在build.onLoad中,具体:
  1. 针对.html .vue svelte这类有 js 逻辑的文件,需要把其中的js部分抽离出来,使用import 、export语法包裹并返回
  2. 针对不同的文件(js、ts、jsx...),加载不同的 loader 解析

预构建的结果

预构建的结果都放在了node_modules/.vite/中,一般如下图所示,包括两方面的信息:

image.png

  1. _metadata.json,是本次预构建产生的一些“版本”和依赖包的信息,如下图所示: image.png

  2. xx.js, xxx.js.map各个依赖包的打包结果

END

预构建部分的代码实现大概就是这样,文章同步放在了vite源码阅读中,关于vite源码相关的学习都会记录在这里,欢迎大家讨论交流,感谢各位🙏