React 基础体系 · 第 59/70 篇。示例以 React 19、现代 TypeScript 和主流框架能力为基础;客户端与服务端边界会明确说明。
React 组件库发布:构建、类型、样式、Tree Shaking 和版本
一个 React 组件库的“发布”不是把几个 .tsx 文件上传到 npm。消费者真正安装的是一个需要被不同工具共同理解的协议:
- JavaScript 构建产物要能被现代 bundler 和 Node.js 加载;
- TypeScript 要能找到与运行时代码一致的声明文件;
- CSS 要被正确发布,并且不能被错误的 Tree Shaking 删除;
- React、React DOM 等宿主依赖不能被重复打包;
- 客户端组件与服务端组件的边界必须明确;
- 版本号要准确表达 API、类型、样式和运行时兼容性。
下面以 React 19、现代 TypeScript 和 ESM/CJS 双格式发布为基础,构建一个最小但完整的组件库。
一、先确定组件库的运行契约
假设组件库名为 @acme/ui,包含一个 Button 组件。消费者希望这样使用:
import { Button } from '@acme/ui';
import '@acme/ui/style.css';
export function Page() {
return <Button variant="primary">保存</Button>;
}
这里隐含了几个契约:
@acme/ui能解析到 JavaScript 入口;Button既有运行时代码,也有正确的 TypeScript 类型;@acme/ui/style.css是一个真实存在且会被发布的文件;- 组件库不会把消费者自己的 React 再打包一份;
- 未使用的组件能够被 bundler 删除;
- 如果组件内部使用了状态或事件处理器,服务端框架知道它必须在客户端运行;
- 新版本改变这些行为时,版本号能准确提示升级风险。
组件库可以抽象成一个映射:
其中任意一项缺失,都会造成不同类型的失败:
- 运行时代码缺失:安装成功,但
import时报错; - 类型声明缺失:运行正常,但编辑器和
tsc报错; - 样式资源缺失:组件出现但没有视觉效果;
- 依赖关系错误:React 重复加载、Hooks 报错或产物体积增加;
- 入口条件错误:ESM、CJS、Node、TypeScript 解析到不同文件。
因此,发布配置不是构建工具的附属物,而是组件库公共 API 的一部分。
二、一个可发布的目录结构
先建立一个最小目录:
acme-ui/
├─ src/
│ ├─ components/
│ │ └─ Button/
│ │ ├─ Button.tsx
│ │ └─ Button.css
│ └─ index.ts
├─ package.json
├─ tsconfig.json
└─ vite.config.ts
安装开发依赖:
npm install react react-dom
npm install -D typescript vite @vitejs/plugin-react
组件库通常不应把 react 和 react-dom 放进最终 JavaScript 包中,而应将它们声明为 peerDependencies。这里仍然需要作为 devDependencies 安装,原因是:
- 本地开发需要它们;
- TypeScript 需要它们的类型;
- 测试和构建需要它们;
- 发布包的消费者则提供实际运行时实例。
使用 React 19 时,可以明确声明:
{
"peerDependencies": {
"react": "^19.0.0",
"react-dom": "^19.0.0"
}
}
peerDependencies 的含义不是“构建时自动排除”,而是“这个包要求宿主项目提供兼容版本”。是否真的被排除,取决于构建工具的 external 配置。
三、组件代码:运行时 API 与类型 API 必须同时设计
3.1 一个带类型的 Button
src/components/Button/Button.tsx:
import type { ButtonHTMLAttributes, ReactNode } from 'react';
import './Button.css';
export interface ButtonProps
extends ButtonHTMLAttributes<HTMLButtonElement> {
variant?: 'primary' | 'secondary' | 'danger';
children: ReactNode;
}
export function Button({
variant = 'primary',
className,
children,
...rest
}: ButtonProps) {
const classes = [
'acme-button',
`acme-button--${variant}`,
className,
]
.filter(Boolean)
.join(' ');
return (
<button className={classes} {...rest}>
{children}
</button>
);
}
这里有几个重要的类型边界:
ButtonHTMLAttributes<HTMLButtonElement>让组件继承原生按钮属性,如disabled、type、onClick;variant是组件库自己的受限联合类型,消费者不能随意传入任意字符串;children: ReactNode允许文本、元素、Fragment 等 React 可渲染内容;import type只用于类型,不应生成运行时导入;...rest让原生属性继续传递到最终的<button>。
如果把 ButtonProps 只写在源文件中但不生成 .d.ts,消费者安装后虽然可能能运行,却无法获得这个公共类型。这就是“源码类型存在”和“发布类型存在”的区别。
3.2 样式文件
src/components/Button/Button.css:
.acme-button {
border: 0;
border-radius: 6px;
cursor: pointer;
font: inherit;
padding: 0.5rem 0.875rem;
}
.acme-button:disabled {
cursor: not-allowed;
opacity: 0.6;
}
.acme-button--primary {
background: #2563eb;
color: white;
}
.acme-button--secondary {
background: #e5e7eb;
color: #111827;
}
.acme-button--danger {
background: #dc2626;
color: white;
}
组件导出文件:
src/index.ts:
export { Button } from './components/Button/Button';
export type { ButtonProps } from './components/Button/Button';
export type 很重要。它明确告诉 TypeScript 和构建工具:ButtonProps 不是运行时值,不需要生成 JavaScript 导出。
四、TypeScript 配置:声明文件必须与 JavaScript 入口对应
使用现代 bundler 时,源码解析可以采用 moduleResolution: "Bundler":
tsconfig.json:
{
"compilerOptions": {
"target": "ES2022",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext",
"moduleResolution": "Bundler",
"jsx": "react-jsx",
"strict": true,
"isolatedModules": true,
"verbatimModuleSyntax": true,
"skipLibCheck": true,
"noEmit": true
},
"include": ["src"]
}
这些选项的作用不是完全相同的:
jsx: "react-jsx"使用现代 JSX 转换,不需要每个文件都写import React from 'react';strict: true打开严格类型检查;isolatedModules: true要求每个文件可以被独立转换,适合 Vite、esbuild 等工具;verbatimModuleSyntax: true强制区分类型导入和运行时导入;noEmit: true表示这份配置只做类型检查,JavaScript 由 Vite 构建;moduleResolution: "Bundler"模拟现代 bundler 的包入口解析。
声明文件单独生成时,可以新建 tsconfig.build.json:
{
"extends": "./tsconfig.json",
"compilerOptions": {
"noEmit": false,
"emitDeclarationOnly": true,
"declaration": true,
"declarationMap": true,
"outDir": "dist"
}
}
执行:
npx tsc -p tsconfig.build.json
预期得到类似结果:
dist/
├─ index.d.ts
├─ index.d.ts.map
└─ components/
└─ Button/
├─ Button.d.ts
└─ Button.d.ts.map
emitDeclarationOnly 只生成类型声明,不生成重复的 JavaScript。这样 JavaScript 由 Vite 负责,类型由 TypeScript 负责,职责清晰。
一个常见错误是把 declaration: true 和 Vite 的 JavaScript 输出混在一起,却没有验证最终目录结构。最终发布包中,package.json 的 types 或 exports.types 必须指向真实存在的 .d.ts 文件,否则消费者会遇到:
Could not find a declaration file for module '@acme/ui'
五、构建 JavaScript:ESM、CJS 与 React 外部化
5.1 ESM 与 CJS 的区别
ESM 使用静态模块语法:
import { Button } from '@acme/ui';
export { Button };
CJS 使用 CommonJS 语法:
const { Button } = require('@acme/ui');
exports.Button = Button;
现代前端 bundler 通常优先使用 ESM,因为:
import和export结构静态可分析;- Tree Shaking 更容易;
- 浏览器和现代 Node.js 原生支持能力更好。
仍然提供 CJS 的原因是部分旧工具或 Node.js 工程仍使用 require()。但双格式也会带来风险:如果 ESM 和 CJS 不是由同一套源码、同一套版本构建,可能出现行为不一致。
5.2 Vite 库模式配置
vite.config.ts:
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import { resolve } from 'node:path';
export default defineConfig({
plugins: [react()],
build: {
lib: {
entry: resolve(__dirname, 'src/index.ts'),
name: 'AcmeUI',
formats: ['es', 'cjs'],
fileName: (format) => {
if (format === 'es') return 'index.js';
return 'index.cjs';
},
cssFileName: 'style'
},
rollupOptions: {
external: ['react', 'react-dom']
}
}
});
external: ['react', 'react-dom'] 的因果关系是:
- 构建器发现组件代码导入了
react; - 如果没有 external,构建器可能把 React 代码打进组件库;
- 消费者应用又通常自带 React;
- 最终页面可能存在两个 React 实例;
- 两个实例交互时可能出现 Hooks 或上下文相关错误,并增加产物体积。
peerDependencies 负责声明“消费者必须提供 React”,external 负责告诉构建器“不要把 React 打进包”。两者缺一不可。
对于只使用 JSX、没有直接使用 react-dom 的组件库,仍然通常将 react-dom 作为 peer dependency,是因为组件库测试、入口适配或未来组件可能需要它。若库确实完全不使用,则可以只声明 react,但必须以实际依赖图为准。
执行:
npm run build
配套的 package.json 脚本:
{
"scripts": {
"typecheck": "tsc -p tsconfig.json --noEmit",
"build:types": "tsc -p tsconfig.build.json",
"build:js": "vite build",
"build": "npm run typecheck && npm run build:types && npm run build:js"
}
}
典型输出应包含:
dist/
├─ index.d.ts
├─ index.d.ts.map
├─ index.js
├─ index.cjs
└─ style.css
实际文件名仍应以构建结果为准。尤其是 CSS 文件名和 Vite 版本、配置有关,不能只凭经验在 package.json 中写一个不存在的路径。
六、package.json:发布包的真实入口
一个较完整的配置如下:
{
"name": "@acme/ui",
"version": "1.0.0",
"type": "module",
"files": [
"dist"
],
"main": "./dist/index.cjs",
"module": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js",
"require": "./dist/index.cjs"
},
"./style.css": "./dist/style.css",
"./package.json": "./package.json"
},
"sideEffects": [
"**/*.css"
],
"peerDependencies": {
"react": "^19.0.0",
"react-dom": "^19.0.0"
},
"scripts": {
"typecheck": "tsc -p tsconfig.json --noEmit",
"build:types": "tsc -p tsconfig.build.json",
"build:js": "vite build",
"build": "npm run typecheck && npm run build:types && npm run build:js"
}
}
6.1 exports 的作用
exports 是现代 Node.js 和 bundler 使用的包入口映射。它不仅告诉工具“主入口在哪里”,还可以限制消费者能够访问哪些路径。
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js",
"require": "./dist/index.cjs"
},
"./style.css": "./dist/style.css"
}
解析过程可以理解为:
- TypeScript 读取
types,找到.d.ts; - 使用
import的 ESM 消费者读取index.js; - 使用
require的 CJS 消费者读取index.cjs; - 导入
@acme/ui/style.css时,读取dist/style.css。
如果没有显式导出 ./style.css,即使文件已经位于 dist 中,消费者也可能收到:
Package subpath './style.css' is not defined by "exports"
6.2 main、module 与 exports 的关系
exports是现代条件导出的主要机制;main是较老的 CommonJS 入口字段;module是 bundler 生态中常见但并非 Node.js 核心标准的 ESM 约定;- 当工具支持
exports时,通常优先使用exports。
保留 main 和 module 是为了兼容部分旧工具,但不能让它们指向与 exports 不一致的文件。否则不同消费者可能得到不同实现,导致调试困难。
6.3 type: "module" 的影响
"type": "module"
意味着包内默认的 .js 文件按 ESM 解释。因此 CJS 文件使用 .cjs 扩展名,避免 Node.js 把它误认为 ESM。
如果构建器输出的是 index.js,但其中仍是 CommonJS 语法,而包又声明了 "type": "module",Node.js 加载时会失败。扩展名、type 字段和实际语法必须一致。
七、Tree Shaking:不是“自动删除没用代码”这么简单
7.1 Tree Shaking 的定义
Tree Shaking 是 bundler 根据模块依赖图,删除不会影响最终程序可观察行为的模块成员或模块代码的过程。
假设库入口是:
export { Button } from './components/Button/Button';
export { Dialog } from './components/Dialog/Dialog';
消费者只写:
import { Button } from '@acme/ui';
理想情况下,最终产物只包含 Button 及其依赖,不包含 Dialog。
但这个结论成立需要几个条件。
设模块图为:
其中:
- 是模块集合;
- 是导入关系;
- 是应用实际使用的入口符号;
- 是具有不可忽略副作用的模块集合。
bundler 可以删除模块 ,需要近似满足:
也就是说,模块既不能从应用使用的符号可达,也不能因为执行模块本身会产生副作用而被保留。
7.2 为什么 ESM 更适合 Tree Shaking
ESM 的导出关系是静态的:
export { Button } from './Button';
bundler 在不执行代码的情况下就能知道有哪些导出。
相反,下面的写法会降低静态分析能力:
if (condition) {
module.exports = require('./Button');
}
或者:
const exportsMap = getExportsDynamically();
module.exports = exportsMap;
这不是说 CJS 一定不能 Tree Shaking,而是 ESM 的静态结构更容易让 bundler 正确判断。
因此,组件库应优先以 ESM 作为现代 bundler 的入口,CJS 作为兼容入口,而不能只发布 CJS 并期待所有工具都能有效删除未使用代码。
7.3 sideEffects 与 CSS
组件文件中有:
import './Button.css';
这个导入的目的不是获取一个 JavaScript 值,而是执行 CSS 导入,让构建器收集样式。因此它是一个副作用导入。
如果错误地写:
"sideEffects": false
bundler 可能推断所有模块都没有副作用。当消费者没有使用 Button 时,它可能删除 Button.tsx,连带删除 Button.css。这对按需使用的 JavaScript 来说可能是正确的,但如果消费者以其他方式依赖样式,就会造成样式消失。
更安全的声明是:
"sideEffects": [
"**/*.css"
]
它表达的是:
- JavaScript 模块默认允许被分析和删除;
- CSS 文件不可视为无副作用;
- 只要 CSS 导入仍在依赖图中,就不能因为“没有导出值”而简单删除。
不过,sideEffects 不是跨 bundler 行为的绝对保证。实际结果还受 bundler、压缩器和 CSS 插件影响。生产验证应当直接检查消费者最终产物,而不是只看配置。
7.4 入口导出方式也影响 Tree Shaking
推荐:
export { Button } from './components/Button/Button';
export { Card } from './components/Card/Card';
需要谨慎:
import * as Components from './components';
export default Components;
命名导出并不自动保证最优产物,但它给 bundler 提供了更清晰的符号边界。默认导出一个包含所有组件的大对象,可能迫使工具保留整个对象构造过程,尤其当对象创建或模块初始化具有副作用时。
还可以提供子路径入口:
{
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js",
"require": "./dist/index.cjs"
},
"./button": {
"types": "./dist/components/Button/Button.d.ts",
"import": "./dist/components/Button/Button.js",
"require": "./dist/components/Button/Button.cjs"
},
"./style.css": "./dist/style.css"
}
}
消费者可以写:
import { Button } from '@acme/ui/button';
但子路径入口会扩大公共 API 面积。以后删除或改名时,必须对每个子路径分别考虑兼容性。
八、样式发布:CSS 是资源,也是一部分公共 API
8.1 两种样式使用方式
组件库常见两种方式。
第一种,由组件内部导入 CSS:
import './Button.css';
优点是消费者只导入组件就能获得样式。缺点是:
- 需要 bundler 支持 CSS 导入;
- 需要处理
sideEffects; - 组件级 CSS 可能被拆成多个资源;
- SSR、测试环境和非 bundler 环境可能不直接支持 CSS 导入。
第二种,由消费者显式导入总样式:
import { Button } from '@acme/ui';
import '@acme/ui/style.css';
优点是入口清晰,应用可以集中控制样式加载。缺点是消费者忘记导入时,组件会以无样式状态出现。
两种方式可以组合,但必须在文档和包入口中保持一致。不能源码中依赖隐式 CSS 导入,发布配置却只暴露 JavaScript 而不发布 CSS。
8.2 CSS 选择器冲突
下面的样式风险很大:
.button {
color: red;
}
因为消费者项目很可能也有 .button。组件库至少应使用命名空间:
.acme-button {
color: red;
}
CSS Modules、Shadow DOM、CSS-in-JS 都可以进一步隔离,但它们改变了构建和运行时契约:
- CSS Modules 需要发布经过处理的类名映射;
- CSS-in-JS 可能增加运行时依赖,并涉及 SSR 样式注入;
- Shadow DOM 改变组件与外部样式的穿透规则。
因此,不能只因为“类名冲突”就盲目替换方案,应该先确定组件库目标框架和 SSR 方式。
8.3 CSS 变量是较稳定的主题边界
可以把可变视觉参数暴露为 CSS 自定义属性:
.acme-button--primary {
background: var(--acme-color-primary, #2563eb);
color: var(--acme-color-primary-text, white);
}
消费者可以覆盖:
:root {
--acme-color-primary: #0f766e;
}
这里的默认值保证组件仍可独立工作,变量名则成为一种样式 API。删除或重命名变量可能破坏消费者主题,因此也应纳入版本兼容性判断。
九、客户端与服务端边界:组件库不能假设所有 React 环境相同
React 组件可以被服务端渲染,但“能被服务端渲染”不等于“可以在服务端执行浏览器交互”。
9.1 三种不同场景
纯展示组件
export function Badge({ children }: { children: ReactNode }) {
return <span className="acme-badge">{children}</span>;
}
它不使用状态、事件或浏览器 API,通常可用于:
- 浏览器客户端渲染;
- 服务端生成 HTML;
- 支持 React Server Components 的服务端模块。
交互组件
前面的 Button 接收了 onClick,本身包含客户端行为契约:
<Button onClick={() => console.log('clicked')}>保存</Button>
在普通 SSR + hydration 架构中,服务端可以先输出 HTML,客户端再加载 JavaScript 绑定事件。
在支持 React Server Components 的框架中,服务端组件不能随意把函数作为可序列化数据传给客户端组件。常见做法是让库提供明确的客户端入口,或由框架应用在自己的客户端文件中导入交互组件。
9.2 "use client" 不是普通运行时 API
在支持 React Server Components 的框架中,文件顶部的:
'use client';
是框架识别的模块边界指令,不是 React 组件运行时函数。它表示该模块及其依赖应按客户端模块处理。
如果库需要发布一个客户端入口,可以组织为:
src/
├─ client.ts
├─ server.ts
└─ components/
└─ Button/
└─ Button.tsx
src/client.ts:
'use client';
export { Button } from './components/Button/Button';
export type { ButtonProps } from './components/Button/Button';
但这里存在发布工具风险:某些构建流程会删除或移动 "use client" 指令。不能只看源码,必须检查最终 dist/client.js 的第一条指令是否仍然存在,并在目标框架中进行真实构建验证。
服务端入口应避免导出依赖浏览器 API 或客户端边界的模块。例如,不能在服务端模块顶层执行:
window.localStorage.getItem('theme');
因为服务端没有 window。即使组件最终只在浏览器显示,顶层模块代码也可能先在服务端加载。
9.3 组件状态的生命周期
以交互按钮为例:
服务端渲染:
props -> HTML
客户端加载:
HTML + JavaScript -> hydration
用户点击:
onClick -> setState / 副作用 -> 重新渲染
卸载:
清理事件监听、订阅、定时器
组件库如果使用 useEffect 注册全局监听,必须处理清理:
import { useEffect } from 'react';
export function OnlineStatus() {
useEffect(() => {
const handleOnline = () => {
// 更新组件状态
};
window.addEventListener('online', handleOnline);
return () => {
window.removeEventListener('online', handleOnline);
};
}, []);
return null;
}
React 的开发模式可能执行额外的挂载与清理检查。组件不能把“effect 只执行一次”当作业务正确性的前提;真正需要保证的是 setup 和 cleanup 成对、重复执行不会破坏状态。
十、React 19 下的依赖与 API 取舍
React 19 带来了新的 React 能力,但组件库不应为了追逐版本而直接使用未明确稳定的 API。发布时要区分:
- React 官方稳定 API;
- 框架提供的能力;
- 实验性 API;
- 构建工具的约定;
- 仅在特定运行环境成立的行为。
例如,组件库可以使用稳定的 Hooks、JSX 和 React DOM 能力,但不能把某个框架专属的服务端函数当成 React 本身的跨框架公共 API。
同时,组件库的 React peer dependency 范围应与实际测试矩阵一致。如果只测试 React 19:
"peerDependencies": {
"react": "^19.0.0",
"react-dom": "^19.0.0"
}
不要为了扩大安装范围而写成:
"peerDependencies": {
"react": ">=18"
}
如果代码依赖 React 19 的行为,声明 >=18 就是不准确的兼容承诺。
相反,如果组件只使用 React 18 和 19 都稳定存在的 API,并且确实测试了两个版本,才可以考虑:
"peerDependencies": {
"react": "^18.2.0 || ^19.0.0"
}
版本范围是事实声明,不是营销数字。
十一、版本号:不仅是 JavaScript API 的版本
组件库通常使用语义化版本:
MAJOR.MINOR.PATCH
MAJOR:不兼容变更;MINOR:向后兼容地增加能力;PATCH:向后兼容的问题修复。
但组件库的公共表面至少有四层:
因此以下变化都可能影响版本:
11.1 运行时 API
从:
<Button variant="primary" />
改成:
<Button kind="primary" />
删除 variant 是破坏性变更,应考虑主版本升级。
11.2 TypeScript API
把:
export interface ButtonProps {
variant?: 'primary' | 'secondary';
}
改成:
export interface ButtonProps {
variant: string;
}
这看起来像“放宽类型”,但可能破坏依赖精确联合类型的消费者。
反过来,把可选属性改为必选:
variant?: 'primary' | 'secondary';
改成:
variant: 'primary' | 'secondary';
会直接造成消费者编译失败,也是破坏性变更。
11.3 样式 API
以下变化可能是破坏性的:
- 删除稳定的 CSS 变量;
- 删除消费者覆盖的类名;
- 修改组件默认布局;
- 改变 z-index、间距或颜色语义;
- 把全局样式改成局部样式;
- 改变 CSS 入口路径。
样式没有 TypeScript 编译器帮你发现所有问题,因此视觉回归测试尤其重要。
11.4 入口 API
把:
@acme/ui/style.css
改成:
@acme/ui/styles.css
即使组件 JavaScript 完全不变,也会破坏消费者构建,应视为公共入口变更。
11.5 peer dependency 变化
把 React peer dependency 从:
"^19.0.0"
改成:
"^19.1.0"
可能使一部分消费者无法安装或收到 peer 冲突。依赖范围收窄通常是兼容性变化,而不是普通补丁。
十二、完整发布配置与验证流程
12.1 构建并检查包内容
先构建:
npm run build
检查发布文件:
npm pack --dry-run
预期应看到:
npm notice package: @acme/ui@1.0.0
npm notice === Tarball Contents ===
npm notice dist/index.js
npm notice dist/index.cjs
npm notice dist/index.d.ts
npm notice dist/style.css
npm notice package.json
如果 style.css 没有出现在列表中,可能原因包括:
- Vite 实际输出了不同文件名;
files字段没有包含它;- CSS 被构建工具内联或拆分;
- 构建尚未执行;
- CSS 被错误地排除。
npm pack --dry-run 比直接查看源码更可靠,因为它检查的是 npm 实际准备发布的文件集合。
12.2 使用 tarball 测试消费者
生成包:
npm pack
假设生成:
acme-ui-1.0.0.tgz
在临时消费者项目中:
mkdir ../acme-ui-consumer
cd ../acme-ui-consumer
npm init -y
npm install ../acme-ui/acme-ui-1.0.0.tgz react@19 react-dom@19
创建测试文件:
import { Button } from '@acme/ui';
import '@acme/ui/style.css';
export function App() {
return (
<Button
variant="primary"
type="button"
onClick={() => console.log('saved')}
>
保存
</Button>
);
}
运行消费者自己的类型检查和构建:
npm run typecheck
npm run build
需要验证:
@acme/ui的 ESM 导入可以解析;ButtonProps能被编辑器识别;- CSS 子路径可以解析;
- 最终产物没有把 React 整体重复打入;
- 只有被使用的组件进入最终 bundle。
12.3 检查 ESM、CJS 和类型入口
ESM 测试:
node --input-type=module -e "import('@acme/ui').then(m => console.log(Object.keys(m)))"
预期应打印包含:
[ 'Button' ]
CJS 测试:
node -e "console.log(Object.keys(require('@acme/ui')))"
如果这里失败,通常检查:
package.json的"type";exports.require指向的文件;- CJS 文件是否使用
.cjs; - 构建器是否真的生成了 CJS;
- CJS 产物是否错误地引用了只能被 ESM 加载的文件。
类型测试可以创建:
import { Button, type ButtonProps } from '@acme/ui';
const props: ButtonProps = {
variant: 'secondary',
children: '取消'
};
<Button {...props} />;
然后执行:
npx tsc --noEmit
如果把 variant 改成:
variant: 'unknown'
预期类型检查失败。这证明消费者拿到的是发布后的声明,而不是偶然引用了本地源码。
十三、Tree Shaking 的实际验证
仅仅看到 ESM 文件并不能证明 Tree Shaking 生效。应在消费者项目中分别测试:
import { Button } from '@acme/ui';
以及:
import '@acme/ui/style.css';
构建后检查 bundle 分析结果或输出文件内容。
如果库中还有一个很大的 Chart 组件,消费者只导入 Button,应观察:
Chart的代码是否出现在最终 JavaScript 中;Chart的第三方依赖是否被带入;- CSS 是否按预期保留;
- 被删除的模块是否仍因顶层副作用而存在。
一个反例:
// chart/register.ts
import { registerChartPlugins } from './plugins';
registerChartPlugins();
export const chartVersion = '1';
即使消费者没有使用 chartVersion,顶层注册动作也可能被视为副作用。若构建器错误地删除它,可能造成行为缺失;若构建器为了安全保留它,Tree Shaking 也不会达到预期。
因此,模块顶层应尽量避免不必要的全局注册、日志、DOM 操作和单例初始化。需要副作用时,应把它设计成显式函数:
export function registerChartPlugins() {
// 由消费者明确调用
}
显式调用比依赖“导入模块时自动发生某件事”更容易分析、测试和恢复。
十四、常见失败表现与诊断路径
14.1 Invalid hook call
常见原因之一是组件库和应用加载了不同 React 实例。诊断:
npm ls react
如果树中出现多个不兼容 React 实例,检查:
- React 是否错误地进入组件库 bundle;
react是否位于peerDependencies;- Vite、Rollup 或其他 bundler 是否配置了 external;
- 本地
npm link是否造成重复依赖; - monorepo 是否错误安装了嵌套 React。
这不是把 React 版本“都升级到最新”就一定能解决的问题,首先要确认实例数量和解析路径。
14.2 找不到 CSS 子路径
错误:
Package subpath './style.css' is not defined by "exports"
诊断步骤:
cat package.json
npm pack --dry-run
确认:
exports中是否存在./style.css;- 目标文件是否位于 tarball;
- 文件名大小写是否一致;
- 包管理器是否使用了旧缓存;
- CSS 是否在构建阶段生成。
14.3 类型存在但导入失败
如果编辑器能找到:
import type { ButtonProps } from '@acme/ui';
但运行时:
import { Button } from '@acme/ui';
失败,可能是 types 指向正确,而 import 指向错误。类型解析和运行时解析是两条路径,必须分别测试。
14.4 SSR 环境中的 window is not defined
如果组件文件顶层执行浏览器代码:
const saved = window.localStorage.getItem('x');
服务端加载模块时就会失败。将浏览器访问放进客户端生命周期并判断环境仍需谨慎;在支持 Server Components 的框架中,更可靠的方式是通过客户端入口隔离整个模块。
14.5 CSS 被 Tree Shaking 删除
典型表现是:
- JavaScript 组件存在;
- DOM 结构正确;
- 页面没有组件样式;
- 生产构建比开发环境异常。
检查 sideEffects、CSS 导入路径和最终 CSS bundle。若采用显式样式入口,还应确认消费者确实导入了:
import '@acme/ui/style.css';
不能只因为开发服务器自动处理了 CSS,就推断发布包消费者也会自动获得它。
十五、哪些内容应由组件库负责,哪些应由应用负责
组件库负责:
- 组件结构和可复用行为;
- 公共 Props 类型;
- 可发布的 JavaScript 和声明文件;
- 样式资源和入口;
- React peer dependency;
- ESM/CJS 与
exports契约; - 构建、类型、消费端验证。
应用负责:
- React 根节点创建;
- 路由、数据获取和业务状态;
- 具体的 SSR 或 RSC 框架配置;
- 全局主题覆盖;
- 浏览器端错误上报;
- 将服务端数据转换成客户端可序列化的 Props。
例如,组件库不应默认在模块顶层创建全局 React 根节点:
// 不应出现在通用组件库中
createRoot(document.getElementById('root')!).render(<App />);
组件库提供组件,应用决定组件如何进入客户端、服务端或混合渲染流程。这样才能避免把某个框架的启动方式硬编码进通用包。
十六、发布前的最小验收标准
可以把一次发布看成以下条件同时成立:
具体需要确认:
npm run typecheck
npm run build
npm pack --dry-run
npm ls react
还应在临时消费者中验证:
import { Button, type ButtonProps } from '@acme/ui';
import '@acme/ui/style.css';
然后分别测试:
- ESM
import; - CJS
require; - TypeScript 声明;
- CSS 子路径;
- React 19 peer dependency;
- 生产构建后的 Tree Shaking;
- SSR 或目标框架中的客户端边界;
- 包升级和回滚。
当组件库的运行时代码、类型、样式、入口和依赖都能从一个真实 tarball 中被消费者正确解析时,发布才算完成。版本号随后表达的也不只是“这次改了多少代码”,而是整个公共契约是否仍然兼容。
系列导航与关联阅读
- 系列入口:React 完整学习路线:从渲染与 Hooks 到服务端组件和生产架构
- 上一篇:React Monorepo:Workspace、共享包、构建图、版本和边界
- 下一篇:React 微前端:路由、状态、样式、依赖隔离和迁移取舍
官方资料
本文依据 React 与生态项目官方文档重新梳理;正文与示例由 WR BLOG 编写。

评论
0 条讨论