跳到主要内容

如何给类库打包

· 阅读需 34 分钟
Oxygen

背景

在业务场景开发过程中,经常会需要我们手动编写一些方法来解决一些业务场景问题,例如防抖、节流、正则表达式表单校验方法等。一般我们会把这些方法统一放在项目的某个目录,例如utils下维护。

但是当涉及到跨团队使用的时候,这些方法通过npm包的形式来维护会减少团队开发成本。这篇文章主要探索使用rollupapi-extractor打包基于 TypeScript 开发的类库的使用过程。

rollup 是干嘛的

rollup(汇总)是一个支持 ES Modules 模块语法的 JS 应用打包工具,可以将 ES 模块语法编译成 CommonJS,AMD,IIFE 等形式的代码。同时,rollup支持Tree-Shaking

Tree-Shaking

rollup提出Tree-Shaking这个词,用来删减无用的代码段。原理上简单来说从编译代码得到的AST(Abstract Syntax Tree),首先标记相关联的代码,然后移除没被标记的代码,类似于标记-清除的内存回收机制。

通过 ES Modules 模块语法支持静态分析的特性,可以让Tree-Shaking更好地发挥作用,但是许多第三方包并不是以 ES Modules 形式对外界暴露 API,例如lodash,要想在代码层面上配合Tree-Shaking,可以通过引入子模块来解决问题。例如使用import map from 'lodash-es/map'而不是import { map } from 'lodash-es'

不支持第三方包

rollup本身不支持处理从node_modules引入的第三方模块,需要通过额外的plugin —— @rollup/plugin-node-resolve来处理。

// rollup.config.js
import resolve from '@rollup/plugin-node-resolve';

export default {
input: 'src/main.js',
output: {
file: 'bundle.js',
format: 'cjs'
},
plugins: [resolve()]
};

配置项

rollup支持 CLI 命令rollup,默认查找项目根目录下的rollup.config.js配置文件,默认配置文件使用 CJS 模块语法,但是也支持以.cjs.mjs两种后缀来区分在配置文件中使用的模块语法。

// rollup.config.js
interface Configuration {
// 打包入口
input?: string | string[] | { [entryAlias: string]: string };
// 插件数组
plugins?: (Plugin | null | false | undefined)[];
// 忽略打包的模块
external?: (string | RegExp)[]
| string
| RegExp
| ((
source: string,
importer: string | undefined,
isResolved: boolean
) => boolean | null | undefined);

// 缓存之前的构建产物,在 watch 模式下提高后续构建速度,开启的时候 rollup 会只分析改变的模块
// 可以指定缓存 plugin 和 module
cache?: false | RollupCache;
// 警示信息自定义处理函数
onwarn?: Function;
preserveEntrySignatures?: false | 'strict' | 'allow-extension' | 'exports-only';
// 是否对使用了 rollup 过时的特性直接报错
strictDeprecations?: boolean;

// acorn 编译器的配置项
acorn?: AcornOptions;
// acorn 使用的插件
acornInjectPlugins?: (() => unknown)[] | (() => unknown);
// 全局上下文对象,例如 window
context?: string;
// 单独配置每个模块的上下文对象
moduleContext?: ((id: string) => string | null | undefined) | { [id: string]: string };
preserveSymlinks?: boolean;
// 是否对使用未定义的模块提示报错
shimMissingExports?: boolean;
// 自定义 tree-shaking 的过程
treeshake?: boolean | TreeshakingPreset | TreeshakingOptions;,

// 确定在运行了多少次之后,插件不再使用的缓存资源应该被删除,默认 10
experimentalCacheExpiry?: number,
// 是否计算构建性能,例如构建时间等
perf?: boolean,

// 构建输出配置,可以是一个数组
output: {
// 输出文件目录
dir?: string;
// 输出文件名
file?: string;
// 指定输出产物的模块语法,默认 es,也就是 ES Modules 语法
format?: 'amd' | 'cjs' | 'es' | 'iife' | 'system' | 'umd'| 'commonjs' | 'esm' | 'module' | 'systemjs';
// 将外部模块 ID 转换为全局变量名,例如 jquery => $, 在所有模块内部不需要引用 jquery 就可以直接使用 $
globals: { [name: string]: string } | ((name: string) => string);
// 打包产物对外暴露的全局变量名
name?: string;
// 只作用于当前输出的插件
plugins?: (OutputPlugin | null | false | undefined)[];

// 其他静态资源的输出文件名
assetFileNames?: string | ((chunkInfo: PreRenderedAsset) => string);
// 放置在打包产物顶部的字符串内容,例如作者信息,版本声明等,和下面的 footer 一样
banner?: string | (() => string | Promise<string>);
footer?: string | (() => string | Promise<string>);
// 指定 code-splitting 拆分出来的 chunk 的文件名
chunkFileNames?: string | ((chunkInfo: PreRenderedChunk) => string);
// 减少 rollup 生成的包装器的代码,可以用于优化生成产物体积,默认 false
compact?: boolean;
// 指定从 entry 指定的 chunk 的文件名
entryFileNames?: string | ((chunkInfo: PreRenderedChunk) => string);
// 是否拓展 name 指定的全局变量的名称
extend?: boolean,
// 当拆分多个 chunk 的时候,rollup 会把后续关联的模块添加到入口模块 import 进来,这样JS 引擎在从
// 入口开始加载 chunk 的时候就会提前发现其他关联的模块并加载它们,从而提升多个 chunk 的加载速度
hoistTransitiveImports?: boolean,
// 当指定一个入口模块的时候,将其内部使用动态导入 import() 的模块直接打包进来而不是创建单独的 chunk
inlineDynamicImports?: boolean,
interop?: boolean | 'auto' | 'esModule' | 'default' | 'defaultOnly';
// 放置在打包产物内部的介绍信息
intro?: string | (() => string | Promise<string>);
outro?: string | (() => string | Promise<string>);
// code-splitting 的方式,可以将第三方包拆分出来成单独的 chunk
manualChunks?: { [chunkAlias: string]: string[] } | GetManualChunk;
// 对于 es 模块或者 compact 设置为 true,会将模块名混淆成单个字母来表示,更好的压缩代码体积
minifyInternalExports?: boolean;
// 配置 external 指定的模块名和模块路径的映射关系,可以是 CDN URL 地址
paths?: Record<string, string> | ((id: string) => string);
// 将每个模块都拆分成单独的 chunk 打包输出,可用于将文件结构转换为不同的模块格式来输出不同打包产物
preserveModules?: boolean;
// 保证指定目录的模块从入口进入可以输出到 output 指定的输出文件夹,从而和 preserveModules 隔离开
preserveModulesRoot?: string;
// 输出 sourcemap 的形式,默认 false
sourcemap?: boolean | 'inline' | 'hidden';
// 指定源代码的实际代码是否被添加到 sourcemap 中,可减小 sourcemap 的体积
sourcemapExcludeSources?: boolean;
sourcemapFile?: string;
sourcemapPathTransform,
// 校验生成的 JS 代码是否有效
validate?: boolean;

// 以下属于模块语法相关的配置项
amd;
esModule?: boolean;
// 决定模块导出的语法形式,默认情况下 rollup 会根据入口模块来决定
exports?: 'default' | 'named' | 'none' | 'auto';
// 是否不允许修改导出的模块
externalLiveBindings?: boolean;
// 是否使用 Object.freeze 冻结 import as 形式导出的模块
freeze?: boolean;
// 是否使用缩进字符
indent?: boolean | string;
namespaceToStringTag?: boolean;
// 为 UMD 模块添加额外的不会导致冲突的导出
noConflict?: boolean;
// 使用 const 定义导出的模块而不是 var
preferConst?: boolean;
// 移除 chunk 名称中的 \0, ? and * 字符
sanitizeFileName?: boolean | (string) => string;
// 是否在非 ES Modules 模块顶部添加 use strict,默认添加
strict?: boolean;
// 当打包模块输出为 SystemJS 的时候,是否用 null 替换空的 setter 方法
systemNullSetters?: boolean;
},
// 热更新监听配置
watch: {
// 配置 Rollup 在触发重新构建之前等待进一步更改的时间
buildDelay?: number;
chokidar?: ChokidarOptions;
// 是否在重新构建的时候清空控制台
clearScreen?: boolean;
// 排除监听的文件
exclude?: string | RegExp | (string | RegExp)[];
include?: string | RegExp | (string | RegExp)[];
// 触发热更新的时候是否跳过 bundle.write()
skipWrite?: boolean;
}
};

常用plugin

rollup目前支持的plugin都在这个列表里 —— rollup/awesome: ⚡️ Delightful Rollup Plugins。常用的有以下这些:

  • @rollup/plugin-node-resolve:解析node_modules中第三方模块
  • @rollup/plugin-typescript:解析 TypeScript 模块,注意配置tsconfig路径
  • @rollup/plugin-aliasalias模块名
  • rollup-plugin-visualizerrollup打包产物可视化图分析依赖项
  • rollup-plugin-progress:构建进度条

api-extractor 是什么

api-extractor是辅助打包 TypeScript 类型系统的工具,属于 TypeScript 语言领域的rollup。一般来说,我们使用rollup只为打包生成.js文件,借助@rollup/plugin-typescript这样的插件也可以生成.d.ts后缀的类型定义文件,但是类型定义往往分散在项目不同的文件下,导致构建产物有很多.d.ts文件,看起来很乱,那么别人用的时候往往还需要从node_modules下引用不同的.d.ts模块,这就很不方便了。

api-extractor主要就是解决上面这个问题的,它可以将所有类型定义从一个入口获取到,最后汇总到一个.d.ts文件内部。此外,还有以下功能:

  • 将所有 TS 类型定义导出到一个.d.ts文件,这一项功能最有用;

  • 从项目入口遍历所有export的类型,并生成一个 markdown 报告,一般没用;

  • 根据类型生成包含类型签名和注释的 JSON 文件,然后内置的api-documenter可以根据这些 JSON 文件生成 API 文档,一般没用;

image-20220326210450101

使用

api-extractor使用比较简单:

  1. 首先全局安装@microsoft/api-extractor
pnpm add @microsoft/api-extractor -g
  1. 然后在项目根目录执行api-extractor init会生成一个api-extractor的 JSON 配置文件api-extractor.json
  2. 配置api-extractor.json,关键需要为其指定两个路径:类型入口文件路径mainEntryPointFilePath和汇总输出的文件路径untrimmedFilePath
{
// 类型入口文件路径,必须为 .d.ts 后缀
"mainEntryPointFilePath": "<projectFolder>/lib/index.d.ts",
"compiler": {
"tsconfigFilePath": "<projectFolder>/tsconfig.json"
}
"apiReport": {
// 是否生成 API 报告,一般用不到,关掉
"enabled": false
},
"docModel": {
// 是否生成 doc model 文档,一般用不到,关掉
"enabled": false
},
"dtsRollup": {
"enabled": true,
// 汇总 *.d.ts 文件后输出的目录,有用而且是关键配置
"untrimmedFilePath": "<projectFolder>/lib/index.d.ts"
}
}
  1. 接着我们需要保证项目生成了api-extractor需要的类型入口文件,因此可以先执行tsc得到构建输出的*.d.ts文件;

  2. 最后执行api-extractor run即可,会在dtsRollup.untrimmedFilePath配置的路径下重新生成.d.ts文件,内部汇总项目所有的类型定义。

image-20220327234721974

vite 是怎么用 api-extractor 的

api-extractor官网的介绍里,我根本看不出api-extractor的使用收益在哪,这里只能先学习vite是怎么用的了。

vite配置的api-extractor.json来看,其指定的类型文件入口为./temp/node/index.d.ts,汇总生成的.d.ts文件位于./dist/node/index.d.ts

{
"$schema": "https://developer.microsoft.com/json-schemas/api-extractor/v7/api-extractor.schema.json",
// 项目目录
"projectFolder": "./src/node",
// 类型定义入口
"mainEntryPointFilePath": "./temp/node/index.d.ts",

"dtsRollup": {
"enabled": true,
"untrimmedFilePath": "",
// 整理汇总后的 .d.ts 输出目录
"publicTrimmedFilePath": "./dist/node/index.d.ts"
},

"apiReport": {
"enabled": false
},

"docModel": {
"enabled": false
},

"tsdocMetadata": {
"enabled": false
}
}

但是在实际项目中并未发现该文件,猜测是经过tsc编译生成的目录,于是查找package.jsonscripts的配置,果然找到和tsc相关的命令。

这里一共四个相关的命令:

  • build-temp-types:使用tsc编译 TypeScript,但是指定了emitDeclarationOnly,只生成.d.ts文件到temp/node目录
  • patch-types:使用ts-node运行scripts目录下的patchTypes.ts程序
  • roll-types:运行api-extractor程序,完事后移除temp文件夹
  • build-type:使用npm-run-allrun-s按顺序执行命令
{
"scripts": {
"build-types": "run-s build-temp-types patch-types roll-types",
"build-temp-types": "tsc --emitDeclarationOnly --outDir temp/node -p src/node",
"patch-types": "ts-node scripts/patchTypes.ts",
"roll-types": "api-extractor run && rimraf temp",
}
}

下面我进入packages/vite分别执行以上命令看看效果。

cd packages/vite

pnpm run build-temp-types

执行build-temp-types得到所有文件的.d.ts文件

image-20220328204143101

然后继续执行patch-typespatch-types这个程序会把src/node目录下代码中从types/*引入的类型转换成相对路径,这样api-extractor才能查找得到。

例如import type { Alias } from "types/alias"转换后得到import type { Alias } from "../../../types/alias"

最后执行api-extractor run来汇总所有的类型定义,并输出到./dist/node/index.d.ts目录下,这个目录同时也是rollup打包输出的 JS 文件目录。

其中关键的地方就在于api-extractor的入口文件,在vite内部的src/node/index.ts只用作类型定义入口,内部负责从其他目录export类型或者方法等成员。

export { resolvePackageEntry } from './plugins/resolve'
export {
splitVendorChunkPlugin,
splitVendorChunk
} from './plugins/splitVendorChunk'
export { resolvePackageData } from './packages'
export { normalizePath } from './utils'
// ...

最佳实践

介绍完以上两种工具的使用,下面开搞,项目结构如下:

-scripts
-build.js // rollup 打包程序
-src
-debounce.ts
-throttle.ts
-index.ts // 导出其他方法

安装rollup到项目依赖后,使用rollup的 JavaScript API 来编写打包程序,放在scripts/build.js下,目的是从项目的多个入口进行打包,从而得到多个分离的.js文件。

const bundle = await rollup({
input,
plugins: [
pluginTS({
// 指定生成 *.d.ts 类型文件
tsconfig: path.resolve(process.cwd(), 'tsconfig.json'),
// 这里我们指定 rollup 打包不需要生成 .d.ts 文件
declaration: false,
}),
nodeResolve(),
analyze &&
visualizer({
filename: path.resolve(process.cwd(), 'analyze/stats.html'),
open: true,
gzipSize: true,
}),
].filter(Boolean),
});

然后使用tsc --emitDeclarationOnly --declaration --outDir dts生成.d.ts文件,index.d.ts如下:

export { default as debounce } from './debounce';
export { default as throttle } from './throttle';

最后执行api-extractor run输出汇总的.d.ts文件,命令配置如下:

{
"scripts": {
"build": "run-s bundle build-types rollup-types",
"bundle": "node scripts/build.js",
"build-types": "tsc --emitDeclarationOnly --declaration --outDir dts",
"rollup-types": "api-extractor run && rimraf dts",
}
}

QA

ae-missing-release-tag

使用api-extractor的时候,可能会报下面的错误

*“___ is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal).”*

image-20220328220425540

主要原因是api-extractor使用的注释比较严格,可以通过@public@alpha(内部测试) 或@beta(公开测试)这些注释来区分一个成员的版本。

两种方法解决上述问题:

  • 方法注释上添加@public
  • api-extractor.json中配置关掉提示
"messages": {
"extractorMessageReporting": {
"ae-missing-release-tag": {
"logLevel": "none"
}
}
}