1. 前言


image.png


落魄前端打字员小李下班后, 觉得有点饿, 于是买了一份炒粉, 分量实在太足, 吃不完决定打包:


小李:

老板, 麻烦打包

老板:

好的, 你要哪种包装盒, 有 UMD, CMD, AMD, ES modules 等等, 由本店金牌打包员 webpack, babel, rollup 为你服务

小李:

好吧, 有点尬, 今天主要想说下前端普遍的打包方式

2. yarn add xx 之后都下载了啥

以 组件库 antd 为例子

1
yarn add antd

下载下来目录结构长这样子
image.png


抛去俄罗斯套娃的 node_modules 外, 可以看到有三个目录, 他们分别对应主流的三个模块规范 (AMD,CMD 远古模块规范现在基本没人使用了)

目录 模块规范 含义
dist UMD 兼容AMD,CMD的模块, 可以直接在浏览器中使用
lib CommonJS 一个文件即一个模块, Node.js 采用的就是这个规范
es ES modules ES6 模块规范

3. 模块的使用方式

UMD


此时, antd 变成了浏览器的全局变量, 可以直接使用, 好处是无需构建, 直接在浏览器使用, 缺点是不好维护, 无法按需加载

1
2
3
4
<script src="https://unpkg.com/antd"/>
<script type="module">
const { Button } = antd
</script>


ES modules


推荐的方式, 由于 import 的 module 都是静态的 , rollup 和 webpack 可以很好的去除无用代码, 也就是传说中的 tree shaking , 确定就是第三方包质量参吃不齐, 很多没有提供 es 的代码包

1
import { Button } from 'antd'


CommonJS

缺点是 可以 require 一个 动态的 module, 所以打包工具不能 tree shaking

1
const { Button } = require('antd')

4. 模块的构建


上面说了不同模块规范的使用方式, 那么怎样发布 npm 的 时候 打包不同规范的模块呢? 前端什么都缺, 就是不缺轮子, 这里主要讲解 babel , webpacktsc 这三种方式

使用 webpack 打包 UMD

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
const fs = require('fs')
const path = require('path')
const webpack = require('webpack')
const ProgressBarPlugin = require('progress-bar-webpack-plugin')

const { version, name, description } = require('./package.json')

module.exports = {
mode: 'production',
entry: {
[name]: path.resolve(__dirname, './src/index.tsx'),
},

output: {
library: name,
libraryTarget: 'umd', // 输出 UMD 格式的包
umdNamedDefine: true, // 是否将模块名称作为 AMD 输出的命名空间
path: path.resolve(__dirname, 'dist'),
filename: '[name].min.js',
},
// 忽略 react 和 react-dom
externals: {
react: {
root: 'React',
commonjs2: 'react',
commonjs: 'react',
amd: 'react',
},
'react-dom': {
root: 'ReactDOM',
commonjs2: 'react-dom',
commonjs: 'react-dom',
amd: 'react-dom',
},
},
resolve: {
enforceExtension: false,
extensions: ['.js', '.ts', '.tsx'],
},
module: {
rules: [
{
test: /\.ts[x]?$/,
use: [
{
loader: 'awesome-typescript-loader',
},
],
},
],
},
plugins: [
new webpack.BannerPlugin(` \n ${name} v${version} \n ${description}
\n ${fs.readFileSync(path.resolve(__dirname, './LICENSE'))}
`)
],
}


这里的重点 主要是 output.libraryTargetexternals , 其他的都是比较常规的配置, 如果代码是 js 编写的, 替换对应的 loader 即可


image.png

使用 babel 打包

编写 .babelrc

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
const env = process.env.BABEL_ENV || process.env.NODE_ENV;
const outputModule = process.env.OUTPUT_MODULE;

module.exports = {
"presets": [
[
"@babel/preset-env",
{
"modules": outputModule || false
}
],
"@babel/preset-react"
],
"plugins": [
"@babel/plugin-proposal-object-rest-spread",
"@babel/plugin-proposal-class-properties",
]
}

这里的 modules 可选项为 “amd” | “umd” | “systemjs” | “commonjs” | “cjs” | “auto” | false

如果代码是 js 编写的, 在命令行执行如下命令

1
2
3
4
5
6
7
8
// umd `-d` 是输出到指定目录
cross-env OUTPUT_MODULE=umd babel src -d dist

// commonjs
cross-env OUTPUT_MODULE=commonjs babel src -d lib

// es modules
babel src -d es

直接 改变环境变量 OUTPUT_MODULE 切换不同的输出格式即可, 可以看到, 在输入 ES modules 的时候, 没有指定环境变量, 相当于是 modules: false , 因为我们开发的时候, 就是采用的 这种规范, 所以不需要做额外的转换, 原样输出到 es 目录即可

如果代码是 ts 编写的, 问题也不大, 加上 tspresets 即可

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
const env = process.env.BABEL_ENV || process.env.NODE_ENV;
const outputModule = process.env.OUTPUT_MODULE;

module.exports = {
"presets": [
[
"@babel/preset-env",
{
"modules": outputModule || false
}
],
"@babel/preset-react"
+ "@babel/preset-typescript"
],
"plugins": [
"@babel/plugin-proposal-object-rest-spread",
"@babel/plugin-proposal-class-properties",
]
}

使用tsc打包


tsc 是 TypeScript 提供的 打包工具


首先编写 tsconfig.json

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
{
"compilerOptions": {
"target": "es5",
"moduleResolution": "node",
"module": "esnext",
"jsx": "react",
"pretty": true,
"sourceMap": false,
"strict": true,
"skipLibCheck": true,
"strictNullChecks": true,
"esModuleInterop": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noImplicitReturns": true,
"noImplicitAny": true,
"removeComments": true,
"noFallthroughCasesInSwitch": true,
"lib": ["es2018", "dom"]
},
"include": ["src"],
}


指定不同的模块规范, 和 babel 类似, 不要忘记 --declaration , 也就是输出 types 定义, 这样别人使用你的包的时候, 才有友好的提示

1
2
3
4
5
6
7
8
// umd
tsc -m umd --outDir dist --declaration

// commonjs
tsc -m commonjs --outDir lib --declaration

// es modules
tsc -m esNext --outDir es --declaration

将打包脚本集成到 package.json


为了方便的进行打包, 我们可以将写好的打包脚本集成到 npm scripts 里面, 最后再配置 npm 的钩子 prepublishOnly 在每次发布前自动打包, 这样不会担心代码更新没有重新构建, 美滋滋!

1
2
3
4
5
6
7
8
9
10
11
12
// package.json

{
...
"scripts" : {
"build:cjs": "tsc -m commonjs --outDir lib --declaration",
"build:es": "tsc -m esNext --outDir es --declaration",
"build:umd": "tsc -m umd --outDir dist --declaration",
"build": "yarn build:cjs && yarn build:es && yarn build:umd",
"prepublishOnly": "yarn build",
}
}
1
npm publish

5. 指定不同模块的路径


代码也打包了, 也发布了, 是不是就完事呢? 当然不是, 这会有个问题, 回想下前面说到的 antd

1
import { Button } from 'antd'

image.png
antd 提供了三种模块代码包, 我们在使用的时候却并没有指明具体的路径, 那我们到底使用的是 dist , es 还是 lib 呢?


查看 antd 代码包下面的 package.json 可以看到这几个字段:


image.png

  • main 表示 Node.js 默认会加载的路径
  • module 表示 ES modules 的路径
  • unpkg 表示 umd 的路径 ( unpkg 是一个免费的cdn)


所以这两句代码是等价的:

1
2
3
import { Button } from 'antd'

import { Button } from './node_modules/antd/lib/index.js'

所以没有指定具体的路径的时候, 默认是加载 lib 目录, 也就是 cjs 模块 , 但是这样会引入全量的 antd 组件, 结果这个问题也很简单, 加载 es 目录 或者使用 babel-plugin-import 即可

1
import { Button } from 'antd/es'

6. 让 webpack 优先加载 ES modules


如果依赖的库提供了 es 目录, 我们可以手动指定, 但会有些麻烦, 我们需要手动查看每个库有没有提供 es 目录, 幸运的是 webpack 可以帮我们处理, 优先去寻找依赖的 es 目录, 如果没找到, 再降级为 lib 目录

1
2
3
4
5
6
7
8
// webpack.config.js

module.exports = {
...
resolve: {
mainFields: ['jsnext:main', 'main'],
},
}

7. 结语

配置工程师还真滴不好当, 现如今,
还出现了 snowpack, vite 之类的打包工具,
也非常值得学习, 以上是我做一些开源项目学习到的一些打包方面的知识点, 希望能对你有帮助.

参考链接

ant-design

webpack-guidebook

react-7h-hooks