对于打包相关命令,主要基于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 packageConfigsconst 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.Testvar Test = /* @__PURE__ */ (Test2 => {
Test2['TEST'] = 'TEST'
return Test2
})(Test || {})
console.log('TEST' /* TEST */)
exports.Testconsole.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注入对象:scanEnumcache 的数据,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
// 5runParallel(
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 = stringtype 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脚本的基本实现了。
vue3-reading