从零读 Vite 4.0 源码(三)———— vite 预构建

为什么需要预构建

我们先看一下官网的定义: 当首次启动 vite 时,Vite 在本地加载你的站点之前预构建了项目依赖。默认情况下,它是自动且透明地完成的。

执行预构建的目的是:

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

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

// 符合预期
import React, { useState } from 'react'
  1. 性能: 为了提高后续页面的加载性能,Vite将那些具有许多内部模块的 ESM 依赖项转换为单个模块。

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

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

以上摘自:https://cn.vitejs.dev/guide/dep-pre-bundling.html

预构建的实现

官网上对预构建的定义的目的描述的十分明确,那么下面我们通过官网的说明,来看看预构建是如何实现的。

先整理下官网对实现细节的一些说明:

  • 首次启动 vite 时进行构建。vite 会扫描源代码,自动寻找引入的依赖项。
  • 将依赖项作为预构建的入口,使用 esbuild 执行。
  • 将 CommonJS 或 UMD 模块转换为 ES 模块。

接下来,让我们深入源码,来看一下预构建具体是如何实现的。

首先,我们发现预构建的入口文件是:vite/packages/vite/src/node/optimizer/index.ts, 对应的函数是:optimizeDeps

vite 中执行预构建的时机有两处:

一个是执行 vite optimizer 命令时,会调用 optimizeDeps, 执行预构建。

一个是在启动 server 启动监听时,会初始化预构建:

const listen = httpServer.listen.bind(httpServer)
httpServer.listen = (async (port: number, ...args: any[]) => {
  try {
    createDepsOptimizer()
  } catch (e) {
    return
  }

  return listen(port, ...args)
})

vite optimize 触发的预构建流程较为完整,我们先以 optimizeDeps 函数来说明预构建的具体实现, 下面我们来看下具体的实现:

async function optimizeDeps(
  config
): Promise<DepOptimizationMetadata> {
  // 1.扫描源码,自动寻找依赖。
  const deps = await discoverProjectDependencies(config).result;

  // 2.将发现的依赖项转换为优化后的依赖项
  const depsInfo = toDiscoveredDependencies(config, deps, true)

  // 3. 预构建项目依赖
  const result = await runOptimizeDeps(config, depsInfo).result

  // 4. 执行构建成功后的一些处理,如将预构建从临时缓存目录拷贝到缓存目录中
  await result.commit()

  return result.metadata
}

可以看到预构建过程主要包括自动寻找依赖和预构建产物。

esbuild 来实现依赖收集与构建

我们先来看一下第一步:如何通过esbuild 来实现依赖收集与构建

import esbuild from 'esbuild';

export function discoverProjectDependencies(config) {
  // 重要!请在以下代码中记得观察此变量的写入时机(esbuildScanPlugin 内)
  const deps = {};

  // 将根目录下的 html 后缀的文件作为依赖收集的入口
  const entries = await globEntries('**/*.html');

  // 自定义一个 esbuild 插件,用来扫描依赖
  const plugin = esbuildScanPlugin(entries, deps);

  // esbuild.context() 方法进行构建操作,具体详见 esbuild 官网:https://esbuild.github.io/api/#build
  const esbuildContext = await esbuild.context({
    // 指定工作目录的绝对路径:当前 node.js 进程的工作目录
    absWorkingDir: process.cwd(),
    // 禁止将构建结果写入硬盘,而是直接返回给调用者
    write: false,
    // 指定要构建的代码内容和加载器类型
    stdin: {
      // 是一个字符串,表示要构建的代码
      contents: entries.map((e) => `import ${JSON.stringify(e)}`).join('\n'),
      // 加载器是 js
      loader: 'js',
    },
    // 指 esbuild 将所有模块打包成一个单独的文件
    bundle: true,
    // 指定构建结果的输出格式为 ESM
    format: 'esm',
    // 不输出日志信息
    logLevel: 'silent',
    // 指定使用的 plugin 插件
    plugins: [plugin],
    // 指定 ts 的配置文件路径
    tsconfig,
    // 解析 ts 的配置文件
    tsconfigRaw: resolveTsconfigRaw(tsconfig, tsconfigRaw)
  });

  // 手动调用 esbuild 构建
  await esbuildContext.rebuild();

  return deps;
}

vite 首先会将 html 文件作为 entry ,之后借助 esbuild 来将依赖打包为 ESM 格式并输出。可以看到 vite 主要是借助自定义 esbuild 插件 esbuildScanPlugin 来实现依赖收集,我们接下来就来看一下 esbuildScanPlugin 插件的具体实现:

import fsp from 'node:fs/promises'

function esbuildScanPlugin(
  config,
  container,
  deps,
) {
  return {
    name: 'vite:dep-scan',
    setup(build) {
       // 拦截 html 后缀的路径, 对路径进行处理,避免直接将其映射到文件系统。
       build.onResolve({ filter: /\.html$/ }, async ({ path, importer }) => {
        // ? 调用插件容器的 resoveId 接口
        const resolved = await pluginContainer.resolveId(id, importer);

        return {
          path: resolved,
          // 使用 "html" 命名空间标记,保留给插件使用
          namespace: 'html',
        }
      })

      // 拦截上一步 html 后缀,命名空间为 'html' 的对象,并提取 html 文件中的 script,将其按照 JS 模块处理
      build.onLoad(
        { filter: /\.html$/, namespace: 'html' },
        async ({ path }) => {
          // 读取该文件的内容
          let raw = await fsp.readFile(path, 'utf-8');

          let match = null;
          // 正则解释可参考:https://jex.im/regulex/#!flags=&re=%5E(a%7Cb)*%3F%24
          const scriptRE = /(<script(?:\s+[a-z_:][-\w:]*(?:\s*=\s*(?:"[^"]*"|'[^']*'|[^"'<>=\s]+))?)*\s*>)(.*?)<\/script>/gis

          // 匹配 script 内容
          let js = '';
          while ((match = scriptRE.exec(raw))) {
            const [, openTag, content] = match;
            // 正则可参考:https://jex.im/regulex/#!flags=i&re=%5Cbsrc%5Cs*%3D%5Cs*(%3F%3A%22(%5B%5E%22%5D%2B)%22%7C'(%5B%5E'%5D%2B)'%7C(%5B%5E%5Cs'%22%3E%5D%2B))
            const srcRE = /\bsrc\s*=\s*(?:"([^"]+)"|'([^']+)'|([^\s'">]+))/i
            // 匹配 src 属性的值
            const srcMatch = openTag.match(srcRE)
            if (srcMatch) {
              const src = srcMatch[1] || srcMatch[2] || srcMatch[3]
              // 将其转换为 import 语法
              js += `import ${JSON.stringify(src)}\n`
            }

            if (!js.includes('export default')) {
              js += '\nexport default {}';
            }
          }

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

      // 处理 bareImports(直接通过模块名称导入而未指定具体的模块名称或路径)
      build.onResolve(
        {
          // avoid matching windows volume
          filter: /^[\w@][^:]/,
        },
        async ({ path: id, importer, pluginData }) => {
          if (id.includes('node_modules')) {

              // 重要!收集依赖的所有三方库; resolvedId 为 id 的绝对路径;
              // deps 变量会传递到上层,对 deps 依赖做进一步处理。
              deps[id] = resolvedId;
              return {
                path,
                external: !entries.includes(path),
              }
            }
        },
      )
    }
  }
}

esbuildScanPlugin 插件主要做的事情是将 html 文件内的 script 脚本转换为 js 的 Import 语法,便于 esbuild 进行依赖分析。之后收集所有依赖的三方库,用于进行后续的处理。

依赖项转换为优化后的依赖项

接下来我们来看一下第 2步,将发现的依赖项转换为优化后的依赖项,这一步主要是用于后续的 xx 处理:

  const discovered: Record<string, OptimizedDepInfo> = {}
  for (const id in deps) {
    const src = deps[id]
    discovered[id] = {
      id,
      file: getOptimizedDepPath(id, config),
      src,
      // 生成一个特定的哈希值来缓存和重用产物;
      // 会基于以下几个来源来确定是否需要重新运行预构建:
      // - 包管理器的锁文件内容,例如 package-lock.json,yarn.lock,pnpm-lock.yaml,或者 bun.lockb;
      // - 补丁文件夹的修改时间;
      // - vite.config.js 中的相关字段;
      // - NODE_ENV 的值。
      // - deps 的值
      browserHash,
    }
  }
  return discovered

这里提供一个转换后的示例:

deps:

{
  "antd": "../../.pnpm/antd@4.16.13_react-dom@17.0.2_react@17.0.2/node_modules/antd/es/index.js",

}

discovered:

{
  "antd": {
    "src": "../../.pnpm/antd@4.16.13_react-dom@17.0.2_react@17.0.2/node_modules/antd/es/index.js",
    "file": "antd.js",
    "browserHash": "124f4611",
    "id": "antd"
  },
}

预构建项目依赖

上一步收集完三方库的所有依赖,并生成了对应的 discovered 对象后,开始进行三方库的依赖预构建,并将构建结果存至物理缓存(.vite/deps)目录下:

function runOptimizeDeps(depsInfo, config) {
  const depsCacheDir = getDepsCacheDir(config)

  // 可在你的 vite 项目下,寻找到该目录:/.vite/deps_temp_97303159
  const processingCacheDir = getProcessingDepsCacheDir(resolvedConfig)

  // 创建一个临时的文件夹用于存放依赖,避免出现错误时导致真正的缓存依赖目录不可用
  // 这是 node 中比较通用的一个处理方式:
  fs.mkdirSync(processingCacheDir, { recursive: true })

  // esbuild 构建准备
  const preparedRun = prepareEsbuildOptimizerRun(depsInfo);

  const successfulResult = {
    commit: async () => {
      // 1. 在临时缓存目录中,写入 meta.json,内容是模块的一些基本信息
      // 2. 将临时缓存目录的内容,写入到真正的缓存目录中(./vite/deps) 
    }
    // 预构建的包相关的信息
    metadata: {}
  }
  const runResult = preparedRun.then(({ context, idToExports }) => {
    return context
      .rebuild()
      .then((result) => {
          return successfulResult
      })
  });
}

我们主要看下 esbuild 构建准备那一步:prepareEsbuildOptimizerRun, 这一步不要和上述寻找依赖时使用的 esbuild 构建配置混淆。二者是不同的。

我们来看一下这个的具体实现:

function prepareEsbuildOptimizerRun(depsInfo) {
  // 自定义一个 esbuild 插件:vite:dep-pre-bundle,
  // 是为了解决某些依赖模块可能不支持原生 ES 模块,或者它们包含一些无法被动态导入的代码的情况。
  const depPlugin = esbuildDepPlugin(flatIdDeps, external, config, ssr);
  const context = await esbuild.context({
    // 指定用于解析相对路径的绝对工作目录
    absWorkingDir: process.cwd(),
    // 构建入口:是上一步生成的 depsInfo 的 key 组成的数组,实际 vite 中对 key 进行了展平,便于处理
    entryPoints: Object.keys(depsInfo),
    // 创建单个打包文件
    bundle: true,
    // 指定输出的模块格式
    format: 'esm',
    // 指定要添加到输出捆绑包前面的字符串或对象。在这种情况下,如果platform为'node',则添加了一个用于Node.js的动态导入替代方案,使用了'module'包中的createRequire函数。
    // See https://github.com/evanw/esbuild/issues/1921#issuecomment-1152991694
    banner:
      platform === 'node'
        ? {
            js: `import { createRequire } from 'module';const require = createRequire(import.meta.url);`,
          }
        : undefined,
    target: [
      'es2020', // support import.meta.url
      'edge88',
      'firefox78',
      'chrome87',
      'safari14',
    ],
    // 一个包含外部依赖项的数组,这些依赖项不应被打包,而应被视为外部导入。
    external,
    // 指定构建过程的日志级别
    logLevel: 'error',
    // 指示是否启用代码拆分
    splitting: true,
    // 指示是否生成源映射
    sourcemap: true,
    // 指定打包文件的输出目录
    outdir: processingCacheDir,
    // 是否忽略注释
    ignoreAnnotations: !isBuild,
    // 是否生成包含有关构建的其他信息的元文件。
    metafile: true,
    // 一个用于构建过程中使用的插件数组
    plugins: [depPlugin],
    charset: 'utf8',
    // TypeScript配置文件的路径。
    tsconfig,
    // 配置文件的原始内容
    tsconfigRaw: resolveTsconfigRaw(tsconfig, tsconfigRaw),
    // 指定构建支持的功能的对象
    supported: {
      'dynamic-import': true,
      'import-meta': true,
    },
  })

  return context
}

根据以上配置,可以得出 esbuild 构建依赖是通过将三方库指定为入口文件,并打包为单个 esm 格式文件,产出会存至临时缓存目录中。

server 阶段的预构建流程

function createDepsOptimizer() {
  // 创建 depsOptimizer 上下文对象
  const depsOptimizer = {
    metadata
    // ... 省略一些公共函数
  }

  // 仅在 dev 模式下执行
  depsOptimizer.scanProcessing = new Promise((resolve) => {
    // 在后台运行,避免阻塞其他高优项目
    ;(async () => {
      try {
        const discover = discoverProjectDependencies(config)
        const deps = await discover.result
        discover = undefined

        // 
        optimizationResult = runOptimizeDeps(config, deps)
      } catch (e) {
      } finally {
        resolve()
        depsOptimizer.scanProcessing = undefined
      }
    })()
  })

  // 在开发过程中,当服务器启动并所有的静态 import 都已经被爬取时,会调用 onCrawlEnd 回调。
  async function onCrawlEnd() {
    // 等待后台的扫描及生成预构建完毕
    // 通常在爬取用户代码完毕时,此步骤也将完成
    await depsOptimizer.scanProcessing
  }
}

可以看到与使用 vite optimize 的实现有细微差别。在启动server监听阶段,会开始扫描项目中的静态 import,并生成预构建的依赖。但是预构建并不会阻塞其他任务的执行,而是当用户发起第一个请求且爬取所有import 完毕后,才会等待预构建生成完毕。

为三方库的依赖设置别名:

先来看一下编译后的源码示例:

import React from "react";
// 409e4d5f 对应 metadata.json 中的 browserHash
import __vite__cjsImport0_react from "/node_modules/.vite/deps/react.js?v=409e4d5f";
const React = __vite__cjsImport0_react.__esModule ? __vite__cjsImport0_react.default : __vite__cjsImport0_react;

// ...

可以看到 vite 对路径做了转换,这一步的转换是由 vite 的一个自定义 plugin 完成的:

function preAliasPlugin() {
  return {
    name: 'vite:pre-alias',
    async resolveId(id, importer, options) {
      // 如果是三方依赖的路径,则进行替换
      if (resolved.id.startsWith('/.vite/deps')) {
          return { id: `${depInfo.file}?v=${depInfo.browserHash}`}
      }
    }
  }
}

浏览器缓存的处理

利用强缓存

对于已经预构建的依赖的请求使用 HTTP 头 max-age=31536000, immutable 进行强缓存,以提高开发期间页面重新加载的性能。一旦被缓存,这些请求将永远不会再次访问开发服务器。

这个处理是在我们熟悉的 viteTransformMiddleware 中间件中处理的:

return async function viteTransformMiddleware(req, res, next) {
  // resolve, load and transform using the plugin container
  const result = await transformRequest(url);

  // 设置缓存相关请求头
  res.setHeader('Etag', result.etag);
  // 若 url 是在 "/node_modules/.vite" 路径下,表示是请求了三方依赖的情况下,则进行强缓存
  res.setHeader('Cache-Control', 'max-age=31536000,immutable')
  res.statusCode = 200

  // 将转换后的内容返回
  res.end(result.code)
}

缓存失效

当依赖的三方库的版本号被修改,或者对应的 patch 被修改,vite 并不会自动更新缓存,而是会继续使用旧的缓存。 只有重新启动 server 时,才会重新进行构建。

但在 .vite/deps/metadata.json 中,记录了很多 Hash 值,这些 hash 值的作用都是什么呢?

metadata.json 文件示例

{
  "hash": "75d0a02b",
  "browserHash": "409e4d5f",
  "optimized": {
    "react": {
      "src": "../../.pnpm/react@17.0.2/node_modules/react/index.js",
      "file": "react.js",
      "fileHash": "3cc9f47d",
      "needsInterop": true
    },
  }
}

hash 基于以下几个来源生成:

  • package-lock.json/yarn.lock/pnpm-lock.yaml/bun.lockb 的内容
  • patches 的时间戳(fs.stat.mtimeMs)
  • 与预构建相关的配置项

其中:browserHash 基于以下几个来源生成:

  • 上一步 hash 的内容
  • 收集的所有依赖项的内容 (上述代码中对应的 deps 字段)

fileHash 基于以下几个来源生成:

  • 上述的 hash 内容
  • 文件路径
  • 文件依赖的模块(esbuild metafile 中的 exports.outputs[‘filePath’].imports)

browserhash 的值被内联到了 import 语句中,这样浏览器就可以根据这个值进行缓存。当 lock 文件被修改,或者 patch 被修改时,或预构建相关的配置项被修改时,浏览器缓存会失效。并去加载最新的预构建产物。

hash 和 fileHash 未找到明确用途,找到我再补充。


untagged

1115 Words

2023-09-25 10:24 +0000