这个月我干了一件我们架构组早就想干、但一直被业务排期压着的事:把内部维护了三年的公共组件库从“发布前必须跑一轮构建”改成了“源码即产物”,也就是整套发布链路彻底变成了纯 ESM、零构建。没动任何组件逻辑,只是把发布管线重写了一遍,结果下游十几个项目里,有八个直接删掉了针对组件库的 webpack alias 和 babel 配置,构建时间平均降了 30% 以上。今天把整个过程和踩过的坑完整写出来,给同样在维护公共组件库、被构建链折磨的朋友一个可落地的参考。
这篇文章适合两类人:一类是组件库维护者,想摆脱“每次改一行代码就要等 rollup 跑几十秒”的困境;另一类是业务项目里被公共组件库的叠加构建坑过的人,比如反复出现双实例、样式被 tree-shaking 干掉、sourcemap 对不上这类问题。我会先讲清楚为什么纯 ESM 能解决这些问题,然后按迁移前的体检、改造链路、接入姿势、踩坑记录、不适配场景这个顺序展开,全程都有配置和代码示例,可以直接抄。
1. 为什么非要做纯 ESM:从一次“构建地狱”说起
先还原一下改造前的状态。我们的公共组件库 my-ui 大概 40 多个组件,源码用 TS 编写,内部依赖了自己的两个基础包 my-utils 和 my-icons。传统发布流程是:每个包各自用 rollup 打包成 dist/esm 和 dist/umd 两个目录,再发布到私有 npm。这听起来很常规,但实际用起来有四个问题,我相信维护过组件库的人都能共鸣:
第一,叠加构建。业务项目用 webpack 或 vite 做应用构建,组件库本身又经过一次 rollup 预构建。组件库里依赖的 my-utils 又被 my-icons 依赖,于是一条 import 链路上可能叠加了四到五层构建。改一行底层工具的代码,要等 my-utils 构建、my-icons 构建、my-ui 构建,再等业务项目构建,一次全量验证跑下来要十几分钟。
第二,双实例和版本错位。因为每个包发布时把依赖的版本写死在了 dependencies 里,业务项目经常出现两个版本的 my-utils,React 或者 Vue 被重复打包。这类问题排查起来极其痛苦,instanceof 判断失败、事件总线收不到消息、context 失效,全是这类坑。
第三,sourcemap 对不上。组件库发布的是 rollup 处理后的产物,业务项目 debug 时经过多层 sourcemap 映射,最终看到的代码和源码经常对不上,尤其当业务项目还开了 babel 时,断点位置直接漂移到天边。
第四,构建脚本本身就是维护负担。外部依赖升级导致 rollup 插件兼容性问题、babel 配置要同步、externals 列表要跟着依赖关系改。构建本身成为了一等公民,反而组件本身的开发时间被压缩了。
纯 ESM 方案直接把这些问题从根上砍掉:组件库发布的产物就是源码本身,不做任何转译和打包。浏览器的 import 语法、Node.js 的 ESM loader、vite 的预构建机制,都能直接消费源码。下游项目拿到的是我们写的原始代码,没有中间产物,就没有 sourcemap 失真、双实例、叠加构建这些问题。
| 维度 | CommonJS | ES Module | UMD |
|---|---|---|---|
| 浏览器原生支持 | 不直接支持 | 支持 | 支持 |
| Node.js 原生支持 | 支持 | 支持(Node 12.17+) | 通过 CJS 包装 |
| 静态分析/tree-shaking | 很难 | 原生支持 | 基本不支持 |
| 同步加载 | 支持 | 支持 | 支持 |
| 顶层 await | 不支持 | 支持 | 不支持 |
| 循环依赖处理 | 运行时 | 编译期 | 运行时 |
| 依赖版本复用 | 容易重复 | 天然共享 | 容易重复 |
注意最后一行,ESM 天然共享依赖这一点,是解决组件库双实例问题的关键。只要组件库把外部依赖声明为 peerDependencies,业务项目安装的 React/Vue 版本就是唯一实例,不会因为组件库内部又锁了一份依赖而出问题。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 迁移前的体检:先搞清楚组件库的家底
不要一上来就改代码,先做一次系统性的体检。我建议按下面四个维度挨个排查,任何一个不过关,都不能直接切纯 ESM。
2.1 盘点产物和入口
先看 package.json 里的入口字段。老的组件库通常是:
json复制{
"main": "dist/cjs/index.js",
"module": "dist/esm/index.js",
"typings": "dist/types/index.d.ts"
}
要切换成纯 ESM,main、module、typings 这三个字段就只能当兼容入口保留,核心逻辑要全部挪到 exports 字段里。在动手前,先列出当前的产物目录清单、哪些文件是构建生成的、哪些是手写的,避免后面误删。
2.2 静态扫描 CJS 痕迹
这一步是硬性检查。组件库源码里只要还存在以下代码,拉到原生 ESM 环境就会直接报错:
require(...)module.exports__dirname/__filenameprocess.env.NODE_ENV- 未显式带扩展名的相对导入(
import './utils') import json from './data.json'
我当时的做法是写一个简单的 Node 脚本,用正则加 esbuild 的 transform API 做一次全量扫描,把命中点位的文件路径和行号全部列出来:
bash复制npx esbuild src/index.ts --bundle --format=esm --outfile=/tmp/check.js --external:* --log-level=warning
这个命令不会真的生成可用产物,但会报出所有无法被 ESM 解析的语法。跑完一遍,你会得到一个很清楚的改造清单。
2.3 检查依赖树的 ESM 兼容性
组件库依赖的不只是自己,还有外部包。这一步要逐个检查依赖是否支持 ESM:
- 看依赖的
package.json是否有exports字段,exports里是否有"import"条件; - 如果依赖只有 CJS,确认你的组件库在“不在构建期处理它”的前提下,能否被 Node 或浏览器接受。Node 可以直接
importCJS 模块,但浏览器不行; - Vite 这类工具会自动做 CJS 依赖预构建,所以如果你的下游主要是 Vite,CJS 依赖也能跑,但要明确这个前提。
我当时列了一张表,把每个依赖的 exports 条件、是否含 "type": "module"、版本号一一记录,然后根据结果决定哪些依赖要升级、哪些要替换、哪些要 external 掉。
2.4 模拟一次纯 ESM 消费
体检的最后一个环节,不是看代码,而是直接试跑。我建议在临时目录里做一次真实模拟:把组件库源码直接 npm link 到本地,然后用原生的 Node ESM 去加载,或者用一个空白的 Vite 项目去引用。
这一步能暴露出所有打包工具“帮你隐藏”的问题。比如源码里 import text from './copy.json',在 webpack 里会被自动处理,但在原生 Node ESM 下就直接报错。不要依赖 bundler 的容错,因为你的目标是“零构建也可用”,而不是“打包环境下能用”。
体检做完,如果发现组件库里 __dirname 用了十几次、JSON 导入有七八处、所有相对导入都没加扩展名,不用慌,这些是纯 ESM 改造的标准工作项,下一节逐个处理。
3. 改造的完整链路:从源码到纯 ESM 产物
体检通过后,进入正式改造。我按模块拆开讲,每一步都给出改造前和改造后的对比。
3.1 package.json 入口重构
这是最关键的一步。改造后的 package.json 核心部分长这样:
json复制{
"name": "my-ui",
"version": "3.0.0",
"type": "module",
"main": "./src/index.js",
"module": "./src/index.js",
"types": "./src/index.d.ts",
"exports": {
".": {
"types": "./src/index.d.ts",
"import": "./src/index.js",
"default": "./src/index.js"
},
"./button": {
"types": "./src/button/index.d.ts",
"import": "./src/button/index.js",
"default": "./src/button/index.js"
},
"./style.css": "./src/style.css",
"./package.json": "./package.json"
},
"sideEffects": [
"**/*.css",
"**/*.scss"
],
"peerDependencies": {
"react": ">=17.0.0",
"react-dom": ">=17.0.0"
}
}
几个关键点:
"type": "module"声明包内.js文件全部按 ESM 解析。这对 Node 环境尤其重要,不然 Node 会把.js当成 CJS 处理。exports里的"types"条件要放在最前面,TS 编译器会优先读取。"./package.json"一定要导出。很多下游工具会读取组件库的package.json,如果不导出,在纯 ESM 的严格解析下会报错。- 子路径导出(
"./button")按组件目录逐一声明。这一步比较繁琐,建议写一个脚本根据目录结构自动生成,别手写,容易漏。 sideEffects列了 CSS 文件类型,这会影响 tree-shaking,后面第 5 节细说。
3.2 源码中的 Node API 适配
__dirname 在 ESM 里不存在,需要用 import.meta.url 替代。最标准的写法是:
js复制import { fileURLToPath } from 'node:url'
import { dirname } from 'node:path'
const __filename = fileURLToPath(import.meta.url)
const __dirname = dirname(__filename)
但要注意一个前提:如果组件库要在浏览器里直接跑,node:url 和 node:path 这些 Node 内置模块是不能用的。我当时的做法是在源码里隔离出一层 platform.ts,把涉及路径和环境的逻辑全部收口,浏览器环境下用简单字符串拼接,Node 环境下用 import.meta.url 获取文件路径。这样既保住了 Node 侧的能力,又不污染浏览器侧。
process.env.NODE_ENV 这类代码相对简单,改成:
js复制const isDev = typeof process !== 'undefined' && process.env?.NODE_ENV === 'development'
如果组件库只在浏览器端运行,更推荐直接用编译期常量 import.meta.env.DEV 或交给下游注入,不要在源码里硬编码环境判断。
3.3 相对导入必须写全扩展名
这个坑我重点说,因为几乎所有组件库迁移时都会在这里翻车。在 webpack 和 rollup 的构建世界里,import './button' 是合法的,解析器会自动补全 ./button/index.js。但原生 ESM 不会,加载器拿到 ./button 后会按字面意思找文件,找不到就抛 ERR_MODULE_NOT_FOUND。
所以源码里的每一个相对导入都要改成带 .js 的形式:
js复制// 改造前
import Button from './button'
import { classNames } from '../utils'
import { config } from '../config.json'
// 改造后
import Button from './button/index.js'
import { classNames } from '../utils/classnames.js'
import { config } from '../config.js'
注意 JSON 文件。原生 ESM 对 JSON Module 的支持依赖 import attributes 语法,浏览器兼容性还不够统一,所以最好的做法是把 config.json 改成 config.js,内容变成 export default { ... },一步到位。
如果源码量很大,改起来容易漏。我建议在 package.json 里加一个 lint:imports 脚本,自己写个正则检查所有相对导入路径是否带 .js。这不是额外负担,是纯 ESM 交付的必要保障。
3.4 外部依赖的 external 策略
组件库不能把 React/Vue 打包进产物里,否则下游必然出现双实例。正确做法是用 peerDependencies 声明外部依赖,然后在源码里直接 import,不需要任何构建处理。
还有一类依赖比较特殊,比如组件库内部的部分工具函数,如果它们本身也是 ESM 包,直接依赖即可;如果是 CJS 包且下游走的是 Vite,交给 Vite 预构建也能跑通。但要明确你支持的环境矩阵,不能笼统说“零构建就是什么都不管”。
3.5 CSS 和资源文件的处理方案
组件库的样式处理有两派做法:
- 做法 A:在 JS 里
import './style.css'。这个写法在原生浏览器里不合法,Node 里也会报错,只能依赖 bundler 处理。如果你要保证零构建可用,不建议作为唯一方案。 - 做法 B:JS 不引样式,CSS 独立导出,由使用方按需引入。这是我现在采用的方式。
改造后组件库会提供 my-ui/style.css 或按组件拆分的 my-ui/button/style.css,下游可以手动引入,也可以在 bundler 环境里通过 import 'my-ui/style.css' 引入。这样零构建场景完全可用,打包场景也能正常 tree-shaking。
sideEffects 字段要对应做好声明。如果写 "sideEffects": false,打包器会认为所有模块都没有副作用,一旦有人还在用 import 'my-ui/style.css',样式模块会被直接削掉。这就是为什么我在 package.json 里写的是:
json复制"sideEffects": ["**/*.css", "**/*.scss"]
明确告诉打包器:CSS 文件是有副作用的,不要动。
4. 零构建接入的几种姿势:本地调试、npm 链路、浏览器直引
改完源码和 package.json,最激动人心也最容易出问题的环节就是“验证到底能不能零构建接入”。我实际试下来,有三种典型接入姿势,复杂度和适用场景各不相同。
4.1 本地调试:Vite 作为开发服务器
这里要先澄清一个容易误解的概念:零构建不是“没有开发服务器”,而是“发布产物不需要构建、下游使用不需要针对组件库做额外构建配置”。本地开发时,我推荐直接用一个极简的 Vite 脚手架来调试组件库:
bash复制npm create vite@latest my-ui-dev -- --template vanilla
cd my-ui-dev
npm link ../my-ui
然后在 index.html 里直接写:
html复制<script type="module">
import { Button } from 'my-ui'
document.body.appendChild(Button())
</script>
Vite 启动后会把 my-ui 的源码直接转换给浏览器,但不会生成任何中间文件。这样验证的就是“组件库源码在真实环境下的可加载性”,而不是 rollup 打完包之后的可用性。这个差别非常重要,打包后能用不代表源码能跑,反过来才是零构建的意义。
4.2 npm 链路:业务项目直接消费
在业务项目里,改造前后的引用方式几乎没有变化:
js复制// 改造前:引用打包后的 dist
import { Button } from 'my-ui' // 指向 dist/esm/index.js
// 改造后:直接命中源码
import { Button } from 'my-ui' // 指向 src/index.js
因为 exports 字段的 "import" 条件已经把入口指到了 src/index.js。业务项目不需要配置 resolve.alias、不需要手动 externals、不需要 babel 转译——前提是团队对浏览器版本有统一要求。我们当时的底线是 Chrome 80+,所以 ESM 原生语法直接用没问题。
4.3 浏览器直引:import maps 场景
最激进但也最能体现“零构建”价值的方案,是用 import maps 直接在页面里加载组件库。这种场景常见于不经过构建的营销页、内容页、CMS 动态页面:
html复制<script type="importmap">
{
"imports": {
"my-ui": "https://cdn.example.com/my-ui/src/index.js",
"my-ui/": "https://cdn.example.com/my-ui/src/"
}
}
</script>
<script type="module">
import { Button } from 'my-ui'
document.querySelector('#app').appendChild(Button())
</script>
注意 import maps 的写法,"my-ui" 和 "my-ui/" 两条要同时配置,否则子路径导入 import { Button } from 'my-ui/button' 会找不到。这算是一个小坑,但真到实际用时能帮你少查十分钟文档。
第三种姿势对静态资源托管和 HTTP/2 有要求,因为组件库源码是几十个甚至上百个小文件,HTTP/1.1 下并发请求会被浏览器限制,严重影响首屏。如果是内部系统且网关有 HTTP/2,可以直接忽略这个问题;如果面向公网且没有 CDN 聚合能力,建议按组件粒度做一次轻量合并,但要确保合并后仍是无构建的纯 ESM,不引入打包器。
5. 实际踩过的坑:Node 版本、exports 冒号、副作用标记
迁移过程中我踩过的坑,按“掉进去之后排查最痛苦”的程度排序,把前几个写出来。
5.1 exports 字段条件顺序导致双实例
第一次切完 exports 后,下游项目出现了一个非常隐蔽的怪现象:两种写法拿到的 Button 不是同一个组件实例。
js复制import { Button } from 'my-ui' // 命中 exports["."]["import"]
import { Button } from 'my-ui/button' // 命中 exports["./button"]["import"]
问题出在我的 exports 里 "./button" 和 "." 的解析逻辑。当 exports 同时存在 "." 和 "./button",且 "./button" 没有走到 "./button/index.js" 而是被某些打包器解析到了另一个入口时,就会出现两个模块实例。排查到最后发现,是某些旧版本打包器对 exports 子路径的解析有差异,它们不认 "./button" 这种没有显式 "index.js" 的写法。
修复方式是在 exports 里显式写出每个子路径的完整文件:
json复制"./button": {
"import": "./src/button/index.js"
}
不要写 "./button": "./src/button/index.js" 这种简写形式,宁可啰嗦一点,兼容性更好。
5.2 sideEffects: false 把样式干掉了
这个问题我们在某个业务项目里被坑得很惨:组件库升级到纯 ESM 后,业务项目里所有按钮的样式全没了,控制台也不报错。这个业务项目的 webpack 配置里开了 sideEffects: true 的优化,而我们的包恰好声明了 "sideEffects": false。
原因很简单:webpack 的 tree-shaking 认为 import 'my-ui/style.css' 是一个“无副作用”的导入,于是整行删掉。这就是为什么组件库的 sideEffects 必须把 CSS/SCSS 明确列出来。如果你的组件库里有全局注册、事件监听、自定义元素注册这类代码,也必须在 sideEffects 里列出来,否则都可能被“误杀”。
5.3 import.meta.url 在 Web Worker 里的行为差异
我在一个需要在高性能场景下动态加载资源的组件里用了 import.meta.url 来定位资源目录。在常规页面里一切正常,但放到 Web Worker 环境后,import.meta.url 指向的是 blob: 或 worker 脚本的地址,而不是组件库源码的地址,导致资源路径全部错乱。
这类问题没有标准答案,我的处理方式是把资源定位逻辑抽象成可覆盖的 hook,默认实现用 import.meta.url,在 Worker 环境里由使用方传入显式的资源前缀。这是在迁移前没预料到的,写出来提醒大家:纯 ESM 不只是语法层面的改变,运行环境对模块语义的影响也要纳入测试范围。
5.4 Node ESM 缓存语义与 CJS 的差别
如果组件库同时要支持 Node 侧(比如跑 SSR 或单元测试),要留意 ESM 的缓存语义。CJS 的 require 缓存可以手动清除,这在测试里经常用到;而 ESM 的缓存是基于模块 URL 的,通常不提供动态清除 API。如果你的组件库有状态型单例,在 SSR 场景下要避免模块级 let 变量持有请求上下文,否则会发生请求数据串扰。
5.5 大量小文件的请求开销
这是零构建唯一无法回避的物理问题。组件库拆成源码文件直接发布,意味着一个组件可能触发几十个模块请求。在开发阶段因为用 dev server,这个问题不明显;生产环境如果直接拖到浏览器里访问,HTTP/1.1 下并发限制会导致明显卡顿。
我实测过一组数据:40 个组件的组件库,在本地 HTTP/1.1 服务下全量加载需要 180 多个模块请求,首次加载耗时比打包后的版本多了约 40%;但走 HTTP/2 后,多路复用把耗时差距拉回到了 5% 以内。所以如果你准备上零构建,基础设施是否支持 HTTP/2 也该纳入评估。
6. 什么时候不该上零构建:评估与预案
技术方案没有银弹,纯 ESM 也不是所有组件库的最佳选择。如果遇到下面几种情况,我的建议是保守一点,不要强行切换。
第一,目标用户包含比较老旧的环境。比如你的组件库要兼容 IE11 或者很老版本的内嵌 WebView,原生 ESM 基本不可用。虽然有 es-module-shims 这类补丁方案,但它终究是运行时 polyfill,体积和性能代价都不小。这种情况下,保留一份打包产物作为兜底是更理性的做法。
第二,组件库依赖了大量 CJS 老包,且下游全是原生浏览器直引场景。浏览器不能像 Node 那样直接加载 CJS 模块,Vite 的预构建也只在开发链路里帮你处理。如果组件库的依赖树里有两三个无法升级的 CJS 包,零构建就变成了变相的“运行时包袱”,不划算。
第三,组件库文件极其细碎,且没有 HTTP/2 基础设施。前面说了,请求开销是物理限制。当单个组件会拆出十个以上模块文件时,公网 HTTP/1.1 环境下直接引源码的体验会很差。这时候可以退一步:组件库保持纯 ESM 源码交付,但额外提供一个“聚合器”脚本,按组件粒度把源码合并成少数几个 ESM 文件,仍然不做语法转译。这个方案保留了零构建的大部分优势,只是加了一个轻量合并步骤。
迁移策略上,我强烈建议采用渐进式,而不是一次性删掉构建脚本。我当时的时间线是这样的:
- 第一个月:源码改为纯 ESM 可加载,同时保留旧 dist 产物,
exports先指向旧产物; - 第二个月:切换到源码入口,保留旧产物作为故障回退;
- 第三个月:下线旧产物和构建脚本,彻底零构建发布。
这个过程中真正有价值的不是最终删除构建,而是让下游项目逐个验证源码入口的兼容性。每个业务团队的使用方式不一样,有的会依赖组件库的内部路径,有的会有样式覆盖需求,这些在代码里看不出来,只有在真实切换时才暴露。
最后再分享一个小技巧:切换完源码入口后,在 CI 里加一条冒烟任务,用 Node 直接 import('my-ui') 做一次启动检查。这条命令几乎零成本,但能拦截掉 90% 以上“代码改了但忘了更新导出”的问题。零构建的核心不是不要构建工具,而是让交付物和源码之间没有任何翻译层,把问题暴露在最早的那一刻。
