概述
虽然 Webpack 多数情况下被用于构建 Web 应用,但与 Rollup、Snowpack 等工具类似,Webpack 同样具有完备的构建 NPM 库的能力。与一般场景相比,构建 NPM 库时需要注意:
- 正确导出模块内容;
- 不要将第三方包打包进产物中,以免与业务方环境发生冲突;
- 将 CSS 抽离为独立文件,以方便用户自行决定实际用法;
- 始终生成 Sourcemap 文件,方便用户调试。
本文将从最基础的 NPM 库构建需求开始,逐步叠加上述特性,最终搭建出一套能满足多数应用场景、功能完备的 NPM 库构建环境。
开发一个 NPM 库
- 为方便讲解,假定我们正在开发一个全新的 NPM 库,暂且叫它
test-lib
吧,首先需要创建并初始化项目:
mkdir test-lib && cd test-lib npm init -y
复制
虽然有很多构建工具能够满足 NPM 库的开发需求,但现在暂且选择 Webpack,所以需要先装好基础依赖:
yarn add -D webpack webpack-cli
复制
接下来,可以开始写一些代码了,首先创建代码文件:
mkdir src touch src/index.js
复制
之后,在 test-lib/src/index.js
文件中随便实现一些功能,比如:
// test-lib/src/index.js export const add = (a, b) => a + b
复制
至此,项目搭建完毕,目录如下:
├─ test-lib │ ├─ package.json │ ├─ src │ │ ├─ index.js
复制
使用 Webpack 构建 NPM 库
接下来,我们需要将上例 test-lib
构建为适合分发的产物形态。虽然 NPM 库与普通 Web 应用在形态上有些区别,但大体的编译需求趋同,因此可以复用前面章节介绍过的大多数知识点。例如 test-lib
所需要的基础编译配置如下:
// webpack.config.js const path = require("path"); module.exports = { mode: "development", entry: "./src/index.js", output: { filename: "[name].js", path: path.join(__dirname, "./dist"), } };
复制
- 提示:我们还可以在上例基础上叠加任意 Loader、Plugin,例如:
babel-loader
、eslint-loader
、ts-loader
等。
上述配置会将代码编译成一个 IIFE 函数,但这并不适用于 NPM 库,我们需要修改 output.library
配置,以适当方式导出模块内容:
module.exports = { // ... output: { filename: "[name].js", path: path.join(__dirname, "./dist"), + library: { + name: "_", + type: "umd", + }, }, // ... };
复制
这里用到了两个新配置项:
-
output.library.name:用于定义模块名称,在浏览器环境下使用
script
加载该库时,可直接使用这个名字调用模块,例如:
复制<!DOCTYPE html> <html lang="en"> ... <body> <script src="https://examples.com/dist/main.js"></script> <script> // Webpack 会将模块直接挂载到全局对象上 window._.add(1, 2) </script> </body> </html> -
output.library.type:用于编译产物的模块化方案,可选值有:
commonjs
、umd
、module
、jsonp
等,通常选用兼容性更强的umd
方案即可。 -
提示:JavaScript 最开始并没有模块化方案,这就导致早期 Web 开发需要将许多代码写进同一文件,极度影响开发效率。后来,随着 Web 应用复杂度逐步增高,社区陆陆续续推出了许多适用于不同场景的模块化规范,包括:CommonJS、UMD、CMD、AMD,以及 ES6 推出的 ES Module 方案,不同方案各有侧重点与适用场景,NPM 库作者需要根据预期的使用场景选择适当方案。
修改前后对应的产物内容如下:
可以看到,修改前(对应上图左半部分)代码会被包装成一个 IIFE ;而使用 output.library
后,代码被包装成 UMD(Universal Module Definition) 模式:
(function webpackUniversalModuleDefinition(root, factory) { if(typeof exports === 'object' && typeof module === 'object') module.exports = factory(); else if(typeof define === 'function' && define.amd) define([], factory); else if(typeof exports === 'object') exports["_"] = factory(); else root["_"] = factory(); })(self, function() { // ... });
复制
这种形态会在 NPM 库启动时判断运行环境,自动选择当前适用的模块化方案,此后我们就能在各种场景下使用 test-lib
库,例如:
// ES Module import {add} from 'test-lib'; // CommonJS const {add} = require('test-lib'); // HTML <script src="https://examples.com/dist/main.js"></script> <script> // Webpack 会将模块直接挂载到全局对象上 window._.add(1, 2) </script>
复制
正确使用第三方包
接下来,假设我们需要在 test-lib
中使用其它 NPM 包,例如 lodash
:
// src/index.js import _ from "lodash"; export const add = (a, b) => a + b; export const max = _.max;
复制
此时执行编译命令 npx webpack
,我们会发现产物文件的体积非常大:
这是因为 Webpack 默认会将所有第三方依赖都打包进产物中,这种逻辑能满足 Web 应用资源合并需求,但在开发 NPM 库时则很可能导致代码冗余。以 test-lib
为例,若使用者在业务项目中已经安装并使用了 lodash
,那么最终产物必然会包含两份 lodash
代码!
为解决这一问题,我们需要使用 externals 配置项,将第三方依赖排除在打包系统之外:
// webpack.config.js module.exports = { // ... + externals: { + lodash: { + commonjs: "lodash", + commonjs2: "lodash", + amd: "lodash", + root: "_", + }, + }, // ... };
复制
-
提示: Webpack 编译过程会跳过 externals 所声明的库,并假定消费场景已经安装了相关依赖,常用于 NPM 库开发场景;在 Web 应用场景下则常被用于优化性能。
-
例如,我们可以将 React 声明为外部依赖,并在页面中通过
<script>
标签方式引入 React 库,之后 Webpack 就可以跳过 React 代码,提升编译性能。
改造后,再次执行 npx webpack
,编译结果如下:
改造后,主要发生了两个变化:
- 产物仅包含
test-lib
库代码,体积相比修改前大幅降低; - UMD 模板通过
require
、define
函数中引入lodash
依赖并传递到factory
。
至此,Webpack 不再打包 lodash
代码,我们可以顺手将 lodash
声明为 peerDependencies
:
{ "name": "6-1_test-lib", // ... + "peerDependencies": { + "lodash": "^4.17.21" + } }
复制
实践中,多数第三方框架都可以沿用上例方式处理,包括 React、Vue、Angular、Axios、Lodash 等,方便起见,可以直接使用 webpack-node-externals 排除所有 node_modules
模块,使用方法:
// webpack.config.js const nodeExternals = require('webpack-node-externals'); module.exports = { // ... + externals: [nodeExternals()] // ... };
复制
抽离 CSS 代码
假设我们开发的 NPM 库中包含了 CSS 代码 —— 这在组件库中特别常见,我们通常需要使用 mini-css-extract-plugin
插件将样式抽离成单独文件,由用户自行引入。
这是因为 Webpack 处理 CSS 的方式有很多,例如使用 style-loader
将样式注入页面的 <head>
标签;使用 mini-css-extract-plugin
抽离样式文件。作为 NPM 库开发者,如果我们粗暴地将 CSS 代码打包进产物中,有可能与用户设定的方式冲突。
为此,需要在前文基础上添加如下配置:
module.exports = { // ... + module: { + rules: [ + { + test: /\.css$/, + use: [MiniCssExtractPlugin.loader, "css-loader"], + }, + ], + }, + plugins: [new MiniCssExtractPlugin()], };
复制
- 提示:关于 CSS 构建的更多规则,可参考《如何借助预处理器、PostCSS 等构建现代 CSS 工程环境?》
生成 Sourcemap
Sourcemap 是一种代码映射协议,它能够将经过压缩、混淆、合并的代码还原回未打包状态,帮助开发者在生产环境中精确定位问题发生的行列位置,所以一个成熟的 NPM 库除了提供兼容性足够好的编译包外,通常还需要提供 Sourcemap 文件。
接入方法很简单,只需要添加适当的 devtool
配置:
// webpack.config.js module.exports = { // ... + devtool: 'source-map' };
复制
再次执行 npx webpack
就可以看到 .map
后缀的映射文件:
├─ test-lib │ ├─ package.json │ ├─ webpack.config.js │ ├─ src │ │ ├─ index.css │ │ ├─ index.js │ ├─ dist │ │ ├─ main.js │ │ ├─ main.js.map │ │ ├─ main.css │ │ ├─ main.css.map
复制
此后,业务方只需使用 source-map-loader
就可以将这段 Sourcemap 信息加载到自己的业务系统中,实现框架级别的源码调试能力
其它 NPM 配置
至此,开发 NPM 库所需的 Webpack 配置就算是介绍完毕了,接下来我们还可以用一些小技巧优化 test-lib
的项目配置,提升开发效率,包括:
-
使用
.npmignore
文件忽略不需要发布到 NPM 的文件; -
在
package.json
文件中,使用prepublishOnly
指令,在发布前自动执行编译命令,例如:
复制// package.json { "name": "test-lib", // ... "scripts": { "prepublishOnly": "webpack --mode=production" }, // ... } -
在
package.json
文件中,使用main
指定项目入口,同时使用module
指定 ES Module 模式下的入口,以允许用户直接使用源码版本,例如:
复制{ "name": "6-1_test-lib", // ... "main": "dist/main.js", "module": "src/index.js", "scripts": { "prepublishOnly": "webpack --mode=production" }, // ... }
总结
站在 Webpack 角度,构建 Web 应用于构建 NPM 库的差异并不大,开发时注意:
- 使用
output.library
配置项,正确导出模块内容; - 使用
externals
配置项,忽略第三方库; - 使用
mini-css-extract-plugin
单独打包 CSS 样式代码; - 使用
devtool
配置项生成 Sourcemap 文件,这里推荐使用devtool = 'source-map'
。
遵循上述规则,基本上就能满足开发一个 NPM 库所需的大部分需求