当你想发布一个 npm 库,却发现 Webpack 把 React、lodash 这类依赖一起打包进了产物,导致库体积膨胀、与宿主应用的同名依赖出现多个副本时,就该认真考虑 externals 和 peerDependencies 的配合了。简单说,externals 是 Webpack 层面“不要打包我指定的模块”,peerDependencies 是 npm 安装层面“这个库需要宿主环境安装某个依赖”。两者看似相近,但作用时机和场景并不相同。本文将通过一个实际操作案例,逐步解释它们各自的原理、差异,以及如何在发布 npm 库时正确配置。

为什么打包 npm 库时需要 externals
假设你正在写一个 React 组件库,名叫 my-ui。直接用 Webpack 打包时,默认行为是将所有 import 进来的代码都打进输出文件。于是 React 也被复制了一份进入 dist/my-ui.js。而使用这个库的项目里,往往也已经安装了 React。此时页面中存在两份 React,一份来自库的副本,一份来自业务项目本身,这很可能导致 hooks 状态丢失、事件系统异常等严重问题。
externals 的关键作用,就是告诉 Webpack:“遇到这些模块时,不要打包它们,而是在运行时从外部全局变量或 CommonJS 环境里去取。” 配置方式是在 webpack.config.js 中增加 externals 字段。比如针对 React,可以这样写:
// webpack.config.js
module.exports = {
// ...
externals: {
react: 'React',
'react-dom': 'ReactDOM',
lodash: '_',
},
};
这里的键名是模块名,值是运行时从宿主环境读取的全局变量名。当产物是浏览器 UMD 格式时,Webpack 会把这些导入语句转换为对全局变量的引用。如果产物是 CommonJS 格式,则会把 require('react') 原样保留,交给使用方去解析。由于 externals 直接作用于打包环节,所以它能从物理上阻止依赖被打进产物,有效控制体积。
那么 externals 是所有场景都需要的吗?也不尽然。如果你开发的是纯工具库,并且希望尽量减小体积但又不要求宿主环境安装特定依赖,也可以直接把某个小依赖打包进产物,避免用户还要额外安装。但当你面对 React、Vue、lodash 这类“宿主环境通常已经存在”的依赖时,使用 externals 是更合理的选择。
peerDependencies 到底在声明什么
externals 只解决 Webpack 打包阶段的“忽略”,它并不会告诉 npm 你的库依赖哪些外部包,更不会提示使用方安装对应依赖。假设你发布的 my-ui 使用了 externals 忽略 React,但 package.json 中没有做任何声明。用户在安装 my-ui 后,如果项目里没有 React,安装过程不会报错,但运行时就会抛出 React is not defined 的错误,使用体验非常糟糕。
这时就需要 peerDependencies 出场了。在 package.json 中声明 peerDependencies,本质上是在表达:“我的库需要某个依赖,但我不会替你安装它,请你在宿主项目中提供并保证版本兼容。” npm 安装依赖时,如果发现当前项目没有满足要求的 peerDependencies,会打印警告提示用户自行安装。较新版本的 npm 甚至会尝试自动安装(不同版本行为差异较大),但一般情况下,把 peerDependencies 视为一种“缺失告警机制”更准确。
{
"name": "my-ui",
"version": "1.0.0",
"main": "dist/my-ui.js",
"peerDependencies": {
"react": ">=16.8.0",
"react-dom": ">=16.8.0"
}
}
注意,peerDependencies 中的版本写法通常要求一个宽松的范围,因为你无法强制宿主项目采用某个精确版本。只有当你的库确实使用了某个 API,而这个 API 只在某个版本之后才存在时,才需要设置下界。例如上例中要求 React 版本高于 16.8.0,目的是确保 hooks 可用。同时建议不要使用精确的 "react": "18.0.0",否则所有安装方都会被限制在一个固定版本上,容易引发依赖冲突。
此外,如果宿主项目没有正确安装 peerDependencies 中的依赖,npm 会在安装时输出类似 unmet peer dependency 的警告。这个警告对专业的前端开发者来说就是明确的信号:需要去补齐该依赖。所以说,peerDependencies 是 npm 生态中向使用者传达“请主动安装我需要的依赖”的正式渠道。
externals 与 peerDependencies 的关系和搭配
看到这里,你可能会问:既然 externals 已经不在打包时引入 React,为什么还要额外配置 peerDependencies?因为二者不是替代关系,而是分别作用于不同阶段。externals 解决的是“构建时如何生成模块引用”,peerDependencies 解决的是“安装时如何告知用户需要什么依赖”。一个成熟的 npm 库,通常两者都要配置。externals 保证产物中没有 React 的拷贝,peerDependencies 则确保用户知道必须自行安装 React,缺一不可。
举个实际例子:my-ui 的构建配置中声明了 externals,那么最终编译出的 dist/my-ui.js 里,原始代码中的 import React from 'react' 会被转换成(取决于 libraryTarget)类似 module.exports = require('react')(CommonJS)或者 var React = window.React(UMD)的形式。如果使用方是 Webpack 项目,那么使用方在打包自身业务代码时,会通过 npm 依赖解析去加载 react 模块。如果使用方项目根本没有安装 react,那么在打包阶段就会报错,或者运行时因为找不到模块而崩溃。因此 peerDependencies 必须在 package.json 中声明。
再看另一个常见的坑:有些人只在 externals 中写了 'react',但忘了同时声明 peerDependencies。于是库发出去后,npm install 时不提示任何缺少依赖的信息,用户代码中 import 了组件库,打包器去找 react 时才发现没有安装,最后自己在 node_modules 里翻半天。这是完全不必要的麻烦。反过来,只写 peerDependencies 而不写 externals,虽然 npm 会提示用户安装 React,但 Webpack 打包时依然会把 React 打进产物,最终导致重复依赖和包体膨胀。可见两者必须搭配使用。
另外还需注意 externals 的具体写法要与输出格式匹配。如果你计划让库同时支持 CommonJS、AMD 和浏览器全局变量,通常将 libraryTarget 设置为 umd,并配合 externals 对象或函数:
// webpack.config.js
const path = require('path');
module.exports = {
mode: 'production',
entry: './src/index.js',
output: {
path: path.resolve(__dirname, 'dist'),
filename: 'my-ui.js',
library: 'MyUI',
libraryTarget: 'umd',
globalObject: 'this',
},
externals: {
react: {
root: 'React',
commonjs: 'react',
commonjs2: 'react',
amd: 'react',
},
'react-dom': {
root: 'ReactDOM',
commonjs: 'react-dom',
commonjs2: 'react-dom',
amd: 'react-dom',
},
},
// 其他配置省略
};
这种写法能适配不同的模块环境:在浏览器脚本直接引入时,库会从 window.React 获取模块;在 CommonJS 环境中,则使用 require('react')。如果宿主项目使用打包器,模块解析会完全交给打包器处理,不会与全局变量冲突。
完整示例:发布一个 React 组件库的配置
下面我们用一个最小但完整的示例串联整个配置流程。假设 my-ui 的源码中需要使用 React 和 ReactDOM,并对外导出一个按钮组件。首先在 package.json 中声明 peerDependencies,告诉使用方必须安装 React 相关依赖:
{
"name": "my-ui",
"version": "1.0.0",
"description": "A sample UI library for demonstration",
"main": "dist/my-ui.js",
"module": "dist/my-ui.esm.js",
"files": [
"dist",
"README.md"
],
"peerDependencies": {
"react": ">=16.8.0",
"react-dom": ">=16.8.0"
},
"devDependencies": {
"react": "^18.2.0",
"react-dom": "^18.2.0",
"webpack": "^5.80.0",
"webpack-cli": "^5.0.0",
"@babel/core": "^7.20.0",
"@babel/preset-react": "^7.18.0",
"babel-loader": "^9.1.0"
}
}
注意这里 React 放在 devDependencies 中,仅供库自身开发测试使用,不会随着库一起被安装到用户项目里。这也正是 peerDependencies 与 devDependencies 的典型配合:开发时需要它,但发布后不把它当作普通依赖。
在 Webpack 配置中,除了设置 externals,还要确保需要构建时忽略 React。如果使用 Babel 处理 JSX,需要保留正确的解析配置:
// webpack.config.js
const path = require('path');
module.exports = {
mode: 'production',
entry: './src/index.js',
output: {
path: path.resolve(__dirname, 'dist'),
filename: 'my-ui.js',
library: 'MyUI',
libraryTarget: 'umd',
globalObject: 'this',
clean: true,
},
module: {
rules: [
{
test: /\.jsx?$/,
exclude: /node_modules/,
use: {
loader: 'babel-loader',
options: {
presets: ['@babel/preset-env', '@babel/preset-react'],
},
},
},
],
},
externals: {
react: {
root: 'React',
commonjs: 'react',
commonjs2: 'react',
amd: 'react',
},
'react-dom': {
root: 'ReactDOM',
commonjs: 'react-dom',
commonjs2: 'react-dom',
amd: 'react-dom',
},
},
};
在这个配置中,Webpack 将不会把 React 及其 API 打包进产物,而是保留对它们的引用。使用方在自己的项目中安装完依赖后,自然会按照自身的依赖树去加载 React 版本。如果宿主项目使用的是 React 17,而你的库需要的 API 在 React 17 下也兼容,那么运行没有问题;如果宿主项目是 React 15,那么 peerDependencies 指定的版本范围(>=16.8.0)就会触发安装警告,提示版本可能存在兼容性风险。
如果你还需要输出 ES Module 版本,方便使用原生 import 的现代项目构建,可以额外设置一个 webpack 配置入口,输出格式为 libraryTarget: 'module',这需要 Webpack 5 的实验特性支持,或者使用打包器直接输出 ESM。不过通常更简单的做法是使用 tsup、rollup 等工具来生成 ESM 格式。但不管使用什么构建工具,externals 和 peerDependencies 依赖管理的理念是相通的。
最后再强调一个容易忽略的点:peerDependencies 中的版本范围不要与 devDependencies 中的实际安装版本完全脱节。假设你在开发库时使用的是 React 18,但 peerDependencies 写成 "react": "^16.0.0",那么用户在 React 16 的环境中使用你的库时,因为你的代码可能使用了 React 18 独有的 API(如并发特性的某些用法),运行时就会出现诡异错误。为了不误导使用者,最好让 peerDependencies 的下界覆盖你测试过的版本,并在文档中注明最低支持版本。
总结一下,externals 负责“构建时隔离”,peerDependencies 负责“安装时告知”。两者相互配合,才能让你的 npm 库既保持体积轻量,又避免与宿主环境发生依赖冲突。在发布前,务必同时检查这两处配置,把它们看作 npm 库工程规范中不可或缺的一部分。
webpackexternalspeerDependencies修改时间:2026-08-28 08:07:56