通过esbuild构建完成之后,在默认缓存文件中会生成收集依赖构建后的文件。同时还需要对每个构建文件的结果进行添加元信息描述, 为了后续加载模块时可以寻找到对应的构建结果文件。

# 构建结果

比如我们收集到以下需要构建的依赖:

{
  lodash: '/Users/admin/zzx/test/node_modules/lodash/lodash.js',
  dayjs: '/Users/admin/zzx/test/node_modules/dayjs/dayjs.min.js'
}

此时将会在目录中生成这样的文件目录结构:

-node_modules
--.vite
-----dayjs.js
-----dayjs.js.map
-----lodash.js
-----lodash.js.map
-----chunk-VK5M77CT.js
-----chunk-VK5M77CT.js.map

每个收集的依赖都会生成对应的 id.js 文件,和 id.js.map 文件, 同时大于两条依赖, 将会生成共公模块的 chunk 文件。

# 生成依赖元信息

不但要对收集好的依赖文件进行 esbuild 构建,同时还需要给每个收集到的依赖模块的构建结果, 生成对应加载时寻找文件映射路径和一些是否需要强制操作的信息映射。 Vite 将这些映射信息存放在元信息dataoptimized 对象内。

for (const id in deps) {
  const entry = deps[id]
  data.optimized[id] = {
    file: normalizePath(path.resolve(cacheDir, flattenId(id) + '.js')),
    src: entry,
    needsInterop: needsInterop(
      id,
      idToExports[id],
      meta.outputs,
      cacheDirOutputPath
    )
  }
}

需要对每个依赖都要进行生成元信息,通过for in 循环对每个依赖进行生成元信息,将会生成以下内容:

  1. file 用来保存依赖文件构建后存在的绝对路径,以便加载时,直接定位到文件路径。
  2. src 依赖的真正需要构建的入口文件。
  3. needsInterop 是否需要进行强制操作。

# needsInterop 理解

needsInterop是一个 Boolean 值,如果构建的入口文件采用 esm 的方式,则为 false, 如果是 umd 或者 cjs 则为true, cjs 的导入模块方式会引起 esbuild 构建结果有差异, 这种差异是因为 esmcjs 语法进行混用导致的结果。

# 导入导出混用:

//entry.js
const a = 1
const b = 2
export default {
  a,
  b
}
// main.js
const entry = require('./entry')
module.exports = entry

通过 esbuild 构建 main.js 入口文件,导入构建后文件将会打印以下结果:

import result from 'esbuild构建mian.js的文件'
console.log(result)

// 打印结果
{
  default: (...)
  __esModule: true
  get default: () => entry2_default
  __proto__: Object
}

可想而知在这并不是最终要的络果。得到真正的结果还需要进行 result.default 才能拿到真正的结果。

# 混用导入导出有时也并不会变异

前面是 entry.js 通过 esm 方式进行导出,在 main 中通过 cjs 的方式进行混用导入导出。

如果 entry.js 通过 cjs 进行导出,在 main.js 中通过 esm 的方式进行混用导入导出。

// entry.js

const a = 1
const b = 2
module.exports = {
  a,b
}
// main.js
import entry2 from './entry2'
export default entry2

通过 esbuild 构建 main.js 入口文件,引入构建后文件将会打印以下结果:

import result from 'esbuild构建mian.js的文件'
console.log(result)

// 打印结果
{
  a: 1, b:2
}

从此结果可以得出如果是构建的文件如果是 cjs 的方式,就可能会存在导入导出混用最后的返回结果存在变异。

# vite对于混用导入导出的解决方案

文件以引入以下模块为例:

// main.js
import dayjs from 'dayjs'
import lodash from 'lodash-es'

打开 network 找到 main.js 文件可以发现,原本的引入方式,在服务端已经被 importAnalysis 插件中的 transfom 进行了转换。以下为转换结果:

import __vite__cjsImport0_dayjs from "/node_modules/.vite/dayjs.js?v=94ade23a"; const dayjs = __vite__cjsImport0_dayjs.__esModule ? __vite__cjsImport0_dayjs.default : __vite__cjsImport0_dayjs
import lodashES from '/node_modules/.vite/lodash-es.js?v=94ade23a'

对于 cjs 导出模块 dayjs 做了一层值的判断,如果导出对象上有 __esModule,最终结果是返回导入模块对象上的 deafult 属性。否则直接返回,这样就可以对于 cjs 模块的预构建后结果差异和非差异的情况都可以进行正确的返回。

# metafile 构建文件描述

构建结果的每个文件元信息。

const meta = result.metafile!

示例:

当导入以下模块时,将被收集到依赖中进行构建产出对应的描述。

import dayjs from 'dayjs'
import lodashES from 'lodash-es'

# metafile中有两大核心描述:

  1. inputs:
'node_modules/dayjs/dayjs.min.js': { bytes: 6479, imports: [] },
'dep:dayjs': { bytes: 60, imports: [Array] },
'node_modules/lodash-es/_freeGlobal.js': { bytes: 171, imports: [] },
'node_modules/lodash-es/_root.js': { bytes: 298, imports: [Array] },
......
......
'dep:lodash-es': { bytes: 119, imports: [Array] }

inputs内包涵了构建时所有引用到模块和构建完成后的模块里的描述

  1. outputs:
'node_modules/.vite/dayjs.js.map': { imports: [], exports: [], inputs: {}, bytes: 15179 },
'node_modules/.vite/dayjs.js': {
  imports: [Array],
  exports: [Array],
  entryPoint: 'dep:dayjs',
  inputs: [Object],
  bytes: 10704
},
'node_modules/.vite/lodash-es.js.map': { imports: [], exports: [], inputs: {}, bytes: 790090 },
'node_modules/.vite/lodash-es.js': {
  imports: [Array],
  exports: [Array],
  entryPoint: 'dep:lodash-es',
  inputs: [Object],
  bytes: 283210
},
'node_modules/.vite/chunk-VK5M77CT.js.map': { imports: [], exports: [], inputs: {}, bytes: 93 },
'node_modules/.vite/chunk-VK5M77CT.js': { imports: [], exports: [Array], inputs: {}, bytes: 221 }

outputs内包含了构建结果描述。

# needsInterop结果实现方式

function needsInterop(
  id: string,
  exportsData: ExportsData,
  outputs: Record<string, any>,
  cacheDirOutputPath: string
)

# 参数理解

  1. id 收集依赖的导入模块名称。
  2. exportsData 收集依赖模块的入口文件内容通过 es-module-lexer 词法解析后的描述。
  3. outputs 构建结果文件信息描述。
  4. cacheDirOutputPath 构建结果的缓存目录。

# 处理伪装模块

const KNOWN_INTEROP_IDS = new Set(['moment'])
if (KNOWN_INTEROP_IDS.has(id)) {
  return true
}

moment 模块单做特殊处理, 因为 moment 使用的是 ESM 导出导入, 但仍使用 require 关键字, 这会导致 esbuild 将它们包装为 CJS,即使其入口看起来是 ESM。所以还是需要当作 CJS 的方式进行处理。 issus (opens new window)

# 处理非esm模块

const [imports, exports] = exportsData
if (!exports.length && !imports.length) {
  return true
}

通过词法分析没有 import 内容描述或者 exports 的内容描述, 一定是 cjs 词法或者 umd 的词法,一律当作 cjs 进行处理。

# 寻找模块对应的生成描述

const flatId = flattenId(id) + '.js'
let generatedExports: string[] | undefined
for (const output in outputs) {
  if (
    normalizePath(output) ===
    normalizePath(path.join(cacheDirOutputPath, flatId))
  ) {
    generatedExports = outputs[output].exports
    break
  }
}

通过循环找到对应依赖的构建描述 exports 属性。同时赋值在 generatedExports 变量上, 进行继续判断。

function isSingleDefaultExport(exports: string[]) {
  return exports.length === 1 && exports[0] === 'default'
}
if (
  !generatedExports ||
  (isSingleDefaultExport(generatedExports) && !isSingleDefaultExport(exports))
) {
  return true
}
  1. 如果模块的构建导出描述中没有任何导出内容, 说明没有用 esm 语法进行导出,则认定为 cjs 语法。
  2. 因为 es-module-lexer 的词法解析方式,与 esbuild 构建生成描述的词法解析方式有差异, 差异在于 export * 这个语法。 es-module-lexer 在解析 export * 这样的语法,exports词法分析的结果不会有任何内容, 存在差异也被认为 cjs 的法语。
  3. 否则其它情况都认为 esm 的正确导入依赖方式。并没有可能存在混用。

# 写入构建元信息文件

writeFile(dataPath, JSON.stringify(data, null, 2))
return data

最后向 cacheDir 中写入 _metadata.json 文件,同时返回结果给到 server._optimizeDepsMetadata