对于打包相关命令,主要基于scripts/build.js这个脚本,以及rollup.config.js
和rollup.dts.config.js
所以了解了这个脚本和 rollup 的配置,我们就基本了解了package.json
下所有的打包相关命令了。
此外rollup.config.js
会用到scripts/aliases.js和scripts/const-enum.js
{
"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"
}
我们会首先看看是如何使用这个脚本来打包的,以及基于此过一遍每种打包格式的介绍,接着对于实现会主要关注以下几点:
- 具体打包一个包的实现与配置
- 并行打包
- 打包类型声明文件
- 了解const-enum.js是如何利用
ast
做扫描和缓存处理的
使用
脚本可以用来并行打多个包,打包名称支持模糊搜索,不提供则打包所有。
$ 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-bundler
和cjs
,配置在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
, cjs
和es
。基于此三类,我们再根据external
, 入口文件
和生产/开发
环境区分出不同的打包格式(输出不同的文件)。
具体描述
- global:
- 通过
iife
的方式打包 inline
所有依赖包- 包的名字,即挂载到全局对象上的变量名,会在对应的
package.json
里buildOptions.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
,rollup
和parcel
; - 类似于
cjs
格式(也是自己处理 bundler),只是以esm
模块的方式提供 external
所有依赖包;所以开发时有提供dev-esm命令- 生成的文件有
<包对应的名字>.esm-bundler.js
;注意没有prod
,因为需要我们自己处理打包问题如压缩代码等 - 对于
vue
包会多一种打包规则:esm-bundler-runtime
,生成vue(.runtime).esm-browser.js
,表示只包含runtime
的的代码,没有包含编译系统
。
- 用于用户自己处理打包问题和选择打包工具(bundler),如使用
实现
build.js
脚本的整体执行顺序是:处理可选参数
-> 扫描缓存 enum -> 并行打包 -> 检查包大小
-> 打包类型声明文件。
可选参数上面已经介绍了;对于const-enum.js的scanEnums
方法如何扫描缓存enum
的,因为涉及代码的语法解析,我们放在最后再去分析,我们只需要知道此方法可以帮我们缓存所有的 enum 变量,然后再并行打包时复用;检查包大小
主要是检查global.prod.js
文件在三种处理下的大小:压缩后代码, gzip 算法和brotli 算法。所以接下来我们来看主要打包过程。
打包单个包
对于并行打包,最终还是回到通过脚本执行单个打包的逻辑,所以我们先看看打一个包的主要内容。主要涉及脚本里的build
方法和rollup.config.js
。
build
build
方法其实主要就是调用rollup -c --environment <环境变量>
并处理所需参数。其中有以下几点逻辑:
- 如果在
isRelease
状态,且没有指定打包的包(all),其会忽略private
包(虽然在过滤匹配包时已经处理了),目前private
的包是dts-test
和runtime-test
,所以这两个包是不会打包的,如果指定如pnpm build runtime-test
就会报Target not found
的错误 - 如果有指定打包的格式,那么就不移除已存在的
dist
文件夹
接着交给rollup
处理
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)
以及生产/开发
环境来确定配置数组,配置数组的个数对应就是最后输出不同打包文件的个数,具体文件类型可看格式关系图。
首先我们看看整体是如何生成配置数组的。
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
, createProductionConfig
和createMinifiedConfig
三个方法创建一个配置对象。其中createProductionConfig
和createMinifiedConfig
也是基于createConfig
的。
createProductionConfig
: 只是简单的打包成.prod.js
文件,单独对cjs
格式处理;但是和直接用createConfig
一样,并没有压缩代码,所以最终cjs.js
和cjs.prod.js
文件内容是一样的,原因暂不清楚createMinifiedConfig
: 打包.prod.js
文件,利用@rollup/plugin-terser压缩了代码
在开发
环境时只会直接用createConfig
生成配置;生产环境会额外(除非 prodOnly)加入.prod.js
文件。我们主要来看一下createConfig
方法里的以下几点:
打包入口文件
主要是区分了runtime-only
和包含comelier
的入口。对于@vue/compat
包进一步区分了esm
和non-esm
的入口文件。
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插件实现。
部分代码如下:
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
以下注入/替换统一称作注入
在注入时用到了两个方法:resolveDefine
和resolveReplace
。 resolveDefine
就是普通的定义需要注入的变量的值:
// 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
注入这些特殊的值。
// 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.js
和const-enum.js
。
aliases 脚本
这个脚本就是为了获取别名
的entries
定义,使别名
对应到packages/<包名>/src/index.ts
。
每个包引用的别名规则是@vue/<对应
packages下目录名>
,除了辅助工具包(不需要应用别名): sfc-playground
, size-check
, template-explorer
和dts-test
以及以下几个包:
包目录名 | 别名 |
---|---|
vue | vue |
compiler-sfc | vue/compiler-sfc |
server-renderer | vue/server-renderer |
vue-compat | @vue/compat |
const-enum 脚本
由于通过rollup-plugin-esbuild
插件在insolation mode
打包会将const enum
类型编译进运行时,此举会减慢打包速度以及最终打包出来的体积。所以脚本会将const enum
类型通过上面提到的resolveReplace
注入掉。最终比较如下:
export const enum Test {
TEST = 'TEST'
}
console.log(Test.TEST)
export const enum Test {
TEST = 'TEST'
}
console.log(Test.TEST)
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
console.log('TEST')
// 此处没有exports,因为其他import了Test的地方也会直接替换
console.log('TEST')
// 此处没有exports,因为其他import了Test的地方也会直接替换
具体实现我们放在最后分析。其主要提供了三个东西:
scanEnum方法
:扫描出仓库里匹配export const enum
的const enum
类型,然后将数据 cache 住直至所有包并行打包完成enumPlugin插件
:在const enum
类型注入后移除export const enum
语句;在rollup-plugin-esbuild
插件之前处理,所以对于esbuild
处理阶段const enum
类型已经被注入实际对应的值,不用再处理了。enumDefines注入对象
:scanEnum
cache 的数据,enum表达式
为键,实际值为值;e.g.export const enum Test { TEST = 'TEST' }
->{ 'Test.TEST': 'TEST' }
并行打包流程 runParallel
实现了对某一个包进行打包后,我们来看看是如何对多个包进行并行打包的。
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
过程。
以下是一个输出例子:
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
相关命令。
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
的打包过程:
patchTypes plugin
里面用到了rollup-plugin-dts和一个自定义插件patchTypes
,我们主要来看看自定义插件patchTypes
。
具体实现和const-enum.js部分类似,都是通过babel
解析ast
,就不重复分析了。我们主要看看其用途:
Details
- 移除所有标记了@internal 的类型属性
export 的类型,以及type
, interface
, class
里的属性只要标记了@internal
就不会打包进去。
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 导出的形式
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
下的文件。
// 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/parser
的parse方法得到ast
并结合magic-string来进行转换源码的操作。我们知道ast
是对源码编译后生成的一个对应对象(即抽象语法树),所以除去具体的如何编译成语法树的算法,剩下的各种树节点的类型(变量,函数,导出等,以及每个节点的起始结束位置等信息)其实也没什么需要说明的,主要还是在文档找到对应的类型含义。
这里有一个在线语法树生成工具可以参考用一下。下面我们对const-enum.js
脚本里用到的一些节点类型进行简单地说明。
enum 类型的 ast
以下代码和编译成的对应 ast 如下所示:大部分 ast 属性已省略。
export const enum Test {
Foo = 'foo value'
}
export { Test }
export const enum Test {
Foo = 'foo value'
}
export { 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'
}
}
]
}
]
}
}
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
所指的东西;start
和end
指的是解析出来的节点类型所覆盖的起始和结束位置。
基于此我们可以来看看具体const-enum.js
里scanEnum
方法和constEnum
方法返回的enumPlugin
插件是如何利用这个ast
对象进行对应处理的。
scanEnum
前面我们说到这个方法的作用是扫描const enum
类型然后将相关信息缓存起来。
信息数据会存在temp/enum.json
里,每次执行完build.js
脚本就会 remove 掉,格式为:
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
对象:jsfor (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
。
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
的数据删除掉就行:
// 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
去找到移除:
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
脚本的基本实现了。