Skip to content

对于打包相关命令,主要基于scripts/build.js这个脚本,以及rollup.config.jsrollup.dts.config.js所以了解了这个脚本和 rollup 的配置,我们就基本了解了package.json下所有的打包相关命令了。

此外rollup.config.js会用到scripts/aliases.jsscripts/const-enum.js

json
{
  "build": "node scripts/build.js",
  "build-dts": "tsc -p tsconfig.build.json && rollup -c rollup.dts.config.js",
  "build-sfc-playground": "run-s build-compiler-cjs build-runtime-esm build-ssr-esm build-sfc-playground-self",
  "build-compiler-cjs": "node scripts/build.js compiler reactivity-transform shared -af cjs",
  "build-runtime-esm": "node scripts/build.js runtime reactivity shared -af esm-bundler && node scripts/build.js vue -f esm-bundler-runtime && node scripts/build.js vue -f esm-browser-runtime",
  "build-ssr-esm": "node scripts/build.js compiler-sfc server-renderer -f esm-browser",
  "build-sfc-playground-self": "cd packages/sfc-playground && npm run build",

  "size": "run-s size-global size-baseline",
  "size-global": "node scripts/build.js vue runtime-dom -f global -p",
  "size-baseline": "node scripts/build.js vue -f esm-bundler-runtime && node scripts/build.js runtime-dom runtime-core reactivity shared -f esm-bundler && cd packages/size-check && vite build && node brotli"
}
{
  "build": "node scripts/build.js",
  "build-dts": "tsc -p tsconfig.build.json && rollup -c rollup.dts.config.js",
  "build-sfc-playground": "run-s build-compiler-cjs build-runtime-esm build-ssr-esm build-sfc-playground-self",
  "build-compiler-cjs": "node scripts/build.js compiler reactivity-transform shared -af cjs",
  "build-runtime-esm": "node scripts/build.js runtime reactivity shared -af esm-bundler && node scripts/build.js vue -f esm-bundler-runtime && node scripts/build.js vue -f esm-browser-runtime",
  "build-ssr-esm": "node scripts/build.js compiler-sfc server-renderer -f esm-browser",
  "build-sfc-playground-self": "cd packages/sfc-playground && npm run build",

  "size": "run-s size-global size-baseline",
  "size-global": "node scripts/build.js vue runtime-dom -f global -p",
  "size-baseline": "node scripts/build.js vue -f esm-bundler-runtime && node scripts/build.js runtime-dom runtime-core reactivity shared -f esm-bundler && cd packages/size-check && vite build && node brotli"
}

我们会首先看看是如何使用这个脚本来打包的,以及基于此过一遍每种打包格式的介绍,接着对于实现会主要关注以下几点:

使用

脚本可以用来并行打多个包,打包名称支持模糊搜索,不提供则打包所有。

bash
$ node scripts/build.js runtime-core runtime-dom
# or fuzzy matching,do not forget the -a option
$ node scripts/build.js runtime -a
$ node scripts/build.js runtime-core runtime-dom
# or fuzzy matching,do not forget the -a option
$ node scripts/build.js runtime -a

下面是具体可选的参数:

  • -f (formats): 指定打包格式文档里说可以支持指定多种打包格式,用","隔开,但是目前应该还是一个没有修复的小issue。默认格式为esm-bundlercjs,配置在rollup.config.js
  • -d (devOnly): 是否只打包开发代码;默认就是 false。
  • -p (prodOnly): 是否只打包生产代码;默认就是 false。如果同时制定了-d-p-d优先级高。
  • -t (buildTypes): 是否打包类型声明文件。
  • -s (sourceMap): 是否打包输出 sourcMap 文件。
  • -a (buildAllMatching): 在模糊搜索时是否将所有匹配到的包都打包,false 则打包第一个匹配到的(按包目录名称顺序)。
  • --release: 是否是 release 状态。

打包格式

在前面已经提到了很多次各种打包格式(formats)了,那么在这里再对每种介绍一遍。

基本关系图

对于rollup来说主要formats有三种,iife, cjses。基于此三类,我们再根据external, 入口文件生产/开发环境区分出不同的打包格式(输出不同的文件)。

null

具体描述

  • global:
    • 通过iife的方式打包
    • inline所有依赖包
    • 包的名字,即挂载到全局对象上的变量名,会在对应的package.jsonbuildOptions.name指定;通常就是用于<script src="...">的方式(e.g. CDN)引入时;<script src="https://cdn.jsdelivr.net/npm/vue/dist/vue.global.prod.js">或者<script src="https://cdn.jsdelivr.net/npm/vue">(注意后面没有/默认指向vue.global.js)
    • 生成的文件有<包对应的名字>.global(.prod).js
    • 对于vue包会多一种打包规则:global-runtime,生成vue(.runtime).global(.prod).js,表示只包含runtime的的代码,没有包含编译系统
  • cjs:
    • 打包成在node环境下可用require的方式引入的格式;用在ssr
    • external所有依赖包
    • 生成的文件有<包对应的名字>.cjs(.prod).js
  • esm-browser:
    • 打包成esm模块供浏览器使用,如<script type="module">通过CDN引入https://cdn.jsdelivr.net/npm/vue@next/dist/vue.esm-browser.js
    • inline所有依赖包
    • 生成的文件有<包对应的名字>.esm-browser(.prod).js
    • 对于vue包会多一种打包规则:esm-browser-runtime,生成vue(.runtime).esm-browser(.prod).js,表示只包含runtime的的代码,没有包含编译系统
  • esm-bundler:
    • 用于用户自己处理打包问题和选择打包工具(bundler),如使用webpack, rollupparcel
    • 类似于cjs格式(也是自己处理 bundler),只是以esm模块的方式提供
    • external所有依赖包;所以开发时有提供dev-esm命令
    • 生成的文件有<包对应的名字>.esm-bundler.js;注意没有prod,因为需要我们自己处理打包问题如压缩代码等
    • 对于vue包会多一种打包规则:esm-bundler-runtime,生成vue(.runtime).esm-browser.js,表示只包含runtime的的代码,没有包含编译系统

实现

build.js脚本的整体执行顺序是:处理可选参数 -> 扫描缓存 enum -> 并行打包 -> 检查包大小 -> 打包类型声明文件

可选参数上面已经介绍了;对于const-enum.jsscanEnums方法如何扫描缓存enum的,因为涉及代码的语法解析,我们放在最后再去分析,我们只需要知道此方法可以帮我们缓存所有的 enum 变量,然后再并行打包时复用;检查包大小主要是检查global.prod.js文件在三种处理下的大小:压缩后代码, gzip 算法brotli 算法。所以接下来我们来看主要打包过程。

打包单个包

对于并行打包,最终还是回到通过脚本执行单个打包的逻辑,所以我们先看看打一个包的主要内容。主要涉及脚本里的build方法和rollup.config.js

build

build方法其实主要就是调用rollup -c --environment <环境变量>并处理所需参数。其中有以下几点逻辑:

  • 如果在isRelease状态,且没有指定打包的包(all),其会忽略private包(虽然在过滤匹配包时已经处理了),目前private的包是dts-testruntime-test,所以这两个包是不会打包的,如果指定如pnpm build runtime-test就会报Target not found的错误
  • 如果有指定打包的格式,那么就不移除已存在的dist文件夹

接着交给rollup处理

js
await execa(
  'rollup',
  [
    '-c',
    '--environment',
    [
      `COMMIT:${commit}`,
      `NODE_ENV:${env}`,
      `TARGET:${target}`, // 包名
      formats ? `FORMATS:${formats}` : ``,
      prodOnly ? `PROD_ONLY:true` : ``,
      sourceMap ? `SOURCE_MAP:true` : ``
    ]
      .filter(Boolean)
      .join(',')
  ],
  { stdio: 'inherit' }
)
await execa(
  'rollup',
  [
    '-c',
    '--environment',
    [
      `COMMIT:${commit}`,
      `NODE_ENV:${env}`,
      `TARGET:${target}`, // 包名
      formats ? `FORMATS:${formats}` : ``,
      prodOnly ? `PROD_ONLY:true` : ``,
      sourceMap ? `SOURCE_MAP:true` : ``
    ]
      .filter(Boolean)
      .join(',')
  ],
  { stdio: 'inherit' }
)

rollup.config.js

主要打包的逻辑应该在rollup配置文件里。

在脚本build方法里跳用rollup命令后会通过--environment参数提供环境变量给rollup.config.js,所以rollup.config.js通过不同的参数生成不同的rollup配置。

TIP

最终的配置是一个数组,会根据有多少种格式(formats)以及生产/开发环境来确定配置数组,配置数组的个数对应就是最后输出不同打包文件的个数,具体文件类型可看格式关系图

首先我们看看整体是如何生成配置数组的。

js
const packageConfigs = process.env.PROD_ONLY
  ? []
  : packageFormats.map(format => createConfig(format, outputConfigs[format]))

if (process.env.NODE_ENV === 'production') {
  packageFormats.forEach(format => {
    if (packageOptions.prod === false) {
      return
    }
    if (format === 'cjs') {
      packageConfigs.push(createProductionConfig(format))
    }
    if (/^(global|esm-browser)(-runtime)?/.test(format)) {
      packageConfigs.push(createMinifiedConfig(format))
    }
  })
}

export default packageConfigs
const packageConfigs = process.env.PROD_ONLY
  ? []
  : packageFormats.map(format => createConfig(format, outputConfigs[format]))

if (process.env.NODE_ENV === 'production') {
  packageFormats.forEach(format => {
    if (packageOptions.prod === false) {
      return
    }
    if (format === 'cjs') {
      packageConfigs.push(createProductionConfig(format))
    }
    if (/^(global|esm-browser)(-runtime)?/.test(format)) {
      packageConfigs.push(createMinifiedConfig(format))
    }
  })
}

export default packageConfigs

可以看到主要用是createConfig, createProductionConfigcreateMinifiedConfig三个方法创建一个配置对象。其中createProductionConfigcreateMinifiedConfig也是基于createConfig的。

  • createProductionConfig: 只是简单的打包成.prod.js文件,单独对cjs格式处理;但是和直接用createConfig一样,并没有压缩代码,所以最终cjs.jscjs.prod.js文件内容是一样的,原因暂不清楚
  • createMinifiedConfig: 打包.prod.js文件,利用@rollup/plugin-terser压缩了代码

开发环境时只会直接用createConfig生成配置;生产环境会额外(除非 prodOnly)加入.prod.js文件。我们主要来看一下createConfig方法里的以下几点:

打包入口文件

主要是区分了runtime-only包含comelier的入口。对于@vue/compat包进一步区分了esmnon-esm的入口文件。

js
let entryFile = /runtime$/.test(format) ? `src/runtime.ts` : `src/index.ts`

// the compat build needs both default AND named exports. This will cause
// Rollup to complain for non-ESM targets, so we use separate entries for
// esm vs. non-esm builds.
if (isCompatPackage && (isBrowserESMBuild || isBundlerESMBuild)) {
  entryFile = /runtime$/.test(format)
    ? `src/esm-runtime.ts`
    : `src/esm-index.ts`
}
let entryFile = /runtime$/.test(format) ? `src/runtime.ts` : `src/index.ts`

// the compat build needs both default AND named exports. This will cause
// Rollup to complain for non-ESM targets, so we use separate entries for
// esm vs. non-esm builds.
if (isCompatPackage && (isBrowserESMBuild || isBundlerESMBuild)) {
  entryFile = /runtime$/.test(format)
    ? `src/esm-runtime.ts`
    : `src/esm-index.ts`
}

rollup-plugin-esbuild

build 的过程其实也是用了esbuild进行处理的,主要通过rollup-plugin-esbuild插件实现。

部分代码如下:

js
import esbuild from 'rollup-plugin-esbuild'

function createConfig() {
  // rollup config object
  return {
    plugins: [
      
      esbuild({
        tsconfig: path.resolve(__dirname, 'tsconfig.json'),
        sourceMap: output.sourcemap,
        minify: false, // 不使用esbuild插件进行代码压缩
        target: isServerRenderer || isNodeBuild ? 'es2019' : 'es2015',
        define: resolveDefine() // 定义打包注入的变量
      })
    ]
  }
}
import esbuild from 'rollup-plugin-esbuild'

function createConfig() {
  // rollup config object
  return {
    plugins: [
      
      esbuild({
        tsconfig: path.resolve(__dirname, 'tsconfig.json'),
        sourceMap: output.sourcemap,
        minify: false, // 不使用esbuild插件进行代码压缩
        target: isServerRenderer || isNodeBuild ? 'es2019' : 'es2015',
        define: resolveDefine() // 定义打包注入的变量
      })
    ]
  }
}

其中代码压缩(.prod.js)并没有用这个插件去处理,而是通过前面提到的createMinifiedConfig方法利用@rollup/plugin-terser进行压缩,因为后者能够压缩更小体积的代码。

define: resolveDefine()这里就是定义注入的变量

打包变量注入/替换

INFO

以下注入/替换统一称作注入

在注入时用到了两个方法:resolveDefineresolveReplaceresolveDefine就是普通的定义需要注入的变量的值:

js
// define object
{
  __VERSION__: '"3.3.2"'
}

// before bundling
console.log(__VERSION__)

// after bundling
console.log('3.3.2')
// define object
{
  __VERSION__: '"3.3.2"'
}

// before bundling
console.log(__VERSION__)

// after bundling
console.log('3.3.2')

resolveReplace也是用来生成注入变量的对象(待注入的key - 注入的值)的,不同之处在于resolveDefine直接将生成的对象提供给rollup-plugin-esbuild插件处理,resolveReplace是通过@rollup/plugin-replace插件进行处理的。

原因是rollup-plugin-esbuild插件处理注入时会严格限定注入的值valid JSON syntax,所以我们需要通过@rollup/plugin-replace注入这些特殊的值。

js
// e.g.
{
  'process.env': '({})',
  'context.onError(': `/*#__PURE__*/ context.onError(`,
}

// rollup-plugin-esbuild will throw error:
// error: Invalid define value (must be an entity name or valid JSON syntax)
// e.g.
{
  'process.env': '({})',
  'context.onError(': `/*#__PURE__*/ context.onError(`,
}

// rollup-plugin-esbuild will throw error:
// error: Invalid define value (must be an entity name or valid JSON syntax)

对于打包时的别名识别和注入enum类型的处理分别涉及脚本aliases.jsconst-enum.js

aliases 脚本

这个脚本就是为了获取别名entries定义,使别名对应到packages/<包名>/src/index.ts

每个包引用的别名规则是@vue/<对应packages下目录名>,除了辅助工具包(不需要应用别名): sfc-playground, size-check, template-explorerdts-test以及以下几个包:

包目录名别名
vuevue
compiler-sfcvue/compiler-sfc
server-renderervue/server-renderer
vue-compat@vue/compat

const-enum 脚本

由于通过rollup-plugin-esbuild插件在insolation mode打包会将const enum类型编译进运行时,此举会减慢打包速度以及最终打包出来的体积。所以脚本会将const enum类型通过上面提到的resolveReplace注入掉。最终比较如下:

ts
export const enum Test {
  TEST = 'TEST'
}

console.log(Test.TEST)
export const enum Test {
  TEST = 'TEST'
}

console.log(Test.TEST)
js
var Test = /* @__PURE__ */ (Test2 => {
  Test2['TEST'] = 'TEST'
  return Test2
})(Test || {})

console.log('TEST' /* TEST */)
exports.Test
var Test = /* @__PURE__ */ (Test2 => {
  Test2['TEST'] = 'TEST'
  return Test2
})(Test || {})

console.log('TEST' /* TEST */)
exports.Test
js
console.log('TEST')
// 此处没有exports,因为其他import了Test的地方也会直接替换
console.log('TEST')
// 此处没有exports,因为其他import了Test的地方也会直接替换

具体实现我们放在最后分析。其主要提供了三个东西:

  • scanEnum方法:扫描出仓库里匹配export const enumconst enum类型,然后将数据 cache 住直至所有包并行打包完成
  • enumPlugin插件:在const enum类型注入后移除export const enum语句;在rollup-plugin-esbuild插件之前处理,所以对于esbuild处理阶段const enum类型已经被注入实际对应的值,不用再处理了。
  • enumDefines注入对象scanEnumcache 的数据,enum表达式为键,实际值为值;e.g. export const enum Test { TEST = 'TEST' } -> { 'Test.TEST': 'TEST' }

并行打包流程 runParallel

实现了对某一个包进行打包后,我们来看看是如何对多个包进行并行打包的。

js
async function buildAll(targets) {
  await runParallel(cpus().length, targets, build)
}
async function runParallel(maxConcurrency, source, iteratorFn) {
  const ret = []
  const executing = []
  for (const item of source) {
    const p = Promise.resolve().then(() => iteratorFn(item, source))
    ret.push(p)
    // 并发限制
    if (maxConcurrency <= source.length) {
      // e 作为一个promise,p fulfilled后就会将对应的e从executing中移除,以让出位置
      // 移除让位后 e 也fulfilled了
      const e = p.then(() => executing.splice(executing.indexOf(e), 1))
      executing.push(e)
      if (executing.length >= maxConcurrency) {
        // 并发数量已满,让现有的任务们race,从而fulfilled让位
        // 只要其中一个任务fulfilled就race完成,即让出一个位置,继续对source的循环
        await Promise.race(executing)
      }
    }
  }
  return Promise.all(ret)
}
async function buildAll(targets) {
  await runParallel(cpus().length, targets, build)
}
async function runParallel(maxConcurrency, source, iteratorFn) {
  const ret = []
  const executing = []
  for (const item of source) {
    const p = Promise.resolve().then(() => iteratorFn(item, source))
    ret.push(p)
    // 并发限制
    if (maxConcurrency <= source.length) {
      // e 作为一个promise,p fulfilled后就会将对应的e从executing中移除,以让出位置
      // 移除让位后 e 也fulfilled了
      const e = p.then(() => executing.splice(executing.indexOf(e), 1))
      executing.push(e)
      if (executing.length >= maxConcurrency) {
        // 并发数量已满,让现有的任务们race,从而fulfilled让位
        // 只要其中一个任务fulfilled就race完成,即让出一个位置,继续对source的循环
        await Promise.race(executing)
      }
    }
  }
  return Promise.all(ret)
}

我们知道前面的build方法本身是一个异步函数,所以我们其实可以直接用Promise.all来让多个build都异步执行即可。但是以上代码却通过cpu的最大个数来进行并行,换句话说就是runParallel做的是并发限制,其中iteratorFn就是对应单个包的build过程。

以下是一个输出例子:

js
runParallel(
  2, // 最大并发数
  [1, 2, 3, 4, 5],
  item =>
    new Promise(res =>
      setTimeout(() => {
        console.log(item)
        res()
      }, 1000)
    )
)

// 1, 2
// after 1s
// 3, 4
// after 1s
// 5
runParallel(
  2, // 最大并发数
  [1, 2, 3, 4, 5],
  item =>
    new Promise(res =>
      setTimeout(() => {
        console.log(item)
        res()
      }, 1000)
    )
)

// 1, 2
// after 1s
// 3, 4
// after 1s
// 5

不过不太清楚这里为什么要做并发控制,估计是考虑到当前打包计算机的资源分配问题吧。

打包类型声明

在将所有指定包打包完后,通过-t (buildTypes)选项可以选择是否同时打包输出类型声明文件。如果需要进行包类型依赖的行为,比如 dts-test 进行类型测试时,则需要打包类型声明文件, 其过程就是执行了rollup相关命令。

js
if (buildTypes) {
  await execa(
    'pnpm',
    [
      'run',
      'build-dts',
      ...(targets.length // targets 是要提供模糊搜索的名字数组
        ? // resolvedTargets 是通过targets解析出来的包名数组
          ['--environment', `TARGETS:${resolvedTargets.join(',')}`]
        : [])
    ],
    {
      stdio: 'inherit'
    }
  )
}
if (buildTypes) {
  await execa(
    'pnpm',
    [
      'run',
      'build-dts',
      ...(targets.length // targets 是要提供模糊搜索的名字数组
        ? // resolvedTargets 是通过targets解析出来的包名数组
          ['--environment', `TARGETS:${resolvedTargets.join(',')}`]
        : [])
    ],
    {
      stdio: 'inherit'
    }
  )
}

可以看到执行的是pnpm build-dts,对应于tsc -p tsconfig.build.json && rollup -c rollup.dts.config.js

首先通过tsc基于tsconfig.build.json文件将所有包 src 下的代码的类型声明文件编译出来,然后再用rollup将每个包对应的类型声明文件打包进一个文件并输出于对应的dist文件中。

tsconfig.build.json

这个文件是 extends 于tsconfig.json的,里面我们主要关注一下几个配置:

  • compilerOptions.outDir: 编译输出的文件夹,此处值为temp,所以tsc -p tsconfig.build.json之后就会在temp文件夹生成每个文件对应的类型声明文件(即 src 目录及文件名和源码一样的结构)
  • compilerOptions.declaration: 值为true,生成类型声明文件
  • compilerOptions.emitDeclarationOnly: 值为true生成类型声明文件

rollup.dts.config.js

此配置文件主要是将前面生成在temp里的类型声明文件打包成一个整体的<包名>.d.ts文件,并输出到对应包的dist中。 其会根据temp/<package>/src/index.d.ts为入口文件进行打包,打包格式为esm,且不会inline依赖包的类型。

例如vue的打包过程:

null

patchTypes plugin

里面用到了rollup-plugin-dts和一个自定义插件patchTypes,我们主要来看看自定义插件patchTypes

具体实现和const-enum.js部分类似,都是通过babel解析ast,就不重复分析了。我们主要看看其用途:

Details
  • 移除所有标记了@internal 的类型属性

export 的类型,以及type, interface, class里的属性只要标记了@internal就不会打包进去。

ts
export type SSRContext = {
  [key: string]: any
  teleports?: Record<string, string>
  /**
   * @internal
   */
  __teleportBuffers?: Record<string, SSRBuffer>
  /**
   * @internal
   */
  __watcherHandles?: (() => void)[]
}

// after bundling
export type SSRContext = {
  [key: string]: any
  teleports?: Record<string, string>
  /* removed internal: __teleportBuffers */
  /* removed internal: __watcherHandles */
}
export type SSRContext = {
  [key: string]: any
  teleports?: Record<string, string>
  /**
   * @internal
   */
  __teleportBuffers?: Record<string, SSRBuffer>
  /**
   * @internal
   */
  __watcherHandles?: (() => void)[]
}

// after bundling
export type SSRContext = {
  [key: string]: any
  teleports?: Record<string, string>
  /* removed internal: __teleportBuffers */
  /* removed internal: __watcherHandles */
}
  • 将 export 里所有的类型转化为 inline 导出的形式
ts
type Foo = number
type Bar = string
export { Foo, Bar }

// ->
export Foo = number
export Bar = string
type Foo = number
type Bar = string
export { Foo, Bar }

// ->
export Foo = number
export Bar = string

如果不转换,会在 vitepress defineComponent里报the inferred type cannot be named without a reference,暂时不清楚为啥。

  • 追加自定义的一些额外类型声明

这一步会将对应包packages/${pkg}/types文件夹下的所有类型声明一个个追加到打包好的整体声明文件末尾。

packages/${pkg}/types下的文件不会纳入编译,tsc -p tsconfig.build.json只包含了packages/${pkg}/src下的文件。

ts
// packages/vue/types/jsx-register.d.ts
import '../jsx'

// packages/vue/distvue.d.ts
...
export { compileToFunction as compile };
...
import '../jsx'
// packages/vue/types/jsx-register.d.ts
import '../jsx'

// packages/vue/distvue.d.ts
...
export { compileToFunction as compile };
...
import '../jsx'

babel 处理 ast

前面的const-enum.js脚本和patchTypes插件具体实现都涉及到了用@babel/parserparse方法得到ast并结合magic-string来进行转换源码的操作。我们知道ast是对源码编译后生成的一个对应对象(即抽象语法树),所以除去具体的如何编译成语法树的算法,剩下的各种树节点的类型(变量,函数,导出等,以及每个节点的起始结束位置等信息)其实也没什么需要说明的,主要还是在文档找到对应的类型含义。

这里有一个在线语法树生成工具可以参考用一下。下面我们对const-enum.js脚本里用到的一些节点类型进行简单地说明。

enum 类型的 ast

以下代码和编译成的对应 ast 如下所示:大部分 ast 属性已省略

ts
export const enum Test {
  Foo = 'foo value'
}

export { Test }
export const enum Test {
  Foo = 'foo value'
}

export { Test }
js
const ast = {
  program: {
    body: [
      {
        type: 'ExportNamedDeclaration',
        start: 0,
        end: 46,
        specifiers: [],
        declaration: {
          type: 'TSEnumDeclaration',
          start: 7,
          end: 46,
          id: {
            type: 'Identifier',
            start: 18,
            end: 22,
            name: 'Test'
          },
          members: [
            {
              type: 'TSEnumMember',
              id: {
                type: 'Identifier',
                name: 'Foo'
              },
              initializer: {
                type: 'StringLiteral',
                value: 'foo value'
              }
            }
          ]
        }
      },
      {
        type: 'ExportNamedDeclaration',
        start: 48,
        end: 63,
        specifiers: [
          {
            type: 'ExportSpecifier',
            start: 57,
            end: 61,
            local: {
              type: 'Identifier',
              start: 57,
              end: 61,
              name: 'Test'
            }
          }
        ]
      }
    ]
  }
}
const ast = {
  program: {
    body: [
      {
        type: 'ExportNamedDeclaration',
        start: 0,
        end: 46,
        specifiers: [],
        declaration: {
          type: 'TSEnumDeclaration',
          start: 7,
          end: 46,
          id: {
            type: 'Identifier',
            start: 18,
            end: 22,
            name: 'Test'
          },
          members: [
            {
              type: 'TSEnumMember',
              id: {
                type: 'Identifier',
                name: 'Foo'
              },
              initializer: {
                type: 'StringLiteral',
                value: 'foo value'
              }
            }
          ]
        }
      },
      {
        type: 'ExportNamedDeclaration',
        start: 48,
        end: 63,
        specifiers: [
          {
            type: 'ExportSpecifier',
            start: 57,
            end: 61,
            local: {
              type: 'Identifier',
              start: 57,
              end: 61,
              name: 'Test'
            }
          }
        ]
      }
    ]
  }
}

重点需要关注用到的已经高亮,其中有很多种节点类型,即type所指的东西;startend指的是解析出来的节点类型所覆盖的起始和结束位置。

基于此我们可以来看看具体const-enum.jsscanEnum方法和constEnum方法返回的enumPlugin插件是如何利用这个ast对象进行对应处理的。

scanEnum

前面我们说到这个方法的作用是扫描const enum类型然后将相关信息缓存起来。

信息数据会存在temp/enum.json里,每次执行完build.js脚本就会 remove 掉,格式为:

ts
type enumData = {
  // Map: 文件路径名 -> "export const enum"匹配到的ExportNamedDeclaration节点起始结束位置数组
  ranges: Record<string, [number, number][]>
  // Map: enum名称.成员 -> 实际值; 后面用于打包注入对应的数据
  defines: Record<string, string>
  // 所有去重的enum名称
  ids: string[]
}
type enumData = {
  // Map: 文件路径名 -> "export const enum"匹配到的ExportNamedDeclaration节点起始结束位置数组
  ranges: Record<string, [number, number][]>
  // Map: enum名称.成员 -> 实际值; 后面用于打包注入对应的数据
  defines: Record<string, string>
  // 所有去重的enum名称
  ids: string[]
}
  • 扫描: 这一步其实就是用git grep "export const enum"命令获取仓库的所有匹配包含export const enum的文件。

    bash
    $ git grep "export const enum"
    
    packages/compiler-core/src/ast.ts:export const enum Namespaces {
    packages/compiler-core/src/ast.ts:export const enum NodeTypes {
    packages/compiler-core/src/ast.ts:export const enum ElementTypes {
    packages/compiler-core/src/ast.ts:export const enum ConstantTypes {
    ...
    $ git grep "export const enum"
    
    packages/compiler-core/src/ast.ts:export const enum Namespaces {
    packages/compiler-core/src/ast.ts:export const enum NodeTypes {
    packages/compiler-core/src/ast.ts:export const enum ElementTypes {
    packages/compiler-core/src/ast.ts:export const enum ConstantTypes {
    ...

    接着通过\n:分割获取所有的文件路径并去重,扫描就算完成了。

  • 遍历文件获取 enum 信息: 遍历上面获取的文件并读取文件内容为字符串,然后传给@babel/parser.parse函数获取ast。 对应上面ast例子,我们可以获取到对应文件里每个匹配到的export const enum节点的起始结束位置,接着一个个 push 到放到enumData.ranges对应的数组里,获取每个 enum 名称,以及每个成员的值。

    以下是部分伪代码,ast的字段参考上面的ast对象:

    js
    for (const filePath of filePaths) {
      const fileRawContent = fs.readFileSync(filePath, 'utf-8')
      const ast = parse(fileRawContent)
    
      for (const node of ast.program.body) {
        // 符合"export const enum <enum名称> { [成员] = 值 }"的类型节点
        if (
          node.type === 'ExportNamedDeclaration' &&
          node.declaration?.type === 'TSEnumDeclaration'
        ) {
          // 添加ranges数据
          enumData.ranges[filePath].push([node.start, node.end])
    
          // 添加enum名称
          const id = node.declaration.id.name
          if (!enumData.ids.includes(id)) {
            enumData.ids.push(id)
          }
    
          // 添加defines数据
          for (const member of node.declaration.members) {
            const memberPath = `${id}.${member.id.name}`
            const memberValue = member.initializer.value
            if (memberPath in enumData.defines) {
              throw new Error(`name conflict for enum ${id} in ${file}`)
            }
            enumData.defines[memberPath] = JSON.stringify(memberValue)
          }
        }
      }
    }
    for (const filePath of filePaths) {
      const fileRawContent = fs.readFileSync(filePath, 'utf-8')
      const ast = parse(fileRawContent)
    
      for (const node of ast.program.body) {
        // 符合"export const enum <enum名称> { [成员] = 值 }"的类型节点
        if (
          node.type === 'ExportNamedDeclaration' &&
          node.declaration?.type === 'TSEnumDeclaration'
        ) {
          // 添加ranges数据
          enumData.ranges[filePath].push([node.start, node.end])
    
          // 添加enum名称
          const id = node.declaration.id.name
          if (!enumData.ids.includes(id)) {
            enumData.ids.push(id)
          }
    
          // 添加defines数据
          for (const member of node.declaration.members) {
            const memberPath = `${id}.${member.id.name}`
            const memberValue = member.initializer.value
            if (memberPath in enumData.defines) {
              throw new Error(`name conflict for enum ${id} in ${file}`)
            }
            enumData.defines[memberPath] = JSON.stringify(memberValue)
          }
        }
      }
    }

    还有省略的部分是一些细节处理部分,如处理BinaryExpression, UnaryExpression等。

    最后将enumData以 JSON 形式写入temp/enum.json等待constEnum函数使用。

constEnum

此方法会返回一个 rollup 插件和用于打包注入的enumData.defines

js
import { constEnum } from './scripts/const-enum.js'
const [enumPlugin, enumDefines] = constEnum()
import { constEnum } from './scripts/const-enum.js'
const [enumPlugin, enumDefines] = constEnum()

上面的代码会在rollup.config.js里执行,对于每一个包,都会重新执行一遍rollup.config.js,也每一个包都会执行一遍constEnum方法,所以我们已经先在build,js里执行了scanEnum缓存了所有enumDefines,并行打包所有包时就可以更快获取 enum 信息了。

enumPlugin

此插件的作用就是在esbuild之前把export const enum相关节点代码移除掉。

分为两种情况:export const enum <enum名称> { [成员] = 值 }export { <enum名称> };第二种情况是指导出的enum名称是应该被移除的export const enum类型,即存在enumData.ids里。

第一种情况很简单,直接通过enumData.ranges的数据删除掉就行:

js
// rollup plugin
const plugin = {
  transform(rawCode, filePath) {
    if (filePath in enumData.ranges) {
      const s = new MagicString(rawCode)
      // 我们知道里面保存的就是节点的起始结束位置
      for (const [start, end] of enumData.ranges[filePath]) {
        s.remove(start, end)
      }
    }
  }
}
// rollup plugin
const plugin = {
  transform(rawCode, filePath) {
    if (filePath in enumData.ranges) {
      const s = new MagicString(rawCode)
      // 我们知道里面保存的就是节点的起始结束位置
      for (const [start, end] of enumData.ranges[filePath]) {
        s.remove(start, end)
      }
    }
  }
}

第二种情况需要动态通过正则检查,有重复导出的话再通过ast去找到移除:

js
const plugin = {
  transform(rawCode, filePath) {
    const reExportsRE = new RegExp(
      `export {[^}]*?\\b(${enumData.ids.join('|')})\\b[^]*?}`
    )

    if (reExportsRE.test(rawCode)) {
      const s = new MagicString(rawCode)
      const ast = parse(rawCode)

      for (const node of ast.program.body) {
        // 匹配为 export 类型,这里有省略其他条件
        if (node.type === 'ExportNamedDeclaration') {
          for (const spec of node.specifiers) {
            // 应该被移除,却重复导出的enum名称
            if (enumData.ids.includes(spec.local.name)) {
              // 下面就是移除操作,通过前一个导出变量或者后一个导出变量位置来处理
              const next = node.specifiers[i + 1]
              if (next) {
                // @ts-ignore
                s.remove(spec.start, next.start)
              } else {
                // last one
                const prev = node.specifiers[i - 1]
                // @ts-ignore
                s.remove(prev ? prev.end : spec.start, spec.end)
              }
            }
          }
        }
      }
    }
  }
}
const plugin = {
  transform(rawCode, filePath) {
    const reExportsRE = new RegExp(
      `export {[^}]*?\\b(${enumData.ids.join('|')})\\b[^]*?}`
    )

    if (reExportsRE.test(rawCode)) {
      const s = new MagicString(rawCode)
      const ast = parse(rawCode)

      for (const node of ast.program.body) {
        // 匹配为 export 类型,这里有省略其他条件
        if (node.type === 'ExportNamedDeclaration') {
          for (const spec of node.specifiers) {
            // 应该被移除,却重复导出的enum名称
            if (enumData.ids.includes(spec.local.name)) {
              // 下面就是移除操作,通过前一个导出变量或者后一个导出变量位置来处理
              const next = node.specifiers[i + 1]
              if (next) {
                // @ts-ignore
                s.remove(spec.start, next.start)
              } else {
                // last one
                const prev = node.specifiers[i - 1]
                // @ts-ignore
                s.remove(prev ? prev.end : spec.start, spec.end)
              }
            }
          }
        }
      }
    }
  }
}

以上就是const-enum.js脚本的基本实现了。

总结

  • 介绍了打包脚本的基本使用打包格式含义及整体关系图
  • 介绍了打包过程的基本实现,包括rollup 配置并行打包
  • 介绍了打包过程中用到的脚本用途和基本实现
  • 介绍了用babel处理astconst-enum.js脚本上的应用