Skip to content

依赖预构建

Vite 是一个提倡 no-bundle 的构建工具,相比于传统的 Webpack,能做到开发时的模块按需编译,而不用先打包完再加载。我们所说的模块代码其实分为两部分,

  • 一部分是源代码,也就是业务代码
  • 另一部分是第三方依赖的代码,即node_modules中的代码。

所谓的no-bundle只是对于源代码而言,对于第三方依赖而言,Vite 还是选择 bundle(打包),并且使用速度极快的打包器 Esbuild 来完成这一过程,达到秒级的依赖编译速度。

为什么需要预构建

1.CommonJS 和 UMD 兼容性:

在开发阶段中,Vite 的开发服务器将所有代码视为原生 ES 模块。因此,Vite 必须先将以 CommonJS 或 UMD 形式提供的依赖项转换为 ES 模块。

在转换 CommonJS 依赖项时,Vite 会进行智能导入分析,这样即使模块的导出是动态分配的(例如 React),具名导入(named imports)也能正常工作:

js
// 符合预期
import React, { useState } from 'react'

2.性能

为了提高后续页面的加载性能,Vite 将那些具有许多内部模块的 ESM 依赖项转换为单个模块。

有些包将它们的 ES 模块构建为许多单独的文件,彼此导入。例如,lodash-es 有超过 600 个内置模块!当我们执行 import { debounce } from 'lodash-es' 时,浏览器同时发出 600 多个 HTTP 请求!即使服务器能够轻松处理它们,但大量请求会导致浏览器端的网络拥塞,使页面加载变得明显缓慢。

通过将 lodash-es 预构建成单个模块,现在我们只需要一个HTTP请求!

注意

依赖预构建仅适用于开发模式,并使用 esbuild 将依赖项转换为 ES 模块。在生产构建中,将使用 @rollup/plugin-commonjs。

缓存

文件系统缓存

Vite 将预构建的依赖项缓存到 node_modules/.vite 中。这就是预构建产物文件存放的目录。在浏览器访问页面后,打开 Dev Tools 中的网络调试面板,你可以发现第三方包的引入路径已经被重写:

ts
import React from "react";
// 路径被重写,定向到预构建产物文件中
import __vite__cjsImport1_react from "/node_modules/.vite/deps/react.js?v=74dec19c";
const StrictMode = __vite__cjsImport1_react["StrictMode"];

它会基于以下几个来源来决定是否需要重新运行预构建步骤:

  • 包管理器的锁文件内容,例如 package-lock.jsonyarn.lockpnpm-lock.yaml,或者 bun.lockb; 补丁文件夹的修改时间;
  • vite.config.js 中的相关字段;
  • NODE_ENV 的值。 只有在上述其中一项发生更改时,才需要重新运行预构建。

如果出于某些原因你想要强制 Vite 重新构建依赖项,你可以在启动开发服务器时指定 --force 选项,或手动删除 node_modules/.vite 缓存目录。

浏览器缓存

已预构建的依赖请求使用 HTTP 头 max-age=31536000, immutable 进行强缓存,以提高开发期间页面重新加载的性能。一旦被缓存,这些请求将永远不会再次访问开发服务器。如果安装了不同版本的依赖项(这反映在包管理器的 lockfile 中),则会通过附加版本查询自动失效。如果你想通过本地编辑来调试依赖项,您可以:

  1. 通过浏览器开发工具的 Network 选项卡暂时禁用缓存;
  2. 重启 Vite 开发服务器指定 --force 选项,来重新构建依赖项;
  3. 重新载入页面。

预构建配置

怎样通过 Vite 提供的配置项来定制预构建的过程。Vite 将预构建相关的配置项都集中在 optimizeDeps 属性上,我们来一一拆解这些子配置项背后的含义和应用场景。

入口文件——entries

第一个是参数是optimizeDeps.entries,通过这个参数你可以自定义预构建的入口文件。

默认情况下,Vite 会抓取你的 index.html 来检测需要预构建的依赖项(忽略了node_modulesbuild.outDir__tests__coverage)。如果指定了 build.rollupOptions.input,Vite 将转而去抓取这些入口点。

如果这两者都不合你意,则可以使用此选项指定自定义条目——该值需要遵循 tinyglobby 模式 ,或者是相对于 Vite 项目根目录的匹配模式数组。当显式声明了 optimizeDeps.entries 时默认只有 node_modulesbuild.outDir 文件夹会被忽略。如果还需忽略其他文件夹,你可以在模式列表中使用以 ! 为前缀的、用来匹配忽略项的模式。如果你不想忽略 node_modulesbuild.outDir,你可以选择直接使用字符串路径(不使用 tinyglobby 模式)

ts
// vite.config.ts
{
  optimizeDeps: {
    // 为一个字符串数组
    entries: ["./src/main.vue"];
  }
}

当然,entries 配置tinyglobby模式,非常灵活,如:

ts
// 将所有的 .vue 文件作为扫描入口
entries: ["**/*.vue"];

不光是.vue文件,Vite 同时还支持各种格式的入口,包括: htmlsvelteastrojsjsxtstsx。可以看到,只要可能存在import语句的地方,Vite 都可以解析,并通过内置的扫描机制搜集到项目中用到的依赖,通用性很强。

添加一些依赖——include

除了 entriesinclude 也是一个很常用的配置,它决定了可以强制预构建的依赖项,使用方式很简单:

ts
// vite.config.ts
optimizeDeps: {
  // 配置为一个字符串数组,将 `lodash-es` 和 `vue`两个包强制进行预构建
  include: ["lodash-es", "vue"];
}

它在使用上并不难,真正难的地方在于,如何找到合适它的使用场景。前文中我们提到,Vite 会根据应用入口(entries)自动搜集依赖,然后进行预构建,这是不是说明 Vite 可以百分百准确地搜集到所有的依赖呢?事实上并不是,某些情况下 Vite 默认的扫描行为并不完全可靠,这就需要联合配置include来达到完美的预构建效果了。接下来,我们好好梳理一下到底有哪些需要配置include的场景。

场景一: 动态 import

在某些动态 import 的场景下,由于 Vite 天然按需加载的特性,经常会导致某些依赖只能在运行时被识别出来。

ts
// src/locales/zh_CN.js
import objectAssign from "object-assign";
console.log(objectAssign);

// main.tsx
const importModule = (m) => import(`./locales/${m}.ts`);
importModule("zh_CN");

在这个例子中,动态 import 的路径只有运行时才能确定,无法在预构建阶段被扫描出来。

Vite 运行时发现了新的依赖,随之重新进行依赖预构建,并刷新页面。这个过程也叫二次预构建。在一些比较复杂的项目中,这个过程会执行很多次。然而,二次预构建的成本也比较大。我们不仅需要把预构建的流程重新运行一遍,还得重新刷新页面,并且需要重新请求所有的模块。尤其是在大型项目中,这个过程会严重拖慢应用的加载速度!因此,我们要尽力避免运行时的二次预构建。具体怎么做呢?你可以通过include参数提前声明需要按需加载的依赖:

ts
// vite.config.ts
{
  optimizeDeps: {
    include: [
      // 按需加载的依赖都可以声明到这个数组里
      "object-assign",
    ];
  }
}

场景二: 某些包被手动 exclude

excludeoptimizeDeps中的另一个配置项,与include相对,用于将某些依赖从预构建的过程中排除。不过这个配置并不常用,也不推荐大家使用。如果真遇到了要在预构建中排除某个包的情况,需要注意它所依赖的包是否具有 ESM 格式

自定义 Esbuild 行为

Vite 提供了esbuildOptions 参数来让我们自定义 Esbuild 本身的配置,常用的场景是加入一些 Esbuild 插件:

ts
// vite.config.ts
{
  optimizeDeps: {
    esbuildOptions: {
       plugins: [
        // 加入 Esbuild 插件
      ];
    }
  }
}

这个配置主要是处理一些特殊情况,如某个第三方包本身的代码出现问题了。由于我们无法保证第三方包的代码质量,在某些情况下我们会遇到莫名的第三方库报错。我举一个常见的案例——react-virtualized库。这个库被许多组件库用到,但它的 ESM 格式产物有明显的问题,在 Vite 进行预构建的时候会直接抛出这个错误。原因是这个库的 ES 产物莫名其妙多出了一行无用的代码。其实我们并不需要这行代码,但它却导致 Esbuild 预构建的时候直接报错退出了。那这一类的问题如何解决呢?

1. 改第三方库代码

首先,我们能想到的思路是直接修改第三方库的代码,不过这会带来团队协作的问题,你的改动需要同步到团队所有成员,比较麻烦。

好在,我们可以使用patch-package这个库来解决这类问题。一方面,它能记录第三方库代码的改动,另一方面也能将改动同步到团队每个成员。

根目录会多出patches目录记录第三方包内容的更改,随后我们在package.jsonscripts中增加如下内容:

json
pnpm i @milahu/patch-package -D

{
  "scripts": {
    // 省略其它 script
    "postinstall": "patch-package"
  }
}

这样一来,每次安装依赖的时候都会通过 postinstall 脚本自动应用 patches 的修改,解决了团队协作的问题。

注意

要改动的包在 package.json 中必须声明确定的版本,不能有 ~ 或者 ^ 的前缀。

2. 加入 Esbuild 插件

第二种方式是通过 Esbuild 插件修改指定模块的内容。

js
// vite.config.ts
const esbuildPatchPlugin = {
  name: "react-virtualized-patch",
  setup(build) {
    build.onLoad(
      {
        filter:
          /react-virtualized\/dist\/es\/WindowScroller\/utils\/onScroll.js$/,
      },
      async (args) => {
        const text = await fs.promises.readFile(args.path, "utf8");

        return {
          contents: text.replace(
            'import { bpfrpt_proptype_WindowScroller } from "../WindowScroller.js";',
            ""
          ),
        };
      }
    );
  },
};

// 插件加入 Vite 预构建配置
{
  optimizeDeps: {
    esbuildOptions: {
      plugins: [esbuildPatchPlugin];
    }
  }
}

代码

调用路径概览

关键文件是:

📁 vite/src/node/server/index.ts

js
createServer() -> _createServer()

const config = await resolveConfig(inlineConfig, 'serve')

resolveConfig() -> resolveEnvironmentOptions() --dev--> resolveDevEnvironmentOptions()
--> createEnvironment() --> defaultCreateClientDevEnvironment() --> new DevEnvironment()

this.depsOptimizer = (
    optimizeDeps.noDiscovery
      ? createExplicitDepsOptimizer
      : createDepsOptimizer
  )(this)

--> createDepsOptimizer() --> runOptimizeDeps()

关键文件是:

📁 vite/src/node/optimizer/optimizer.ts

js
// 收集依赖
discoverProjectDependencies() 

//
runOptimizer()-->runOptimizeDeps()
js
export function runOptimizeDeps(
  environment: Environment,
  depsInfo: Record<string, OptimizedDepInfo>,
): {
  cancel: () => Promise<void>
  result: Promise<DepOptimizationResult>
} {
  const optimizerContext = { cancelled: false }

  // 获取缓存目录路径
  const depsCacheDir = getDepsCacheDir(environment)
  const processingCacheDir = getProcessingDepsCacheDir(environment)

  // 创建一个临时目录,用来先写入预构建产物,防止直接污染缓存目录
  fs.mkdirSync(processingCacheDir, { recursive: true })

  // 在临时目录中创建 package.json 设置 type: "module",提示 Node 用 ES Module 方式处理
  fs.writeFileSync(
    path.resolve(processingCacheDir, 'package.json'),
    `{\n  "type": "module"\n}\n`,
  )

  // 初始化元数据结构(包含 hash、依赖信息等)
  const metadata = initDepsOptimizerMetadata(environment)

  // 计算浏览器用来校验缓存的 hash
  metadata.browserHash = getOptimizedBrowserHash(
    metadata.hash,
    depsFromOptimizedDepInfo(depsInfo),
  )

  const qualifiedIds = Object.keys(depsInfo)

  // 清理与提交状态控制
  let cleaned = false
  let committed = false

  const cleanUp = () => {
    // 如果已经提交则不再清理,防止误删
    if (!cleaned && !committed) {
      cleaned = true
      // 删除临时目录(无需 await,后台处理即可)
      fs.rmSync(processingCacheDir, { recursive: true, force: true })
    }
  }

  // 最终的返回结果结构
  const successfulResult: DepOptimizationResult = {
    metadata,
    cancel: cleanUp,
    commit: async () => {
      if (cleaned) throw new Error('本次优化已被取消,无法提交')

      committed = true

      // 将 metadata 写入临时目录
      fs.writeFileSync(
        path.join(processingCacheDir, METADATA_FILENAME),
        stringifyDepsOptimizerMetadata(metadata, depsCacheDir),
      )

      // 将临时目录原子性地替换到正式缓存目录,避免中间状态不一致
      const temporaryPath = depsCacheDir + getTempSuffix()
      const depsCacheDirPresent = fs.existsSync(depsCacheDir)

      if (isWindows) {
        // Windows 下使用 safer rename
        if (depsCacheDirPresent) await safeRename(depsCacheDir, temporaryPath)
        await safeRename(processingCacheDir, depsCacheDir)
      } else {
        // 非 Windows 使用 renameSync
        if (depsCacheDirPresent) fs.renameSync(depsCacheDir, temporaryPath)
        fs.renameSync(processingCacheDir, depsCacheDir)
      }

      // 后台删除旧目录
      if (depsCacheDirPresent) {
        fsp.rm(temporaryPath, { recursive: true, force: true })
      }
    },
  }

  // 没有依赖可优化,仍然清空旧目录
  if (!qualifiedIds.length) {
    return {
      cancel: async () => cleanUp(),
      result: Promise.resolve(successfulResult),
    }
  }

  const cancelledResult: DepOptimizationResult = {
    metadata,
    commit: async () => cleanUp(),
    cancel: cleanUp,
  }

  const start = performance.now()

  // 准备 esbuild 的运行上下文和构建计划
  const preparedRun = prepareEsbuildOptimizerRun(
    environment,
    depsInfo,
    processingCacheDir,
    optimizerContext,
  )

  // 执行构建
  const runResult = preparedRun.then(({ context, idToExports }) => {
    function disposeContext() {
      return context?.dispose().catch((e) => {
        environment.logger.error('释放 esbuild context 失败', { error: e })
      })
    }

    // 构建被取消或 context 不存在
    if (!context || optimizerContext.cancelled) {
      disposeContext()
      return cancelledResult
    }

    // 使用 esbuild 的 rebuild 方法进行实际预构建
    return context
      .rebuild()
      .then((result) => {
        const meta = result.metafile!

        // 将 esbuild 输出路径映射回缓存结构
        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,
            needsInterop: needsInterop(environment, id, idToExports[id], output),
          })
        }

        // 检查是否还有其他 chunk 产物写入 metadata
        for (const o of Object.keys(meta.outputs)) {
          if (!jsMapExtensionRE.test(o)) {
            const id = path
              .relative(path.relative(process.cwd(), processingCacheDir), o)
              .replace(jsExtensionRE, '')
            const file = getOptimizedDepPath(environment, id)

            if (!findOptimizedDepInfoInRecord(metadata.optimized, dep => dep.file === file)) {
              addOptimizedDepInfo(metadata, 'chunks', {
                id,
                file,
                needsInterop: false,
                browserHash: metadata.browserHash,
              })
            }
          } else {
            // 移除空的 source map 引用(修复 Firefox 警告)
            const output = meta.outputs[o]
            if (output.bytes === 93) {
              const jsMapPath = path.resolve(o)
              const jsPath = jsMapPath.slice(0, -4)
              if (fs.existsSync(jsPath) && fs.existsSync(jsMapPath)) {
                const map = JSON.parse(fs.readFileSync(jsMapPath, 'utf-8'))
                if (map.sources.length === 0) {
                  const js = fs.readFileSync(jsPath, 'utf-8')
                  fs.writeFileSync(jsPath, js.slice(0, js.lastIndexOf('//# sourceMappingURL=')))
                }
              }
            }
          }
        }

        debug?.(`依赖预构建耗时 ${(performance.now() - start).toFixed(2)}ms`)
        return successfulResult
      })
      .catch((e) => {
        // 构建取消是正常现象,返回空结果即可
        if (e.errors && e.message.includes('The build was canceled')) {
          return cancelledResult
        }
        throw e
      })
      .finally(() => disposeContext())
  })

  runResult.catch(() => cleanUp())

  return {
    async cancel() {
      optimizerContext.cancelled = true
      const { context } = await preparedRun
      await context?.cancel()
      cleanUp()
    },
    result: runResult,
  }
}