在发布组件库之前,你需要先掌握构建和发布函数库

2,806 阅读12分钟

本文为稀土掘金技术社区首发签约文章,14天内禁止转载,14天后未获授权禁止转载,侵权必究!

专栏上篇文章传送门:实现一个靠谱好用的全屏组件,顺手入门 Headless 组件

专栏下篇文章传送门:函数库Rollup构建优化

本节涉及的内容源码可在vue-pro-components c6 分支找到,欢迎 star 支持!

前言

本文是 基于Vite+AntDesignVue打造业务组件库 专栏第 7 篇文章【在发布组件库之前,你需要先掌握构建和发布函数库】,聊聊怎么构建和发布一个函数库。

如上篇文章结语所述,开发组件发布可用的组件之间还隔着一条鸿沟,这就是从开发环境到生产环境必经的路,也是组件库研发过程中最复杂的部分。要越过这条鸿沟,就必须掌握一些工程化能力。

然而,构建和发布组件库是一个较复杂的体系化的工程,构建组件库不仅要处理 js, ts,可能还要处理 jsx, tsx, 样式等内容,如果采用的开发框架是 Vue,你可能还需要处理 SFC 的 parse, transpile 等过程。总之,这中间会涉及很多种 DSL(领域特定语言)的处理,还要注意各个工序的顺序问题,这听起来似乎不是很简单的一件事,容易让初学者摸不着头脑。

image.png

为了打破这种迷茫,我们可以将构建整个组件库的工作拆解出来,选择从某一个方向切入,由点到面逐个突破,最终形成构建组件库的全局思维。那么最适合作为我们学习入口的当然是函数库的构建,因为它通常只涉及 JS/TS,这是我们最熟悉的领域。

构建函数库

为什么要做构建工作?

截至到目前,我们在本专栏中实现的一些组件/函数/Hook等内容都还停留在源码层面,基本上是以.ts, .tsx, .vue等形式存在的,并且我们可以发现,package.json中的main入口都是index.ts

image.png

而在我们的认知中,我们用的一些常见的库,它们提供的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"
  ],
  // ...剩余字段
}
  • lib 目录下输出的是符合 CJS 模块规范的产物,通过main字段指定。
  • es 目录下输出的是符合 ES 模块规范的产物,通过module字段指定。
  • types 目录用于放置类型声明文件,也可以通过@types/xxx来提供类型声明。
  • unpkg 和 jsdelivr 用于通过 cdn 访问发布在 npm 上的 umd 内容。以我之前发布的一个进度条组件为例,你只要按这个格式去访问,就能得到你发布的内容。
https://unpkg.com/vue-awesome-progress@1.9.7/dist/vue-awesome-progress.min.js

image.png

jsdelivr 也是类似的,只不过是路径前缀有点区别。

https://cdn.jsdelivr.net/npm/vue-awesome-progress@1.9.7/dist/vue-awesome-progress.min.js

image.png

同样地,以 vue-pro-components 这个包为例,之前讲解简单发布流程时也发布到了 npm,因此也可以通过 cdn 访问到。

https://unpkg.com/vue-pro-components@0.0.2/lib/vue-pro-components.js

image.png

由于先前没有写什么实际内容就以教程的形式发布了,纯属是浪费资源了。建议不要随意发布没有意义的包。

  • files 则是指定发布和安装时包含哪些文件或目录(支持 glob pattern),合理的配置可以减少 publish 和 install 的资源数。如果不做任何配置,就会发布和安装整个工程,这实际上是一种浪费。

根据前面的叙述,我们可以知道,一个函数库大体上要提供符合 ESM, CJS, UMD 模块规范的制品。

从 TS 源码到 ESM, CJS, UMD 等规范下的制品,其实就是对应打包构建的过程了。

怎么构建函数库?

先画个图列举一下我们要做什么事情:

image.png

再确定哪些事情是串行的,哪些事情可以并行做。

仔细品味,不难想明白除了清理目录(dist, es, lib, types 等目录)的工作需要先行,其他的工作都可以并行执行(因为它们之间没有依赖关系)。所以,整个构建的任务流大概是这样的:

image.png

大概的流程梳理清楚后,就可以逐个实现任务,并且把所有任务有序组织起来。

在打包函数库这方面,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 编写任务的指导性文档,

image.png

并且推荐我们采用gulpfile.babel.js来组织我们的配置文件,这背后依赖了@babel/register,而@babel/register底层是用到了 NodeJS 的 require hook。

引用自 babel 官网

@babel/register uses Node's require() hook system to compile files on the fly when they are loaded.

其他可选的方案还有 sucrase/register。

基于此,我们可以做到统一使用 ESM 来组织构建流程。

清理目录

因为在开始新的构建工作之前可能存在上一次构建的产物,所以对于构建产生的 dist, es, lib, types 等目录,我们需要将其清理干净,这本质上是文件操作,但是在 gulp 生态中有很多插件可以让我们选择,就没必要自己手撸一个文件清理的流程了。这里我们选用gulp-clean

文件处理最重要的是把路径设置正确,否则一波类似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 })

我们目前还没有实现打包过程,可以先加几个临时文件测试一下。

清理工作.gif

构建目标产物

构建工作就是 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 模块,我们可以单独引用某一个模块,配合一些工具,还能做到按需引入。

image.png

构建 UMD bundle

凡事从易到难,我们还是先从最简单的生成 UMD bundle 开始。

由于我们的源码是用 ts 写的,所以要引入一个插件@rollup/plugin-typescript

入口文件就用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 来标记任务完成信号的,同样也可以用异步函数。

image.png

image.png

import { series, src } from "gulp";
// ...省略其他代码
// 先 cleanUtils,再 buildBundle,通过 series 按顺序执行
export const buildUtils = series(cleanUtils, buildBundle);

测试一下效果,发现已经可以构建出符合 UMD 模块规范的产物了,第一小步算是迈出去了。

build_bundle.gif

构建 ESM & CJS,支持按需加载

接下来就是看怎么构建符合 ESM 和 CJS 规范的产物,同时要支持多文件独立输出,以支持按需加载。

要输出多个文件,其实可以考虑指定多个构建入口,以单个模块作为入口,就能输出这个模块对应的构建结果。Rollup 本身也支持指定数组或对象形式的 input 参数作为多入口,这和 Webpack 也是相似的。

image.png

我们用到一个fast-glob,这可以让我们避免繁琐的文件列举。

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 方法并行执行构建buildModulesbuildBundle的任务。因为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"
  • 配合一些工具,也能实现后者到前者的转换,同时保障开发效率和生产质量。

build_utils.gif

构建类型声明文件

到这里,我们发现还缺少的就是类型声明了,我试着在buildBundle时同时把declaration给生成了,但是报了一个错,生成的 types 目录不能在bundle.write指定的dir目录之外。

image.png

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文件。

image.png

看了一下@rollup/plugin-typescript的文档,发现是插件忽略了这部分配置。

image.png

来不及想为什么了,这里直接改用一个专门用于生成类型声明的插件rollup-plugin-dtsbuildTypes函数改造成如下:

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)

build_utils_types.gif

发布函数库

构建的工作做好之后,就可以准备发布到 npm 上了。

首先将package/utils的版本号修改一下,我们可以根据lerna version的提示修改版本号。

image.png

接着运行package.json中定义的publish:package脚本,就可以发布到 npm 上了。

image.png

接着我们可以找个地方验证一下@vue-pro-components/utils这个包是不是可以正常使用,在线 IDE 可能是最直观的。

image.png

由于某在线 IDE 的 iframe 没有 allow fullscreen 特性,我们需要手动给它修改一下。

image.png

效果这就有了:

测试utils全屏.gif

结语

本文主要介绍了一个函数库的构建和发布的基本流程,虽然打通了基本流程,但也还存在很多优化的空间,比如怎么把构建和发布的流程串起来,而不是一条接一条命令地手动执行。不过,以此为基础,我们就可以继续探索更为复杂的组件库的构建和发布流程了。如果您对我的专栏感兴趣,欢迎您订阅关注本专栏,接下来可以一同探讨和交流组件库开发过程中遇到的问题。