haiku-ui 选择的打包方式(Monorepo + Tsup)

这个项目的目标很明确:

  • packages/ui:UI 组件库,未来希望能发布到 npm,给外部项目直接用。
  • apps/docs:文档站点,用来展示组件、写博客、沉淀工程化经验。

为了同时兼顾“本地联调效率”和“未来发布的兼容性”,这里采用了一套很常见、也很稳的打包思路:

在 Monorepo 里开发(pnpm workspace),在 UI 包里产出标准分发物(dist:ESM + CJS + d.ts),Docs 作为真实消费者验证一切。


1. Monorepo 的结构与依赖关系

本项目的工作区配置是:

pnpm-workspace.yaml
packages:
  - packages/*
  - apps/*

目录上大致是:

HaikuDesign/
  apps/
    docs/                 # Rspress 文档站点(真实消费者)
  packages/
    ui/                   # 组件库(未来发布到 npm)
    cli/                  # 预留

Docs 通过 workspace 直接依赖 UI:

apps/docs/package.json
{
  "dependencies": {
    "haiku-ui": "workspace:*"
  }
}

这样做的好处是:UI 改动可以最快被 Docs 验证;坏处是:一旦 UI 变成“需要构建才可消费”(exports 指向 dist),就必须把构建链路/联调链路也设计好(后面会讲)。


2. UI 包为什么要“构建出多个后缀的产物”

你会在 packages/ui/dist 看到类似这些文件:

  • index.js:ESM 产物(给 import 用)
  • index.cjs:CJS 产物(给 require 用)
  • index.d.ts:类型声明(给 TS/IDE 用)
  • index.js.map / index.cjs.map:sourcemap(调试用)

2.1 ESM vs CJS:为什么要同时输出两套?

现实世界里还存在很多不同的消费方式:

  • 现代工具链/Node ESM:import { Button } from 'haiku-ui'
  • 老一些的 Node/工具链:const { Button } = require('haiku-ui')

如果你只输出其中一种,就会限制用户或引入各种兼容问题;双输出是“发布到 npm 的组件库”最稳妥的默认选择之一。

2.2 为什么 CJS 用 .cjs,而不是也叫 .js

UI 包的 package.json 里有:

{ "type": "module" }

这会让 Node 把 .js 默认当作 ESM。如果你把 CJS 也输出为 .js,Node 就可能按 ESM 解释它,导致运行时错误。

所以我们用 .cjs 来明确告诉 Node:这是 CommonJS 文件。


3. packages/ui:Tsup 打包配置的核心思想

UI 选择 tsup(底层是 esbuild)主要是因为:配置简单、速度快、很适合组件库“多格式输出 + 类型声明”。

关键配置(简化理解版):

packages/ui/tsup.config.ts
export default defineConfig({
  entry: ['src/index.ts'],
  format: ['esm', 'cjs'],
  dts: true,
  sourcemap: true,
  splitting: false,
  treeshake: true,
  external: ['react', 'react-dom', 'react/jsx-runtime'],
  outExtension({ format }) {
    return { js: format === 'cjs' ? '.cjs' : '.js' };
  },
});

这套配置背后的“打包思想”是:

  • 入口统一:只暴露一个 src/index.ts 作为公共出口,内部怎么组织组件都可以。
  • 双模块输出:ESM + CJS 一次构建产出。
  • 类型声明同步输出:避免“能跑但没类型”的尴尬。
  • React 不打进产物react/react-dom 作为 peer + external,避免用户项目里出现多份 React(会引起 hooks/上下文等灾难)。
  • 不做代码分割:组件库通常更希望入口清晰、exports 简单,减少多 chunk 带来的发布/解析复杂度。

4. packages/ui:package.json 为什么要这样写(exports 的意义)

packages/ui/package.json 里你看到的这些字段:

{
  "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"
    }
  }
}

可以把它理解成:我发布出去的“可见世界”只有 dist,并且不同的消费方式指向不同的文件。

  • files: ["dist"]:发布到 npm 时只带上 dist(干净、体积小、避免泄露源码/配置)。
  • main/module/types:兼容老工具(历史字段,仍有价值)。
  • exports:现代 Node/打包器的“权威入口表”,能精确区分 importrequire,并且把类型入口也绑定到正确文件。

对未来要发布到 npm 的 UI 包来说,exports 基本是必选项。


5. TypeScript 声明(d.ts)与模块解析的小坑

在实际构建中,我们遇到过这种错误(典型):

Cannot find module './components/Button'... Did you mean to set 'moduleResolution' to 'nodenext' ...

这类问题经常发生在:

  • 代码用的是“打包器友好”的解析方式(比如目录导入 ./components/Button 依赖 index.ts
  • 但生成 d.ts 时走 TypeScript 自己的解析规则,默认配置不足时就会找不到模块

所以 packages/ui/tsconfig.json 里补上了更适合打包器场景的配置:

packages/ui/tsconfig.json
{
  "compilerOptions": {
    "jsx": "react-jsx",
    "module": "esnext",
    "moduleResolution": "bundler"
  },
  "include": ["src"]
}

一句话总结:JS 产物 esbuild 能“猜对”,但 d.ts 产物必须让 TypeScript 也“看懂”。


6. Docs 站点如何“作为真实消费者”验证 UI 的打包设计

文档站点用的是 Rspress:

apps/docs/rspress.config.ts
export default defineConfig({
  root: path.join(__dirname, 'docs'),
  globalStyles: path.join(__dirname, 'docs/styles/tailwind.css'),
});

Docs 页面里直接引入组件库:

apps/docs/docs/components/button.mdx
import { Button } from 'haiku-ui'

<Button>默认按钮</Button>

这能验证几件非常关键的事:

  • exports 是否正确(Docs 实际会按它解析入口)
  • 类型是否正确(MDX/TS 插件是否能拿到 Button 的 props 类型)
  • React 是否被正确 external/peer(不会打进两份)

7. Tailwind 样式在 Monorepo 联调时的“生成归属”

当前项目选择的是:

UI 包里只写 className(Tailwind utility),真正生成 CSS 的工作交给消费端(Docs)。

这意味着消费端必须能“扫描到”UI 里的 class 字符串,否则就会出现:className 写了,但页面没样式。

在 Tailwind v4 里,我们用 CSS-first 的方式声明扫描来源:

apps/docs/docs/styles/tailwind.css
@import "tailwindcss";

@source "../**/*.{md,mdx}";
@source "../../../../packages/ui/src/**/*.{js,jsx,ts,tsx}";

这样 Docs 构建时会把 packages/ui/src 中出现的类(如 bg-blue-600)生成到最终的 CSS 里。

注意:如果未来 UI 发布到 npm,很多项目默认不会扫描 node_modules。要做到“发布即有样式”,更推荐 UI 包自己发布 styles.css(后续可优化方向)。


8. 一套可复用的“打包 checklist”

如果你也在做类似的 UI 包,下面这份清单几乎可以直接照抄:

  • 入口是否只有一个(src/index.ts),避免用户从深层路径 import?
  • 是否同时输出 ESM + CJS?CJS 是否用 .cjs(在 "type":"module" 下)?
  • react/react-dom 是否 external + peerDependencies?
  • 是否生成 .d.ts 并且构建能稳定通过?
  • exports 是否为不同消费方式提供了明确映射?
  • Monorepo 下是否有“联调脚本”(UI watch + docs dev)?
  • 样式策略是否清晰:消费端生成(需要扫描)还是 UI 包发布 CSS(开箱即用)?

9. 可以进一步优化的方向(面向 npm 发布)

如果你明确要把 haiku-ui 发布到 npm,建议尽早规划:

  • 发布即有样式:发布 dist/styles.css + dist/tokens.css,用户只要 import 即可。
  • 版本管理:引入 changesets 管理版本与 changelog。
  • CI:在 CI 跑 build/typecheck/lint,并保证 prepublishOnly 会强制构建出 dist。
  • 兼容范围:peerDependencies 建议支持 react 的主流区间(例如 ^18.2.0 || ^19.0.0)。

总结

这套打包方式的核心是:

  • UI 包产出“标准分发物”(ESM + CJS + 类型 + sourcemap)
  • exports 做入口治理与环境兼容
  • React 外置,避免多 React 问题
  • Docs 作为真实消费者,尽早暴露解析/类型/样式链路的坑

当你从“内部联调”走向“对外发布”时,只需要在这个框架上补齐“样式产物与发布流程”,就能获得一个真正可用、可维护的组件库工程。