所有依赖已经收集完毕, 接下来需要做的对收集完的依赖进行一些处理, esbuild 生成具有最低公共祖先基的嵌套目录输出,这是不可预测的,因此很难分析输入和输出。所以展平所有 id 以消除斜杠, 在插件中,将条目作为虚拟文件读取以保留路径。

# 展平所有入口 id

const flatIdDeps: Record<string, string> = {}
export const flattenId = (id: string): string => id.replace(/[\/\.]/g, '_')
const flatIdDeps: Record<string, string> = {}
for (const id in deps) {
  const flatId = flattenId(id)
  flatIdDeps[flatId] = deps[id]
}

声明flatIdDeps为展平化的依赖映射,把 /. 重写为 _

# 转化示例:

@ant-design-vue/use   =>  @ant-design-vue_use
lodash.debounce  => lodash_debounce

flatIdDepsdeps 不同的就是一个是展平后的key

# 为什么需要展平 id

假设不进行展平, 在构建元信息中 optimized 对于构建依赖的模块信息描述将会以下结果:

"@ant-design/icons-vue": {
  "file": "/Users/admin/crm/node_modules/.vite/@ant-design/icons-vue.js",
  "src": "/Users/admin/crm/node_modules/@ant-design/icons-vue/es/index.js",
  "needsInterop": false
},

对于 file 是构建结果的文件路径, @ant-design/icons-vue.js 是一个完整的 NPM 模块名称, 当加载时会当路径进行路径, 并不会达到预期效果, 所以最后通过展平 id/. 转换成 _ 保证正确的路径读取。正确结果如下:

"@ant-design/icons-vue": {
  "file": "/Users/admin/crm/node_modules/.vite/@ant-design_icons-vue.js",
  "src": "/Users/admin/crm/node_modules/@ant-design/icons-vue/es/index.js",
  "needsInterop": false
},

esbuild 构建时候会在 entryPoint 以数组的方式传入传入收集来的构建依赖 id 模块名称。构建完后会以这些模块名称作为构建结果的文件名。通过展平之后, 生成的文件名称就与路径寻找的文件名称保持一致。

# es-module-lexer 对依赖的模块内容进行词法解析

通过es-module-lexer (opens new window)需要对所有构建依赖的入口文件进行词法分析解决两个问题:

  1. 防止构建生成重复代码, 对构建入口文件进行模块代理, 判断是 cjs 模块还是 esm 模块, 生成不同的代理方法。
  2. 对于 cjsesm 的导入导出方式混用, 导致 esbuild 构建导出结果变异, 需要判断是否是 cjs 模块,以 needsInterop 作为标识, 可能会存在需要进行强制操作提取导出结果。
import { init, parse } from 'es-module-lexer'
const idToExports: Record<string, ExportsData> = {}
const flatIdToExports: Record<string, ExportsData> = {}
await init
for (const id in deps) {
  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
}

# 词法分析构建依赖入口文件中的内容

对依赖进行循环, entryContent变量收集依赖入口文件的文本内容。 exportsData 得到需要构建依赖的入口进行 paser 词法分析的结果。

构建入口文件内容词法分析示例

import one from './one.js';
export { two } from './two.js';
export const three = 3
console.log(1)
function demo() {
  console.log(2)
}

exportsData 得到的词法分析结果为:

[
  { n: './one.js', s: 22, e: 30, ss: 5, se: 31, d: -1, a: -1 },
  { n: './two.js', s: 58, e: 66, ss: 37, se: 67, d: -1, a: -1 }
] [ 'two', 'three' ] true
  1. 数组中第一个下标为对 import 词法分析的内容。
  2. 数组中第二个下标为 export 词法分析的内容。

# 截取 import 内容

import 的词法进行循环,截取每行 import 的全部内容。

截取的内容

import one from './one.js';
export { two } from './two.js';

将对截取的条目进行匹配, 匹配方式 /export\s+\*\s+from/,如果有同时进行导入导出 export 语法,则在解析后的对象 exportsData 上设置 hasReExports:true

存储词法分析后的内容

对每个依赖的词法解析后的结果进行对象存储

  1. idToExports 原始依赖模块名称的存储。
  2. flatIdToExports 展平后依赖模块名称的存储。

# 定义全局常量替换

在构建时候, 代码块的内容往往存在一些全局的常量,有些根据项目封装 node_modules 包模块模,或者通过 include 手动收集的业务模块, 会存在以下代码内容。

if (process.env.NODE_ENV === 'development') {
  ...
}
if (process.env.TEST_ENV === 'test') {
  ...
}

在构建中 process.env.TEST_ENVprocess.env.NODE_ENV 并不会读取环境变量中的内容,通过 define 可以把环境变量作为常量进行对待,启动时可以进行对环境量变进行获取,写入 define 之后, esbuild 在构建时传入 define,会进行自动替换这些环境变量。

定义内容的环境常量

const define: Record<string, string> = {
  'process.env.NODE_ENV': JSON.stringify(config.mode)
}

pocess.env.NODE_ENV 定义为设置的 mode 模式。

寻找自定义常量

for (const key in config.define) {
  const value = config.define[key]
  define[key] = typeof value === 'string' ? value : JSON.stringify(value)
}

config 配置中有 define API 选项,通过此选项可以自定义一些常量名和值,同时把这些定义的常量赋值给 esbuild 需要的 define 变量上。这样 esbuild 在构建时就可以进行未能解析的常量替换。

开始构建

const { plugins = [], ...esbuildOptions } =
config.optimizeDeps?.esbuildOptions ?? {}
const result = await build({
  absWorkingDir: process.cwd(),
  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, ssr)
  ],
  ...esbuildOptions
})

通过 esbuildbuild 方法进行开始构建。

  1. entryPoints 构建入口,写入的则是展平化的依赖模块路径。
  2. bundle 进行递归的方式进行解析。
  3. format 构建成esm导出的模块。
  4. external 过滤 optimizeDeps.exclude 强制排出的依赖。
  5. logLevel 提示级别。
  6. splitting 对构建时对相同的依赖做代码分隔。
  7. sourcemap 产生 sourcemap 文件。
  8. outdir 输出目录,为设置缓存文件的目录。
  9. treeShaking 构建时去除注释内容。
  10. metafile 生成文件元信息。
  11. define 常量替换的内容。
  12. plugins 构建插件。