JavaScript39 分钟阅读

封装组件库

封装自己的组件库

李李顺2026 · 10 · 16

从零手写一个对标 antd 的 React 组件库:分层架构、实施流程与 25 个实测坑

用 Vite 8 + React 19 + TypeScript 7 + LESS 4 + pnpm workspace + Storybook 10,从空目录做一个视觉上与 antd 6.6.6 逐像素对齐、支持运行时换肤(亮暗 / 品牌色 / 密度三轴)的组件库。

本文不是"设计令牌概念科普",而是一次完整实施的复盘:每一处决策都写清依据与代价,每一个坑都是本机实测出来的,附症状、根因与解法。所有数值都是跑出来的,没有推测。

目录


0. 背景:这个库要解决什么

0.1 定位

这个库叫 ls-design,定位是练手项目,目标不是"企业级治理完备",而是跑通一条完整闭环:

text
设计令牌 → 组件 → 文档工作台 → 库构建产物 → 公网可访问

首版 10 个组件:通用 5(Button / Input / Typography / Tag / Switch)+ 布局 5(Layout / Grid / Space / Divider / Card)。

视觉基准锁定 antd@6.6.6,且必须 pin 版本。antd 每个 minor 都可能微调视觉值,不 pin 就无法判定"对齐是否回归"。升级 antd 时会变成一次显式的 diff,而不是"某天发现颜色变了"。

0.2 三条裁剪原则

定位决定裁剪。明确砍掉的东西和保留的东西一样重要:

砍掉 理由
体积预算(size-limit) 练手项目没有发布压力,多一道 CI 门禁只增加维护面
视觉回归矩阵 亮暗 × N 品牌色 × 2 密度的组合会爆炸,降级为"亮暗两态各留一张截图"
commitlint / CODEOWNERS 单人项目,规范成本 > 收益
完整 locale 机制 用常量文件替代,等真有多语言需求再抽上下文
自建私有 registry 公开发布免费,私有包 $7/月,练手项目不值得
保留 理由
三层令牌 + 运行时主题切换 这是本项目的核心练习价值
10 个组件覆盖 forwardRef / 受控非受控 / 复合组件 / 栅格等模式 覆盖常见的组件设计模式
Storybook props 文档 + 交互工作台 + 可公网部署
库构建(ESM + 类型 + CSS 产物) 不产物化就不算"库"

0.3 「像素级对齐」其实是「数值级对齐」

这是整个项目最重要的一条认知:

antd 把视觉决策全部数值化了。所谓像素级对齐,等于数值级对齐,不靠肉眼比对。

antd 的样式最终由一套 token 计算得出(v4 是 LESS 变量,v5 是 cssinjs,v6 是静态 CSS + CSS 变量)。只要:

  1. 把 antd 的 MapToken 值原样抄进语义令牌层;
  2. 把 antd 组件样式的推导代码逐条移植;

那么在字体栈一致的前提下,两边是逐像素相同的——不需要截图比对来"调"。

唯一需要 JS 运行时参与的是品牌色:antd 的色阶不是"把主色调亮调暗",而是一套带感知校正的算法,必须复用 @ant-design/colors 的 generate()。


1. 技术选型:每个选择都写清代价

1.1 依赖版本基线(本机实测)

包 版本 说明
node 22.22.2
pnpm 11.15.0
react / react-dom 19.3.1 归 peerDependencies
typescript 7.0.2 ⚠️ 有破坏性变更,见 §6.5
vite 8.3.4 ⚠️ 内核已换 Rolldown,见 §6.2
less 4.9.1
antd 6.6.6 仅作基准,不进产物
@ant-design/colors 8.0.1 唯一的运行时依赖
@vitejs/plugin-react 6.1.2
vite-plugin-dts 5.1.2 底层已是 unplugin-dts
storybook / @storybook/react-vite 10.6.2

1.2 为什么用 LESS,但只用来写 mixin

antd 自己的演进路线就是最好的论据:v4 用 LESS 变量 → 发现无法做运行时主题 → v5 转 cssinjs → v6 回到"静态 CSS + CSS 变量"。

结论:LESS 只负责编译期工作——mixin、嵌套、结构组织;一切主题值(颜色、圆角、字号)走 CSS 变量。

这条边界一旦模糊,运行时换肤就会退化:LESS 变量在编译期就被求值替换成字面量,浏览器无法再改。

less
// ✅ 正确:mixin 产出 CSS 变量声明
.var(@name; @value) {
  --ls-@{name}: @value;
}

// ❌ 错误:用 LESS 变量承载主题值,编译后变成字面量,换肤失效
@color-primary: #1677ff;

注意参数分隔符必须用 ;:值里含逗号时(rgba(0,0,0,.88)、字体栈)用逗号分隔会被 LESS 当成多个参数,报参数个数不匹配。

1.3 为什么不用 CSS-in-JS

维度 CSS 变量方案 cssinjs
运行时开销 零 每个组件实例都要算样式 + 注入
宿主覆盖 直接写 CSS 覆盖 --ls-* 即可 需要知道类名哈希或走主题 API
SSR 天然可用(<link> 引入静态 CSS) 需收集、脱水、注水
灵活度 表达不了"根据 props 任意计算样式" 可以

组件库这个场景里,"任意计算样式"的需求几乎不存在,而"宿主能覆盖"和"零运行时"是硬需求。所以选 CSS 变量。

1.4 为什么最终放弃了 Tailwind —— 以及那次实测

一开始的设想是"Tailwind 只进应用层,不进组件内部"。为此做了一轮实测(Tailwind 4.3.3 + Vite 8.3.4 + LESS 4.9.1),结论有价值,记录在此:

场景 结果
.css 中 @apply 且无 @reference ❌ 构建直接失败:Cannot apply unknown utility class
.css 中 @reference "./main.css" + @apply ✅ 生效
.less 中 @apply ❌ 静默失效:构建通过,@apply 原样进产物,只有 minify 阶段一条 warning
@theme inline 映射 --ls-* ✅ 产物为 var(--ls-color-primary),保留变量引用,主题可穿透
@custom-variant dark ([data-ls-theme=dark]) ✅ dark: 变体正常生成

最危险的是第三行:.less 文件里的 @apply 既构建通过又不报错,样式却完全丢失。这类"静默失败"比构建失败难排查得多。

最终决定整个项目不用 Tailwind,样式体系只保留单轨:LESS + var(--ls-*)。文档与工作台的外观交给 Storybook 自带主题承接,不自建工具类体系。

如果未来要重新引入,三条纪律必须写进 README:

  1. .less 文件里禁止写 @apply;
  2. .css 里用 @apply 必须在同文件加 @reference;
  3. 库构建不得把 Tailwind 打进 ls-design.css。

1.5 为什么上 pnpm workspace

判据:看"构建器与依赖是否需要隔离",不看"是不是文档"。

  • Storybook 用 @storybook/react-vite,与项目同栈(Vite),可以直接复用 vite.config.ts 的 LESS 配置 → 留在包内
  • Rspress 自带 Rsbuild/Rspack,是第二套构建器,依赖树与 Vite 隔离 → 独立成 apps/docs

monorepo 的触发条件是"出现第 2 个消费方,且组件 API 与其文档必须同批次原子提交"。这个条件在决定加 Rspress 文档站时成立,所以上 workspace。

1.6 目录结构

text
ls-design/                          # workspace 根,只放 workspace 级内容
├── package.json                    # private,只放脚本,不装依赖
├── pnpm-workspace.yaml             # packages: [packages/*] + allowBuilds
├── .npmrc                          # shamefully-hoist=false
├── .gitignore                      # 只忽略 node_modules / dist / storybook-static
└── packages/
    └── ls-design/                  # 唯一功能包
        ├── package.json  tsconfig.json  vite.config.ts  index.html
        ├── .storybook/             # main.ts + preview.tsx(不放 stories)
        ├── scripts/                # gen-antd-tokens.mjs 等构建期脚本
        └── src/
            ├── index.ts            # 库出口
            ├── main.tsx  App.tsx  App.less   # dev 演示页
            ├── styles/             # 编译期为主
            │   ├── index.less  base.less
            │   ├── tokens/         # 三层令牌。seed/palette 由脚本生成,alias 手写
            │   └── mixins/         # 编译期工具,必须独立成目录
            ├── theme/              # 运行时:把用户选择翻译成 html 属性 + 内联变量
            ├── utils/              # 与框架无关的纯函数
            ├── icons/              # 内联 SVG,零图标库依赖
            └── components/         # 扁平一层,每个组件一个目录
                                    # 固定 4 文件:X.tsx / X.less / X.stories.tsx / index.ts

两条刻意的简化:

  1. 组件目录不按 general / layout 分两级。分组交给 Storybook 的 title: '通用/Button' 命名解决,物理分层只会让 import 路径变长。
  2. apps/ 现阶段不建,等上 Rspress 时再建 apps/docs。

2. 实施流程:12 个阶段与门禁

顺序不是随便排的。阶段 0–5 是地基,约占 60% 工作量,且后面每一步都依赖前一步的产物。跳步会导致组件样式反复返工——比如没定令牌就去写组件,写完一个组件发现令牌命名要改,前面的全部重写。

阶段 内容 主要产出 门禁
0 初始化 workspace 配置 + 包骨架 + 最小可跑的 dev/build 骨架能 pnpm dev / pnpm build 通过
1 令牌层 seed.less / palette.less(脚本生成)+ alias.less(手写)+ mixins 令牌齐全,无硬编码 hex 的组件级样式
2 核对色阶映射 脚本内的断言校验 不可跳过。色阶索引错一位整套主色都偏,且肉眼难以判定
3 主题层 ThemeProvider / useTheme / 持久化 / 防闪屏脚本 三轴切换可用,刷新不闪屏
4 基础能力 utils / icons / 焦点环 mixin / base reset 后续组件的公共依赖就绪
5 Button 组件 + 样式 + stories 不通过不得横向铺其余组件
6 Layout
7 Grid
8 其余 4 通用 + 3 布局 Input / Typography / Tag / Switch / Space / Divider / Card
9 演示页 App.tsx + 主题工具条
10 工程化 库构建 / stylelint 纪律 / 类型 / 测试
11 文档站 Rspress(apps/docs) 公网可访问

为什么阶段 5(Button)是硬门禁

Button 是全部样式模式的样板:变体 × 语义色的正交矩阵、尺寸 × 形状的交叉、图标的间距、加载态、禁用态、focus 环、两汉字字距。

它跑通了,其余组件就是套模板;它跑不通,错误会被复制 9 次。

单个组件的 DoD(完成定义)

定义:"一个工作项在被判定为完成之前必须全部满足的显式清单"。这是质量门槛,不随组件变化。

类别 要求
功能 props 全部可用且有 story 覆盖组合态;受控/非受控双模式走统一 hook;forwardRef 透传;继承原生 props 时用 Omit 去掉语义冲突键;disabled 与 loading 并存时优先级明确
主题 亮/暗 × 品牌色 × 密度四种组合下无异常;无任何硬编码颜色;控件尺寸与 antd 6.6.6 对齐
文档 story 与组件同目录;autodocs 的 props 表无 any、无缺失注释;至少一个 story 展示边界态
质量 a11y 基线(ARIA、键盘可达、焦点环统一);TS 无 any 逃逸;亮暗两态各一张截图留档

3. 令牌体系:三层,且只有一个载体

3.1 分层

text
基础令牌(Primitive)     --ls-primary-6, --ls-control-height, --ls-motion-duration-mid
        ↓ 引用
语义令牌(Semantic)      --ls-color-primary, --ls-color-text-secondary, --ls-color-border
        ↓ 引用
组件令牌(Component)     --ls-btn-padding-inline, --ls-btn-bg-color-hover

规则:组件样式只允许消费 var(--ls-*),禁止硬编码 hex。

三层中只有基础令牌的 palette.less 会被 JS 在运行时覆盖。契约很清晰:

JS 只负责 --ls-primary-1..10 这 10 个变量;语义层及以下一个字都不动。

命名空间统一:包名 ls-design,类名 ls-,CSS 变量 --ls-,LESS 变量 @ls-。

3.2 坑:LESS mixin 里的变量不会外泄

这是本项目推翻的第一个"想当然"。

原设想:写一个 .var(@name, @value) mixin,同时产出 CSS 变量和 LESS 变量,让组件里可以写 @ls-color-text 参与编译期算术。

less
.var(@n; @v) { --ls-@{n}: @v; @ls-@{n}: @v; }

.a { .var(color-text; rgba(0, 0, 0, .88)); }
.b { color: @ls-color-text; }   // ✗ variable @ls-color-text is undefined

实测结论:LESS 的 mixin 内变量定义不会外泄到兄弟作用域,同一作用域内还要求调用早于使用。

修正后的约定:令牌的唯一载体是 CSS 变量。 组件里的编译期算术一律走 calc() 组合 CSS 变量:

less
// 水平内边距 = 内容区内边距 - 线宽(antd: paddingContentHorizontal - lineWidth)
// 用 calc 而不是算好 15px:紧凑密度下 --ls-padding-content-horizontal 会变成 12px,
// 写死就失去了密度轴
--ls-btn-padding-inline: calc(var(--ls-padding-content-horizontal) - var(--ls-line-width));

这条修正还带来一个附带好处:值只在运行时解析,天然跟随主题与品牌色,不需要为"换主题时重算"写第二套逻辑。

3.3 中性色为什么用 rgba(0,0,0,α) 而不是灰色 hex

antd 的文本色梯度是:

css
--ls-color-text:             rgba(0, 0, 0, 0.88);
--ls-color-text-secondary:   rgba(0, 0, 0, 0.65);
--ls-color-text-tertiary:    rgba(0, 0, 0, 0.45);
--ls-color-text-quaternary:  rgba(0, 0, 0, 0.25);

理由是叠加性:透明黑落在任何着色的背景上都能自然融合,而灰色 hex 是固定色,放到有色底上会显脏。这也是 antd 与大量早期设计体系的关键差异之一。

3.4 令牌速查表(antd 6.6.6 实测值)

尺寸 / 字体 / 动效(亮暗共用)

类别 值
控件高度 32 / SM 24 / LG 40 / XS 16
圆角 6 / SM 4 / LG 8 / XS 2
字号 14 / SM 12 / LG 16 / XL 20 / Heading1 38
行高 1.5714285714285714(= 22px 派生)/ SM 1.6666… / LG 1.5
间距 sizeUnit 4 / sizeStep 4;padding 16 · XS 8 · SM 12 · LG 24 · XL 32
线宽 1;lineWidthFocus 3;controlOutlineWidth 2
动效 fast 0.1s / mid 0.2s / slow 0.3s;easeInOut cubic-bezier(0.645,0.045,0.355,1)
z-index zIndexPopupBase 1000

亮色语义令牌

语义 值
主色 base #1677ff / hover #4096ff / active #0958d9 / bg #e6f4ff / border #91caff
背景 base #fff / layout #f5f5f5 / container #ffffff / elevated #ffffff / spotlight rgba(0,0,0,0.85)
文本 rgba(0,0,0,0.88 / 0.65 / 0.45 / 0.25)
边框 border #d9d9d9 / borderSecondary #f0f0f0 / split rgba(5,5,5,0.06)
填充 fill rgba(0,0,0,0.15) / secondary 0.06 / quaternary 0.02
功能色 success #52c41a / warning #faad14 / error #ff4d4f / info #1677ff
链接 link = colorPrimary;linkHover #69b1ff(色阶 4,与 colorPrimaryHover 的色阶 5 不同)
状态外圈 controlOutline rgba(5,145,255,0.1) / colorErrorOutline rgba(255,38,5,0.06)
禁用 borderDisabled #d9d9d9 / bgContainerDisabled rgba(0,0,0,0.04) / textDisabled rgba(0,0,0,0.25)

暗色语义令牌(与亮色差异很大,不能只改背景色了事)

语义 值
主色 base #1668dc / hover #3c89e8 / active #1554ad / bg #15325b / border #15325b(与 bg 相同)
背景 base #000 / layout #000000 / container #141414 / elevated #1f1f1f / spotlight #424242
文本 rgba(255,255,255,0.85 / 0.65 / 0.45 / 0.25)
边框 border #424242 / borderSecondary #303030 / split rgba(253,253,253,0.12)
功能色 success #49aa19 / warning #d89614 / error #dc4446
阴影 0 6px 16px 0 rgba(255,255,255,0.016), …(白色低透明,肉眼近乎不可见)
状态外圈 controlOutline rgba(23,117,249,0.31) / colorErrorOutline rgba(238,38,56,0.11)

暗色下阴影基本不可见,这是深色主题的固有约束。真正表达层级要靠表面色阶(#141414 → #1f1f1f → #424242),不能靠 elevation。


4. 主题:三个正交的轴

4.1 三轴与契约

轴 载体 实现 是否需要 JS
亮暗模式 data-ls-theme="light|dark" 两套 CSS 变量块 否(system 需 JS 解析一次)
密度 data-ls-density="default|compact" 覆盖控件高度/内边距/字号 否
品牌色 内联 --ls-primary-1..10 @ant-design/colors 的 generate() 是

三轴正交是关键设计:亮暗与密度是纯 CSS,只有品牌色需要 JS。这样绝大多数的主题切换(切亮暗、切密度)是零 JS 重渲染的。

4.2 属性落地必须在绘制之前

tsx
/**
 * useLayoutEffect 在 SSR 时会触发 React 告警,浏览器里又必须用它 ——
 * 主题变量得在绘制前写好,否则会闪一帧无主题的样式。
 */
const useIsomorphicLayoutEffect = typeof window === 'undefined' ? useEffect : useLayoutEffect;

/* ① 属性落地。排在写变量之前,保证任何"读当前生效值"的逻辑拿到的都是新主题。 */
useIsomorphicLayoutEffect(() => {
  const el = resolveTarget();
  if (!el) return;
  el.setAttribute(THEME_ATTR, resolvedMode);
  el.setAttribute(DENSITY_ATTR, density);
  // 让原生 UI(滚动条、表单控件、自动填充底色)跟随主题
  el.style.colorScheme = resolvedMode;
}, [resolveTarget, resolvedMode, density]);

挂载元素默认 document.documentElement:只有挂在 <html> 上,<body> 和所有 Portal 到 body 的浮层(弹窗、下拉)才能继承到主题。

持久化配置用惰性初始化读取,不在 effect 里补读——否则会先用默认配置渲染一帧,再改成持久化配置,表现为一次可见的跳变,正好抵消掉防闪屏脚本的作用。

4.3 品牌色运行时派生

ts
export const DARK_BG_CONTAINER = '#141414';

export function generateBrandPalettes(primaryColor: string): BrandPalettes {
  return {
    light: buildPalette(generate(primaryColor), 'light'),
    dark: buildPalette(
      generate(primaryColor, { theme: 'dark', backgroundColor: DARK_BG_CONTAINER }),
      'dark',
    ),
  };
}

亮暗两套一起算,而不是按需算。理由是持久化:防闪屏脚本需要"不论当前是哪套模式都能直接回填",所以缓存里必须两套俱全。单次 generate() 开销在微秒量级,不值得为它引入分支。

默认品牌色走静态 CSS,只有非默认色才写内联变量:

tsx
if (primaryColor === DEFAULT_PRIMARY_COLOR) {
  // 交给 tokens/palette.less 的静态声明,保持 DOM 干净,
  // 也让消费方仍能通过 CSS 层覆盖同名变量
  for (let level = 1; level <= 10; level += 1) el.style.removeProperty(`--ls-primary-${level}`);
  el.style.removeProperty(CONTROL_OUTLINE_VAR);
  return;
}

4.4 getAlphaColor:反解而不是混合

antd 的 controlOutline(控件外圈阴影色)不是"主色加透明度",而是一个反解出来的颜色:

text
getAlphaColor('#e6f4ff', '#ffffff') = rgba(5,145,255,0.1)

注意 RGB 是 (5,145,255),不是主色 (22,119,255)。它是让"以 α 叠加在衬底上"恰好等于目标实色的那一组解。

ts
export function getAlphaColor(frontColor: string, backgroundColor: string): string {
  // 从 α=0.01 起以 0.01 步长递增,取第一个能让 r/g/b 三个分量
  // 都落回 [0,255] 的 α
  for (let alpha = 0.01; alpha <= 1.0001; alpha += 0.01) {
    const r = Math.round((front.r - back.r * (1 - alpha)) / alpha);
    const g = Math.round((front.g - back.g * (1 - alpha)) / alpha);
    const b = Math.round((front.b - back.b * (1 - alpha)) / alpha);

    if (isStable(r) && isStable(g) && isStable(b)) {
      const rounded = Math.round(alpha * 100) / 100;
      return `rgba(${r},${g},${b},${rounded})`;
    }
  }
  return `rgba(${front.r},${front.g},${front.b},1)`;
}

为什么必须同序同精度:解不唯一时 antd 取的是最小可行 α。用别的 α 会得到视觉等价但字符串不同的结果,diff 时无法判定是否回归。

四值校验通过(亮暗 × 主色/错误):

text
#e6f4ff / #ffffff          → rgba(5,145,255,0.1)
#15325b / #141414          → rgba(23,117,249,0.31)
#fff2f0 / #ffffff          → rgba(255,38,5,0.06)
#2c1618 / #141414          → rgba(238,38,56,0.11)

品牌色换色时,--ls-control-outline 必须跟着重算——它由主题层用当前品牌色算出来写进内联变量。Button 的主色阴影就是引用它:

less
--ls-btn-shadow-primary: 0 var(--ls-control-outline-width) 0 var(--ls-control-outline);

4.5 首屏防闪屏:双写缓存 + 只回填不计算

防闪屏脚本必须满足三个条件,缺一条就会闪:

  1. 同步执行(不能加 defer / async)
  2. 位置在所有 CSS 与模块脚本之前
  3. 只做"回填",不做"计算"

第 3 点最容易写错。色阶由 generate() 算出,如果在关键路径上调它,等于把颜色算法搬进阻塞渲染的链路。

正确做法是 ThemeProvider 把算好的亮暗两套色阶一并缓存,脚本只负责按当前模式取一套写回:

html
<script>
  (function () {
    try {
      var STORAGE_KEY = 'ls-design-theme';
      var el = document.documentElement;
      var cfg = JSON.parse(localStorage.getItem(STORAGE_KEY) || '{}');

      var mode = cfg.mode || 'system';
      var resolved =
        mode === 'system'
          ? window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'
          : mode;

      el.setAttribute('data-ls-theme', resolved);
      el.setAttribute('data-ls-density', cfg.density || 'default');
      el.style.colorScheme = resolved;

      var brand = cfg.brand && cfg.brand[resolved];
      if (brand && Object.prototype.toString.call(brand.palette) === '[object Array]') {
        for (var i = 0; i < brand.palette.length; i++) {
          el.style.setProperty('--ls-primary-' + (i + 1), brand.palette[i]);
        }
        // 主色阴影是色阶的派生值,算法不在这里重跑,所以必须连它一起缓存
        if (brand.outline) el.style.setProperty('--ls-control-outline', brand.outline);
      }
    } catch (e) {
      // localStorage 被禁用或数据损坏时不阻断渲染,静默用默认主题
    }
  })();
</script>

缓存结构里品牌色存两份是刻意的:

ts
{
  mode, density,
  primaryColor,                                  // 「输入」,给 React 用
  brand: { light: { palette, outline },          // 「输出」,给防闪屏脚本用
           dark:  { palette, outline } },
}

只存输入,脚本就得在关键路径上跑色彩算法;只存输出,React 无法还原出当前品牌色是什么。

4.6 一个必须提前决定的边界:SSR

ThemeProvider 用了 useIsomorphicLayoutEffect 和 typeof window === 'undefined' 判断,但没有加 "use client"。如果将来要支持 Next.js App Router,需要确认这个文件被正确地归入客户端边界。

同理,"首个渲染就用 localStorage 的值"这个模式在 SSR 下会导致 hydration mismatch(服务端不知道用户的偏好)。彻底解法是服务端读出 cookie,把 mode 作为 prop 传进来。这是必须在写组件之前决定的事,后补要动所有组件。


5. 数值级对齐 antd 的方法论

5.1 用脚本生成,不手抄

js
const light = theme.getDesignToken({ ...opts, algorithm: theme.defaultAlgorithm });
const dark = theme.getDesignToken({ ...opts, algorithm: theme.darkAlgorithm });
const compact = theme.getDesignToken({
  ...opts,
  algorithm: [theme.defaultAlgorithm, theme.compactAlgorithm],
});

手抄 100+ 个令牌必然出错,且 antd 升 minor 后无从 diff。脚本把"基准版本"锚定在 devDependencies 里的 antd 上,升级即是一次显式的 diff。

脚本还从实际安装的 antd 读版本号写进文件头,避免脚本内的版本号与实际基线脱节:

js
const ANTD_VERSION = JSON.parse(
  readFileSync(require.resolve('antd/package.json'), 'utf8'),
).version;

5.2 色阶索引在暗色下是反向的

这是全项目最容易错、且错了最难发现的地方。

语义令牌 亮色 暗色
colorPrimaryBg 1 3
colorPrimaryBgHover 2 4
colorPrimaryBorder 3 3
colorPrimaryBorderHover 4 4
colorPrimaryHover 5 7 ← 反向
colorPrimary 6 6
colorPrimaryActive 7 5 ← 反向

原因:亮色色阶是"浅 → 深"(索引越大越暗),暗色色阶是"深 → 浅"(索引越大越亮)。所以 hover 走高阶、active 走低阶,与亮色完全相反。

另外两个实测事实:

  1. 暗色的色阶 1、2 在 antd 的 MapToken 里根本没被使用。
  2. 暗色下 --ls-primary-6 不等于传入的品牌色:generate('#1677ff', { theme:'dark', backgroundColor:'#141414' })[5] = #1668dc ≠ #1677ff。这是算法的感知校正,不是 bug。

所以不靠人记,靠断言。脚本里内置自校验,任一项不匹配就中止,绝不生成一份静默错误的令牌:

js
const assertIndex = (label, palette, index, actual) => {
  const expected = palette[index - 1];
  if (String(expected).toLowerCase() !== String(actual).toLowerCase()) {
    errors.push(`${label}  语义令牌色阶[${index}] 期望 ${actual},实际 ${expected}`);
  }
};

const DARK_MAP = {
  colorPrimaryBg: 3, colorPrimaryBgHover: 4, colorPrimaryBorder: 3,
  colorPrimaryBorderHover: 4, colorPrimaryHover: 7,   // ← 亮色为 5
  colorPrimary: 6, colorPrimaryActive: 5,             // ← 亮色为 7
};

if (errors.length > 0) {
  console.error('\n✖ 色阶索引校验失败,已中止生成。antd 的映射关系可能已变更:\n');
  process.exit(1);
}

5.3 ⚠️ 暗色 generate() 必须传 backgroundColor

js
generate(primary, { theme: 'dark', backgroundColor: '#141414' })

传 '#000' 不会报错,但会得到一整套与 antd MapToken 零命中(逐项比对全部不匹配)的色阶。表现为"暗色下所有主色系控件偏亮偏艳",而排查时没有任何报错线索。

必须是 #141414,即 antd 暗色主题的 colorBgContainer,不是 colorBgBase 的 #000。

5.4 组件令牌拿不到,只能读源码

js
theme.getDesignToken({ components: { Button: {...} } })

实测:theme.getDesignToken() 只返回全局令牌,不返回组件级令牌(传入 components 也不会合并出来)。

Button 的 paddingInline / iconGap / shadow 只能读 antd/lib/button/style/token.js 的 prepareComponentToken。

环境提示:antd 有 lib/(CJS)与 es/(ESM)两份。es/ 是无扩展名 import,Node 直接跑会 ERR_MODULE_NOT_FOUND;探测脚本必须走 require('antd/lib/...') 的 CJS 解析。

5.5 那些没被令牌化的数字

这是"对齐"最容易漏的一环。全局令牌表抄完仍会有像素差,因为 antd 组件内部存在硬编码数值:

antd 源码 值 说明
paddingInline = paddingContentHorizontal - lineWidth 15 不是 16
paddingInlineLG 15 与默认档同值,不是 23
paddingInlineSM = 8 - lineWidth 7 字面量 8,不随密度变
buttonPaddingVertical 0 三档全是 0,padding: 0 15px,高度由 height 决定
contentFontSizeSM 14 = fontSize,小号按钮的字号不缩小
contentFontSizeLG 16 = fontSizeLG
iconGap 8 = marginXS
onlyIconSize 'inherit' 图标跟随按钮字号
solidTextColor #fff / #000 = colorTextInverse,直接复用

其余三个容易抄错的点:

js
// ① 危险「填充」态 hover 用的是 colorErrorBgFilledHover,不是 colorErrorBgHover
//    两者不同名,也不同值(#ffdfdc / #441e1f)
// ② box-shadow 只在 solid / outlined / dashed 三种变体上投射
//    filled / text / link 没有阴影
// ③ antd 里三个「同名等价 / 从未被读取」的中间变量可以跳过:
//    dashedBgDisabled ≡ defaultBgDisabled ≡ colorBgContainerDisabled
//    --btn-border-color-disabled / --btn-text-color-disabled 被赋值但从不被读取

原则:跟随 antd 的行为,而不是它的代码形状。 抄形状会把死代码也搬进来,抄行为才能保证视觉一致且代码干净。


6. 构建与工程化

6.1 pnpm 的纪律,以及它不兜底的地方

.npmrc:

ini
# 保持 pnpm 的符号链接严格布局。组件里 import 了未声明的包,本地直接报错,
# 而不是静默通过。这是发布质量的前置防线。
shamefully-hoist=false
auto-install-peers=true
strict-peer-dependencies=false   # 阶段 10 依赖树定型后改 true

pnpm-workspace.yaml:

yaml
packages:
  - "packages/*"

# ⚠️ 语法是 map(name: true),不是数组
allowBuilds:
  esbuild: true

四条实测事实:

  1. allowBuilds 写成数组不报错,但会静默失效。pnpm 会把它改写成 {'0': esbuild} 加一条占位项 esbuild: set this to true or false,构建脚本依然被拦。要注意字段名是 allowBuilds(不是旧版的 onlyBuiltDependencies)。
  2. pnpm install 一律在 workspace 根跑。在子包目录跑也一样——输出 Scope: all N workspace projects,装的仍是整个 workspace。依赖实体只有一份在根 node_modules/.pnpm/,子包 node_modules/ 顶层只有指向它的软链;lockfile 全仓只有一份在根。
  3. pnpm add 写入"当前所在目录"的 package.json。装到子包要 pnpm -F ls-design add -D <pkg>。
  4. ⚠️ "根不装任何依赖"这条纪律没有机制兜底:在根跑 pnpm add(不加 -w)不报错,直接静默写入根 package.json。旧版 pnpm 会报 ERR_PNPM_ADDING_TO_ROOT,11.15.0 不报。只能靠自觉。

严格布局拦截的边界(从子包目录解析):

依赖声明位置 结果
子包自己声明的 ✅
只在根声明的 ✅(Node 向上遍历 node_modules 命中根,不是 pnpm 的提升)
完全未在任何 package.json 声明的间接依赖 ❌ MODULE_NOT_FOUND

结论:pnpm 拦的是"完全未声明",不是"根声明"。

另注:两个配置文件(pnpm-workspace.yaml / .npmrc)都支持 # 注释且实测被保留(.npmrc 另支持 ;)。但 pnpm 会重写它自己维护的节点(如 minimumReleaseAgeExclude),被重写节点上的注释会丢。

6.2 Vite 8 / Rolldown:三个必须知道的改动

Vite 8 的构建内核已换成 Rolldown(依赖 rolldown + lightningcss,不再依赖 esbuild)。由此产生三个改动:

ts
// ① external 写在 rolldownOptions 下
//    build.rollupOptions 仍存在但已标注 @deprecated,写它等于走兼容层
rolldownOptions: {
  external: [
    'react', 'react-dom',
    // ② React 19 的自动 JSX 运行时。这两条极容易漏,漏了会把整份 jsx-runtime 打进产物
    'react/jsx-runtime', 'react/jsx-dev-runtime',
    '@ant-design/colors',
  ],
},

// ③ 库构建选项必须按 mode === 'lib' 隔离
//    原因:Storybook 会合并 vite.config,若 build.lib 无条件存在,会干扰它自己的打包
const isLib = mode === 'lib';
export default defineConfig(({ mode }) => ({
  build: { ...(isLib && { /* lib 相关全部选项 */ }) },
}));

React 全家桶必须 external 的理由:否则业务侧会打进两份 React 实例,表现为 hooks 报 Invalid hook call 或 Symbol 不匹配——排查成本极高。

6.3 库产物的命名与 exports 映射

实测(vite 8.3.4,type: "module" + fileName: 'ls-design'):

formats 产物
['es', 'cjs'] ls-design.js / ls-design.cjs / ls-design.css
['es', 'umd'] ls-design.js / ls-design.umd.cjs
json
{
  "type": "module",
  "sideEffects": ["**/*.css", "**/*.less"],
  "exports": {
    ".": {
      "types": "./dist/index.d.ts",
      "import": "./dist/ls-design.js",
      "require": "./dist/ls-design.cjs"
    },
    "./style.css": "./dist/ls-design.css"
  }
}

⚠️ sideEffects: ["*.css"] 只匹配包根,是常见错误——必须写 **/*.css,否则组件样式会被 tree-shaking 摇掉。

不产 UMD:React 组件库要在 <script> 下用需要宿主提供 React 全局,实际场景近乎为零,多一个产物只是多一份要维护的映射。

6.4 让 Storybook 复用 vite.config

ts
const config: StorybookConfig = {
  stories: ['../src/**/*.mdx', '../src/**/*.stories.@(ts|tsx)'],
  addons: ['@storybook/addon-docs', '@storybook/addon-a11y'],
  framework: { name: '@storybook/react-vite', options: {} },
  typescript: {
    // ⚠️ 必须是 'react-docgen',不能用 'react-docgen-typescript'
    reactDocgen: 'react-docgen',
  },
};

@storybook/react-vite 会自动合并根目录的 vite.config.ts,于是 LESS 的 additionalData(自动注入 mixin)、@ 别名都直接生效——这就是 §6.2 第 ③ 条要把 build.lib 收进分支的原因。

stories 必须与组件同目录(src/**),不集中到独立 app:组件的 props、样式、用例在同一批次改动,物理上分开会让"改一个加一个"变成跨目录的两步操作,久而久之 stories 就会落后于组件。

6.5 TypeScript 7 的两处破坏性变更

① baseUrl 被移除

text
error TS5102: Option 'baseUrl' has been removed

删掉即可,paths 保留(TS 4.4 起 paths 就是相对 tsconfig 目录解析的,baseUrl 早已无必要):

json
{
  "compilerOptions": {
    "paths": { "@/*": ["./src/*"] }
  }
}

② 不再提供 JavaScript Compiler API

text
[unplugin-dts] The installed "typescript" package does not provide
the JavaScript Compiler API (this happens with TypeScript 7+)

TS 7 是 Go 原生实现,vite-plugin-dts / unplugin-dts 无法生成 .d.ts。

解法:装兜底包

bash
pnpm -F ls-design add -D @typescript/typescript6

装后 tsc --noEmit 走 TS 7(快),d.ts 生成走 TS 6 的 API,两者并存。

连带影响:Storybook 的 react-docgen-typescript 也依赖这套 API,在 TS 7 下会让 props 表静默变空(不报错)。必须改用 react-docgen(走 Babel 解析,只读 AST)。代价是它从 JSDoc 注释取描述、不做深层类型推断——所以组件里的 props 注释必须写全,这正是 DoD 里"props 表无缺失注释"的由来。

6.6 自动注入 mixin 的绝对路径陷阱

想省去在每个 .less 里手写 @import "../../styles/mixins",可以用 additionalData 自动注入:

ts
const MIXINS_ENTRY = fileURLToPath(new URL('./src/styles/mixins/index.less', import.meta.url));

css: {
  preprocessorOptions: {
    less: {
      javascriptEnabled: true,
      additionalData: (content, filename) =>
        filename === MIXINS_ENTRY
          ? content
          : `@import (reference) "${MIXINS_ENTRY}";\n${content}`,
    },
  },
}

三个必须遵守的点:

  1. 必须用绝对路径。LESS 解析 @import 是相对导入方文件而不是项目根:写成相对路径时,src/icons/Icon.less 会被解析成 src/icons/mixins/index.less,报 'mixins/index.less' wasn't found。
  2. 排除自身要用全等而不是 includes。filename.includes('mixins/index.less') 会被任何同名的深层文件误命中,造成循环导入。
  3. 必须带 (reference)。否则每个组件文件都会把 mixin 里的 CSS 完整重复输出一遍,产物体积翻倍。

对应地,mixins/ 必须独立成目录且只放 mixin——这是唯一可靠的防线。任何裸声明一旦 (reference) 被去掉就会被重复输出。


7. 组件实现:以 Button 为例

7.1 API 取向:两个正交属性替代一个 type

antd v6 把 type 拆成了两个正交属性:

ts
variant: 'outlined' | 'dashed' | 'solid' | 'filled' | 'text' | 'link'   // 外形
color:   'default' | 'primary' | 'danger'                              // 语义色

拆开的价值在于能表达原来表达不出的组合——比如"虚线红框按钮"。v5 里 danger 会把外观强制成主色实心,只能二选一。

有意不提供的部分也写进注释:

  • 旧 type 属性:保留它要维护一张映射表,且两套 API 打架时行为不可预测。这个仓库不背迁移包袱。
  • 13 种预设色(cyan / purple / geekblue…):每种要一整套 10 级色阶,属于"等真正用到再加"。
  • Button.Group / Space.Compact:依赖尚未实现的其他组件。

htmlType 的改名与默认值同样是刻意的:

ts
/**
 * 原生 `type`。默认 `'button'`。
 * 为什么改名:`type` 在旧版 antd 里被用作「按钮外观」,
 * 且 <form> 内不写 type 的原生按钮默认是 `submit`,会意外提交表单 ——
 * 所以默认值必须是 `button`,名字也必须让位。
 */
htmlType?: 'submit' | 'reset' | 'button';

继承原生 props 时要去掉语义冲突的键:

ts
export interface ButtonProps
  extends Omit<ButtonHTMLAttributes<HTMLButtonElement>, 'type' | 'color'> {
  // 'type' 与 htmlType 语义重复且极易误用(不写等于 submit);
  // 'color' 在 DOM 上是 CSS 颜色字符串,与语义色枚举完全不是一回事
}

7.2 用 CSS 变量吸收组合爆炸

这是整个组件库最优雅的一处设计。

6 种变体 × 3 种语义色 = 18 种外观,但样式代码只需要 3 个变体块 + 3 个色块:

less
.ls-btn {
  /* 语义槽位分两层是刻意的:
       color-*         由「语义色」块设置(default / primary / danger)
       border/text/bg  由「变体」块把 color-* 映射上来
     于是新增一个语义色只需写一个色块,新增一个变体只需写一个变体块 */
  --ls-btn-color-base: var(--ls-color-border);
  --ls-btn-color-hover: var(--ls-color-primary-hover);
  --ls-btn-border-color: var(--ls-color-border);
  --ls-btn-text-color: var(--ls-color-text);
  --ls-btn-bg-color: var(--ls-color-bg-container);
}

组合由 CSS 变量的层叠天然完成,不需要 JS 拼类名,也不需要 18 条 CSS 规则。

移植时的唯一结构性改动:antd 用 cssinjs 在构建期把 token 值拼成字面量写进 CSS,而我们需要运行时换肤,所以把 antd 的 varRef('xxx') 一对一翻译成 CSS 自定义属性 --ls-btn-xxx。

变量初值取"default + outlined"的语义值,而不是 antd 的 #000 / #ddd 占位符——这样手写一个裸 .ls-btn 也能得到合理外观(antd 手写 HTML 会呈现黑边黑底)。

7.3 size / shape 是同一个决策的两面

less
/**
 * ⚠️ 参数用 `;` 分隔:值里有逗号(calc、rgba)时用逗号会被 LESS 当作多个参数。
 */
.btn-size(@height; @font-size; @padding-inline; @radius) {
  height: @height;
  font-size: @font-size;
  padding: 0 @padding-inline;
  border-radius: @radius;

  /* 纯图标按钮:宽高相等成正方形。图标字号用 inherit —— antd 的
     onlyIconSize 值就是 'inherit',不需要为每个尺寸再配一套图标字号 */
  &.ls-btn-icon-only {
    width: @height;
    .ls-icon { font-size: inherit; }
  }

  /* 直径以控件高度为准,必须逐档重算 */
  &.ls-btn-circle { min-width: @height; }

  /* 胶囊:圆角半径取整个高度(远大于一半,因此必为胶囊) */
  &.ls-btn-round {
    border-radius: @height;
    &:not(.ls-btn-icon-only) { padding-inline: @padding-inline; }
  }
}

抽成 mixin 的理由:"三档尺寸"与"形状的尺寸基准"是同一个决策的两面——圆形按钮的直径、胶囊按钮的圆角半径都等于控件高度。逐档手写必然漏改其一。

加载图标的收放用 CSS transition + 一次 requestAnimationFrame 实现,不引 rc-motion:

less
/* 收起来的关键是 width: 0 + opacity: 0 + 负 margin 吃掉 flex gap,
   否则图标"消失"后仍占着间距,文字会跳位 */

8. 没有浏览器时怎么验证组件

组件库的正确性验证通常依赖浏览器。在无法开浏览器的环境里,可以走这条路径(本项目实测跑通,28/28 通过):

js
// ① 从隔离的 workspace 解析 happy-dom,不污染项目 node_modules
const require = createRequire('/path/to/isolated/workspace/package.json');
const { Window } = require('happy-dom');

const window = new Window();
const document = window.document;

// ② 装全局量
globalThis.window = window;
globalThis.document = document;

// ⚠️ Node 22 的 navigator 只有 getter,直接赋值报 "Cannot set property navigator"
Object.defineProperty(globalThis, 'navigator', {
  value: window.navigator, configurable: true, writable: true,
});

globalThis.IS_REACT_ACT_ENVIRONMENT = true;   // 否则 act() 会告警

// ③ 用 Vite 的 ssrLoadModule 加载 TSX(自动处理 LESS / TS / JSX)
const server = await vite.createServer({ middlewareMode: true });
const { Button } = await server.ssrLoadModule('/src/components/button/Button.tsx');

// ④ 真渲染 + act() 刷 effects
const { createRoot } = await server.ssrLoadModule('react-dom/client');
const root = createRoot(document.getElementById('root'));
await act(async () => { root.render(<Button loading>提交</Button>); });

// ⑤ 断言:类名、属性、内联变量、计算后的 style 值

这套组合能验证:主题属性落地、品牌色内联变量写入/清除、色阶与 antd 逐字节一致、loading 延时语义、点击拦截、htmlType、持久化结构、两汉字空格插入。

它验不了:真实的层叠效果、box-shadow、CSS 动画的中间帧、以及"看起来对不对"。所以它只是补充,不能替代截图比对。


9. 踩坑速查表(25 条)

# 症状 根因 解法
1 variable @ls-color-text is undefined LESS mixin 内定义的变量不外泄到兄弟作用域 令牌唯一载体改为 CSS 变量,编译期算术走 calc()
2 'mixins/index.less' wasn't found LESS 解析 @import 是相对导入方文件 additionalData 注入用绝对路径;排除自身用 === 而非 includes
3 TS5102: Option 'baseUrl' has been removed TS 7 移除 baseUrl 删掉 baseUrl,只留 paths
4 dts 生成失败:不提供 JS Compiler API TS 7 是 Go 实现,无 JS API pnpm add -D @typescript/typescript6
5 Storybook props 表静默变空(不报错) react-docgen-typescript 依赖已移除的 API 改 reactDocgen: 'react-docgen',并把 props 注释写全
6 allowBuilds 写了但构建脚本仍被拦 写成了数组,pnpm 静默改写成 {'0': ...} 必须是 map:allowBuilds: { esbuild: true }
7 根 package.json 被写入依赖 pnpm 11 在根跑 pnpm add(不加 -w)不报错 装子包用 pnpm -F ls-design add,纪律靠自觉
8 子包目录出现 lockfile workspace 未被识别 pnpm install 一律在根跑;lockfile 全仓只有一份在根
9 以为"根声明的依赖对子包不可见" Node 会向上遍历 node_modules 命中根 pnpm 拦的是"完全未声明",不是"根声明"
10 暗色主色系控件整体偏色 暗色色阶索引与亮色相反(hover 7 / active 5) 用两套映射表 + 脚本断言校验,不靠人记
11 暗色色阶与 antd 零命中且无报错 generate() 传了 backgroundColor: '#000' 必须传 '#141414'(= colorBgContainer)
12 以为换品牌色没生效 暗色 --ls-primary-6 ≠ 传入品牌色(#1677ff → #1668dc) 这是算法的感知校正,不是 bug
13 外圈阴影色算不对 controlOutline 不是 alpha 混合,是 getAlphaColor 反解 复刻算法,同序同精度(α 从 0.01 起,步长 0.01)
14 危险填充态 hover 色抄错 应是 colorErrorBgFilledHover,不是 colorErrorBgHover 两个不同名也不同值
15 小号按钮字号变小了 / 内边距对不上 contentFontSizeSM = fontSize;paddingInlineSM 用字面量 8(不随密度变) 读 prepareComponentToken 源码,不猜
16 填充/文字按钮上多了一圈阴影 box-shadow 只在 solid / outlined / dashed 上投射 按变体分别处理,不要全局加
17 ls-btn-two-chinese-chars 类永远不出现 空格在渲染阶段就插入了,isTwoCNChar 匹配不到"恰好两个汉字" 这是 antd 的既有行为(该补偿规则是死代码);真正生效的是插入的空格。照抄 DOM 与 CSS 即可,只加注释不改行为
18 loading={{ delay: 300 }} 挂了就转圈 延时只作用于「非 loading → loading」这一跳;初始 state 直接取自入参 需要"首次也延时"要自己加状态机
19 rollupOptions 里的配置不生效 Vite 8 内核换 Rolldown,rollupOptions 已 deprecated 写 build.rolldownOptions.external
20 产物里打进了整份 jsx-runtime 只 external 了 react / react-dom 必须一起 external react/jsx-runtime + react/jsx-dev-runtime
21 组件样式被 tree-shaking 摇掉 sideEffects: ["*.css"] 只匹配包根 写 "**/*.css"、"**/*.less"
22 Storybook 打包异常 build.lib 无条件存在,被合并进 Storybook 配置 库构建选项按 mode === 'lib' 分支隔离
23 部署到子路径后资源全 404 未设 base GitHub Pages 等项目页必须设 base;Vercel/Netlify 根路径无需处理
24 .less 里 @apply 样式静默丢失 Tailwind v4 不处理 .less:构建通过、原样进产物、无报错 本项目已弃用 Tailwind;若重引入则 .less 内禁写 @apply,.css 内必须同文件加 @reference
25 Cannot set property navigator Node 22 的 navigator 只有 getter Object.defineProperty(globalThis, 'navigator', { value, configurable: true, writable: true })

三条贯穿始终的元原则

  1. 静默失败比报错危险得多。#5、#11、#17、#24 全都"构建通过、无警告、结果错",排查成本远高于直接报错。遇到这类问题,第一反应应该是"我的验证方法能不能发现它",而不是"再加点日志"。
  2. 看不清的推论不要写进文档当结论。本项目里"LESS 变量与 CSS 变量可以成对产出"曾经是个看起来很合理的推断,实测直接被推翻。凡是能花 5 分钟实测的,就不要靠推断。
  3. 跟随行为,不跟随代码形状。antd 有死代码、有从未被读取的变量、有半废弃物。抄行为才能保证视觉一致;抄形状只会把技术债一起搬过来。

10. 当前状态与未完成的部分

已完成并验证

项 结果
pnpm typecheck ✅ 零错
pnpm build ✅ ls-design.css 21.19 kB (gzip 3.70) / ls-design.js 11.15 kB / ls-design.cjs 8.93 kB;19 个 .d.ts
pnpm build-storybook ✅ 12 个条目(1 Docs + 11 stories),props 描述正常提取
客户端渲染冒烟 ✅ 28/28 通过(happy-dom + 真实 react-dom 渲染)
演示页 ✅ 主题工具条(模式 / 密度 / 品牌色预设 + 自定义)可用

明确未闭合

  • 只做了数值级对齐,没做截图级像素 diff。真正跑一次并排截图比对才算闭合。
  • 未装 stylelint,color-no-hex 门禁未落地(若落地必须排除 src/styles/tokens/**,那里必然有 hex)。
  • 未接 vitest / RTL,冒烟脚本是一次性的。
  • Button 缺:13 种预设色、Button.Group、Space.Compact 联动、ConfigProvider 级的 autoInsertSpace / componentConfig、classNames / styles 语义化 API。
  • 其余 9 个组件未开始。useMergedState(统一受控/非受控)尚未写——Button 无内部状态,用不到,Input 会第一个需要它。
  • @layer 级联策略未采用,当前靠"导入顺序 + 选择器特异性"控制层叠。

待决策(后补要改所有组件)

  • 目标浏览器基线(是否需要支持国产浏览器内核 → 决定 :has() / oklch() / @container 能否用)
  • 是否支持 SSR / RSC(决定 "use client"、hydration mismatch 的解法)
  • 文案与 locale 机制(组件内文案是否需要上下文)

附录 A:命令清单

bash
# 依赖安装(一律在 workspace 根)
pnpm install

# 装到子包
pnpm -F ls-design add -D <pkg>

# 从 antd 重新生成令牌(seed.less / palette.less)
pnpm -F ls-design tokens

# 类型检查(走 TS 7,快)
pnpm -F ls-design typecheck

# 库构建(产出 dist/:ESM + CJS + CSS + d.ts)
pnpm -F ls-design build

# 组件工作台
pnpm -F ls-design storybook          # dev,:6006
pnpm -F ls-design build-storybook    # 静态构建,可直投静态托管

# 演示页
pnpm -F ls-design dev                # :5173

附录 B:库构建产物

text
dist/
├── ls-design.js        11.15 kB │ gzip 4.68 kB   # ESM(主产物)
├── ls-design.cjs        8.93 kB │ gzip 4.18 kB   # CJS
├── ls-design.css       21.19 kB │ gzip 3.70 kB   # 单份样式,不按 chunk 切分
├── index.d.ts                                     # 类型入口
└── components/ theme/ utils/ icons/  …            # 逐模块 d.ts

cssCodeSplit: false 是刻意的:组件库的样式应该是一次性引入的整体,切成多份只会让消费方难以处理。

附录 C:ThemeProvider 的公开 API

tsx
<ThemeProvider
  defaultMode="system"          // 'light' | 'dark' | 'system'
  defaultDensity="default"      // 'default' | 'compact'
  defaultPrimaryColor="#1677ff"
  persist={true}                // 是否写入 localStorage
  target={undefined}            // 属性落地元素,默认 document.documentElement
  storageKey="ls-design-theme"  // 与防闪屏脚本共用
>
  {children}
</ThemeProvider>

persist={false} 用于"主题被外层固定"的场景(如文档站里演示某个固定主题的区块),避免演示区块的挂载污染用户的真实偏好。

useTheme() 提供 { mode, density, primaryColor, resolvedMode, setMode, setDensity, setPrimaryColor, toggleMode }。在 Provider 外部调用会抛错——不静默回退到默认值,因为那会让"忘了包 Provider"变成一个难查的样式问题。

标签 · TAGS

评论 · COMMENTS

评论由 Giscus 承载 · 需要 GitHub 账号登录

站点不保存任何读者账号与凭证

未配置:在 lib/site.config.ts 填入 giscus 的 repo / repoId / category / categoryId