导航
导航:0. 导论
上一章节: 3. 集成 lint 代码规范工具
下一章节:(编写中)5. 设计组件库的样式方案
本章节示例代码仓:Github
这节分享的内容是偏个性化的,涉及的前置内容也比较多,需要读者尽可能多地掌握包管理、依赖、构建工具(Vite)的相关知识,才方便跟进思路,理解内容。我推荐大家可以先对之前的相关内容做一些回顾:
2. 在 monorepo 模式下集成 Vite 和 TypeScript - 上
2. 在 monorepo 模式下集成 Vite 和 TypeScript - 下
下面所介绍的只是一种实现统一打包体系的思路,其中有借鉴其他开源软件的成分,也有根据我自己的想法以及结合现有架构自行调整的成分,并不是什么金科玉律。不熟悉的朋友可以跟着流程实践尝试,努力形成自己的理解;熟悉的朋友可以看看能不能从中获取一些新的思路,也欢迎提出自己的想法和建议。
为什么要定制打包体系
公共配置提取
我们先前 2. 在 monorepo 模式下集成 Vite 和 TypeScript - 上 中,对于每一个子包都要准备一个 vite.config.ts
文件,例如:
ts
// packages/button/vite.config.ts
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
export default defineConfig({
// 增加插件的使用
plugins: [vue()],
build: {
lib: {
entry: './src/index.ts',
name: 'OpenxuiButton',
fileName: 'openxui-button',
},
minify: false,
rollupOptions: {
external: [
// 除了 @openxui/shared,未来可能还会依赖其他内部模块,不如用正则表达式将 @openxui 开头的依赖项一起处理掉
/@openxui.*/,
'vue'
],
},
}
})
其实,各个包的打包配置,其中有 99% 都是相同的。 即使存在着外部依赖(rollupOptions.external
),包名(lib.fileName
) 这样的不同配置,仔细思考也会发现它们都能够从各自的 package.json
中自动获取:
- 包名、全局变量名可以根据
name
字段生成,为啥要手动填写呢? - 需要被外部化处理的依赖项也在
peerDependencies
和dependencies
中,为啥要手动维护呢?
json
// packages/button/package.json
{
"name": "@openxui/button",
// 其他配置。。。
"peerDependencies": {
"vue": ">=3.0.0"
},
"dependencies": {
"@openxui/shared": "workspace:^"
}
}
构建全量产物
除了上面的原因,还有一个因素促使我们去定制打包体系:我们要不要提供更丰富的产物供用户选择?
目前我们构建出的 umd
、es
以及 d.ts
类型声明产物,足以让通过包管理器(npm
/ pnpm
)集成组件的用户正常使用,我们称这种产物为常规产物 。常规产物适用于构建场景,必须配合包管理器使用。
但是这些产物如果直接通过 <script src="xxxx"></script>
的方式引入,是无法正常工作的。这是因为我们的 umd
产物经过了依赖外部化处理(回顾:2. 在 monorepo 模式下集成 Vite 和 TypeScript - 上),直接引用会缺少大量依赖。这种场景需要取消依赖的外部化处理,构建出全量产物。 例如 element-plus
产物中的 dist/index.full.js
、vue
产物中的 vue(.runtime).global.js
。全量产物适用于非构建场景,不必配合包管理器使用。
全量产物的使用场景其实是不容忽视的,一方面,许多新手用户需要这样一个快速上手的途径(可以暂时不折腾构建工具);另一方面,在线演示的沙盒环境也正需要这样的产物。
在全量产物的基础上,还可以进一步扩展:
- 全量产物体积可能太大了,我需要压缩混淆后的
.min.js
版本。 .min.js
版本体积虽小但是调试困难,我需要提供sourcemap
文件(sourcemap
推荐阅读:sourcemap 这么讲,我彻底理解了、关于sourcemap,这篇文章就够了)。
这时我们回过头来想想,Vite
对于这些各式各样的打包需求都能支持,但是它们各自对应了不同的打包配置,Vite
无法在一次构建中生成全部类型的产物 。 这就更需要我们在 Vite
的基础上增强构建能力,演化出自己的打包体系。
总结
根据上面提到的线索,我们总结出以下定制打包体系的理由:
- 促进
Clean Code
,消除大量的重复代码。 - 集中维护构建配置,避免分散管理造成的多地多次修改的困扰。
- 提高构建的自动化程度,进一步降低维护构建配置的心智负担。
- 增强构建能力,支持同时生成不同类型的产物。
当然,我们的例子中为了演示方便,只有寥寥几个子包,体现不出优化工作的必要性。但是我们去看那些成熟的 monorepo
开源软件仓库,动辄几十个子包,对于它们而言,则是实实在在的需求推动了它们的优化。
打包体系初步分析
如果你观察过很多开源软件,会发现它们的打包路数都五花八门、各不相同。由此可以看出,打包体系是"因包而异"的。在动手实现前,我们有必要先分析并设计自己的体系。
我们先以一款优秀的 2D 渲染库 pixi.js(采用 monorepo
方式组织) 为案例,学习其统一构建的流程:
- 每个子包中移除了专属的
rollup.config
或者vite.config
文件。 - 在根目录下有一个集中的脚本,一口气完成整个构建任务。
- 在构建脚本中,首先获取所有的子包工作目录。
- 遍历上一步获取到的子包列表,获取每个子包的文件目录、
package.json
等信息,在循环中结合子包信息动态拼接出每个包构建配置。
- 最后调用构建工具 API / CLI 读取配置,执行构建。
其过程大致可以用以下流程表现出来:
getPkgs
- 获取待构建的子包。forEach
、nextPkg
、endForEach?
- 控制子包列表的遍历。getPkgInfo
- 获取子包package.json
等信息。generateConfig
- 生成子包的构建配置。build
- 执行构建。
我们的组件库并不计划编写一个集中脚本去编排所有流程 ,我们打算充分利用包管理器和构建工具的能力。之前在 2. 在 monorepo 模式下集成 Vite 和 TypeScript - 上 中讲过,pnpm
本身的命令,就足以实现"获取所有待构建的子包"、"对子包进行拓扑排序"、"遍历子包执行脚本"。
bash
pnpm --filter ./packages/** run build
因此,上述例子中的获取子包 getPkgs
,循环控制 forEach
系列,我们都不打算自己实现,而是借助 pnpm
本身的能力。
另外,我们也不计划废除每个子包中的 vite.config
,去调用 Vite
的 API 单独实现 build
构建方法。而是计划在 vite.config
中调用公共的 generateConfig
方法直接生成完善的打包配置,通过 vite build
的 CLI 命令去读取配置,启动构建进程。
届时,vite.config
配置会大幅简化,变成类似下面的形式。
ts
import { generateConfig } from '@openxui/build'
export default generateConfig(/** ... */);
这样一来,我们就只需要考虑如何实现生成打包配置的 generateConfig
方法 。当然,generateConfig
的实现也依赖于 getPkgInfo
对各自子包 package.json
信息的获取。
实现前准备
创建 build 子模块
在正式实现打包体系之前,我们先做好以下准备:创建 packages/build
子包作为组件库项目的"构建能力"模块,所有与构建配置相关的封装都集中于此。
bash
📦openx-ui
┣ 📂...
┣ 📂packages
┃ ┣ 📂build
┃ ┃ ┣ 📂src
┃ ┃ ┃ ┣ 📜... # 其他源码文件
┃ ┃ ┃ ┗ 📜index.ts # 源码出口
┃ ┃ ┣ 📜package.json # 包的信息
┃ ┃ ┗ 📜vite.config.ts # 构建配置
┃ ┗ 📂...
┗ 📜...
build
包的 package.json
可参考其他子包(例如 openxui/shared
,回顾:2. 在 monorepo 模式下集成 Vite 和 TypeScript - 上)的形式填写,唯一需要注意 peerDependencies
的不同:
json
// packages/build/package.json
{
// 其他相似配置省略...
"peerDependencies": {
// 构建模块的上游正是我们集成的构建工具 `Vite`
"vite": ">=3.0.0",
"vue": ">=3.0.0"
},
"peerDependenciesMeta": {
"vue": {
"optional": true
}
},
}
build
包中的 vite.config
暂时不管,等实现打包体系后,用新的方式获取构建配置。
build 子模块依赖注意事项
在实现 build
包的过程中,需要注意以下两点:
build
包不引用其他子包内部依赖。build
包谨慎安装只提供esm
产物的外部依赖。
按照先前的规划,我们要在 vite.config
中直接引入源码获取构建配置:
ts
// packages/build/vite.config.ts
import { generateConfig } from './src';
export default generateConfig(/** ... */);
但是,Vite
解析配置文件 vite.config
的方式却相对简单直接,本质上是先用 esbuild
将 ts
配置解析成 js
,再通过 Node.js
原生的机制加载,并不像构建代码那样时做了很多兼容性处理。
关于第一条注意事项 。这是由于加载 vite.config
时,各种路径别名能力------无论是 tsconfig
的 paths
还是 Vite
自身的 alias
都无法作用,内部模块之间的引用无法定位到正确的源码。
ts
// build 模块中引入内部依赖,会导致 `vite.config` 配置文件解析出错。
import {/** ... */} from '@openxui/shared'
关于第二条注意事项 。.ts
后缀的配置文件在 package.json
中不声明 "type": "module"
的情况下无法使用 esm
模块。这个例子甚至更好复现,我们试着在一个 vite.config.ts
中引入纯 esm
依赖 lodash-es。
ts
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import { camelCase } from 'lodash-es'
console.log(camelCase('hello'));
export default defineConfig({
plugins: [vue()],
})
这种情况下,我们只能把配置文件改成 vite.config.mts
来适应这些纯 esm
产物的外部依赖。
前端工程化探索的道路上永远充满了你意想不到的坑。但是只要我们发现问题时能够冷静分析原因,及时补充相关知识,它们终将变得不再神秘而费解。这就是为何我在前面的分享中,总是希望大家在实战的同时,也能同步了解包管理、模块化、配置解析、构建过程等细节。
想要详细了解这个踩坑过程的话,可以阅读下面的资料:
Issue:Make vite.config.ts support pure esm modules without setting type: "module"
Issue: ESM-only packages are not supported in vite.config.ts
Vite是如何对我们写的(vite.config.x)进行解析?。
实现打包体系
接下来,我们开始编写代码完成 @openxui/build
子模块,并在过程中穿插实现思路的讲解。
代码组织
下面是 @openxui/build
的源码文件结构。我们依然将 src/index.ts
作为模块的出口;generateConfig
中负责实现生成构建配置的主体方法;由于不方便引入其他公共方法模块,我们需要建立 utils
目录统一存放本模块用到的公共方法。
bash
# packages/build/src
📦src
┣ 📂generateConfig # 实现生成构建配置的主体方法
┃ ┣ 📜external.ts # 依赖外部化相关,获取构建配置的 build.rollupOptions.external 字段
┃ ┣ 📜index.ts # 模块出口,主题方法实现,整合构建配置
┃ ┣ 📜lib.ts # 产物相关,获取构建配置的 build.lib 字段
┃ ┣ 📜options.ts # 配置项对象声明
┃ ┣ 📜pluginMoveDts.ts # 移动 d.ts 产物的自定义插件
┃ ┣ 📜pluginSetPackageJson.ts # 自动将产物路径写入 package.json 的自定义插件
┃ ┗ 📜plugins.ts # 插件相关,获取构建配置的 plugins 字段
┃
┣ 📂utils # 存放本模块用到的公共方法
┃ ┣ 📜formatVar.ts # 变量名格式转换方法,如驼峰式,连字符式等
┃ ┣ 📜index.ts # 公共方法统一出口
┃ ┣ 📜json.ts # JSON 文件的读写
┃ ┣ 📜resolvePath.ts # 路径的处理方法
┃ ┗ 📜typeCheck.ts # 判断对象类型的方法
┃
┗ 📜index.ts # 模块出口
generateConfig - 获取构建配置的主方法
我们在 src/generateConfig/index.ts
中实现 generateConfig
的主体方法:
- 首先要处理自定义的构建选项
options
,并且读取子包的package.json
。它们将决定生成构建配置的具体行为。 - 生成构建配置的整体过程是比较复杂的,于是我们将其拆分成三部分:
- 与产物相关的配置 :
build.lib
- 与依赖相关的配置 :
build.rollupOptions.external
- 与插件相关的配置 :
plugins
- 与产物相关的配置 :
- 最后为了使我们的打包体系具有扩展性,我们还要将初步生成的构建配置与用户自定义的
Vite
配置做一个深合并,得到最终构建配置。
ts
// packages/build/src/generateConfig/index.ts
import { mergeConfig, UserConfig } from 'vite';
import { PackageJson } from 'type-fest';
import { readJsonFile, absCwd } from '../utils';
import { getOptions, GenerateConfigOptions } from './options';
import { getPlugins } from './plugins';
import { getExternal } from './external';
import { getLib } from './lib';
/**
* 生成 Vite 构建配置
* @param customOptions 自定义构建选项
* @param viteConfig 自定义 vite 配置
*/
export async function generateConfig(
customOptions?: GenerateConfigOptions,
viteConfig?: UserConfig,
) {
/** 获取配置选项 */
const options = getOptions(customOptions);
// 获取每个子包的 package.json 对象
const packageJson = await readJsonFile<PackageJson>(absCwd('package.json'));
// 生成产物相关配置 build.lib
const libOptions = getLib(packageJson, options);
// 生成依赖外部化相关配置 build.rollupOptions.external
const external = getExternal(packageJson, options);
// 插件相关,获取构建配置的 plugins 字段
const plugins = getPlugins(packageJson, options);
// 拼接各项配置
const result: UserConfig = {
plugins,
build: {
...libOptions,
rollupOptions: {
external,
},
},
};
// 与自定义 Vite 配置深度合并,生成最终配置
return mergeConfig(result, viteConfig || {}) as UserConfig;
}
// 导出其他模块
export * from './plugins';
export * from './options';
export * from './lib';
export * from './external';
export * from './pluginMoveDts';
export * from './pluginSetPackageJson';
构建选项定义
generateConfig
方法中使用到的构建选项 GenerateConfigOptions
是在 src/generateConfig/options.ts
中定义的:
- 每一个构建选项的含义可以参考对应的注释;
defaultOptions
用于设置构建选项的缺省值;getOptions
用于合并自定义选项与缺省选项,生成完整的构建选项;
ts
// packages/build/src/generateConfig/options.ts
import { PackageJson } from 'type-fest';
import type { GenerateConfigPluginsOptions } from './plugins';
/** 自定义构建选项 */
export interface GenerateConfigOptions extends GenerateConfigPluginsOptions {
/**
* 代码入口
* @default 'src/index.ts'
*/
entry?: string;
/**
* 产物输出路径,同:https://cn.vitejs.dev/config/build-options.html#build-outdir
* @default 'dist'
*/
outDir?: string;
/**
* 生成的文件名称,
*
* 默认情况下取 package 包名,转换为 kebab-case,如:@openx/request -> openx-request
*
* 当产物为 umd 格式时,驼峰化后的 fileName 会作为全局变量名,如:openx-request -> openxRequest
*/
fileName?: string;
/**
* 打包模式
* - package - 常规构建。会将所有依赖外部化处理,打包出适用于构建场景的 `es`、`umd` 格式产物。并在构建结束后将产物路径回写入 package.json 的入口字段中。
* - full - 全量构建。大部分依赖都不做外部化处理,打包出适用于非构建场景的 `umd` 格式产物。不参与 d.ts 的移动;不参与构建完成后的产物路径回写。
* - full-min - 在全量构建的基础上,将产物代码混淆压缩,并生成 sourcemap 文件。
* @default 'package'
*/
mode?: 'package' | 'full' | 'full-min';
/**
* 是否将 d.ts 类型声明文件的产物从集中目录移动到产物目录,并将类型入口回写到 package.json 的 types 字段。
*
* 必须在 mode 为 packages 时生效。
*
* 输入 tsc 编译生成 d.ts 文件时所读取的 tsconfig 文件的路径。
* @default ''
*/
dts?: string;
/**
* 完成构建后,准备回写 package.json 文件前对其对象进行更改的钩子。
*
* 必须在 mode 为 packages 时生效。
*/
onSetPkg?: (pkg: PackageJson) => void | Promise<void>;
}
/** 构建选项的默认值 */
export function defaultOptions(): Required<GenerateConfigOptions> {
return {
entry: 'src/index.ts',
outDir: 'dist',
fileName: '',
mode: 'package',
dts: '',
onSetPkg: () => {},
pluginVue: false,
pluginInspect: false,
pluginVisualizer: false,
pluginReplace: false,
};
}
/** 解析构建选项 */
export function getOptions(options?: GenerateConfigOptions): Required<GenerateConfigOptions> {
return {
...defaultOptions(),
...options,
};
}
ts
// packages/build/src/generateConfig/plugins.ts
/** 预设插件相关配置选项 */
export interface GenerateConfigPluginsOptions {
/**
* 是否启用 @vitejs/plugin-vue 进行 vue 模板解析。配置规则如下,对于其他插件也适用。
* - false / undefined 不启用该插件
* - true 启用该插件,采用默认配置
* - Options 启用该插件,应用具体配置
* @default false
*/
pluginVue?: boolean | VueOptions;
/**
* 是否启用 vite-plugin-inspect 进行产物分析。
* @default false
*/
pluginInspect?: boolean | InspectOptions;
/**
* 是否启用 rollup-plugin-visualizer 进行产物分析。
* @default false
*/
pluginVisualizer?: boolean | PluginVisualizerOptions;
/**
* 是否启用 @rollup/plugin-replace 进行产物内容替换。
* @default false
*/
pluginReplace?: boolean | RollupReplaceOptions;
}
getPkgInfo - 实现 package.json 的读写
package.json
的读写不一定需要自己实现,可以集成 npm 包 read-pkg-up 以及 write-pkg 分别处理。
但是我们的实例中打算自己实现,一方面因为 package.json
的读写并不复杂,另外也是因为这两个包只支持 esm 模块 (package.json
中声明了 "type": "module"
),会导致 Vite
加载配置文件时出现错误(除非用 .mts
文件)。
我们在 src/utils/json.ts
中实现读写方法:
ts
// packages/build/src/utils/json.ts
import {
readFile,
writeFile,
} from 'node:fs/promises';
/**
* 从文件中读取出 JSON 对象
* @param filePath 文件路径
* @returns JSON 对象
*/
export async function readJsonFile<
T extends Record<string, any> = Record<string, any>,
>(filePath: string): Promise<T> {
const buffer = await readFile(filePath, 'utf-8');
return JSON.parse(buffer);
}
/**
* 将 JSON 对象写入文件
* @param filePath 文件路径
* @param rests {@link JSON.stringify} 的参数
*/
export async function writeJsonFile(filePath: string, ...rests: Parameters<typeof JSON.stringify>) {
await writeFile(filePath, JSON.stringify(...rests), 'utf-8');
}
我们注意到读取 JSON 文件的方法 readJsonFile
是具有泛型参数的,这个泛型负责指定返回对象的类型。为了类型安全,我们需要 package.json
配置对象的类型定义。这里推荐安装 npm 包 type-fest,它是一个纯类型库,除了包含 package.json
、tsconfig.json
这种复杂配置对象的类型声明,还包括了大量的 TypeScript
类型运算方法。
bash
pnpm --filter @openxui/build i -S type-fest
进而在 src/generateConfig/index.ts
中,我们得以从 type-fest
中导出 PackageJson
接口作为泛型参数传入 readJsonFile
,使读取到的 package.json
对象具有完整的类型:
ts
// packages/build/src/generateConfig/index.ts
// ...
import { PackageJson } from 'type-fest';
export async function generateConfig(/** ... */) {
// ...
const packageJson = await readJsonFile<PackageJson>(absCwd('package.json'));
// ...
}
其他工具方法的实现
除了 package.json
的读写外,我们还需要很多其他的工具方法。首先就是实现对象类型检查的相关方法:src/utils/typeCheck.ts
。
ts
// packages/build/src/utils/typeCheck.ts
export function isObjectLike(val: unknown): val is Record<any, any> {
return val !== null && typeof val === 'object';
}
export function isFunction(val: unknown): val is Function {
return typeof val === 'function';
}
相对路径与绝对路径的计算也是高频率使用的方法。例如我们要获取每个子包的 package.json
文件路径就需要使用 absCwd
方法,因为通过 pnpm --filter xxx
为每个子包执行任务时,脚本执行目录 process.cwd()
会变成子包的根目录,而不是整个项目的根目录。
另外,我们的路径解析工具还会处理 Windows
和 Linux
系统下文件分隔符的差异。
我们在 src/utils/resolvePath.ts
中实现路径解析相关方法。
ts
// packages/build/src/utils/resolvePath.ts
import { relative, resolve, sep } from 'node:path';
/** 给予一个基础路径,获取到一个以此为基准计算绝对路径的方法 */
export function usePathAbs(basePath: string) {
return (...paths: string[]) => normalizePath(resolve(basePath, ...paths));
}
/** 获取相对于当前脚本执行位置的绝对路径 */
export const absCwd = usePathAbs(process.cwd());
/** 给予一个基础路径,获取到一个以此为基准计算相对路径的方法 */
export function usePathRel(basePath: string) {
return (path: string, ignoreLocalSignal: boolean = true) => {
const result = normalizePath(relative(basePath, path));
if (result.slice(0, 2) === '..') {
return result;
}
return ignoreLocalSignal ? result : `./${result}`;
};
}
/** 获取相对于当前脚本执行位置的相对路径 */
export const relCwd = usePathRel(process.cwd());
/** 抹平 Win 与 Linux 系统路径分隔符之间的差异 */
function normalizePath(path: string) {
if (sep === '/') {
return path;
}
return path.replace(new RegExp(`\\${sep}`, 'g'), '/');
}
我们的产物文件名默认情况下是通过 package.json
中的 name
字段生成的,例如:@openxui/build => openxui-build
。因此我们还需要实现变量名转换的工具方法。
lodash 提供了这些方法,但是由于这个库并不提供兼容性产物------lodash
包只支持 cjs
,lodash-es
包只支持 esm
,容易导致 vite.config
出现依赖解析错误,因此我们选择在 src/utils/formatVar.ts
自己实现。
ts
// packages/build/src/utils/formatVar.ts
function splitVar(varName: string) {
const reg = /[A-Z]{2,}(?=[A-Z][a-z]+|[0-9]|[^a-zA-Z0-9])|[A-Z]?[a-z]+|[A-Z]|[0-9]/g;
return varName.match(reg) || <string[]>[];
}
/** 将变量名转换为肉串形式:@openxui/build -> openxui-build */
export function kebabCase(varName: string) {
const nameArr = splitVar(varName);
return nameArr.map((item) => item.toLowerCase()).join('-');
}
/** 将变量名转换为驼峰形式:@openxui/build -> openxuiBuild */
export function camelCase(varName: string, isFirstWordUpperCase = false) {
const nameArr = splitVar(varName);
return nameArr.map((item, index) => {
if (index === 0 && !isFirstWordUpperCase) {
return item.toLowerCase();
}
return item.charAt(0).toUpperCase() + item.slice(1).toLowerCase();
}).join('');
}
最后,我们建立 src/utils/index.ts
为工具方法提供统一出口。
ts
// packages/build/src/utils/index.ts
export * from './formatVar';
export * from './typeCheck';
export * from './json';
export * from './resolvePath';
常规构建与全量构建
在上文中,我们提到了常规构建 与全量构建 的两个概念,我们在 generateConfig
接口中定义的 mode
字段用于区分两种构建行为:
- 常规构建 :
- 对应
mode
的值为package
。 - 产物适用于构建场景,必须配合包管理器使用。
- 构建
umd
、cjs
格式的产物。 - 构建完成后要将产物路径回写入
package.json
的入口字段。 - 所有的依赖都要外部化处理。
- 不混淆压缩产物代码,不生成 sourcemap。
- 典型
Vite
配置案例:
- 对应
ts
export default defineConfig({
build: {
lib: {
entry: './src/index.ts',
formats: ['es', 'umd'],
fileName: (format) => `pkg-name.${format}.js`
}
rollupOptions: {
external: [
...Object.keys(packageJson['dependencies']),
...Object.keys(packageJson['peerDependencies']),
],
},
}
})
- 全量构建 :
- 对应
mode
的值为full
或full-min
。 - 产物适用于非构建场景,无需包管理器,支持浏览器环境直接引入。
- 只产出
umd
格式产物。 - 构建完成后不回写
package.json
。 - 只外部化
vue
这样的peerDependencies
(上游框架),其他依赖项都要打包进来。 - 当
mode
为full-min
时,在全量构建的基础上,混淆压缩产物代码、生成 sourcemap。 - 典型
Vite
配置案例:
- 对应
ts
export default defineConfig({
build: {
lib: {
entry: './src/index.ts',
formats: ['umd'],
// 当 mode 为 full-min 时,后缀为 .full.min.js
fileName: () => `pkg-name.full.js`
},
// 当 mode 为 full-min 时为 true,压缩产物代码
minify: false,
// 当 mode 为 full-min 时为 true,生成 sourcemap 文件
sourcemap: false,
rollupOptions: {
external: [
...Object.keys(packageJson['peerDependencies']),
],
},
}
})
因此,在后续拼接构建配置的过程中,我们要注意根据不同的构建模式,采取不同的构建行为。
产物格式 build.lib
generateConfig
将整个拼接构建配置的过程按照配置类别划分成了三部分,其中 getLib
方法负责生成产物格式相关的配置项 build.lib
,在 src/generateConfig/lib.ts
中实现:
ts
// packages/build/src/generateConfig/lib.ts
import { PackageJson } from 'type-fest';
import { LibraryOptions, LibraryFormats, BuildOptions } from 'vite';
import { statSync } from 'node:fs';
import { join } from 'node:path';
import {
kebabCase,
camelCase,
absCwd,
relCwd,
} from '../utils';
import { getOptions, GenerateConfigOptions } from './options';
/**
* 获取 build.lib 产物相关配置
* @param packageJson package.json 文件内容
* @param options 构建选项
*/
export function getLib(
packageJson: PackageJson = {},
options: GenerateConfigOptions = {},
): Pick<BuildOptions, 'lib' | 'minify' | 'sourcemap' | 'outDir' | 'emptyOutDir'> {
const {
entry,
outDir,
mode,
fileName,
} = getOptions(options);
// 文件名称,默认取 package.json 的 name 字段转换成 kebab-case:@openxui/build => openxui-build
const finalName = fileName || kebabCase(packageJson.name || '');
const libOptions: LibraryOptions = {
entry,
// 全量构建只生产 umd 产物
formats: mode === 'package' ? ['es', 'umd'] : ['umd'],
name: camelCase(finalName),
fileName: (format) => {
const formatName = format as LibraryFormats;
return getOutFileName(finalName, formatName, mode);
},
};
return {
lib: libOptions,
// full-min 模式下全量构建,需要混淆代码,生成 sourcemap 文件,且不清空产物目录
minify: mode === 'full-min' ? 'esbuild' : false,
sourcemap: mode === 'full-min',
emptyOutDir: mode === 'package',
outDir,
};
}
/**
* 获取产物文件名称
* @param fileName 文件名称
* @param format 产物格式
* @param buildMode 构建模式
*/
export function getOutFileName(fileName: string, format: LibraryFormats, buildMode: GenerateConfigOptions['mode']) {
const formatName = format as ('es' | 'umd');
const ext = formatName === 'es' ? '.mjs' : '.umd.js';
let tail: string;
// 全量构建时,文件名后缀的区别
if (buildMode === 'full') {
tail = '.full.js';
} else if (buildMode === 'full-min') {
tail = '.full.min.js';
} else {
tail = ext;
}
return `${fileName}${tail}`;
}
interface EntryInfo {
/** 子包源码入口文件的绝对路径 */
abs: string;
/** 子包源码入口文件相对于脚本执行位置的路径 */
rel: string;
/** 子包源码入口是不是文件 */
isFile: boolean;
}
/**
* 解析子包源码入口
* @param entry 源码入口路径
* @returns 子包源码入口信息,解析结果
*/
export function resolveEntry(entry: string): EntryInfo {
/** 入口绝对路径 */
const absEntry = absCwd(entry);
/** 入口是否为文件 */
const isEntryFile = statSync(absEntry).isFile();
/** 入口文件夹绝对路径 */
const absEntryFolder = isEntryFile ? join(absEntry, '..') : absEntry;
return {
abs: absEntry,
rel: relCwd(absEntryFolder),
isFile: isEntryFile,
};
}
依赖外部化 build.rollupOptions.external
getExternal
方法负责生成依赖外部化相关的配置项 build.rollupOptions.external
,在 src/generateConfig/external.ts
中实现。
在常规构建中,getExternal
会将 package.json
中所有的依赖项都推入 build.rollupOptions.external
;全量构建中,则只推入 peerDependencies
部分。
ts
// packages/build/src/generateConfig/external.ts
import { PackageJson } from 'type-fest';
import { getOptions, GenerateConfigOptions } from './options';
/**
* 获取 build.rollupOptions.external 依赖外部化相关的配置
* @param packageJson package.json 文件内容
* @param options 构建选项
*/
export function getExternal(
packageJson: PackageJson = {},
options: GenerateConfigOptions = {},
) {
const {
dependencies = {},
peerDependencies = {},
} = packageJson;
const { mode } = getOptions(options);
const defaultExternal: (string | RegExp)[] = [
// 将所有 node 原生模块都进行外部化处理
/^node:.*/,
];
const toReg = (item: string) => new RegExp(`^${item}`);
return defaultExternal.concat(
Object.keys(peerDependencies).map(toReg),
// 全量构建时,依赖不进行外部化,一并打包进来
mode === 'package' ? Object.keys(dependencies).map(toReg) : [],
);
}
插件管理
generateConfig
的最后一部分是插件管理相关的配置,按照我们的规划,插件管理又分为两个部分:
管理预设插件
第一部分,我们集成了一些常用的插件作为预设,通过 GenerateConfigOptions
继承的 GenerateConfigPluginsOptions
选项快速设置这些插件是否应用到构建配置中。
我们选择了以下四款插件作为预设:
- @vitejs/plugin-vue:提供 Vue 3 单文件组件支持。
- vite-plugin-inspect:检查
Vite
构建过程的中间状态,为调试Vite
配置提供支持。 - rollup-plugin-visualizer:可视化分析构建结果中各个模块的空间占用情况。
- @rollup/plugin-replace:在构建过程中替换源码中的内容。
当然,要给 build
包安装好这些插件。
bash
pnpm --filter @openxui/build i -S @vitejs/plugin-vue vite-plugin-inspect rollup-plugin-visualizer @rollup/plugin-replace
每一个插件预设的选项,都按照以下规则处理:
- 选项为 false 或 undefined,不集成插件。
- 选项为 true,以默认配置集成插件。
- 选项为 object 对象,则将此对象作为插件配置集成插件。
具体实现如下:
ts
// packages/build/src/generateConfig/plugins.ts
import inspect, { Options as InspectOptions } from 'vite-plugin-inspect';
import { visualizer, PluginVisualizerOptions } from 'rollup-plugin-visualizer';
import vue, { Options as VueOptions } from '@vitejs/plugin-vue';
import replace, { RollupReplaceOptions } from '@rollup/plugin-replace';
import { PluginOption } from 'vite';
import { PackageJson } from 'type-fest';
import { isObjectLike } from '../utils';
import type { GenerateConfigOptions } from './options';
import { pluginSetPackageJson } from './pluginSetPackageJson';
import { pluginMoveDts } from './pluginMoveDts';
/** 预设插件相关配置选项 */
export interface GenerateConfigPluginsOptions {
/**
* 是否启用 @vitejs/plugin-vue 进行 vue 模板解析。配置规则如下,对于其他插件也适用。
* - false / undefined 不启用该插件
* - true 启用该插件,采用默认配置
* - Options 启用该插件,应用具体配置
* @default false
*/
pluginVue?: boolean | VueOptions;
/**
* 是否启用 vite-plugin-inspect 进行产物分析。
* @default false
*/
pluginInspect?: boolean | InspectOptions;
/**
* 是否启用 rollup-plugin-visualizer 进行产物分析。
* @default false
*/
pluginVisualizer?: boolean | PluginVisualizerOptions;
/**
* 是否启用 @rollup/plugin-replace 进行产物内容替换。
* @default false
*/
pluginReplace?: boolean | RollupReplaceOptions;
}
/**
* 获取预设插件配置
* @param options 预设插件相关配置选项
*/
export function getPresetPlugins(options: GenerateConfigPluginsOptions = {}) {
const result: PluginOption[] = [];
result.push(
getPresetPlugin(options, 'pluginVue', vue),
getPresetPlugin(options, 'pluginInspect', inspect),
getPresetPlugin(options, 'pluginVisualizer', visualizer),
getPresetPlugin(options, 'pluginReplace', replace),
);
return result;
}
/**
* 获取完整的插件配置
* @param packageJson package.json 文件内容
* @param options 构建选项
*/
export function getPlugins(
packageJson: PackageJson = {},
options: GenerateConfigOptions = {},
) {
const { mode, dts } = options;
const result = getPresetPlugins(options);
if (mode === 'package') {
// 常规构建的情况下,集成自定义插件,回写 package.json 的入口字段
result.push(pluginSetPackageJson(packageJson, options));
if (dts) {
// 常规构建的情况下,集成自定义插件,移动 d.ts 产物
result.push(pluginMoveDts(options));
}
}
return result;
}
/**
* 处理单个预设插件
* @param options 预设插件相关配置选项
* @param key 目标选项名称
* @param plugin 对应的插件函数
* @param defaultOptions 插件默认选项
*/
export function getPresetPlugin<K extends keyof GenerateConfigPluginsOptions>(
options: GenerateConfigPluginsOptions,
key: K,
plugin: (...args: any[]) => PluginOption,
defaultOptions?: GenerateConfigPluginsOptions[K],
) {
const value = options[key];
if (!value) {
return null;
}
return plugin(
isObjectLike(value) ? value : defaultOptions,
);
}
实现构建完成后修改 package.json 的插件
第二部分,我们要自己实现 Vite
插件来扩展构建过程中的行为。
Vite
插件的原理非常简单:Vite
在构建流程中提供了多个钩子,所谓钩子就是在构建过程中的重要节点执行的函数,插件开发者可以在钩子函数中获取到配置对象、上下文、产物等数据,对它们进行读写,从而达到影响构建过程的目的。
一个典型的 Vite
插件是一个包含插件名称 name
以及各种 Vite
钩子函数的对象:
ts
export default function myPlugin(options) {
return {
name: 'my-plugin',
configResolved(config) {
// ...
},
transform(code, id) {
// ...
},
resolveId(id) {
// ...
},
load(id) {
// ...
},
closeBundle() {
// ...
}
}
}
Vite
的各种钩子的执行顺序可以参考下图(图片来源:掘金小册:深入浅出 Vite):
想要更详细地了解如何编写 Vite
插件,可以进行下面的拓展阅读:
手把手教你开发一个快速、高性能、高质量压缩图片的 Vite 插件
回到我们的代码中,我们要在 src/generateConfig/pluginSetPackageJson.ts
中实现一个自定义插件,用于在构建完成后,将产物路径正确写入 package.json
的入口字段 (关于入口字段,回顾:1. 基于 pnpm 搭建 monorepo 工程目录结构)。
插件的实现比较容易,只需要在 Vite
构建结束阶段的钩子 closeBundle
中,对之前读取到的 package.json
对象进行修改,然后再写回文件中即可:
ts
// packages/build/src/generateConfig/pluginSetPackageJson.ts
import { PluginOption } from 'vite';
import { PackageJson } from 'type-fest';
import { basename } from 'node:path';
import {
isFunction,
isObjectLike,
absCwd,
relCwd,
kebabCase,
writeJsonFile,
} from '../utils';
import { getOutFileName, resolveEntry } from './lib';
import { getOptions, GenerateConfigOptions } from './options';
/**
* 自定义插件,实现对 package.json 内容的修改与回写。
* @param packageJson package.json 文件内容
* @param options 构建选项
*/
export function pluginSetPackageJson(
packageJson: PackageJson = {},
options: GenerateConfigOptions = {},
): PluginOption {
const {
onSetPkg,
mode,
fileName,
outDir,
dts,
} = getOptions(options);
if (mode !== 'package') {
return null;
}
const finalName = fileName || kebabCase(packageJson.name || '');
return {
name: 'set-package-json',
// 只在构建模式下执行
apply: 'build',
async closeBundle() {
const packageJsonObj = packageJson || {};
// 将 types main module exports 产物路径写入 package.json
const exportsData: Record<string, any> = {};
// 获取并设置 umd 产物的路径
const umd = relCwd(
absCwd(outDir, getOutFileName(finalName, 'umd', mode)),
false,
);
packageJsonObj.main = umd;
exportsData.require = umd;
// 获取并设置 es 产物的路径
const es = relCwd(
absCwd(outDir, getOutFileName(finalName, 'es', mode)),
false,
);
packageJsonObj.module = es;
exportsData.import = es;
// 获取并设置 d.ts 产物的路径
if (dts) {
const dtsEntry = getDtsPath(options);
packageJsonObj.types = dtsEntry;
exportsData.types = dtsEntry;
}
if (!isObjectLike(packageJsonObj.exports)) {
packageJsonObj.exports = {};
}
Object.assign(packageJsonObj.exports, { '.': exportsData });
// 支持在构建选项中的 onSetPkg 钩子中对 package.json 对象进行进一步修改
if (isFunction(onSetPkg)) {
await onSetPkg(packageJsonObj);
}
// 回写入 package.json 文件
await writeJsonFile(absCwd('package.json'), packageJsonObj, null, 2);
},
};
}
/** 根据源码入口和产物目录,计算出 d.ts 类型声明的入口的相对地址 */
function getDtsPath(options: GenerateConfigOptions = {}) {
const {
entry,
outDir,
} = getOptions(options);
const { rel, isFile } = resolveEntry(entry);
/** 入口文件 d.ts 产物名称 */
const entryFileName = isFile ? basename(entry).replace(/\..*$/, '.d.ts') : 'index.d.ts';
return relCwd(
absCwd(outDir, rel, entryFileName),
false,
);
}
实现 d.ts 移动的插件
先前,我们在 2. 在 monorepo 模式下集成 Vite 和 TypeScript - 下 中采用了 vue-tsc
整体生成 d.ts
类型声明产物,再通过 mv-dts.ts
脚本将产物分别移动到各个子包中。
这里,我们在 src/generateConfig/pluginMoveDts.ts
中通过自定义插件,将移动 d.ts
产物的过程集成到每个子包的构建过程中。
之前,我们通过 npx vue-tsc -p tsconfig.src.json --composite false --declaration --emitDeclarationOnly
指令,读取了 tsconfig.src.json
文件,生成了所有子包的 d.ts
产物,集中到了根目录的 dist
下面。
由于 dist
中产物的目录结构与我们的子包目录结构相似,我们利用这一点,通过计算相对路径的方式,可以准确定位产物移动的源路径和目标路径。 为了顺利计算出相对路径,我们的插件也需要依赖 typescript
中的方法,读取 tsconfig.src.json
文件中的根路径 rootDir
和 outDir
(tsc
产物路径) 字段作为计算依据。
当然,为了使用 typescript
中的方法,还是要给 build
包增添相应的依赖。
bash
pnpm --filter @openxui/build i -S typescript
明确了思路以后,插件的实现并不复杂,同样只需要使用到构建结束阶段的钩子 closeBundle
。
ts
// packages/build/src/generateConfig/pluginMoveDts.ts
import { PluginOption } from 'vite';
import { getParsedCommandLineOfConfigFile, sys } from 'typescript';
import { cp } from 'node:fs/promises';
import { getOptions, GenerateConfigOptions } from './options';
import {
absCwd,
usePathAbs,
usePathRel,
} from '../utils';
import { resolveEntry } from './lib';
/** 自定义插件,将 d.ts 产物从集中目录移动到子包的产物目录 */
export function pluginMoveDts(options: GenerateConfigOptions = {}): PluginOption {
const {
entry,
outDir,
mode,
dts,
} = getOptions(options);
if (mode !== 'package' || !dts) {
return null;
}
// 解析用于生成 d.ts 总体产物的 tsconfig 文件,并获取解析后的配置
const tsconfigs = getParsedCommandLineOfConfigFile(dts, {}, sys as any);
if (!tsconfigs) {
throw new Error(`Could not find tsconfig file: ${dts}`);
}
// 解析出来的路径都是绝对路径
const { rootDir, outDir: tsOutDir } = tsconfigs.options;
if (!rootDir || !tsOutDir) {
throw new Error(`Could not find rootDir or outDir in tsconfig file: ${dts}`);
}
const relRoot = usePathRel(rootDir);
const absRoot = usePathAbs(rootDir);
/** 当前包相对于根目录的路径 */
const relPackagePath = relRoot(process.cwd());
// 源码入口的相对路径
const { rel: relEntryPath } = resolveEntry(entry);
return {
name: 'move-dts',
apply: 'build',
async closeBundle() {
const source = absRoot(tsOutDir, relPackagePath, relEntryPath);
const target = absCwd(outDir, relEntryPath);
try {
// 移动产物
await cp(source, target, {
force: true,
recursive: true,
});
} catch (err) {
// eslint-disable-next-line no-console
console.log(`[${relPackagePath}]: failed to move dts!`);
// eslint-disable-next-line no-console
console.error(err);
}
},
};
}
集成打包体系
到此,我们的打包体系就已经全部实现了,最后在 src/index.ts
的出口中导出一下:
ts
// packages/build/src/index.ts
export * from './generateConfig';
下面,我们开始进入集成阶段,来让我们的整套组件库都应用上最新的成果。
构建纯 JS / TS 模块
推荐在 packages/build
目录下创建 build.config.ts
文件,导出一些构建预设:
ts
// packages/build/build.config.ts
import { UserConfig } from 'vite';
import {
generateConfig as baseGenerateConfig,
GenerateConfigOptions,
} from './src';
import { absCwd } from './src/utils';
/** 构建普通的纯 TS / JS 模块的预设 */
export function generateConfig(
customOptions?: GenerateConfigOptions,
viteConfig?: UserConfig,
) {
return baseGenerateConfig({
// 指定 d.ts 文件相关 tsconfig 的位置
dts: absCwd('../../tsconfig.src.json'),
...customOptions,
}, viteConfig);
}
之后我们以 build
包的自我构建为例,在 vite.config.ts
中引入 build.config.ts
中的预设即可:
ts
// packages/build/vite.config.ts
import { generateConfig } from './build.config';
export default generateConfig();
之后测试一下打包的效果,不要忘记先整体生成 d.ts
产物,否则我们的 d.ts
移动插件 pluginMoveDts
将会报错。
bash
pnpm run type:src
pnpm --filter @openxui/build run build
成功完成构建;构建产物、d.ts 产物均在正确的路径;package.json
的入口字段也指向正确。
接着,我们可以按照同样的方式构建 @openxui/shared
包。
ts
// packages/shared/vite.config.ts
import { generateConfig } from '../build/build.config';
export default generateConfig();
可以针对 vite.config
文件关闭 eslint
的禁止用相对路径引用其他包的规则(回顾:3. 集成 lint 代码规范工具):
diff
// .eslintrc.js
module.exports = defineConfig({
// ...
overrides: [
// 对于 vite 和 vitest 的配置文件,不对 console.log 进行错误提示
{
files: [
'**/vite.config.*',
'**/vitest.config.*',
'scripts/**/*',
],
rules: {
+ 'import/no-relative-packages': 'off',
'no-console': 'off',
},
},
],
})
构建 Vue 组件模块
由于构建 Vue
组件需要 @vitejs/plugin-vue
的支持,所以我们有必要在 packages/build/build.config.ts
中再建立一个构建 Vue
组件专用的预设:
ts
// packages/build/build.config.ts
import { UserConfig } from 'vite';
import {
generateConfig as baseGenerateConfig,
GenerateConfigOptions,
} from './src';
import { absCwd } from './src/utils';
/** 构建普通的纯 TS / JS 模块的预设 */
export function generateConfig(/** ... */) {
// ...
}
/** 构建 Vue 组件模块的预设 */
export function generateVueConfig(
customOptions?: GenerateConfigOptions,
viteConfig?: UserConfig,
) {
return generateConfig({
pluginVue: true,
...customOptions,
}, viteConfig);
}
我们以 @openxui/button
为例,应用新的打包体系,其他组件可以按照相同的方式集成。
ts
// packages/build/vite.config.ts
import { generateVueConfig } from '../build/build.config';
export default generateVueConfig();
构建组件库主包
组件库主包 @openxui/ui
是所有组件库模块的总出口(回顾:2. 在 monorepo 模式下集成 Vite 和 TypeScript - 上),我们不仅要进行常规构建,还要提供全量构建产物,这意味这我们要执行两次不同的构建行为。
我们利用 Vite
的 构建模式 特性,配合我们 GenerateConfigOptions
中的构建选项 mode
来实现。
首先先在 @openxui/ui
的 package.json
中定义三种不同的构建行为:
diff
// packages/ui/package.json
{
// ...
"scripts": {
- "build": "vite build",
+ "build:package": "vite build --mode package",
+ "build:full": "vite build --mode full",
+ "build:full-min": "vite build --mode full-min",
+ "build": "pnpm run build:package && pnpm run build:full && pnpm run build:full-min",
"test": "echo test"
},
// ...
}
在 vite.config.ts
中获取构建模式 mode
,传入到我们 generateVueConfig
的构建选项中。
ts
// packages/ui/vite.config.ts
import { defineConfig } from 'vite';
import { generateVueConfig } from '../build/build.config';
export default defineConfig(({ mode }) => generateVueConfig({ mode }));
我们试着运行组件库主包的构建指令:
bash
pnpm --filter @openxui/ui run build
可以看到三次不同的构建行为都成功串联配合了起来;构建产物也完全符合预期------ .full.js
文件中只外部化了 vue
,其他依赖内容都被悉数打包进来。
整体构建调整
为了适应我们新的打包体系,我们对之前的整体构建流程也做一些微调:
- 因为移动
d.ts
产物的过程已经集成到了各子包构建的过程中,所以我们可以删去之前的scripts/dts-mv.ts
脚本。 - 在
package.json
中也一并删去scripts/dts-mv.ts
相关的步骤。
diff
// package.json
{
// ...
"scripts": {
// ...
- "mv-type": "tsx ./scripts/dts-mv.ts",
- "build:ui": "pnpm run type:src && pnpm --filter ./packages/** run build && pnpm run mv-type"
+ "build:ui": "pnpm run type:src && pnpm --filter ./packages/** run build",
},
}
最后来一次完美的整体构建,结束本章的全部内容:
bash
pnpm run build:ui
结尾与资料汇总
按照惯例,我们在结尾梳理一下本章的思路:
- 我们首先明晰了定制自己的打包体系的目的:将重复部分抽取为预设、集中管理构建配置、提高自动化程度、支持复杂的构建行为。
- 在分析目的的过程中,我们提出了全量构建的概念。与产出构建场景专用产物的常规构建 不同,全量构建主要产出适用于非构建场景的产物。
- 之后,我们选取开源
monorepo
仓库作为案例,研究其构建过程:获取并遍历子包 -> 读取package.json
-> 拼接构建配置 -> 调用构建 API。 - 我们计划用
pnpm --filter
的能力实现子包的获取与遍历,用vite build
自然执行构建,因而明确了自己的打包体系只需要实现上述两个特性:读取package.json
、拼接构建配置。 - 之后,我们创建
@openxui/build
包,结合先前分析的思路以及我们的特殊需求,一步步实现了打包体系。我们的打包体系具有以下能力:
- 读取各包的
package.json
,自动用其中的重要字段合成构建配置。 - 配合
vue-tsc
,支持为各子包生成d.ts
类型声明产物。 - 结合产物信息,自动更新
package.json
文件。 - 支持不同的构建模式:常规构建与全量构建。
- 支持与原始
Vite
配置合并,具有高度拓展性。
- 最后使用全新的打包体系替代了先前零散的
vite.config
,使整体构建流程得到进一步优化。
官网与文档:
read-pkg-up: 读取 package.json 的工具方法
write-pkg: 写入 package.json 的工具方法
分享博文: