组件库打包格式选型:ESM、CJS、UMD 的工程权衡

📅 发布时间:2026/7/10 0:08:10
组件库打包格式选型:ESM、CJS、UMD 的工程权衡 组件库打包格式选型ESM、CJS、UMD 的工程权衡一、前端组件库打包格式的技术背景前端组件库需要同时支持多种使用场景现代构建工具Vite、Webpack 5、传统 Node.js 环境、CDN 直接引入。不同场景对模块格式的要求不同单一格式无法满足所有用户。当前主流的模块格式包括ESMECMAScript Modules现代 JavaScript 标准模块系统支持静态分析、Tree ShakingCJSCommonJSNode.js 传统模块系统require() 语法UMDUniversal 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 typemodule直接加载。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 Shakingbr/劣势兼容性] 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字段指向 CJSmodule字段指向 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 ButtonHTMLAttributesHTMLButtonElement { variant?: primary | secondary | outline; size?: small | medium | large; loading?: boolean; } export const Button forwardRefHTMLButtonElement, 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 classNamebtn-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.FCModalProps ({ 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 classNamemodal-overlay onClick{onClose} div className{modal modal--${size}} onClick{e e.stopPropagation()} roledialog aria-modaltrue aria-label{title ?? 对话框} {title ( div classNamemodal-header h2 classNamemodal-title{title}/h2 button classNamemodal-close onClick{onClose} aria-label关闭对话框 × /button /div )} div classNamemodal-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_TARGETesm rollup -c, build:cjs: BUILD_TARGETcjs rollup -c, build:umd: BUILD_TARGETumd 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 支持、副作用标记、类型定义生成等环节确保组件库提供良好的开发体验和最优的包体积。