深入挖掘,Vite 如何利用 Esbuild 实现令人惊艳的秒级预构建

Vite 在项目启动之前,会首先对项目中的依赖进行预构建,主要解决两个问题:将非 ESM 规范依赖转换为 ESM 规范;解决请求瀑布流问题,如 lodash-es 会被打包成一个模块,避免同时发起太多 HTTP 请求,造成页面卡顿。

预构建是 Vite 本地开发优化的关键策略,可以有效的减少兼容性问题,且预构建依赖可以结合浏览器的缓存,提升加载速率。

在本专栏的 初探 Vite 秒级预构建实现文章中,讲解了预构建的一些表现和常用配置,但这远远不够,大家想必都知道 Esbuild 作为 Vite 极速打包的两驾马车之一,Vite 又是如何利用 Esbuild 那?在依赖预构建的过程中,Esbuild 就被使用了两次,每一次的巧思都让人不由得赞叹。

学习本文,你将学到

  • 掌握完整的预构建核心流程及源码
  • 学习 Esbuild 核心配置的使用
  • 了解 Esbuild 中钩子的执行逻辑和具体使用

预构建核心思路

在正式阅读预构建核心代码之前,提前先讲一下预构建的核心思路,方便下文理解

  1. 创建 Vite 开发服务器,加载 Vite 配置信息,根据配置执行预构建的 init 方法
  2. 进行缓存判断,命中缓存,返回缓存结果,不会进行后续的预构建流程
  3. 扫描和记录 optimizeDeps.include 中引入的依赖
  4. 寻找依赖预构建入口文件,解析
  5. 使用 Esbuild 进行依赖扫描,找到项目的第三方依赖(Vite 官方称作 bare import),记录依赖,注意依赖扫描的过程中,并不会进行打包
  6. 第二次使用 Esbuild,进行依赖打包
  7. 打包过程结束,将打包产物写入磁盘,也就是生成 node_modules/.vite 目录

本章后续源码为 playgroud/optimize-deps-no-discovery 项目,并将 noDiscovery 配置注释

缓存判断

项目第一次启动时,会将预构建的关键信息写入到 _metadata.json 文件,后续项目启动,会根据 _metadata.json 文件中存储的 lockfileHashconfigHash 值,判断是否命中缓存,具体代码如下:

ts 复制代码
export async function loadCachedDepOptimizationMetadata(
  environment: Environment,
  force = environment.config.optimizeDeps.force ?? false,
  asCommand = false,
): Promise<DepOptimizationMetadata | undefined> {
  // 获取缓存目录
  const depsCacheDir = getDepsCacheDir(environment)
  // optimizeDeps.force 为true,会强制进行预构建
  if (!force) {
    let cachedMetadata: DepOptimizationMetadata | undefined
    try {
      // _metadata.json 路径
      const cachedMetadataPath = path.join(depsCacheDir, METADATA_FILENAME)
      // 读取 _metadata.json 内容
      cachedMetadata = parseDepsOptimizerMetadata(
        await fsp.readFile(cachedMetadataPath, 'utf-8'),
        depsCacheDir,
      )
    }
    // hash is consistent, no need to re-bundle
    if (cachedMetadata) {
      if (cachedMetadata.lockfileHash !== getLockfileHash(environment)) {
        // xxx
      } else if (cachedMetadata.configHash !== getConfigHash(environment)) {
        // xxx
      } else {
        // 命中缓存
        return cachedMetadata
      }
    }
  } 

  // hash 发生变化,或者使用了 force 参数
  await fsp.rm(depsCacheDir, { recursive: true, force: true })
}

关键点在于哈希计算的策略,需要根据可能影响预构建产物的配置和文件信息来生成 hash。

以 getConfigHash 为例,使用 define、root、plugins 等多个配置信息构建 content 对象,通过 getHash 计算出 hash 值,content 的某一项配置发生改变后,缓存实效,Vite 会重新触发预构建

ts 复制代码
function getConfigHash(environment: Environment): string {
  const { config } = environment
  const { optimizeDeps } = config
  const content = JSON.stringify(
    {
      // 开发/生成环境
      define: !config.keepProcessEnv
        ? process.env.NODE_ENV || config.mode
        : null,
      // 项目根目录
      root: config.root,
      // 路径解析配置
      resolve: config.resolve,
      // 自定义资源类型
      assetsInclude: config.assetsInclude,
      // 插件
      plugins: config.plugins.map((p) => p.name),
      // 预构建配置
      optimizeDeps: {
        include: optimizeDeps.include
          ? unique(optimizeDeps.include).sort()
          : undefined,
        exclude: optimizeDeps.exclude
          ? unique(optimizeDeps.exclude).sort()
          : undefined,
        esbuildOptions: {
          ...optimizeDeps.esbuildOptions,
          plugins: optimizeDeps.esbuildOptions?.plugins?.map((p) => p.name),
        },
      },
    },
    // 特殊处理函数和正则
    (_, value) => {
      if (typeof value === 'function' || value instanceof RegExp) {
        return value.toString()
      }
      return value
    },
  )
  return getHash(content)
} 

从上面代码还可以看到,JSON.stringify 进行了特殊处理,主要为了应对 stringify 序列化时的一些副作用表现,例如:

  • 函数、undefined和 Symbol 会被忽略
  • NaN 和 Infinity 格式的数值及 null 都会被当做 null。
  • 其他类型的对象,包括 Map/Set/WeakMap/WeakSet,仅会序列化可枚举的属性。

而传入的对象中,包含 plugins 属性,plugin 通常为一个包含 setup 函数的对象,需要借助 JSON.stringify 的 replacer 参数,来实现一些自定义的序列化行为,避免序列化后函数丢失

下面模拟一个简单的案例,理解一下 replacer 参数

ts 复制代码
const obj = {
  name: "Alice",
  greet: function () {
    return `Hello, ${this.name}!`;
  },
  regex: /hello/gi
};

console.log(JSON.stringify(obj));

console.log(JSON.stringfy(obj, (_, value) => {
  if (typeof value === 'function' || value instanceof RegExp) {
    return value.toString()
  }
  return value
}))

createDepsOptimizer.init

预构建的代码都在 optimizer 文件夹中

未设置 noDiscovery,depsOptimizer 会执行 createDepsOptimizer 类

ts 复制代码
// packages/vite/src/node/server/environment.ts
const { optimizeDeps } = this.config
if (context.depsOptimizer) {
  this.depsOptimizer = context.depsOptimizer
} else if (isDepOptimizationDisabled(optimizeDeps)) {
  this.depsOptimizer = undefined
} else {
  this.depsOptimizer = (
    optimizeDeps.noDiscovery
      ? createExplicitDepsOptimizer
      : createDepsOptimizer
  )(this)
}

// 触发 init 方法
async listen(server: ViteDevServer): Promise<void> {
  this.hot.listen()
  await this.depsOptimizer?.init()
  warmupFiles(server, this)
}

依赖扫描

删除掉 .vite 文件夹或者添加 force 属性,使缓存失效,重新执行预构建

没有命中缓存,会执行预构建流程,预构建会使用两次 esbuild,第一次扫描项目中使用的依赖,收集依赖信息;第二次进行依赖打包,输出预构建产物。

预构建扫描的依赖会有两个来源,一个是项目 import 引入的依赖,另一个为手动配置在 optimizeDeps 对象的 include。

optimizeDeps.include

预构建过程会先处理后者,代码如下:

ts 复制代码
const manuallyIncludedDeps: Record<string, string> = {}
// 手动添加 include 的依赖,
await addManuallyIncludedOptimizeDeps(environment, manuallyIncludedDeps)
// 将解析到的依赖项转换为 Vite 需要的格式
const manuallyIncludedDepsInfo = toDiscoveredDependencies(
  environment,
  manuallyIncludedDeps,
  sessionTimestamp,
)

for (const depInfo of Object.values(manuallyIncludedDepsInfo)) {
  addOptimizedDepInfo(metadata, 'discovered', {
    ...depInfo,
    processing: depOptimizationProcessing.promise,
  })
  newDepsDiscovered = true
}

addManuallyIncludedDeps 负责解析 include 中配置的依赖,精简后,代码如下

ts 复制代码
export async function addManuallyIncludedOptimizeDeps(
  environment: Environment,
  deps: Record<string, string>,
): Promise<void> {
  const { logger } = environment
  const { optimizeDeps } = environment.config
  // 获取 include 配置
  const optimizeDepsInclude = optimizeDeps.include ?? []
  if (optimizeDepsInclude.length) {
    // 路径加载函数
    const resolve = createOptimizeDepsIncludeResolver(environment)
    for (const id of includes) {
     
      const normalizedId = normalizeId(id)
      if (!deps[normalizedId]) {
        // 解析依赖所在路径
        const entry = await resolve(id)
        if (entry) {
          if (isOptimizable(entry, optimizeDeps)) {
            deps[normalizedId] = entry
          } 
        }
      }
    }
  }
}

解析后路径如下

'/Users/xxx/Desktop/daily/test/vite/node_modules/.pnpm/@vitejs+test-dep-no-discovery@file+playground+optimize-deps-no-discovery+dep-no-discovery/node_modules/@vitejs/test-dep-no-discovery/index.js'

然后借助,toDiscoveredDependencies 函数作用是将解析到的依赖项转换为 Vite 需要的格式,详情如下:

有没有感觉到这个结构有些眼熟,类似于预构建产物 _metadata.json 中 optimized 属性,此时依赖扫描阶段,depInfo 信息写入到 discovered 属性中

ts 复制代码
export function addOptimizedDepInfo(
  metadata: DepOptimizationMetadata,
  type: 'optimized' | 'discovered' | 'chunks',
  depInfo: OptimizedDepInfo,
): OptimizedDepInfo {
  metadata[type][depInfo.id] = depInfo
  metadata.depInfoList.push(depInfo)
  return depInfo
}

寻找入口文件

对于第三方依赖的扫描,首先需要找到一个入口,沿着入口文件,就可以按图索骥的查找到第三方依赖,对项目进行递归处理,便可以扫描到项目中所有的第三方依赖。

scanImports 中通过 computeEntries 函数实现入口文件的寻找,其支持三种配置方式

  1. 首先从 optimizeDeps.entries 属性配置寻找
  2. 然后查找 rollup 提供的 build.rollupOptions.input 配置,支持字符串、对象和数组
  3. 最后从根目录中扫描 html文件,过滤掉 node_modules、test、dist 等文件夹

当前项目没有前两项配置,寻找的入口文件路径 /vite/playground/optimize-deps-no-discovery/index.html

ts 复制代码
function computeEntries(environment: ScanEnvironment) {
  let entries: string[] = []

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

  if (explicitEntryPatterns) {
    // 先从 optimizeDeps.entries 寻找入口,支持 glob 语法
    entries = await globEntries(explicitEntryPatterns, environment)
  } else if (buildInput) {
    // 其次从 build.rollupOptions.input 配置中寻找,注意需要考虑数组和对象的情况
    const resolvePath = async (p: string) => {
      const id = (
        await environment.pluginContainer.resolveId(p, undefined, {
          scan: true,
        })
      )?.id
      return id
    }
    if (typeof buildInput === 'string') {
      entries = [await resolvePath(buildInput)]
    } else if (Array.isArray(buildInput)) {
      entries = await Promise.all(buildInput.map(resolvePath))
    } else if (isObject(buildInput)) {
      entries = await Promise.all(Object.values(buildInput).map(resolvePath))
    } else {
      throw new Error('invalid rollupOptions.input value.')
    }
  } else {
    // 如果用户没有进行上述配置,则自动从根目录开始寻找
    entries = await globEntries('**/*.html', environment)
  }

  return entries
}

找到入口文件后,使用 Esbuild 进行依赖分析,这部分 vite 做了几个非常巧思的设计

  • 自定义多款 Esbuild 插件,这部分是核心中的核心,后续详细解读
  • Esbuild 参数中 write 设置为 false,代表构建产物不写入硬盘中
  • 通过 stdin 声明构建起点

为什么会这么做那?vite在预构建时,找到入口文件,然后扫描它的第三方依赖,对于 ESM 模式(html 文件中的第三方依赖的引入如下),需要进行递归的依赖处理,stdin 中处理后,content 内容为 import xxx.html,相当于将 html 入口文件也视作了模块。

ts 复制代码
async function prepareEsbuildScanner(
  environment: ScanEnvironment,
  entries: string[],
  deps: Record<string, string>,
  missing: Record<string, string>,
  scanContext: { cancelled: boolean },
): Promise<BuildContext | undefined> {
  if (scanContext.cancelled) return
  // 加载 Esbuild 扫描插件
  const plugin = esbuildScanPlugin(environment, deps, missing, entries)

  const { plugins = [], ...esbuildOptions } =
    environment.config.optimizeDeps.esbuildOptions ?? {}
  // 通过 Esbuild 进行依赖扫描
  return await esbuild.context({
    // esbuild 当前工作目录
    absWorkingDir: process.cwd(),
    // 核心参数,不写入硬盘中
    write: false,
    stdin: {
      contents: entries.map((e) => `import ${JSON.stringify(e)}`).join('\n'),
      loader: 'js',
    },
    // 是否打包第三方依赖
    bundle: true,
    // 打包后的格式产物为 ESM
    format: 'esm',
    logLevel: 'silent',
    // 自定义的 scan 插件
    plugins: [...plugins, plugin],
    ...esbuildOptions,
    tsconfigRaw,
  })
}

Esbuild 插件基础知识

为了更好的理解依赖扫描的过程,下面先补充一点 Esbuild 插件的基本知识

Esbuild 插件通常是一个对象,里面有namesetup两个属性,name是插件的名称,setup是一个函数,参数是一个 build 对象,该对象上挂载了一些可供操作的钩子函数,下面是一个简单案例,定义了 onResolve 和 onLoad 两个钩子

ts 复制代码
const buildPlugin = {
  name: 'env',
  setup(build) {
    build.onResolve({ filter: /^env$/ }, args => ({
      path: args.path,
      namespace: 'env-ns',
    }))

    build.onLoad({ filter: /.*/, namespace: 'env-ns' }, () => ({
      contents: JSON.stringify(process.env),
      loader: 'json',
    }))
  },
}

onResolve 和 onLoad 是 Esbuild 中两个核心钩子,分别在路径解析和模块内容加载的时机进行触发。

两个钩子都分别会接受两个参数:Options 和 Callback,其中 Options 定义钩子的匹配规则,包含下面两个属性

  • filter: 该字段可以传入一个正则表达式(注意:go 语言正则),Esbuild执行过程中, 会将模块路径与该正则进行匹配,匹配成功,钩子方法才会执行。
  • namespace: 每个模块都有一个关联的命名空间,默认每个模块的命名空间为 file (表示文件系统),可以显示声明命名空间规则进行匹配,匹配成功,钩子方法才会执行。

通常情形下,会在 onResolve 钩子的回调中返回 namespace 属性作为标识,后续在 onLoad 钩子中根据 namespace 进行过滤,例如上面的代码中过滤了要处理的 env 模块。

这里要非常注意钩子间执行顺序:如果同一个路径匹配的多个 onResolve 钩子,会按照代码编写的顺序进行匹配,如果其中某个 onResolve 存在返回值,那么将不会执行后续 onResolve,而是会进入下一个阶段(onLoad),同样的如果有一个 onLoad 钩子存在返回值,后续 onLoad 也不会再触发。

Callback 参数相对复杂一些,这里就不展开了,有兴趣的可以去官网做进一步的学习

入口文件处理

依赖扫描阶段,很难通过单纯的源码阅读,分析出钩子的执行调用顺序,推荐先给所有钩子打上断点,逐步调试,熟悉一下整体思路

  • Esbuild 中 stdin 参数为 import xxx.html,filter 为 htmlTypesRE 的 onResolve 钩子被触发,返回结果 namespace 标注为 html。

Vite 不止支持 html 作为入口文件,还支持 vuesvelteastroimba文件

ts 复制代码
const htmlTypesRE = /.(html|vue|svelte|astro|imba)$/;
function esbuildScanPlugin( /* 一些入参 */ ): Plugin {
  // 初始化一些变量
  // 返回一个 Esbuild 插件
  return {
    name: "vite:dep-scan",
    setup(build) {
      // 标记「类 HTML」文件的 namespace
      build.onResolve({ filter: htmlTypesRE }, async ({ path, importer }) => {
        // 获取当前模块的绝对路径
        const resolved = await resolve(path, importer)
        if (!resolved) return
        // 如果当前 html 位于 node_module
        // 或者不符合入口文件要求的后缀,返回undefined,
        // 继续执行 后续 onResolve 钩子
        if (
          isInNodeModules(resolved) &&
          isOptimizable(resolved, optimizeDepsOptions)
        )
          return
        return {
          path: resolved,
          namespace: 'html',
        }
      })
      // 接受 htmlTypesRE onResolve 的结果,进行模块内容加载
      build.onLoad(
        { filter: htmlTypesRE, namespace: "html" },
        async ({ path }) => {
          // 解析「类 HTML」文件
        }
      );
    },
  };
}
  • htmlTypesRE 有返回值,进入 namespace:html 的 onLoad 钩子,提取 <script> 标签的内容,并将该模块转换为虚拟模块

先熟悉一下后续使用到的一些正则表达式,否则理解后续代码会有些困难

ts 复制代码
// demo1
<script type="module">
    console.log('Hello, world!');
</script> 
// demo2 
<script src="path/to/file.js"></script>

// 匹配 HTML 中的 <script type=module> 标签,并提取 script 内容
const scriptModuleRE =
  /(<script\b[^>]*type\s*=\s*(?: module |'module')[^>]*>)(.*?)</script>/gims

// --匹配结果
<script type="module">
console.log('Hello, world!');
// --

// 匹配 HTML 中的 <script> 标签,并提取 script 内容
export const scriptRE = /(<script\b(?:\s[^>]*>|>))(.*?)</script>/gims

// --匹配结果
<script type="module">
console.log('Hello, world!');
// --

// 匹配 <script> 标签中的 type 属性
const typeRE = /\btype\s*=\s*(?: ([^ ]+) |'([^']+)'|([^\s' >]+))/im

// -- 匹配结果
module
// --


// 匹配 <script> 标签中的 src 属性,并提取 src 路径
const srcRE = /\bsrc\s*=\s*(?: ([^ ]+) |'([^']+)'|([^\s' >]+))/im
// 注意 这里其实匹配了三种情形
// 空格分隔的路径(如 src=path/to/file)。
// 单引号包裹的路径(如 src='path/to/file')
// 双引号包裹的路径(如 src="path/to/file")

//-- 匹配结果
path/to/file.js
//--

// 匹配 <!-- xxx --> 注释信息
export const commentRE = /<!--(.|[\r\n])*?-->/

// 匹配 <script> 标签中的 lang 属性
const langRE = /\blang\s*=\s*(?: ([^ ]+) |'([^']+)'|([^\s' >]+))/im

下面是 htmlTypesRE onLoad 钩子的代码,主要处理逻辑如下:

Vite 中通过前缀添加 virtual-module: 标识为虚拟模块,后缀添加唯一标识 scriptId 保证不重复

  • 对于 html 文件,匹配 script type=module 标签,存储标签内容,返回虚拟模块
  • 其他类型入口文件,匹配 script 标签,存储标签内容,返回虚拟模块
ts 复制代码
build.onLoad(
  { filter: htmlTypesRE, namespace: 'html' },
  htmlTypeOnLoadCallback,
)

const htmlTypeOnLoadCallback: (
    args: OnLoadArgs,
  ) => Promise<OnLoadResult | null | undefined> = async ({ path: p }) => {
    let raw = await fsp.readFile(p, 'utf-8')
    // 移除注释内容,以防止干扰解析过程
    raw = raw.replace(commentRE, '<!---->')
    const isHtml = p.endsWith('.html')
    let js = ''
    // 匹配所有的 script 标签和内容
    const matches = raw.matchAll(scriptRE)
    for (const [, openTag, content] of matches) {
      // 提取 script 标签的 type 属性
      const typeMatch = typeRE.exec(openTag)
      const type =
        typeMatch && (typeMatch[1] || typeMatch[2] || typeMatch[3])
      
      // html 情形只处理 type = module 情形
      if (isHtml && type !== 'module') {
        continue
      }
      // 设定当前模块的加载器
      let loader: Loader = 'js'
      if (lang === 'ts' || lang === 'tsx' || lang === 'jsx') {
        loader = lang
      } else if (p.endsWith('.astro')) {
        loader = 'ts'
      }
      const srcMatch = srcRE.exec(openTag)
      if (srcMatch) {
        // 如果是 src=path 格式导入,直接通过 import 导入即可
        const src = srcMatch[1] || srcMatch[2] || srcMatch[3]
        js += `import ${JSON.stringify(src)}\n`
      } else if (content.trim()) {
        // p 为当前解析的模块路径
        const key = `${p}?id=${scriptId++}`
        // 记录当前模块
        scripts[key] = {
            loader,
            contents,
            resolveDir: normalizePath(path.dirname(p)),
            pluginData: {
              htmlType: { loader },
            },
        }
        // 将当前模块路径转变为虚拟模块路径
        const virtualModulePath = JSON.stringify(virtualModulePrefix + key)
        let addedImport = false

        if (p.endsWith('.svelte')) {
          // svelte 特殊处理,有概率会修改 addedImport
        }
        // 导出虚拟模块        
        if (!addedImport) {
          js += `export * from ${virtualModulePath}\n`
        }
      }
    }

    return {
      loader: 'js',
      contents: js,
    }
  }
    

为什么将模块转换为虚拟模块那?有以下方面的原因

  1. 当前会有多种类型的 script 脚本,如 Svelte 中的 <script context="module"><script> 和 Vue 中的 <script><script setup>
  2. HTML 文件也可能同时存在多个 type=module 内联 script
  3. 诸如上述的模块可能同时存在一个文件中,且可能使用相同的变量,为了避免命名冲突,抽离为虚拟模块,动态生成唯一的模块路径。

着重关注下列代码

ts 复制代码
// .html 文件 script 要求必须满足 type = module 
if (isHtml && type !== 'module') {
    continue
}
// 其他 .vue .svelte .astro .imba
// 只需要存在 script 标签就进行处理

此时返回的 contents 结果如下,将 index.html 转换为 virtual-module,且 id 标识为 0。

ts 复制代码
"export * from "virtual-module:/Users/zcxiaobao/Desktop/daily/test/vite/playground/optimize-deps-no-discovery/index.html?id=0"\n\nexport default {}"

返回 namespace:virtual-module 虚拟模块,会依次触发 virtualModuleRE 的 onResolve 和 onLoad 钩子,最终返回当前 script 脚本的内容

ts 复制代码
build.onResolve({ filter: virtualModuleRE }, ({ path }) => {
    return {
      path: path.replace(virtualModulePrefix, ''),
      namespace: 'script',
    }
  })

  build.onLoad({ filter: /.*/, namespace: 'script' }, ({ path }) => {
    // 返回前面存储的内容
    return scripts[path]
  })

Bare import 处理

Vite 在处理第三方依赖时,仅会处理 bare import 型导入,bare import 指的是直接使用模块名称进行导入,而不是使用相对路径或绝对路径。例如:

ts 复制代码
import vue from 'vue'
import subRep from '@vuejs/xxx'

对于 bare import 导入,vite 定义了 /^[\w@][^:]/ onResolve 钩子

正则表达式 /^[\w@][^:]/ 的作用是匹配以字母、数字、下划线或 @ 开头,且第二个字符不是冒号 : 的字符串

ts 复制代码
 // bare imports: record and externalize ----------------------------------
build.onResolve(
{
  filter: /^[\w@][^:]/,
},
    async ({ path: id, importer }) => {
      // 判断是否在  optimizeDeps.exclude 中记录过,记录过则 extrenalize
      if (moduleListContains(exclude, id)) {
        return externalUnlessEntry({ path: id })
      }
      // 判断是否记录过 depImports 中记录过,记录过则 extrenalize
      if (depImports[id]) {
        return externalUnlessEntry({ path: id })
      }
      // 解析路径,内部调用各个插件的 resolveId 进行解析
      const resolved = await resolve(id, importer)
      if (resolved) {
        // 判断是否应该 externalize,下面会详细讲一下这里
        if (shouldExternalizeDep(resolved, id)) {
          return externalUnlessEntry({ path: id })
        }
        // 如果当前依赖位于 node_modules 或者 optimizeDep.include
        // 记录依赖
        if (isInNodeModules(resolved) || include?.includes(id)) {
          if (isOptimizable(resolved, optimizeDepsOptions)) {
            depImports[id] = resolved
          }
          return externalUnlessEntry({ path: id })
        } else if (isScannable(resolved, optimizeDepsOptions.extensions)) {
          // resolved 为 「类 html」 文件,则标记上 'html' 的 namespace
          const namespace = htmlTypesRE.test(resolved) ? 'html' : undefined
          return {
            path: path.resolve(resolved),
            namespace,
          }
        } else {
          return externalUnlessEntry({ path: id })
        }
      } else {
        // 没有解析到路径,记录到 missing 中,后续会进行检查,抛出相关路径未找到的报错
        missing[id] = normalizePath(importer)
      }
    },
)

依赖扫描过程,只会 bare import 导入的依赖进行记录,因此需要制定一个 external 逻辑来剔除不符合要求的模块,对此 Vite 是分成两种情形来处理的

在 esbuild 中,钩子函数返回值中 external 标注为 true,esbuild 就不会对该路径进行处理。

一种是资源型,通过 onResolve 钩子进行过滤掉

ts 复制代码
// 协议开头的 URL 或绝对路径,直接标记 external:true
build.onResolve({ filter: externalRE }, ({ path }) => ({
  path,
  external: true,
}))

// data url,直接标记 external: true,不让 esbuild 继续处理
build.onResolve({ filter: dataUrlRE }, ({ path }) => ({
  path,
  external: true,
}))

const setupExternalize = (
  filter: RegExp,
  doExternalize: (path: string) => boolean,
) => {
  build.onResolve({ filter }, ({ path }) => {
    return {
      path,
      external: doExternalize(path),
    }
  })
}

// css 类型
setupExternalize(CSS_LANGS_RE, isUnlessEntry)
// json & wasm
setupExternalize(/.(json|json5|wasm)$/, isUnlessEntry)
// known asset types(主要为一些 images、media 和 font类型)
setupExternalize(
new RegExp(`\.(${KNOWN_ASSET_TYPES.join('|')})$`),
isUnlessEntry,
)
// ?worker 或者 ?raw 这种 query 的资源路径,直接 external
setupExternalize(SPECIAL_QUERY_RE, () => true)

externalUnlessEntry 实现非常简单,即非入口文件,标注为 external:true

typescript 复制代码
const isUnlessEntry = (path: string) => !entries.includes(path)

const externalUnlessEntry = ({ path }: { path: string }) => ({
    path,
    // 非 entry 则标记 external
    external: isUnlessEntry(path),
})

另外一种为模块型,通过 resolve 函数解析出模块的路径,然后使用 shouldExternalizeDep 函数进行过滤

ts 复制代码
function shouldExternalizeDep(resolvedId: string, rawId: string): boolean {
  // 解析之后不是一个绝对路径,不在 esbuild 中进行加载
  if (!path.isAbsolute(resolvedId)) {
    return true
  }
  // 1. import 路径本身就是一个绝对路径
  // 2. 虚拟模块(Rollup 插件中约定虚拟模块以`\0`开头)
  // 都不在 esbuild 中进行加载
  if (resolvedId === rawId || resolvedId.includes('\0')) {
    return true
  }
  return false
}

由于正则中添加对于 @ 字符的判断,例如项目中定义 alias------@src/xxx ,也会被匹配到,因此在记录第三方依赖时,无论是否符合要求,都会标注为 external:true,避免 esbuild 进行打包处理

ts 复制代码
// 如果当前依赖位于 node_modules 或者 optimizeDep.include
if (isInNodeModules(resolved) || include?.includes(id)) {
  if (isOptimizable(resolved, optimizeDepsOptions)) {
    depImports[id] = resolved
  }
  // 不管是否满足 bare import,都要标记为 true
  return externalUnlessEntry({ path: id })
}

依赖扫描结果如下:

另一种预构建情形分析

为了更加深刻的理解预构建依赖扫描过程,使用 npx create-vite 创建一个 vue+ts 项目,分析另一种依赖预构建的情形。

修改 launch.json 中 cwd 字段,指向新创建的项目目录。

该项目中 index.html 中,引用如下:

ts 复制代码
<script type="module" src="/src/main.ts"></script>

// main.ts
import { createApp } from 'vue'
import './style.css'
import App from './App.vue'

createApp(App).mount('#app')
  1. 触发 htmlTypesRE 的 onResolve 钩子,路径为 xxx/playground/vite-demo/index.html
  2. 触发 htmlTypesRE 的 onLoad 钩子,此时 script 存在 src 标签,执行以下流程
ts 复制代码
if (srcMatch) {
  const src = srcMatch[1] || srcMatch[2] || srcMatch[3]
  js += `import ${JSON.stringify(src)}\n`
}
// 返回结果
{
  loader: 'js',
  contents: 'import "/src/main.ts"export default {}',
}
  1. ts 文件会触发保底钩子(上一个项目中不会触发这里)

ts 文件,返回 namespace 为 undefined--注意,仍旧是有返回值的,进入 onLoad 钩子

ts 复制代码
build.onResolve({
  filter: /.*/,
},
async ({ path: id, importer }) => {
    // 加载路径
    const resolved = await resolve(id, importer)
    if (resolved) {
      if (
        shouldExternalizeDep(resolved, id) ||
        !isScannable(resolved, optimizeDepsOptions.extensions)
      ) {
        return externalUnlessEntry({ path: id })
      }
      // 判断是否为类 html 的入口文件,如果不是 namespace 为 undefined
      const namespace = htmlTypesRE.test(resolved) ? 'html' : undefined
      // 返回结果
      return {
        path: path.resolve(cleanUrl(resolved)),
        namespace,
      }
    } else {

      return externalUnlessEntry({ path: id })
    }
  },
)
  1. 匹配 JS_TYPES_RE 的 onLoad 钩子,该钩子没有 namespace 的限制,只需满足 filter 校验
ts 复制代码
build.onLoad({ filter: JS_TYPES_RE }, async ({ path: id }) => {
    // 提取后缀名
    let ext = path.extname(id).slice(1)
    if (ext === 'mjs') ext = 'js'

    const esbuildConfig = environment.config.esbuild
    // 读取文件内容
    let contents = await fsp.readFile(id, 'utf-8')
    
    return {
      loader,
      // 值为 main.ts 内容
      contents,
    }
  })
  1. 依次解析 main.ts 中的第三方依赖,其中 App.vue 会触发 htmlTypesRE 的 onResolve 钩子,触发路径为 vite/playground/vite-demo/src/App.vue,执行 vitrual-module 逻辑,返回结果
ts 复制代码
{
  contents: 'export * from "virtual-module:/Users/zcxiaobao/Desktop/daily/test/vite/playground/vite-demo/src/App.vue?id=0"\n',
  loader: "js"
}
  1. 依次执行 vitrualModueRE 的 onResolve 和 onLoad 钩子,返回当前模块内容
  2. 由于 App.vue 中引入了 HelloWorld.vue,会继续进入 htmlTypesRE 的 onResolve 钩子,重复上述流程,直至所有模块解析完毕。

小结

经过上面两个项目的依赖扫描分析,可以梳理出依赖处理的流程图,具体如下:

在上面的文章中,系统的讲解了依赖扫描的步骤,核心要注意 esbuild 的 watch 配置信息,以及plugin 中各个钩子的调用逻辑和调用时机,理解这两点,依赖扫描部分就可以较为轻松的理解了。

依赖打包

经过依赖扫描之后,扫描结果如下:

相较于复杂的依赖扫描,打包阶段相对会简单一些,核心流程位于 packages/vite/src/node/optimizer/index.ts 中的 runOptimizeDeps 方法,这里小包把它拆解为几个阶段

准备阶段

在预构建的过程中,Vite 会先创建一个临时目录,用来存放当前的预构建产物,避免直接操作 .vite/deps 文件夹,以防预构建失败,导致现有缓存目录遭到损坏。

ts 复制代码
// 获取缓存目录 node_module/.vite/deps
const depsCacheDir = getDepsCacheDir(environment)
// 临时缓存目录 node_module/.vite/deps_temp_hash:8
const processingCacheDir = getProcessingDepsCacheDir(environment)
// 创建缓存目录
fs.mkdirSync(processingCacheDir, { recursive: true })

预构建完成后,也就是 successfulResult 函数,将当前的临时目录 processingCacheDir 重命名为 depsCacheDir。源码中根据是否存在 depsCacheDir 文件夹,分别执行 safeRename 和 renameSync 方法。

ts 复制代码
const temporaryPath = depsCacheDir + getTempSuffix()
const depsCacheDirPresent = fs.existsSync(depsCacheDir)
if (isWindows) {
  if (depsCacheDirPresent) {
    await safeRename(depsCacheDir, temporaryPath)
  }
  await safeRename(processingCacheDir, depsCacheDir)
  } else {
    if (depsCacheDirPresent) {
    fs.renameSync(depsCacheDir, temporaryPath)
  }
  fs.renameSync(processingCacheDir, depsCacheDir)
}

同时还会初始化 metadata 内容,_metadata.json 内容的写入也位于 successfulResult 函数中

ts 复制代码
// 初始化 matadata 数据
const metadata = initDepsOptimizerMetadata(environment)
// 根据 medadata.hash 和第三方依赖的路径,构建 browserHash
metadata.browserHash = getOptimizedBrowserHash(
  metadata.hash,
  depsFromOptimizedDepInfo(depsInfo),
)

function depsFromOptimizedDepInfo(
  depsInfo: Record<string, OptimizedDepInfo>,
): Record<string, string> {
  const obj: Record<string, string> = {}
  for (const key in depsInfo) {
    // 获取第三方依赖的 src 属性
    obj[key] = depsInfo[key].src!
  }
  return obj
}

function getOptimizedBrowserHash(
  hash: string,
  deps: Record<string, string>,
  timestamp = '',
) {
  // 根据 hash,依赖的路径信息,时间戳构建 hash
  return getHash(hash + JSON.stringify(deps) + timestamp)
}

// 写入 .vite/deps/_metadata.json
fs.writeFileSync(
    dataPath,
    stringifyDepsOptimizerMetadata(metadata, depsCacheDir),
  )

cleanUp 函数需要稍微关注一下,当取消预构建或者预构建失败时,清空临时目录

php 复制代码
const cleanUp = () => {
    if (!cleaned && !committed) {
      cleaned = true
      try {
        fs.rmSync(processingCacheDir, { recursive: true, force: true })
      } 
    }
  }

扁平化操作

由于各个第三方依赖的产物目录结构不一致,深层次的目录嵌套增加 Vite 路径解析的难度,还可能带来了一些不可控的因素。因此为了解决嵌套目录带来的问题,Vite 首先会对依赖扫描的结果进行路径扁平化处理,例如对于 @vitejs/test-dep-no-discovery 依赖,处理结果如下:

本质上其实就是进行正则替换,具体匹配规则如下:

ts 复制代码
// 匹配路径中的 / 或者 :
const replaceSlashOrColonRE = /[/:]/g
// 匹配路径中的 .
const replaceDotRE = /./g
// 匹配 >,并允许其前后有任意数量的空白字符(\s*),
const replaceNestedIdRE = /\s*>\s*/g
// 匹配 #
const replaceHashRE = /#/g
const flattenId = (id: string): string => {
const flatId = limitFlattenIdLength(
    id
      .replace(replaceSlashOrColonRE, '_')
      .replace(replaceDotRE, '__')
      .replace(replaceNestedIdRE, '___')
      .replace(replaceHashRE, '____'),
  )
  return flatId
}

扁平化的操作发生在 prepareEsbuildOptimizerRun 函数中

ts 复制代码
await Promise.all(
    Object.keys(depsInfo).map(async (id) => {
      const src = depsInfo[id].src!
      const exportsData = await (depsInfo[id].exportsData ??
        extractExportsData(environment, src))
      if (exportsData.jsxLoader && !esbuildOptions.loader?.['.js']) {
        esbuildOptions.loader = {
          '.js': 'jsx',
          ...esbuildOptions.loader,
        }
      }
      const flatId = flattenId(id)
      // 得到扁平化依赖产物 flatIdDeps
      flatIdDeps[flatId] = src
      idToExports[id] = exportsData
    }),
  )

Esbuild 配置

依赖打包过程同样由 Esbuild 完成,参数配置位于 prepareEsbuildOptimizerRun 方法中,完整配置见下面代码

与依赖扫描存在区别的核心有两点:

  1. 通过 entryPoints 设置入口文件, 为扁平化处理的依赖扫描后的结果
  2. 未声明 write:false,同时设置 outdir: processingCacheDir,构建产物会写入到临时目录
ts 复制代码
const context = await esbuild.context({
  absWorkingDir: process.cwd(),
  // 所有依赖的 id 数组,在插件中会转换为真实的路径
  entryPoints: Object.keys(flatIdDeps),
  bundle: true,
  platform,
  define,
  format: 'esm',
    banner:
      platform === 'node'
        ? {
            js: `import { createRequire } from 'module';const require = createRequire(import.meta.url);`,
          }
        : undefined,
    target: ESBUILD_MODULES_TARGET,
    external,
    logLevel: 'error',
    splitting: true,
    sourcemap: true,
    outdir: processingCacheDir,
    ignoreAnnotations: true,
    metafile: true,
    // 预构建专用的插件
    plugins,
    charset: 'utf8',
    ...esbuildOptions,
    supported: {
      ...defaultEsbuildSupported,
      ...esbuildOptions.supported,
    },
  })
  return { context, idToExports }
}

插件钩子逻辑

外部依赖

首先关注对于外部依赖的处理,主要逻辑为

  • 处理外部依赖:将某些依赖标记为外部依赖,避免重复打包。
  • 转换 require import:通过命名空间和模块路径的处理,将 CommonJS 的 require 调用转换为 ES 模块的 import
  • 优化依赖解析:通过自定义解析逻辑,确保依赖路径的正确性和性能。

allExternalTypes 包含可能的外部依赖类型

ts 复制代码
build.onResolve(
{
  filter: new RegExp(
    `\.(` + allExternalTypes.join('|') + `)(\?.*)?$`,
  ),
},
async ({ path: id, importer, kind }) => {
  // 已经转换为外部依赖
  if (id.startsWith(convertedExternalPrefix)) {
    // 当前依赖已被转换,返回路径,exteral 标注为 true
    return {
      path: id.slice(convertedExternalPrefix.length),
      external: true,
    }
  }
  // 解析模块路径
  const resolved = await resolve(id, importer, kind)
  if (resolved) {
    // 解析外部依赖中含有类 js|ts 文件,标记为非外部依赖
    if (JS_TYPES_RE.test(resolved)) {
      return {
        path: resolved,
        external: false,
      }
    }
    // 如果为 require 调用
    if (kind === 'require-call') {
      // 返回name: externalWithConversionNamespace
      // 移交 onLoad 处理
      return {
        path: resolved,
        namespace: externalWithConversionNamespace,
      }
    }
    return {
      path: resolved,
      external: true,
    }
  }
})

require 函数转换通过 onLoad 钩子实现,会特化处理一下 css 情形

javascript 复制代码
build.onLoad(
  { filter: /./, namespace: externalWithConversionNamespace },
(args) => {
  const modulePath = `"${convertedExternalPrefix}${args.path}"`
  return {
    contents:
      // 如果是 css 请求,同时并非模块化请求
      isCSSRequest(args.path) && !isModuleCSSRequest(args.path)
        // 生成 import 语句
        ? `import ${modulePath};`
        // 否则,生成 export 语句,导出默认和所有内容
        : `export { default } from ${modulePath};` +
          `export * from ${modulePath};`,
    loader: 'js',
  }
})

第三方依赖

其他依赖,通过 /^[\w@][^:]/ 的 onResolve 钩子进行处理

  1. 如果为入口文件,或者是入口文件的别名,直接返回 entry。

resolveEntry 函数返回入口路径的路径信息

ts 复制代码
let entry: { path: string } | undefined
if (!importer) {
  // 判断是否为入口文件
  if ((entry = resolveEntry(id))) return entry
  // 判断是否为入口文件别名
  const aliased = await _resolve(environment, id, undefined, true)
  if (aliased && (entry = resolveEntry(aliased))) {
    return entry
  }
}

function resolveEntry(id: string) {
    const flatId = flattenId(id)
    if (flatId in qualified) {
      return {
        path: qualified[flatId],
      }
    }
  }
  1. 其他依赖,通过 resolveResult 方法进行判断
ts 复制代码
const resolveResult = (id: string, resolved: string) => {
    // 浏览器外部依赖
    if (resolved.startsWith(browserExternalId)) {
      return {
        path: id,
        namespace: 'browser-external',
      }
    }
    // 可选的对等依赖
    if (resolved.startsWith(optionalPeerDepId)) {
      return {
        path: resolved,
        namespace: 'optional-peer-dep',
      }
    }
    // node 内置模块
    if (isBuiltin(environment.config.resolve.builtins, resolved)) {
      return
    }
    // 是否为外部 url,标记为 true
    if (isExternalUrl(resolved)) {
      return {
        path: resolved,
        external: true,
      }
    }
    // 排除上述情形后,返回模块路径
    return {
      path: path.resolve(resolved),
    }
  }

假设依赖为 optional-peer-dep类型,触发 onLoad 钩子,非开发环境会抛出错误

ts 复制代码
build.onLoad(
  { filter: /.*/, namespace: 'optional-peer-dep' },
  ({ path }) => {
    if (isProduction) {
      return {
        contents: 'module.exports = {}',
      }
    } else {
      const [, peerDep, parentDep] = path.split(':')
      return {
        contents: `throw new Error(`Could not resolve "${peerDep}" imported by "${parentDep}". Is it installed?`)`,
      }
    }
  },
)

继续调试,会发现出现类似 @vue/compiler-dom, @vue/shared 模块,esbuild 在进行依赖分析时进行递归分析的结果,上述模块正是来源于 vue 中。

第三方依赖扫描完毕后,触发 rebuild 方法,向 metadata 对象中写入 optimized 属性值,返回 successfulResult 函数。

当依赖打包完成后,最终由 successfulResult 将产物写入到 .vite/deps文件夹中。

总结

预构建的核心流程位于 packages/vite/src/node/optimizer/optimizer.ts中的 init 函数,简化后的代码大致如下:

ts 复制代码
async function init() {
  // 缓存判断
  const cachedMetadata = await loadCachedDepOptimizationMetadata(environment)
  // 没有缓存执行预构建
  if (!cachedMetadata) {
    // 记录 optimizeDeps.include 手动引入的依赖
    await addManuallyIncludedOptimizeDeps(environment, manuallyIncludedDeps)
    // bare import 依赖扫描和记录
    discover = discoverProjectDependencies(
      devToScanEnvironment(environment),
    )
    const deps = await discover.result
    // 处理需要预构建的依赖
    const knownDeps = prepareKnownDeps()
    // 依赖打包的产物写入
    optimizationResult = runOptimizeDeps(environment, knownDeps)
  }
}

Vite 的预构建流程有几分复杂,难点要理解两次 Esbuild 使用的配置和插件区别,第一次 Esbuild 核心要点是扫描和记录,第二次要点的是打包,在源码阅读和理解的时候,始终围绕这两点。

学习本文之后,你是否对 Vite 和 Esbuild 有了新的认识,留下一个思考,你认为 Vite 中还会有哪些场景使用了 Esbuild 那?

相关推荐
悬炫几秒前
闭包、作用域与作用域链:概念与应用
前端·javascript
jiaHang2 分钟前
小程序中通过IntersectionObserver实现曝光统计
前端·微信小程序
打野赵怀真23 分钟前
前端资源发布路径怎么实现非覆盖式发布(平滑升级)?
前端·javascript
顾林海32 分钟前
Flutter Dart 流程控制语句详解
android·前端·flutter
tech_zjf34 分钟前
装饰器:给你的代码穿上品如的衣服
前端·typescript·代码规范
xiejianxin52035 分钟前
如何封装axios和取消重复请求
前端·javascript
parade岁月35 分钟前
从学习ts的三斜线指令到项目中声明类型的最佳实践
前端·javascript
狼性书生37 分钟前
electron + vue3 + vite 渲染进程与渲染进程之间的消息端口通信
前端·javascript·electron
阿里巴巴P8资深技术专家38 分钟前
使用vue3.0+electron搭建桌面应用并打包exe
前端·javascript·vue.js
coder_leon42 分钟前
Vite打包优化实践:从分包到性能提升
前端