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>;
}

这里隐含了几个契约:

  1. @acme/ui 能解析到 JavaScript 入口;
  2. Button 既有运行时代码,也有正确的 TypeScript 类型;
  3. @acme/ui/style.css 是一个真实存在且会被发布的文件;
  4. 组件库不会把消费者自己的 React 再打包一份;
  5. 未使用的组件能够被 bundler 删除;
  6. 如果组件内部使用了状态或事件处理器,服务端框架知道它必须在客户端运行;
  7. 新版本改变这些行为时,版本号能准确提示升级风险。

组件库可以抽象成一个映射:

包入口(运行时代码,类型声明,样式资源,依赖关系)\text{包入口} \longrightarrow (\text{运行时代码}, \text{类型声明}, \text{样式资源}, \text{依赖关系})

其中任意一项缺失,都会造成不同类型的失败:

  • 运行时代码缺失:安装成功,但 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

组件库通常不应把 reactreact-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> 让组件继承原生按钮属性,如 disabledtypeonClick
  • 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.jsontypesexports.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,因为:

  • importexport 结构静态可分析;
  • 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'] 的因果关系是:

  1. 构建器发现组件代码导入了 react
  2. 如果没有 external,构建器可能把 React 代码打进组件库;
  3. 消费者应用又通常自带 React;
  4. 最终页面可能存在两个 React 实例;
  5. 两个实例交互时可能出现 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 mainmoduleexports 的关系

  • exports 是现代条件导出的主要机制;
  • main 是较老的 CommonJS 入口字段;
  • module 是 bundler 生态中常见但并非 Node.js 核心标准的 ESM 约定;
  • 当工具支持 exports 时,通常优先使用 exports

保留 mainmodule 是为了兼容部分旧工具,但不能让它们指向与 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

但这个结论成立需要几个条件。

设模块图为:

G=(V,E)G=(V,E)

其中:

  • VV 是模块集合;
  • EE 是导入关系;
  • RR 是应用实际使用的入口符号;
  • SS 是具有不可忽略副作用的模块集合。

bundler 可以删除模块 vv,需要近似满足:

vReachable(R)vSv \notin Reachable(R) \quad \land \quad v \notin S

也就是说,模块既不能从应用使用的符号可达,也不能因为执行模块本身会产生副作用而被保留。

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:向后兼容的问题修复。

但组件库的公共表面至少有四层:

APIpublic=APIruntimeAPItypesAPIstylesAPIentrypointsAPI_{public} = API_{runtime} \cup API_{types} \cup API_{styles} \cup API_{entrypoints}

因此以下变化都可能影响版本:

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

需要验证:

  1. @acme/ui 的 ESM 导入可以解析;
  2. ButtonProps 能被编辑器识别;
  3. CSS 子路径可以解析;
  4. 最终产物没有把 React 整体重复打入;
  5. 只有被使用的组件进入最终 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

确认:

  1. exports 中是否存在 ./style.css
  2. 目标文件是否位于 tarball;
  3. 文件名大小写是否一致;
  4. 包管理器是否使用了旧缓存;
  5. 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 />);

组件库提供组件,应用决定组件如何进入客户端、服务端或混合渲染流程。这样才能避免把某个框架的启动方式硬编码进通用包。


十六、发布前的最小验收标准

可以把一次发布看成以下条件同时成立:

Release=BuildTypesStylesEntryResolutionPeerCompatibilityConsumerVerificationRelease = Build \land Types \land Styles \land EntryResolution \land PeerCompatibility \land ConsumerVerification

具体需要确认:

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 与生态项目官方文档重新梳理;正文与示例由 WR BLOG 编写。