组件库打包格式选型:ESM、CJS、UMD 的工程权衡
一、前端组件库打包格式的技术背景
前端组件库需要同时支持多种使用场景:现代构建工具(Vite、Webpack 5)、传统 Node.js 环境、CDN 直接引入。不同场景对模块格式的要求不同,单一格式无法满足所有用户。
当前主流的模块格式包括:
- ESM(ECMAScript Modules):现代 JavaScript 标准模块系统,支持静态分析、Tree Shaking
- CJS(CommonJS):Node.js 传统模块系统,require() 语法
- UMD(Universal Module Definition):通用模块定义,兼容 AMD、CJS 和全局变量
选择哪些格式进行打包,需要在兼容性、包体积、构建复杂度之间进行权衡。
// ESM 格式示例(现代构建工具首选) export const Button = ({ children, onClick }) => { return <button onClick={onClick}>{children}</button>; }; export default Button; // CJS 格式示例(Node.js / 旧版工具) exports.Button = ({ children, onClick }) => { return React.createElement('button', { onClick }, children); }; module.exports = exports; // UMD 格式示例(浏览器直接引入) (function (root, factory) { if (typeof define === 'function' && define.amd) { define(['react'], factory); } else if (typeof exports === 'object') { module.exports = factory(require('react')); } else { root.Button = factory(root.React); } })(this, function (React) { return { Button: ({ children, onClick }) => { return React.createElement('button', { onClick }, children); } }; });二、三种格式的工程特性对比
ESM的优势在于支持静态分析,构建工具能够识别未使用的导出并进行 Tree Shaking,显著减少最终包体积。现代浏览器原生支持 ESM,可以通过<script type="module">直接加载。
ESM 的劣势在于兼容性问题。旧版 Node.js(< 12)对 ESM 支持不完善,部分传统工具链无法正确处理 ESM 模块。
CJS的优势在于广泛的工具链兼容性。所有 Node.js 版本都支持 CJS,大量现有工具依赖 CJS 格式。
CJS 的劣势在于不支持静态分析,无法实现 Tree Shaking。动态 require() 使得构建工具难以优化。
UMD的优势在于真正的通用性,可以在任何环境中运行。适合需要通过 CDN 直接引入组件库的场景。
UMD 的劣势在于文件体积较大(包含兼容代码),不支持 Tree Shaking,且难以进行代码分割。
graph TD A[模块格式选型] --> B{目标用户群体} B -->|现代构建工具| C[优先 ESM] B -->|Node.js 环境| D[优先 CJS] B -->|CDN 引入| E[优先 UMD] B -->|全场景支持| F[多格式打包] C --> G[优势:Tree Shaking<br/>劣势:兼容性] D --> H[优势:兼容性好<br/>劣势:无静态分析] E --> I[优势:通用性强<br/>劣势:体积大] F --> J[优势:覆盖面广<br/>劣势:构建复杂] style C fill:#e8f5e9 style D fill:#e3f2fd style E fill:#fff3e0 style F fill:#f3e5f5实际选型建议:
- 优先 ESM:面向现代前端项目,使用 Vite、Webpack 5、Rollup 等支持 ESM 的构建工具
- 保留 CJS:兼容旧版工具链和 Node.js 环境
- 可选 UMD:仅当有 CDN 引入需求时提供
// package.json 中的模块入口配置 { "name": "my-component-library", "version": "1.0.0", "main": "./dist/index.cjs", // CJS 入口(传统工具) "module": "./dist/index.js", // ESM 入口(现代构建工具) "browser": "./dist/index.umd.js", // UMD 入口(浏览器) "exports": { ".": { "import": "./dist/index.js", // ESM 导入 "require": "./dist/index.cjs", // CJS 导入 "browser": "./dist/index.umd.js" // 浏览器环境 } }, "files": [ "dist" ], "peerDependencies": { "react": ">=16.8.0", "react-dom": ">=16.8.0" } }选型决策的实际踩坑:曾有一个组件库同时输出了 ESM 和 CJS,但package.json的main字段指向 CJS,module字段指向 ESM。在一个使用 Webpack 5 的项目中,由于配置了resolve.mainFields优先级问题,Webpack 命中了 ESM 版本,但 ESM 版本中引用的 CSS 文件在node_modules下未被正确处理,导致构建报错。解决方式是在exports字段中为不同环境提供精确的入口路径,并把所有带有 CSS 导入的文件在sideEffects中标记。
三、实战:使用 Rollup 打包多格式组件库
以下展示一个完整的 Rollup 配置,同时输出 ESM、CJS、UMD 三种格式。
项目结构:
my-component-library/ ├── src/ │ ├── components/ │ │ ├── Button/ │ │ │ ├── Button.tsx │ │ │ └── index.ts │ │ └── Modal/ │ │ ├── Modal.tsx │ │ └── index.ts │ ├── index.ts // 库入口 │ └── types.ts // 共享类型 ├── dist/ // 构建输出 ├── rollup.config.js // Rollup 配置 ├── tsconfig.json // TypeScript 配置 └── package.json源代码(src/index.ts):
// src/index.ts - 组件库入口 export { Button } from './components/Button'; export { Modal } from './components/Modal'; export type { ButtonProps } from './components/Button'; export type { ModalProps } from './components/Modal'; // src/components/Button/Button.tsx import React, { forwardRef, ButtonHTMLAttributes } from 'react'; import './Button.css'; export interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> { variant?: 'primary' | 'secondary' | 'outline'; size?: 'small' | 'medium' | 'large'; loading?: boolean; } export const Button = forwardRef<HTMLButtonElement, ButtonProps>( ({ variant = 'primary', size = 'medium', loading = false, disabled, children, className = '', ...props }, ref) => { const classNames = [ 'btn', `btn--${variant}`, `btn--${size}`, loading ? 'btn--loading' : '', disabled ? 'btn--disabled' : '', className ].filter(Boolean).join(' '); return ( <button ref={ref} className={classNames} disabled={disabled || loading} aria-busy={loading} {...props} > {loading && <span className="btn-spinner" />} {children} </button> ); } ); Button.displayName = 'Button'; export default Button; // src/components/Modal/Modal.tsx import React, { useEffect, useCallback } from 'react'; import { createPortal } from 'react-dom'; import './Modal.css'; export interface ModalProps { isOpen: boolean; onClose: () => void; title?: string; children: React.ReactNode; size?: 'small' | 'medium' | 'large'; } export const Modal: React.FC<ModalProps> = ({ isOpen, onClose, title, children, size = 'medium' }) => { const handleEscape = useCallback((e: KeyboardEvent) => { if (e.key === 'Escape' && isOpen) { onClose(); } }, [isOpen, onClose]); useEffect(() => { if (isOpen) { document.addEventListener('keydown', handleEscape); document.body.style.overflow = 'hidden'; } return () => { document.removeEventListener('keydown', handleEscape); document.body.style.overflow = ''; }; }, [isOpen, handleEscape]); if (!isOpen) return null; const modalContent = ( <div className="modal-overlay" onClick={onClose}> <div className={`modal modal--${size}`} onClick={e => e.stopPropagation()} role="dialog" aria-modal="true" aria-label={title ?? '对话框'} > {title && ( <div className="modal-header"> <h2 className="modal-title">{title}</h2> <button className="modal-close" onClick={onClose} aria-label="关闭对话框" > × </button> </div> )} <div className="modal-body"> {children} </div> </div> </div> ); return createPortal(modalContent, document.body); }; export default Modal;Rollup 配置(rollup.config.js):
// rollup.config.js import { defineConfig } from 'rollup'; import typescript from '@rollup/plugin-typescript'; import commonjs from '@rollup/plugin-commonjs'; import resolve from '@rollup/plugin-node-resolve'; import postcss from 'rollup-plugin-postcss'; import { terser } from 'rollup-plugin-terser'; import dts from 'rollup-plugin-dts'; import peerDepsExternal from 'rollup-plugin-peer-deps-external'; // 通用插件配置 const commonPlugins = [ peerDepsExternal(), // 排除 peerDependencies resolve(), commonjs(), typescript({ tsconfig: './tsconfig.json' }), postcss({ extensions: ['.css'], extract: true, minimize: true }) ]; // ESM 构建配置 const esmConfig = defineConfig({ input: 'src/index.ts', output: { dir: 'dist/esm', format: 'esm', sourcemap: true, entryFileNames: '[name].js', chunkFileNames: 'chunks/[name]-[hash].js' }, plugins: [ ...commonPlugins, terser() // 生产环境压缩 ], external: ['react', 'react-dom', 'react/jsx-runtime'] }); // CJS 构建配置 const cjsConfig = defineConfig({ input: 'src/index.ts', output: { dir: 'dist/cjs', format: 'cjs', sourcemap: true, entryFileNames: '[name].cjs', chunkFileNames: 'chunks/[name]-[hash].cjs', exports: 'named' // 命名导出 }, plugins: [ ...commonPlugins, terser() ], external: ['react', 'react-dom', 'react/jsx-runtime'] }); // UMD 构建配置 const umdConfig = defineConfig({ input: 'src/index.ts', output: { file: 'dist/index.umd.js', format: 'umd', name: 'MyComponentLibrary', // 全局变量名 sourcemap: true, globals: { 'react': 'React', 'react-dom': 'ReactDOM' } }, plugins: [ ...commonPlugins, terser() ] }); // 类型定义构建配置 const dtsConfig = defineConfig({ input: 'dist/types/index.d.ts', output: { file: 'dist/index.d.ts', format: 'esm' }, plugins: [dts()], external: ['react', 'react-dom'] }); // 根据环境变量决定构建哪些格式 const buildTargets = process.env.BUILD_TARGET || 'all'; const configs = { esm: esmConfig, cjs: cjsConfig, umd: umdConfig, dts: dtsConfig, all: [esmConfig, cjsConfig, umdConfig, dtsConfig] }; export default configs[buildTargets] || configs.all;TypeScript 配置(tsconfig.json):
{ "compilerOptions": { "target": "ES2020", "lib": ["ES2020", "DOM", "DOM.Iterable"], "module": "ESNext", "moduleResolution": "node", "jsx": "react-jsx", "declaration": true, "declarationDir": "dist/types", "outDir": "dist", "strict": true, "esModuleInterop": true, "skipLibCheck": true, "forceConsistentCasingInFileNames": true, "resolveJsonModule": true, "isolatedModules": true, "noEmit": false }, "include": ["src"], "exclude": ["node_modules", "dist", "**/*.test.tsx"] }package.json 配置:
{ "name": "my-component-library", "version": "1.0.0", "description": "A modern React component library", "main": "./dist/cjs/index.cjs", "module": "./dist/esm/index.js", "browser": "./dist/index.umd.js", "types": "./dist/index.d.ts", "files": [ "dist" ], "scripts": { "dev": "rollup -c --watch", "build": "rm -rf dist && rollup -c", "build:esm": "BUILD_TARGET=esm rollup -c", "build:cjs": "BUILD_TARGET=cjs rollup -c", "build:umd": "BUILD_TARGET=umd rollup -c", "type-check": "tsc --noEmit" }, "peerDependencies": { "react": ">=16.8.0", "react-dom": ">=16.8.0" }, "devDependencies": { "@rollup/plugin-commonjs": "^25.0.0", "@rollup/plugin-node-resolve": "^15.0.0", "@rollup/plugin-typescript": "^11.0.0", "@types/react": "^18.2.0", "@types/react-dom": "^18.2.0", "rollup": "^3.0.0", "rollup-plugin-dts": "^6.0.0", "rollup-plugin-peer-deps-external": "^2.2.4", "rollup-plugin-postcss": "^4.0.0", "rollup-plugin-terser": "^7.0.0", "typescript": "^5.0.0", "react": "^18.2.0", "react-dom": "^18.2.0" } }Rollup 多格式打包的实战经验:使用rollup-plugin-dts打包类型定义时,如果组件库中包含import './Component.css'这样的样式导入,dts 插件会因为无法处理 CSS 导入而报错。解决方案是用@rollup/plugin-typescript先生成.d.ts到临时目录,再用 dts 插件单独打包,跳过 CSS 相关的导入声明。
另外,peerDepsExternal插件虽然能把 React 等 peer 依赖标记为 external,但在 UMD 模式下需要设置globals把 React 映射为window.React,否则 UMD 打包产物会包含一个完整的 React 副本,体积膨胀到数百 KB。
四、打包优化的工程实践
Tree Shaking 支持:确保 ESM 格式的导出是静态可分析的。避免使用export default { ... }这种动态导出方式。
// 错误示例:阻碍 Tree Shaking export default { Button, Modal }; // 正确示例:支持 Tree Shaking export { Button } from './Button'; export { Modal } from './Modal';副作用标记:在 package.json 中标记无副作用的文件,帮助构建工具优化。
{ "sideEffects": [ "*.css", "*.scss" ] }多入口打包:对于大型组件库,可以提供按组件拆分的入口,让用户按需引入。
// rollup.config.js - 多入口配置 const componentEntries = fs.readdirSync('src/components') .filter(file => fs.statSync(path.join('src/components', file)).isDirectory()) .reduce((entries, component) => { entries[component] = `src/components/${component}/index.ts`; return entries; }, {}); const esmConfig = defineConfig({ input: { index: 'src/index.ts', ...componentEntries }, output: { dir: 'dist/esm', format: 'esm', preserveModules: true, // 保留模块结构 preserveModulesRoot: 'src' }, // ... 其他配置 });类型定义打包:使用 rollup-plugin-dts 生成类型定义文件,确保所有格式都有对应的类型支持。
// 类型定义的子路径导出配置 { "exports": { ".": { "import": "./dist/esm/index.js", "require": "./dist/cjs/index.cjs", "types": "./dist/index.d.ts" }, "./button": { "import": "./dist/esm/Button/index.js", "require": "./dist/cjs/Button/index.cjs", "types": "./dist/types/components/Button/index.d.ts" } } }Tree Shaking 与按需加载的工程心得:preserveModules: true虽然能保留模块结构、支持用户按组件单独引入,但会生成大量小文件(一个组件库可能有上百个.js文件),对版本管理不友好。折中方案是按"二级入口"拆分:把组件的子包(如 antd 的lib/button)作为独立 exports,组件内部细节不暴露。
sideEffects的配置也容易踩坑:如果某个组件文件中导入了全局 CSS 且有副作用(如注册全局 class),但在sideEffects: false下被 Tree Shaking 掉,会导致样式丢失。正确做法是只把"无副作用"的文件标记为 false,把所有带样式或全局注册逻辑的文件都列入sideEffects数组。
五、总结
组件库打包格式选型需要根据目标用户和使用场景进行权衡。ESM 是现代前端项目的首选,CJS 用于兼容旧工具链,UMD 仅在需要 CDN 引入时提供。
使用 Rollup 等现代打包工具,可以同时输出多种格式,并通过 package.json 的 exports 字段提供清晰的入口指引。
工程化实践中,需要关注 Tree Shaking 支持、副作用标记、类型定义生成等环节,确保组件库提供良好的开发体验和最优的包体积。