Skip to main content

Rollup + Babel 编写和分发 JavaScript 库

搭建工程项目搭建库项目之间可能有些小区别,以致工程项目用的打包工具 webpack,不太适用于库项目上。

工程项目输出的代码多是在浏览器运行的,调用浏览器专有的接口(document、window)。库项目可能在浏览器环境被引用,也可能在 Node 环境引用,所以库项目的代码要适配多个环境。

这个是实验将编写一个温标转换的库,然后通过 rollup 和 babel 工具编译成不同格式的代码,适应不同的平台。

首先用 yarn 初始化项目:

yarn init

配置 Babel

Babel 是一个代码转换的工具,它能把具有新特性的代码转换成旧平台能识别的等效代码,如箭头函数。

const fn = () => 1
// Babel 会编译成 var fn = function fn() { return 1; };

TypeScript(TS) 是带有类型声明和类型检查的 JS,使用 TypeScript 编写项目可以提供代码提示和重构的便利,减少出错。TS 本身也要翻译成 JS 才能运行,所以可以配置 TS 编译成 JS 代码后,再交给 Babel 做兼容性处理。

安装 Babel 和 TS:

  • @babel/core Babel 核心包
  • @babel/cli Babel 命令行工具
  • @babel/preset-env Babel 兼容性整合包
  • @babel/preset-typescript Babel TypeScript 整合包
  • typescript TypeScript 编译器工具
yarn add -D @babel/core @babel/cli @babel/preset-env @babel/preset-typescript typescript

# 安装完毕后,初始化 TS 配置
yarn tsc --init

根目录新建 Babel 配置文件 babel.config.json` 编辑

给 Babel 配置整合包。

{
  "presets": [
    "@babel/preset-env",
    "@babel/preset-typescript"
  ]
}

*试一下: 新建 TS 文件 index.ts` 编写一个箭头函数

const fn: Function = () => 1

执行 Babel 编译即可看到 JS 输出结果:

Babel 默认只编译 JS 代码,所以要增加参数 --extensions 让 Babel 识别 TS。

yarn babel --extensions '.ts' ./index.ts

编写库实现

现在编写温标转换库,实现非常简单,即摄氏度后华氏度的互相转换。

文件 src/utils.ts

/** 保留N位小数,不执行四舍五入,保留0位小数=取整 */
export function formatDecimal(dec: number, fixes: number) {
  const scale = Math.pow(10, fixes);
  const atoi = (val: any) => parseInt(`${val}`, 10) || 0;
  return atoi(dec * scale) / scale;
}

文件 src/unit-transform.ts

这里省略具体实现 查看具体实现(Github)

import { formatDecimal } from './utils';
type TransformOptions = {
  scale?: number;
  unit: 'c' | 'f';
};
export function createUnitTransform(opts: TransformOptions) {...}

文件 src/index.ts

到这里,定义了 formatDecimal()createUnitTransform()

在入口(index)处把这些方法都导出来,方便开发者直接引用入口,而不是引用具体的文件。

export * from './utils'
export * from './unit-transform'
export { createUnitTransform as default } from './unit-transform'

对于开发者来说,直接从入口引用需要的方法即可。

// import { formatDecimal, createUnitTransform } from 'unit-transform'
// import createUnitTransform from 'unit-transform' 这里等同于 default

现在简单的温标转换库已经完成了。

编写测试

代码库的稳定性非常重要,在库项目完成之际,应该通过测试,尽可能找出更多的bug并修复,保证项目的稳定性。

JEST 是一个 JavaScript 代码测试库。

  • jest JEST 核心库
  • ts-jest JEST TypeScript 配置库
  • @types/jest JEST TypeScript 声明库,提供代码提示

JEST 需要通过配置支持 TypeScript,所以建议用 ts-jest 直接一步创建

yarn add -D jest ts-jest @types/jest

# 安装完毕后,创建配置
yarn ts-jest config:init

文件 _test_/basic.test.ts

编写简单的摄氏度和华氏度互相转换的测试用例。

import createUnitTransform from '../src'

test('c2f', () => {
  const units = createUnitTransform({ scale: 0, unit: 'c' })
  expect(units.c2f(10)).toBe(50)
  expect(units.cf2f(10)).toBe(50)
})

test('f2c', () => {
  const units = createUnitTransform({ scale: 0, unit: 'f' })
  // 106.66666 -> scale(0) -> 106
  expect(units.f2c(224)).toBe(106)
  expect(units.cf2c(224)).toBe(106)
})

执行测试

指令 jest 即可执行测试,JEST 会自动扫描项目中名字匹配 .test.* 的代码文件并执行它们。

yarn jest

分发目标代码

在本文的开头处已经说明了,库代码会在各种环境下被引用。TS 文件肯定无法直接执行,JS 可能包含一些不兼容的代码,所以必须针对不同的目标环境,生成特定平台的代码。

常用输出格式

CommonJS

CommonJS(CJS)是 Node 自带的模块化 JS 格式,通用性较好,适用小型 Node 项目和脚本编码。

// 导出
module.exports = { createUnitTransform, formatDecimal }
// 导入
const module = require('./index') 
// { createUnitTransform: Function, formatDecimal: Function }

ESM

ES Module(ESM)是 ESMAScript 制定的模块化 JS 格式,包含较多新特性,是目前流行的前端项目编码格式。

ESM 模块化要求突出,通过 import、export 关键字,一些模块化打包工具(webpack)可以轻松实现 Tree Shaking(去掉未引用的代码,缩小产物体积)。

// 导出
export { createUnitTransform, formatDecimal }
export default 1

// 导入
import { formatDecimal } from './index' // Function
import numberValue from './index' // 1

// 对于 webpack 项目来说, createUnitTransform 没有被引用,所以编译后不会包含 createUnitTransform 的代码

UMD

Universal Module Definition(UMD)常用于浏览器,目标代码通常全部写到一个 JS 文件里,执行这个 JS 文件会往全局变量增加一个模块入口。

UMD 打包后的代码体积非常大,通常只在旧的项目、非常轻量的项目上,通过 HTML 引用。

<!DOCTYPE HTML>
<html>
  <head>
    <!-- 假定这个模块名称叫 unit_transform -->
    <script type="text/javascript" src="./index.umd.js"></script>
    <script>
      window.onload = function () { 
        console.log(unit_transform) // { createUnitTransform: Function }
      }
    </script>
  </head>
</html>

Babel 编译 CommonJS

Babel 可以编译出 CommonJS、ES Module 代码。但是 babel.config.json 只能配置单个输出,为了能针对特定的配置而生成不同的代码,这里要用到 .babelrc.json 配置文件,它的格式和 babel.config.json 一样。

继续配置 Babel,尝试编译出 CommonJS(CJS)代码。

根目录新建 Babel 配置文件 .babelrc.cjs.json

{
  "presets": [
    "@babel/preset-env",
    "@babel/preset-typescript"
  ]
}

显式指定配置,目标位置 dist/cjs,编译 src/ 的代码,同时生成 SourceMap

yarn babel --config-file ./.babelrc.cjs.json --extensions '.ts' -d dist/cjs/ src/

# 编译完毕,会在 dist/cjs 看到结果
# dist/cjs/index.js 由于是 CJS 格式,可以通过 node 命令行直接引用
# 例子: node -e 'console.log(require("./dist/cjs"))

Babel 编译 ES Module

其实如果不用 TypeScript,源代码就是一份 ES Module 格式的代码(import、export)。

所以 Babel 在这里再编译一次可以视为仅仅把 TypeScript 转换成 JavaScript。

通过设置 modules 可以关闭 Babel 转换 CJS 的功能,即编译出 ES Module,提供给其他前端项目引用。

数组形式的整合包配置,可以传入参数。

根目录新建 Babel 配置文件 .babelrc.esm.json

{
  "presets": [
    ["@babel/preset-env", {
      "modules": false
    }],
    "@babel/preset-typescript"
  ]
}

显式指定配置,目标位置 dist/esm,编译 src/ 的代码,同时生成 SourceMap

yarn babel --config-file ./.babelrc.esm.json --extensions '.ts' --source-maps -d dist/esm/ src/

# 编译完毕,会在 dist/esm 看到结果
# dist/esm 并不是兼容 Node 的版本,所以只能通过支持 ESM 的工具识别,如: webpack

Rollup + Babel 打包 UMD

Rollup 是一个类似 webpack 的模块打包工具,但 Rollup 适合把模块打包成单文件的形式,所以在这里可以尝试用 rollup 打包出 UMD 代码。

安装 Rollup:

  • rollup Rollup 核心包
  • @rollup/plugin-node-resolve 能让 Rollup 引用其他模块的插件
  • @rollup/plugin-babel 能让 Rollup 借助 Babel 编译的插件
  • @rollup/plugin-typescript 能让 Rollup 启动时读取 TypeScript 配置文件
  • rollup-plugin-terser 能让 Rollup 对输出结果进行 Terser 压缩的插件
  • rollup-plugin-bundle-size 能让 Rollup 显示 gzip 压缩前后代码大小的插件
yarn add -D rollup @rollup/plugin-node-resolve @rollup/plugin-babel @rollup/plugin-typescript rollup-plugin-terser rollup-plugin-bundle-size

根目录新建 Rollup 配置文件 rollup.config.ts

Rollup 可以支持 TypeScript,故直接使用 TypeScript 编写配置,避开不必要的错误。

@rollup/plugin-babel 在 Rollup 运行时会读取 Babel 默认配置文件,如根目录的 babel.config.json

下方的配置将生成目标代码:

  • dist/unit_transform.umd.js 未压缩的 UMD 代码,带 SourceMap。
  • dist/unit_transform.umd.umi.js 经过 Terser 压缩的 UMD 代码。

注意 nodeResolvebabel,甚至 Rollup 本身只识别 .js 代码,对于 TypeScript 项目,要配置 extensions 让它们识别 TS 文件。

import { defineConfig } from 'rollup'
import { nodeResolve } from '@rollup/plugin-node-resolve'
import { babel } from '@rollup/plugin-babel'
import { terser } from 'rollup-plugin-terser'
// @ts-ignore
import bundleSize from 'rollup-plugin-bundle-size'

export default defineConfig({
  input: './src/index.ts',
  output: [
    {
      file: './dist/unit_transform.umd.js',
      format: 'umd',
      name: 'unit_transform', // umd 导出的名称
      sourcemap: 'inline', // 把 SourceMap 写在目标代码里面
    },
    {
      file: './dist/unit_transform.umd.min.js',
      format: 'umd',
      name: 'unit_transform',
      plugins: [terser()], 
    },
  ],
  plugins: [
    bundleSize(), // e.g. 命令行附带 unit_transform.umd.js: 2.91 kB → 923 B (gzip)
    nodeResolve({ extensions: ['.ts'] }),
    babel({ extensions: ['.ts'] }),
  ],
})

指定 Rollup 配置文件,执行 Rollup 打包。

注意 –configPlugin typescript 是为了能让 Rollup 读取 TypeScript 配置文件

yarn rollup -c rollup.config.ts --configPlugin typescript

# 打包完成,在 dist/ 看到结果
# 挑其中一份 UMD 代码,复制到浏览器 Console 执行
# 即可通过 unit_transform、window.unit_transform 调用模块
# 其中变量 unit_transform 即是 UMD 导出的名称

TypeScript 生成类型声明

带有具体实现的 js 代码已经生成了,但它们都没有类型声明,其他的 TS 项目引用这个包时没法获得代码提示,所以在这里给库项目生成类型声明文件(.d.ts)。

tsconfig.json

  • rootDir 相对于 src/ 生成
  • declaration 启用生成 .d.ts
  • emitDeclarationOnly 仅生成 .d.ts ,不生成JS

    JS 部分已经由 Babel 生成了

  • declarationDir 生成 .d.ts 的目标目录
  • include 仅对 src/ 内的 TS 生成 .d.ts

    编译器将专注 src/ 生成,忽略根目录和测试目录的 TS 文件

{
  "compilerOptions": {
    ...
+   "rootDir": "./src",
+   "declaration": true,
+   "emitDeclarationOnly": true,
+   "declarationDir": "./dist/types",
  },
+ "include": ["./src"]
}

配置好 TS,执行生成

yarn tsc

# 生成完毕,结果存放 dist/types 

整合项目

整合编译过程

现在把分发部分的构建过程整合起来,手写 yarn build 实现一次性生成所有的分发格式,编译前清空旧的结果 dist/,所以安装 rimraf 工具进行清空。

yarn add -D rimraf
# 等同于 Unix 的 rm,但它能跨平台执行

package.json

{
  ...
+ "scripts": {
+   "build:types": "tsc",
+   "build:cjs": "babel --config-file ./.babelrc.cjs.json --extensions '.ts' --source-maps -d dist/cjs/ src/",
+   "build:esm": "babel --config-file ./.babelrc.esm.json --extensions '.ts' --source-maps -d dist/esm/ src/",
+   "build:umd": "rollup -c rollup.config.ts --configPlugin typescript",
+   "build:clean": "rimraf -rf ./dist",
+   "build": "yarn build:clean && yarn build:cjs && yarn build:esm && yarn build:umd && yarn build:types"
+ }
  ...
}

执行 yarn build 能保证分发结果 dist/ 保持最新。

引出 CJS 根入口

虽然 dist/ 分发的 ESM、CJS 格式无误,但是入口文件(index.js)毕竟不是在根目录,在项目开发时,经常能在库项目根目录直接引用 index.js,如:

import { Card } from 'antd'
import { Button } from 'bootstrap'

库项目和它们一样,都应该在根目录给出一个入口,供其他项目引用,但是这个入口存放在 dist/ 里面,而不是根目录,对于这个情况,可以在根目录直接建立入口(index.js),把 dist/ 的模块引出来。

根目录 index.js

// 在此之前,CommonJS 需要包名和路径引用:
// const unit_transform = require('unit_transform/dist/cjs')

module.exports = require('./dist/cjs')
module.exports.default = require('./dist/cjs').default
// 此时,CommonJS 可以通过包名直接引用:
// const unit_transform = require('unit_transform')

因应 package.json 的规范,同时应该把入口写在 package.json 里面:

{
  ...
+ "main": "dist/cjs/index.js",
  ...
}

引出 ESM 根入口和 Tree Shaking

上述配置是针对 CommonJS 的,对于 ES Module,webpack 会读取 package.json 中的 module 作为模块入口。

可以把 CommonJS 的入口作为 ES Module 的入口,但 webpack 无法分析哪些代码未被引用,造成前端项目 Tree Shaking 失效。

package.json 配置 Tree Shaking

  • module 表示 ES Module 的模块入口。

  • sideEffects webpack 会读取它,如果为 false,表示所有代码都模块化了,遇到未引用的代码可直接去掉。

    // 显式引用了模块,webpack 会引入 createUnitTransform 代码
    import { createUnitTransform } from 'unit-transform'
    // 如果 sideEffect: false,则 webpack 会去掉下方2段代码,导致这部分代码没有被执行
    // 如果 sideEffect: ["*.css"],则 webpack 继续引入 ./my-style.css 代码
    import './my-code.js'
    import './my-style.css'
    
{
  ...
  "main": "dist/cjs/index.js",
+ "module": "dist/esm/index.js",
+ "sideEffects": false,
  ...
}

引出类型声明入口

同样地, .d.ts 也需要配置入口,否则其他项目的 TS 编译器会搜索根目录的 index.d.ts

package.json 配置类型声明

{
  ...
  "main": "dist/cjs/index.js",
  "module": "dist/esm/index.js",
  "sideEffects": false,
+ "types": "dist/types/index.d.ts",
  ...
}

Husky 自动化: Git 提交前自动编译

由于功能更新、错误修复等原因,修改 src/ 源码后,必须执行 yarn builddist/ 结果保持最新,才能继续使用 Git Commit。其实很多时候会忘记执行 yarn build,其他项目拿到的分发还是旧的,于是会出现这种情况:

改完bug提交完发现忘记build了于是回去build再重新提交

Husky 是一个基于钩子的自动化管理工具,它可以在 Git 一些操作前触发一些自动化动作(如: 提交前更新版本、重新编译proto),一些 React 组件库会利用 Husky 在 Git Commit 后自动刷新文档站点。

回到这里,为了避免每次手动构建的重复工作,试着用 Husky 配置自动化:

# 先在当前项目建立 Git 记录
# git init

# 安装 husky
yarn add -D husky

修改 package.json

这里表示:在每次安装依赖后,yarn 会自动运行 prepare,初始化 husky:

{
  ...
  "scripts": {
    "build": ...,
+   "prepare": "husky install"
  }
  ...
}

编辑完毕,再次执行 yarn,根目录会看到 .husky/

添加一个 Git Hook,告诉 Husky:

“在提交前,记得执行 yarn build,把最新的编译结果也加到 Git 里面”

yarn husky add .husky/pre-commit "yarn build && git add dist/"
git add .husky/pre-commit

现在 husky 已经配置好了,当项目进行 Git Commit 时,会对 dist/ 进行一次重新构建,再把新的结果添加进 Git 中,随着其他改动一起完成一次提交。

附录

编译器 Babel(https://babel.dev/)

打包工具 Rollup(https://rollupjs.org/)

自动化工具 Husky(https://typicode.github.io/husky)

单元测试工具 JEST(https://jestjs.io/)

参考了 Axios(https://github.com/axios/axios)

参考了 Ant Design(https://github.com/ant-design/ant-design)

参考了 Underscore(https://github.com/jashkenas/underscore)