本节涉及的内容源码可在vue-pro-components c6 分支[1]找到,欢迎 star 支持!
本文是 基于Vite+AntDesignVue打造业务组件库[2] 专栏第 7 篇文章【在发布组件库之前,你需要先掌握构建和发布函数库】,聊聊怎么构建和发布一个函数库。
如上篇文章结语所述,开发组件和发布可用的组件之间还隔着一条鸿沟,这就是从开发环境到生产环境必经的路,也是组件库研发过程中最复杂的部分。要越过这条鸿沟,就必须掌握一些工程化能力。
然而,构建和发布组件库是一个较复杂的体系化的工程,构建组件库不仅要处理 js, ts,可能还要处理 jsx, tsx, 样式等内容,如果采用的开发框架是 Vue,你可能还需要处理 SFC 的 parse, transpile 等过程。总之,这中间会涉及很多种 DSL(领域特定语言)的处理,还要注意各个工序的顺序问题,这听起来似乎不是很简单的一件事,容易让初学者摸不着头脑。
为了打破这种迷茫,我们可以将构建整个组件库的工作拆解出来,选择从某一个方向切入,由点到面逐个突破,最终形成构建组件库的全局思维。那么最适合作为我们学习入口的当然是函数库的构建,因为它通常只涉及 JS/TS,这是我们最熟悉的领域。
截至到目前,我们在本专栏中实现的一些组件/函数/Hook等内容都还停留在源码层面,基本上是以.ts
, .tsx
, .vue
等形式存在的,并且我们可以发现,package.json
中的main
入口都是index.ts
。
而在我们的认知中,我们用的一些常见的库,它们提供的main
, module
等入口通常是xxx.js
,而不是用一个.ts
文件作为入口。
这并不是说,不能把 TS 之类的源码发布到 npm 上并作为引用入口,实际上只要使用依赖的项目方把构建的流程打通,也不是不可行。但是对于项目方来说,我引用一个依赖,就是要用标准化的东西,拿来即用,如果你让我自己把构建流程做出来,那我可能就不想用了。
简单的库还好说,可能接入 Webpack 或者 Vite 之类的工具就搞定了。但是对于一些复杂的库来说,从源码到输出标准化的制品会经历很多道工序,你不能寄希望于调用方把这个事情做了,因此库的维护者非常有必要做好构建工作。
一个典型的 npm 包,可能会在其package.json
中包含以下关键字段:
{
// ...省略部分字段
"main": "lib/index.js",
"module": "es/index.js",
"types": "types/index.d.ts",
"unpkg": "dist/index.js",
"jsdelivr": "dist/index.js",
"files": [
"dist",
"lib",
"es",
"types"
],
// ...剩余字段
}
main
字段指定。module
字段指定。@types/xxx
来提供类型声明。https://unpkg.com/vue-awesome-progress@1.9.7/dist/vue-awesome-progress.min.js
jsdelivr 也是类似的,只不过是路径前缀有点区别。
https://cdn.jsdelivr.net/npm/vue-awesome-progress@1.9.7/dist/vue-awesome-progress.min.js
同样地,以 vue-pro-components 这个包为例,之前讲解简单发布流程时也发布到了 npm,因此也可以通过 cdn 访问到。
https://unpkg.com/vue-pro-components@0.0.2/lib/vue-pro-components.js
由于先前没有写什么实际内容就以教程的形式发布了,纯属是浪费资源了。建议不要随意发布没有意义的包。
根据前面的叙述,我们可以知道,一个函数库大体上要提供符合 ESM, CJS, UMD 模块规范的制品。
从 TS 源码到 ESM, CJS, UMD 等规范下的制品,其实就是对应打包构建的过程了。
先画个图列举一下我们要做什么事情:
再确定哪些事情是串行的,哪些事情可以并行做。
仔细品味,不难想明白除了清理目录(dist, es, lib, types 等目录)的工作需要先行,其他的工作都可以并行执行(因为它们之间没有依赖关系)。所以,整个构建的任务流大概是这样的:
大概的流程梳理清楚后,就可以逐个实现任务,并且把所有任务有序组织起来。
在打包函数库这方面,rollup 是一个绝佳的选择。
yarn add -DW rollup
为了组织任务流,我们需要选用一个好用的工具,而 gulp 就是这个不二之选。
yarn add -DW gulp
gulp 默认采用的是 CJS 模块规范,这是执行 Node 脚本时的常规操作。
而 Rollup 默认支持 ES6 的配置写法,这是因为 Rollup Cli 内部会处理配置文件。
引用自 rollup 官网 Note: Rollup itself processes the config file, which is why we're able to use
export default
syntax – the code isn't being transpiled with Babel or anything similar, so you can only use ES2015 features that are supported in the version of Node.js that you're running.
一个是 CJS,一个是 ESM,这让两者的结合出现了一点阻碍。还好,gulp 4.x 版本也提供了使用 ESM 编写任务的指导性文档,
并且推荐我们采用gulpfile.babel.js
来组织我们的配置文件,这背后依赖了@babel/register
,而@babel/register
底层是用到了 NodeJS 的 require hook。
引用自 babel 官网
@babel/register
uses Node'srequire()
hook system to compile files on the fly when they are loaded.
其他可选的方案还有 sucrase/register。
基于此,我们可以做到统一使用 ESM 来组织构建流程。
因为在开始新的构建工作之前可能存在上一次构建的产物,所以对于构建产生的 dist, es, lib, types 等目录,我们需要将其清理干净,这本质上是文件操作,但是在 gulp 生态中有很多插件可以让我们选择,就没必要自己手撸一个文件清理的流程了。这里我们选用gulp-clean[4]。
文件处理最重要的是把路径设置正确,否则一波类似rm -rf
的操作,可能就真的啥都没了,特别是当你写完的代码还没提交到 git 时,一波命令行操作那就是血与泪的教训(本人亲身经历,二次撸码真的痛苦)。
我们把常用的路径放在build/path.js
中维护。
import { resolve } from "path";
// 工程根目录
export const ROOT_PATH = resolve(__dirname, "../");
// utils 包的根目录
export const UTILS_PATH = resolve(ROOT_PATH, "./packages/utils");
接着就可以写 gulpfile 了。
import { src } from "gulp";
import clean from "gulp-clean";
import { UTILS_PATH } from "./build/path";
// 待清理的目标目录
const ARTIFACTS_DIRS = ["dist", "es", "lib", "types"]
// 把清理的过程稍微封装下,便于各个子包都能用上
function cleanDir(dir = "dist", options = {}) {
return src(dir, { allowEmpty: true, ...options }).pipe(clean({ force: true }))
}
// 暴露出清理 utils 包产物目录的方法
export const cleanUtils = cleanDir.bind(null, ARTIFACTS_DIRS, { cwd: UTILS_PATH })
我们目前还没有实现打包过程,可以先加几个临时文件测试一下。
构建工作就是 Rollup 的舞台了,我们把各个构建的子任务用 Rollup 组织好后让 gulp 去调用即可。
我们先看看 Rollup 会干什么,
Rollup is a module bundler for JavaScript which compiles small pieces of code into something larger and more complex, such as a library or application.
看这意思,应该是会把多个文件打包成一个 bundle。一个入口文件,引用了其他模块,模块下面可能还有引用其他的依赖,这会形成一个依赖图,最终根据 format 参数打包成一个符合指定模块规范的 bundle,这比较符合我们的常规思维。但是,对于库开发者来说,我不仅要打包出符合模块规范的内容,通常还要生成独立的文件,用于支持按需加载等场景。就像 lodash,它有很多个工具函数,打包后除了提供 bundle,也会提供很多独立的 js 模块,我们可以单独引用某一个模块,配合一些工具,还能做到按需引入。
凡事从易到难,我们还是先从最简单的生成 UMD bundle 开始。
由于我们的源码是用 ts 写的,所以要引入一个插件@rollup/plugin-typescript[5]。
入口文件就用packages/utils/src/index.ts
即可,它引用了其他独立的模块,这样就能把 utils 的各个工具函数都打包到一起。
// packages/utils/src/index.ts
export * from './install'
export * from './fullscreen'
考虑到要用 gulp 集成,我们采用的是 Rollup 提供的 Javascript API 来编写构建流程。
import { rollup } from 'rollup'
import rollupTypescript from '@rollup/plugin-typescript'
import { resolve } from 'path'
import { UTILS_PATH } from './path'
export const buildBundle = async () => {
// 调用 rollup api 得到一个 bundle 对象
const bundle = await rollup({
input: resolve(UTILS_PATH, 'src/index.ts'),
plugins: [rollupTypescript()],
})
// 根据 name, format. dir 等参数调用 bundle.write 输出到磁盘
await bundle.write({
name: 'VpUtils',
format: 'umd',
dir: resolve(UTILS_PATH, 'dist'),
sourcemap: true
})
}
接着,就可以把这个buildBundle
函数集成到 gulp 中起来使用了。gulp 是支持通过 Promise 来标记任务完成信号的,同样也可以用异步函数。
import { series, src } from "gulp";
// ...省略其他代码
// 先 cleanUtils,再 buildBundle,通过 series 按顺序执行
export const buildUtils = series(cleanUtils, buildBundle);
测试一下效果,发现已经可以构建出符合 UMD 模块规范的产物了,第一小步算是迈出去了。
接下来就是看怎么构建符合 ESM 和 CJS 规范的产物,同时要支持多文件独立输出,以支持按需加载。
要输出多个文件,其实可以考虑指定多个构建入口,以单个模块作为入口,就能输出这个模块对应的构建结果。Rollup 本身也支持指定数组或对象形式的 input 参数作为多入口,这和 Webpack 也是相似的。
我们用到一个fast-glob[6],这可以让我们避免繁琐的文件列举。
import fastGlob from 'fast-glob'
import { UTILS_PATH } from './path'
// 通过 fast-glob 快速得到多入口,避免繁琐的文件列举
const getInputs = async (glob = 'src/**/*.ts') => {
return await fastGlob(glob, {
cwd: UTILS_PATH,
absolute: true,
onlyFiles: true,
ignore: ['node_modules'],
})
}
接着就是把构建流程写好。其实构建 ESM 和 CJS 模块有很多相似性,因为它们的输入都是一样的,只不过输出不一样。所以,我们可以在同一个函数buildModules
中把这两件事情一起做了。
export const buildModules = async () => {
// 得到多文件入口
const input = await getInputs()
// 得到公共的 bundle 对象
const bundle = await rollup({
input,
plugins: [rollupTypescript()],
})
// 用 Promise.all 标识:ESM 和 CJS 都完成了,才算 buildModules 完成
await Promise.all([
// 输出 ESM 到 es 目录
bundle.write({
format: 'esm',
dir: resolve(UTILS_PATH, 'es'),
}),
// 输出 CJS 到 lib 目录
bundle.write({
format: 'cjs',
dir: resolve(UTILS_PATH, 'lib'),
})
])
}
然后,我们可以在build/build-utils.js
新增一个startBuildUtils
函数,作为对外提供的调用接口。
startBuildUtils
函数通过 gulp 的 parallel
方法并行执行构建buildModules
和buildBundle
的任务。因为buildModules
内部是通过Promise.all
并行执行 ESM 和 CJS 的输出,所以本质上 ESM, CJS, UMD 模块的构建都是并行的,这也符合我们最开始的规划。
gulpfile.babel.js
可以改造为:
export const buildUtils = series(cleanUtils, startBuildUtils);
我们看看效果,可以发现生成的内容完全符合预期,
@vue-pro-components/utils/es/install
或者@vue-pro-components/utils/es/fullscreen
按需引入独立的模块。import { enterFullscreen } from "@vue-pro-components/utils"
。到这里,我们发现还缺少的就是类型声明了,我试着在buildBundle
时同时把declaration
给生成了,但是报了一个错,生成的 types 目录不能在bundle.write
指定的dir
目录之外。
把declarationDir
改为resolve(UTILS_PATH, './dist/types')
倒是可以,不过生成到 dist/types 目录下不符合我的预期。
于是我就考虑加一个buildTypes
方法用于单独生成类型声明。
export const buildTypes = async () => {
const bundle = await rollup({
input: resolve(UTILS_PATH, 'src/index.ts'),
plugins: [
rollupTypescript({
compilerOptions: {
rootDir: resolve(UTILS_PATH, "src"),
declaration: true,
declarationDir: resolve(UTILS_PATH, './types'),
emitDeclarationOnly: true,
},
}),
],
})
await bundle.write({
dir: resolve(UTILS_PATH, 'types'),
})
}
不过我发现,即便我配置了emitDeclarationOnly
,最终生成的 types 目录下还是有一个index.js
文件。
看了一下@rollup/plugin-typescript
的文档,发现是插件忽略了这部分配置。
来不及想为什么了,这里直接改用一个专门用于生成类型声明的插件rollup-plugin-dts[7],buildTypes
函数改造成如下:
export const buildTypes = async () => {
const input = await getInputs()
const bundle = await rollup({
input,
plugins: [dts()],
})
await bundle.write({
dir: resolve(UTILS_PATH, 'types'),
})
}
startBuildUtils
函数中也可以加入buildTypes
任务了。
export const startBuildUtils = parallel(buildModules, buildBundle, buildTypes)
构建的工作做好之后,就可以准备发布到 npm 上了。
首先将package/utils
的版本号修改一下,我们可以根据lerna version
的提示修改版本号。
接着运行package.json
中定义的publish:package
脚本,就可以发布到 npm 上了。
接着我们可以找个地方验证一下@vue-pro-components/utils
这个包是不是可以正常使用,在线 IDE 可能是最直观的。
由于某在线 IDE 的 iframe 没有 allow fullscreen 特性,我们需要手动给它修改一下。
效果这就有了:
本文主要介绍了一个函数库的构建和发布的基本流程,虽然打通了基本流程,但也还存在很多优化的空间,比如怎么把构建和发布的流程串起来,而不是一条接一条命令地手动执行。不过,以此为基础,我们就可以继续探索更为复杂的组件库的构建和发布流程了。如果您对我的专栏感兴趣,欢迎您订阅关注本专栏[8],接下来可以一同探讨和交流组件库开发过程中遇到的问题。
[1]
vue-pro-components c6 分支: https://github.com/cumt-robin/vue-pro-components/tree/c6
[2]
基于Vite+AntDesignVue打造业务组件库: https://juejin.cn/column/7140103979697963045
[3]
进度条组件: https://juejin.cn/post/6844903990610624520
[4]
gulp-clean: https://www.npmjs.com/package/gulp-clean
[5]
@rollup/plugin-typescript: https://www.npmjs.com/package/@rollup/plugin-typescript
[6]
fast-glob: https://www.npmjs.com/package/fast-glob
[7]
rollup-plugin-dts: https://www.npmjs.com/package/rollup-plugin-dts
[8]
订阅关注本专栏: https://juejin.cn/column/7140103979697963045