乐趣区

关于javascript:Vite是如何实现Esbuild打包的

后面文章说过(Vite 如何实现秒级依赖预构建的能力),在 Vite 依赖预构建的底层实现中,大量地应用到了 Esbuild 这款构建工具,实现了比较复杂的 Esbuild 插件性能和技巧。接下来,我就来带你揭开 Vite 预构建神秘的面纱,从外围流程到依赖扫描、依赖打包的具体实现,带你彻底了解 Esbuild 预构建背地的技术。

一、预构建流程

对于预构建所有的实现代码都在 optimizeDeps 函数当中,对应的仓库源码:packages/vite/src/node/optimizer/index.ts,大家能够下载下来对照学习。

1.1 缓存判断

首先,是预构建缓存的判断。Vite 在每次预构建之后都将一些要害信息写入到了_metadata.json 文件中,第二次启动我的项目时会通过这个文件中的 hash 值来进行缓存的判断,如果命中缓存则不会进行后续的预构建流程,代码如下所示。

// _metadata.json 文件所在的门路
const dataPath = path.join(cacheDir, "_metadata.json");
// 依据以后的配置计算出哈希值
const mainHash = getDepHash(root, config);
const data: DepOptimizationMetadata = {
  hash: mainHash,
  browserHash: mainHash,
  optimized: {},};
// 默认走到外面的逻辑
if (!force) {
  let prevData: DepOptimizationMetadata | undefined;
  try {
    // 读取元数据
    prevData = JSON.parse(fs.readFileSync(dataPath, "utf-8"));
  } catch (e) {}
  // 以后计算出的哈希值与 _metadata.json 中记录的哈希值统一,示意命中缓存,不必预构建
  if (prevData && prevData.hash === data.hash) {log("Hash is consistent. Skipping. Use --force to override.");
    return prevData;
  }
}

值得注意的是哈希计算的策略,即决定哪些配置和文件有可能影响预构建的后果,而后依据这些信息来生成哈希值。这部分逻辑集中在 getHash 函数中,代码如下。

const lockfileFormats = ["package-lock.json", "yarn.lock", "pnpm-lock.yaml"];
function getDepHash(root: string, config: ResolvedConfig): string {
  // 获取 lock 文件内容
  let content = lookupFile(root, lockfileFormats) || "";
  // 除了 lock 文件外,还须要思考上面的一些配置信息
  content += JSON.stringify(
    {
      // 开发 / 生产环境
      mode: config.mode,
      // 我的项目根门路
      root: config.root,
      // 门路解析配置
      resolve: config.resolve,
      // 自定义资源类型
      assetsInclude: config.assetsInclude,
      // 插件
      plugins: config.plugins.map((p) => p.name),
      // 预构建配置
      optimizeDeps: {
        include: config.optimizeDeps?.include,
        exclude: config.optimizeDeps?.exclude,
      },
    },
    // 非凡处理函数和正则类型
    (_, value) => {if (typeof value === "function" || value instanceof RegExp) {return value.toString();
      }
      return value;
    }
  );
  // 最初调用 crypto 库中的 createHash 办法生成哈希
  return createHash("sha256").update(content).digest("hex").substring(0, 8);
}

1.2 依赖扫描

如果没有命中缓存,则会正式地进入依赖预构建阶段。不过 Vite 不会间接进行依赖的预构建,而是在之前探测一下我的项目中存在哪些依赖,收集依赖列表,也就是进行依赖扫描的过程。并且,这个过程是必须的,因为 Esbuild 须要晓得咱们到底要打包哪些第三方依赖,要害代码如下:

({deps, missing} = await scanImports(config));

在 scanImports 办法外部次要会调用 Esbuild 提供的 build 办法,如下所示。

const deps: Record<string, string> = {};
// 扫描用到的 Esbuild 插件
const plugin = esbuildScanPlugin(config, container, deps, missing, entries);
await Promise.all(
  // 利用我的项目入口
  entries.map((entry) =>
    build({absWorkingDir: process.cwd(),
      // 留神这个参数
      write: false,
      entryPoints: [entry],
      bundle: true,
      format: "esm",
      logLevel: "error",
      plugins: [...plugins, plugin],
      ...esbuildOptions,
    })
  )
);

值得注意的是,其中传入的 write 参数被设为 false,示意产物不必写入磁盘,这就大大节俭了磁盘 I/O 的工夫,也是依赖扫描为什么往往比依赖打包快很多的起因之一。接下来,会输入预打包信息:

if (!asCommand) {if (!newDeps) {
    logger.info(chalk.greenBright(`Pre-bundling dependencies:\n  ${depsString}`)
    );
    logger.info(`(this will be run only when your dependencies or config have changed)`
    );
  }
} else {logger.info(chalk.greenBright(`Optimizing dependencies:\n  ${depsString}`));
}

到此,你可能曾经明确,为什么第一次启动时会输入预构建相干的 log 信息了,其实这些信息都是通过依赖扫描阶段来收集的,而此时还并未开始真正的依赖打包过程。

可能大家有个疑难,为什么我的项目入口打包一次就可能收集到所有的依赖信息呢?事实上,日志的收集都是应用 esbuildScanPlugin 这个函数创立 scan 插件来实现的,在 scan 插件外面就是解析各种 import 语句,最终通过它来记录依赖信息。
 

1.3 依赖打包

收集完依赖之后,就正式地进入到依赖打包的阶段了,次要是调用 Esbuild 进行打包并写入产物到磁盘中,要害代码如下:

const result = await build({absWorkingDir: process.cwd(),
  // 所有依赖的 id 数组,在插件中会转换为实在的门路
  entryPoints: Object.keys(flatIdDeps),
  bundle: true,
  format: "esm",
  target: config.build.target || undefined,
  external: config.optimizeDeps?.exclude,
  logLevel: "error",
  splitting: true,
  sourcemap: true,
  outdir: cacheDir,
  ignoreAnnotations: true,
  metafile: true,
  define,
  plugins: [
    ...plugins,
    // 预构建专用的插件
    esbuildDepPlugin(flatIdDeps, flatIdToExports, config, ssr),
  ],
  ...esbuildOptions,
});
// 打包元信息,后续会依据这份信息生成 _metadata.json
const meta = result.metafile!;

1.4 元信息写入磁盘

在打包过程实现之后,Vite 会拿到 Esbuild 构建的元信息,也就是下面代码中的 meta 对象,而后将元信息保留到_metadata.json 文件中。

const data: DepOptimizationMetadata = {
  hash: mainHash,
  browserHash: mainHash,
  optimized: {},};
// 省略两头的代码
for (const id in deps) {const entry = deps[id];
  data.optimized[id] = {file: normalizePath(path.resolve(cacheDir, flattenId(id) + ".js")),
    src: entry,
    // 判断是否须要转换成 ESM 格局,前面会介绍
    needsInterop: needsInterop(
      id,
      idToExports[id],
      meta.outputs,
      cacheDirOutputPath
    ),
  };
}
// 元信息写磁盘
writeFile(dataPath, JSON.stringify(data, null, 2));

到此,预构建的外围流程就梳理完了。能够看到,总体的流程下面并不简单,具体参考依赖扫描和依赖打包这两个局部的代码。
 

二、依赖扫描剖析

2.1 获取入口

当初让咱们把眼光聚焦在 scanImports 这个函数的实现上。在正式扫描之前,须要先找到入口文件。不过,入口文件可能存在于多个配置当中,比方 optimizeDeps.entries 和 build.rollupOptions.input,同时还须要思考数组和对象的状况,当然也可能用户没有配置须要主动探测入口文件。那么,scanImports 是如何做到这么多场景的解决的呢?首先来看一段代码:

const explicitEntryPatterns = config.optimizeDeps.entries;
const buildInput = config.build.rollupOptions?.input;
if (explicitEntryPatterns) {
  // 先从 optimizeDeps.entries 寻找入口,反对 glob 语法
  entries = await globEntries(explicitEntryPatterns, config);
} else if (buildInput) {
  // 其次从 build.rollupOptions.input 配置中寻找,留神须要思考数组和对象的状况
  const resolvePath = (p: string) => path.resolve(config.root, p);
  if (typeof buildInput === "string") {entries = [resolvePath(buildInput)];
  } else if (Array.isArray(buildInput)) {entries = buildInput.map(resolvePath);
  } else if (isObject(buildInput)) {entries = Object.values(buildInput).map(resolvePath);
  } else {throw new Error("invalid rollupOptions.input value.");
  }
} else {
  // 兜底逻辑,如果用户没有进行上述配置,则主动从根目录开始寻找
  entries = await globEntries("**/*.html", config);
}

其中,globEntries 办法即通过 fast-glob 库来从我的项目根目录扫描文件。接下来,还须要思考入口文件的类型,个别状况下入口须要是 js/ts 文件,但实际上像 html、vue 单文件组件这种类型咱们也是须要反对的。

在源码当中,同时对 html、vue、svelte、astro(一种新兴的类 html 语法) 四种后缀的入口文件进行了解析,当然,具体的解析过程在依赖扫描阶段的 Esbuild 插件中得以实现,接着就让咱们在插件的实现中一探到底。

const htmlTypesRE = /.(html|vue|svelte|astro)$/;
function esbuildScanPlugin(/* 一些入参 */): Plugin {
  // 初始化一些变量
  // 返回一个 Esbuild 插件
  return {
    name: "vite:dep-scan",
    setup(build) {
      // 标记「类 HTML」文件的 namespace
      build.onResolve({filter: htmlTypesRE}, async ({path, importer}) => {
        return {path: await resolve(path, importer),
          namespace: "html",
        };
      });


      build.onLoad({ filter: htmlTypesRE, namespace: "html"},
        async ({path}) => {// 解析「类 HTML」文件}
      );
    },
  };
}

这里来咱们以 html 文件的解析为例来解说,原理如下图所示。
 

此插件中会扫描出所有带有 type=module 的 script 标签,对于含有 src 的 script 改写为一个 import 语句,对于含有具体内容的 script,则抽离出其中的脚本内容,最初将所有的 script 内容拼接成一段 js 代码。接下来咱们来看具体的代码,其中会以上图中的 html 为示例来拆解两头过程。

const scriptModuleRE =
  /(<script\b[^>]*type\s*=\s*(?: module |'module')[^>]*>)(.*?)</script>/gims
export const scriptRE = /(<script\b(?:\s[^>]*>|>))(.*?)</script>/gims
export const commentRE = /<!--(.|[\r\n])*?-->/
const srcRE = /\bsrc\s*=\s*(?: ([^]+) |'([^']+)'|([^\s' >]+))/im
const typeRE = /\btype\s*=\s*(?: ([^]+) |'([^']+)'|([^\s' >]+))/im
const langRE = /\blang\s*=\s*(?: ([^]+) |'([^']+)'|([^\s' >]+))/im
// scan 插件 setup 办法外部实现
build.onLoad({ filter: htmlTypesRE, namespace: 'html'},
  async ({path}) => {let raw = fs.readFileSync(path, 'utf-8')
    // 去掉正文内容,避免烦扰解析过程
    raw = raw.replace(commentRE, '<!---->')
    const isHtml = path.endsWith('.html')
    // HTML 状况下会寻找 type 为 module 的 script
    // 正则:/(<script\b[^>]*type\s*=\s*(?: module |'module')[^>]*>)(.*?)</script>/gims
    const regex = isHtml ? scriptModuleRE : scriptRE
    regex.lastIndex = 0
    let js = ''let loader: Loader ='js'
    let match: RegExpExecArray | null
    // 正式开始解析
    while ((match = regex.exec(raw))) {
      // 第一次: openTag 为 <script type= module  src= /src/main.ts >, 无 content
      // 第二次: openTag 为 <script type= module >,有 content
      const [, openTag, content] = match
      const typeMatch = openTag.match(typeRE)
      const type =
        typeMatch && (typeMatch[1] || typeMatch[2] || typeMatch[3])
      const langMatch = openTag.match(langRE)
      const lang =
        langMatch && (langMatch[1] || langMatch[2] || langMatch[3])
      if (lang === 'ts' || lang === 'tsx' || lang === 'jsx') {
        // 指定 esbuild 的 loader
        loader = lang
      }
      const srcMatch = openTag.match(srcRE)
      // 依据有无 src 属性来进行不同的解决
      if (srcMatch) {const src = srcMatch[1] || srcMatch[2] || srcMatch[3]
        js += `import ${JSON.stringify(src)}\n`
      } else if (content.trim()) {js += content + '\n'}
  }
  return {
    loader,
    contents: js
  }
)

2.2 记录依赖

入口的问题解决了,接下来还有一个问题: 如何在 Esbuild 编译的时候记录依赖呢?Vite 中会把 bare import 的门路当做依赖门路,对于 bare import,你能够了解为间接引入一个包名。而以. 结尾的相对路径或者以 / 结尾的绝对路径都不能算 bare import,比方上面这样就不算是 bare import:

// 以下都不是 bare import
import React from "../node_modules/react/index.js";
import React from "/User/sanyuan/vite-project/node_modules/react/index.js";

对于解析 bare import、记录依赖的逻辑仍然实现在 scan 插件当中,对应的代码如下:

build.onResolve(
  {
    // avoid matching windows volume
    filter: /^[\w@][^:]/,
  },
  async ({path: id, importer}) => {// 如果在 optimizeDeps.exclude 列表或者曾经记录过了,则将其 externalize ( 排除),间接 return


    // 接下来解析门路,外部调用各个插件的 resolveId 办法进行解析
    const resolved = await resolve(id, importer);
    if (resolved) {
      // 判断是否应该 externalize,下个局部具体拆解
      if (shouldExternalizeDep(resolved, id)) {return externalUnlessEntry({ path: id});
      }


      if (resolved.includes("node_modules") || include?.includes(id)) {
        // 如果 resolved 为 js 或 ts 文件
        if (OPTIMIZABLE_ENTRY_RE.test(resolved)) {
          // 留神了! 当初将其正式地记录在依赖表中
          depImports[id] = resolved;
        }
        // 进行 externalize,因为这里只用扫描出依赖即可,不须要进行打包,具体实现前面的局部会讲到
        return externalUnlessEntry({path: id});
      } else {
        // resolved 为「类 html」文件,则标记上 'html' 的 namespace
        const namespace = htmlTypesRE.test(resolved) ? "html" : undefined;
        // linked package, keep crawling
        return {path: path.resolve(resolved),
          namespace,
        };
      }
    } else {
      // 没有解析到门路,记录到 missing 表中,后续会检测这张表,显示相干门路未找到的报错
      missing[id] = normalizePath(importer);
    }
  }
);

2.3 external 规定制订

下面咱们剖析了在 Esbuild 插件中如何针对 bare import 记录依赖,那么在记录的过程中还有一件十分重要的事件,就是决定哪些门路应该被排除,不应该被记录或者不应该被 Esbuild 来解析。这就是 external 规定的概念。在这里,须要 external 的门路分为两类: 资源型和模块型。

首先,对于资源型的门路,个别是间接排除,在插件中的解决形式如下:

// data url,间接标记 external: true,不让 esbuild 持续解决
build.onResolve({filter: dataUrlRE}, ({path}) => ({
  path,
  external: true,
}));
// 加了 ?worker 或者 ?raw 这种 query 的资源门路,间接 external
build.onResolve({filter: SPECIAL_QUERY_RE}, ({path}) => ({
  path,
  external: true,
}));
// css & json
build.onResolve(
  {filter: /.(css|less|sass|scss|styl|stylus|pcss|postcss|json)$/,
  },
  // 非 entry 则间接标记 external
  externalUnlessEntry
);
// Vite 内置的一些资源类型,比方 .png、.wasm 等等
build.onResolve(
  {filter: new RegExp(`.(${KNOWN_ASSET_TYPES.join("|")})$`),
  },
  // 非 entry 则间接标记 external
  externalUnlessEntry
);

其中,externalUnlessEntry 的实现绝对简略:

const externalUnlessEntry = ({path}: {path: string}) => ({
  path,
  // 非 entry 则标记 external
  external: !entries.includes(path),
});

其次,对于模块型的门路,也就是当咱们通过 resolve 函数解析出了一个 JS 模块的门路,如何判断是否应该被 externalize 呢?这部分实现次要在 shouldExternalizeDep 函数中,外围代码如下:

export 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;}
  // 不是 JS 或者 类 HTML 文件,不在 esbuild 中进行加载
  if (!JS_TYPES_RE.test(resolvedId) && !htmlTypesRE.test(resolvedId)) {return true;}
  return false;
}

三、依赖打包剖析

3.1 产物文件构造

个别状况下,esbuild 会输入嵌套的产物目录构造,比方对于 Vue 来说,其产物在 dist/vue.runtime.esm-bundler.js 中,那么通过 esbuild 失常打包之后,预构建的产物目录如下:

node_modules/.vite
├── _metadata.json
├── vue
│   └── dist
│       └── vue.runtime.esm-bundler.js

因为各个第三方包的产物目录构造不统一,这种深层次的嵌套目录对于 Vite 门路解析来说,其实是减少了不少的麻烦的,带来了一些不可控的因素。为了解决嵌套目录带来的问题,Vite 做了两件事件来达到扁平化的预构建产物输入。

  • 嵌套门路扁平化,/ 被换成下划线,如 react/jsx-dev-runtime,被重写为 react_jsx-dev-runtime;
  • 用虚构模块来代替实在模块,作为预打包的入口。

回到 optimizeDeps 函数中,在执行完依赖扫描的步骤后,就会执行门路的扁平化操作,代码如下。

const flatIdDeps: Record<string, string> = {};
const idToExports: Record<string, ExportsData> = {};
const flatIdToExports: Record<string, ExportsData> = {};
// deps 即为扫描后的依赖表
// 形如: {//    react :  /Users/sanyuan/vite-project/react/index.js}
//    react/jsx-dev-runtime :  /Users/sanyuan/vite-project/react/jsx-dev-runtime.js
// }
for (const id in deps) {
  // 扁平化门路,`react/jsx-dev-runtime`,被重写为 `react_jsx-dev-runtime`;const flatId = flattenId(id);
  // 填入 flatIdDeps 表,记录 flatId -> 实在门路的映射关系
  const filePath = (flatIdDeps[flatId] = deps[id]);
  const entryContent = fs.readFileSync(filePath, "utf-8");
  // 后续代码省略
}

对于虚构模块的解决,大家能够把眼光放到 esbuildDepPlugin 函数下面,它的逻辑实现大抵如下:

export function esbuildDepPlugin(/* 一些传参 */) {
  // 定义门路解析的办法


  // 返回 Esbuild 插件
  return {
    name: 'vite:dep-pre-bundle',
    set(build) {
      // bare import 的门路
      build.onResolve({ filter: /^[\w@][^:]/ },
        async ({path: id, importer, kind}) => {// 判断是否为入口模块,如果是,则标记上 `dep` 的 namespace,成为一个虚构模块}
    }


    build.onLoad({filter: /.*/, namespace: 'dep'}, ({path: id}) => {// 加载虚构模块}
  }
}

如此一来,Esbuild 会将虚构模块作为入口来进行打包,最初的产物目录会变成如下的扁平构造:

node_modules/.vite
├── _metadata.json
├── vue.js
├── react.js
├── react_jsx-dev-runtime.js

不过,须要阐明的是,虚构模块加载局部的代码在 Vite 3.0 中已被移除,起因是 Esbuild 输入扁平化产物门路已不再须要应用虚构模块。

3.2 代理模块加载

虚构模块代替了实在模块作为打包入口,因而也能够了解为代理模块,前面也对立称之为代理模块。咱们首先来剖析一下代理模块到底是如何被加载进去的,换句话说,它到底了蕴含了哪些内容。

拿 import React from “react” 来举例,Vite 会把 react 标记为 namespace 为 dep 的虚构模块,而后管制 Esbuild 的加载流程,而后应用实在模块的内容进行从新导出。所以,第一步就是确定实在模块的门路:

// 实在模块所在的门路,拿 react 来说,即 `node_modules/react/index.js`
const entryFile = qualified[id];
// 确定相对路径
let relativePath = normalizePath(path.relative(root, entryFile));
if (!relativePath.startsWith("./") &&
  !relativePath.startsWith("../") &&
  relativePath !== "."
) {relativePath = `./${relativePath}`;
}

确定了门路之后,接下来就是对模块的内容进行从新导出。这里须要分以下两种状况:

  • CommonJS 模块
  • ES 模块

那么,如何来辨认这两种模块标准呢?咱们能够临时把眼光转移到 optimizeDeps 中,实际上在进行真正的依赖打包之前,Vite 会读取各个依赖的入口文件,通过 es-module-lexer 这种工具来解析入口文件的内容。这里略微解释一下 es-module-lexer,这是一个在 Vite 被常常应用到的工具库,次要是为了解析 ES 导入导出的语法,大抵用法如下:

import {init, parse} from "es-module-lexer";
// 期待 `es-module-lexer` 初始化实现
await init;
const sourceStr = `
  import moduleA from './a';
  export * from 'b';
  export const count = 1;
  export default count;
`;
// 开始解析
const exportsData = parse(sourceStr);
// 后果为一个数组,别离保留 import 和 export 的信息
const [imports, exports] = exportsData;
// 返回 `import module from './a'`
sourceStr.substring(imports[0].ss, imports[0].se);
// 返回 ['count', 'default']
console.log(exports);

值得注意的是, export * from 导出语法会被记录在 import 信息中。接下来,咱们来看看 optimizeDeps 中如何利用 es-module-lexer 工具来解析入口文件的,实现代码如下:

import {init, parse} from "es-module-lexer";
// 省略两头的代码
await init;
for (const id in deps) {
  // 省略后面的门路扁平化逻辑
  // 读取入口内容
  const entryContent = fs.readFileSync(filePath, "utf-8");
  try {exportsData = parse(entryContent) as ExportsData;
  } catch {// 省略对 jsx 的解决}
  for (const { ss, se} of exportsData[0]) {const exp = entryContent.slice(ss, se);
    // 标记存在 `export * from` 语法
    if (/export\s+*\s+from/.test(exp)) {exportsData.hasReExports = true;}
  }
  // 将 import 和 export 信息记录下来
  idToExports[id] = exportsData;
  flatIdToExports[flatId] = exportsData;
}

在代码的最初,会有两张表记录下 ES 模块导入和导出的相干信息,而 flatIdToExports 表会作为入参传给 Esbuild 插件:

// 第二个入参
esbuildDepPlugin(flatIdDeps, flatIdToExports, config, ssr);

如此,咱们就能依据实在模块的门路获取到导入和导出的信息,通过这份信息来甄别 CommonJS 和 ES 两种模块标准。当初,回到 Esbuild 打包插件中加载代理模块的代码:

let contents = "";
// 上面的 exportsData 即内部传入的模块导入导出相干的信息表
// 依据模块 id 拿到对应的导入导出信息
const data = exportsData[id];
const [imports, exports] = data;
if (!imports.length && !exports.length) {// 解决 CommonJS 模块} else {// 解决 ES  模块}

如果是 CommonJS 模块,则执行如下的代码:

let contents = "";
contents += `export default require(${relativePath} );`;

如果是 ES 模块,则分默认导出和非默认导出这两种状况来解决:

// 默认导出,即存在 export default 语法
if (exports.includes("default")) {contents += `import d from  ${relativePath} ;export default d;`;
}
// 非默认导出
if (
  // 1. 存在 `export * from` 语法,前文剖析过
  data.hasReExports ||
  // 2. 多个导出内容
  exports.length > 1 ||
  // 3. 只有一个导出内容,但这个导出不是 export default
  exports[0] !== "default"
) {
  // 但凡命中上述三种状况中的一种,则增加上面的重导出语句
  contents += `\nexport * from  ${relativePath} `;
}

当初,曾经组装好了代理模块 的内容,接下来就能够释怀地交给 Esbuild 加载了:

let ext = path.extname(entryFile).slice(1);
if (ext === "mjs") ext = "js";
return {
  loader: ext as Loader,
  // 虚构模块内容
  contents,
  resolveDir: root,
};

3.3 代理模块为何要与实在模块拆散

当初,咱们曾经分明了 Vite 是如何组装代理模块,以此作为 Esbuild 打包入口的,整体的思路就是先剖析一遍模块实在入口文件的 import 和 export 语法,而后在代理模块中进行重导出。那为什么代理模块要与实在模块进行辨别呢?

对于这个问题,官网的答复是:这种重导出的做法是必要的,它能够拆散虚构模块和实在模块,因为实在模块能够通过绝对地址来引入。如果不这么做,Esbuild 将会对打包输入两个一样的模块。

刚开始看确实不太容易了解,接下来我会通过比照的形式来通知你这种设计到底解决了什么问题。假如我不像源码中这么做,在虚构模块中间接将实在入口的内容作为传给 Esbuild,代码如下:

build.onLoad({filter: /.*/, namespace: 'dep'}, ({path: id}) => {
  // 拿到查表拿到实在入口模块门路
  const entryFile = qualified[id];
  return {
    loader: 'js',
    contents: fs.readFileSync(entryFile, 'utf8');
  }
}

那么,此时会产生什么问题呢?咱们能够先看看失常的预打包流程:

能够看到,Vite 会应用 dep:react 这个代理模块来作为入口内容在 Esbuild 中进行加载,与此同时,其余库的预打包也有可能会引入 React,比方 @emotion/react 这个库外面会有 require(‘react’) 的行为。那么在 Esbuild 打包之后,react.js 与 @emotion_react.js 的代码中会援用同一份 Chunk 的内容,这份 Chunk 也就对应 React 入口文件 (node_modules/react/index.js)。

当然,这是现实状况下的打包后果,接下来咱们来看看上述有问题的版本是如何工作的:

当初如果代理模块通过文件系统间接读取实在模块的内容,而不是进行重导出,因而因为此时代理模块跟实在模块并没有任何的援用关系,这就导致最初的 react.js 和 @emotion/react.js 两份产物并不会援用同一份 Chunk,Esbuild 最初打包出了内容完全相同的两个 Chunk。

这也就能解释为什么 Vite 中要在代理模块中对实在模块的内容进行重导出了,次要是为了防止 Esbuild 产生反复的打包内容。

退出移动版