从零手写一个对标 antd 的 React 组件库:分层架构、实施流程与 25 个实测坑
用 Vite 8 + React 19 + TypeScript 7 + LESS 4 + pnpm workspace + Storybook 10,从空目录做一个视觉上与 antd 6.6.6 逐像素对齐、支持运行时换肤(亮暗 / 品牌色 / 密度三轴)的组件库。
本文不是"设计令牌概念科普",而是一次完整实施的复盘:每一处决策都写清依据与代价,每一个坑都是本机实测出来的,附症状、根因与解法。所有数值都是跑出来的,没有推测。
目录
- 0. 背景:这个库要解决什么
- 1. 技术选型:每个选择都写清代价
- 2. 实施流程:12 个阶段与门禁
- 3. 令牌体系:三层,且只有一个载体
- 4. 主题:三个正交的轴
- 5. 数值级对齐 antd 的方法论
- 6. 构建与工程化
- 7. 组件实现:以 Button 为例
- 8. 没有浏览器时怎么验证组件
- 9. 踩坑速查表(25 条)
- 10. 当前状态与未完成的部分
- 附录
0. 背景:这个库要解决什么
0.1 定位
这个库叫 ls-design,定位是练手项目,目标不是"企业级治理完备",而是跑通一条完整闭环:
设计令牌 → 组件 → 文档工作台 → 库构建产物 → 公网可访问首版 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 变量)。只要:
- 把 antd 的 MapToken 值原样抄进语义令牌层;
- 把 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 变量在编译期就被求值替换成字面量,浏览器无法再改。
// ✅ 正确: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:
.less文件里禁止写@apply;.css里用@apply必须在同文件加@reference;- 库构建不得把 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 目录结构
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两条刻意的简化:
- 组件目录不按 general / layout 分两级。分组交给 Storybook 的
title: '通用/Button'命名解决,物理分层只会让 import 路径变长。 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 分层
基础令牌(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 参与编译期算术。
.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 变量:
// 水平内边距 = 内容区内边距 - 线宽(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 的文本色梯度是:
--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 属性落地必须在绘制之前
/**
* 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 品牌色运行时派生
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,只有非默认色才写内联变量:
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(控件外圈阴影色)不是"主色加透明度",而是一个反解出来的颜色:
getAlphaColor('#e6f4ff', '#ffffff') = rgba(5,145,255,0.1)注意 RGB 是 (5,145,255),不是主色 (22,119,255)。它是让"以 α 叠加在衬底上"恰好等于目标实色的那一组解。
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 时无法判定是否回归。
四值校验通过(亮暗 × 主色/错误):
#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 的主色阴影就是引用它:
--ls-btn-shadow-primary: 0 var(--ls-control-outline-width) 0 var(--ls-control-outline);4.5 首屏防闪屏:双写缓存 + 只回填不计算
防闪屏脚本必须满足三个条件,缺一条就会闪:
- 同步执行(不能加
defer/async) - 位置在所有 CSS 与模块脚本之前
- 只做"回填",不做"计算"
第 3 点最容易写错。色阶由 generate() 算出,如果在关键路径上调它,等于把颜色算法搬进阻塞渲染的链路。
正确做法是 ThemeProvider 把算好的亮暗两套色阶一并缓存,脚本只负责按当前模式取一套写回:
<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>缓存结构里品牌色存两份是刻意的:
{
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 用脚本生成,不手抄
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 读版本号写进文件头,避免脚本内的版本号与实际基线脱节:
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、2 在 antd 的 MapToken 里根本没被使用。
- 暗色下
--ls-primary-6不等于传入的品牌色:generate('#1677ff', { theme:'dark', backgroundColor:'#141414' })[5]=#1668dc≠#1677ff。这是算法的感知校正,不是 bug。
所以不靠人记,靠断言。脚本里内置自校验,任一项不匹配就中止,绝不生成一份静默错误的令牌:
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
generate(primary, { theme: 'dark', backgroundColor: '#141414' })传 '#000' 不会报错,但会得到一整套与 antd MapToken 零命中(逐项比对全部不匹配)的色阶。表现为"暗色下所有主色系控件偏亮偏艳",而排查时没有任何报错线索。
必须是 #141414,即 antd 暗色主题的 colorBgContainer,不是 colorBgBase 的 #000。
5.4 组件令牌拿不到,只能读源码
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,直接复用 |
其余三个容易抄错的点:
// ① 危险「填充」态 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:
# 保持 pnpm 的符号链接严格布局。组件里 import 了未声明的包,本地直接报错,
# 而不是静默通过。这是发布质量的前置防线。
shamefully-hoist=false
auto-install-peers=true
strict-peer-dependencies=false # 阶段 10 依赖树定型后改 truepnpm-workspace.yaml:
packages:
- "packages/*"
# ⚠️ 语法是 map(name: true),不是数组
allowBuilds:
esbuild: true四条实测事实:
allowBuilds写成数组不报错,但会静默失效。pnpm 会把它改写成{'0': esbuild}加一条占位项esbuild: set this to true or false,构建脚本依然被拦。要注意字段名是allowBuilds(不是旧版的onlyBuiltDependencies)。pnpm install一律在 workspace 根跑。在子包目录跑也一样——输出Scope: all N workspace projects,装的仍是整个 workspace。依赖实体只有一份在根node_modules/.pnpm/,子包node_modules/顶层只有指向它的软链;lockfile 全仓只有一份在根。pnpm add写入"当前所在目录"的 package.json。装到子包要pnpm -F ls-design add -D <pkg>。- ⚠️ "根不装任何依赖"这条纪律没有机制兜底:在根跑
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)。由此产生三个改动:
// ① 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 |
{
"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
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 被移除
error TS5102: Option 'baseUrl' has been removed删掉即可,paths 保留(TS 4.4 起 paths 就是相对 tsconfig 目录解析的,baseUrl 早已无必要):
{
"compilerOptions": {
"paths": { "@/*": ["./src/*"] }
}
}② 不再提供 JavaScript Compiler API
[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。
解法:装兜底包
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 自动注入:
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}`,
},
},
}三个必须遵守的点:
- 必须用绝对路径。LESS 解析
@import是相对导入方文件而不是项目根:写成相对路径时,src/icons/Icon.less会被解析成src/icons/mixins/index.less,报'mixins/index.less' wasn't found。 - 排除自身要用全等而不是
includes。filename.includes('mixins/index.less')会被任何同名的深层文件误命中,造成循环导入。 - 必须带
(reference)。否则每个组件文件都会把 mixin 里的 CSS 完整重复输出一遍,产物体积翻倍。
对应地,mixins/ 必须独立成目录且只放 mixin——这是唯一可靠的防线。任何裸声明一旦 (reference) 被去掉就会被重复输出。
7. 组件实现:以 Button 为例
7.1 API 取向:两个正交属性替代一个 type
antd v6 把 type 拆成了两个正交属性:
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 的改名与默认值同样是刻意的:
/**
* 原生 `type`。默认 `'button'`。
* 为什么改名:`type` 在旧版 antd 里被用作「按钮外观」,
* 且 <form> 内不写 type 的原生按钮默认是 `submit`,会意外提交表单 ——
* 所以默认值必须是 `button`,名字也必须让位。
*/
htmlType?: 'submit' | 'reset' | 'button';继承原生 props 时要去掉语义冲突的键:
export interface ButtonProps
extends Omit<ButtonHTMLAttributes<HTMLButtonElement>, 'type' | 'color'> {
// 'type' 与 htmlType 语义重复且极易误用(不写等于 submit);
// 'color' 在 DOM 上是 CSS 颜色字符串,与语义色枚举完全不是一回事
}7.2 用 CSS 变量吸收组合爆炸
这是整个组件库最优雅的一处设计。
6 种变体 × 3 种语义色 = 18 种外观,但样式代码只需要 3 个变体块 + 3 个色块:
.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 是同一个决策的两面
/**
* ⚠️ 参数用 `;` 分隔:值里有逗号(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:
/* 收起来的关键是 width: 0 + opacity: 0 + 负 margin 吃掉 flex gap,
否则图标"消失"后仍占着间距,文字会跳位 */8. 没有浏览器时怎么验证组件
组件库的正确性验证通常依赖浏览器。在无法开浏览器的环境里,可以走这条路径(本项目实测跑通,28/28 通过):
// ① 从隔离的 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 }) |
三条贯穿始终的元原则
- 静默失败比报错危险得多。#5、#11、#17、#24 全都"构建通过、无警告、结果错",排查成本远高于直接报错。遇到这类问题,第一反应应该是"我的验证方法能不能发现它",而不是"再加点日志"。
- 看不清的推论不要写进文档当结论。本项目里"LESS 变量与 CSS 变量可以成对产出"曾经是个看起来很合理的推断,实测直接被推翻。凡是能花 5 分钟实测的,就不要靠推断。
- 跟随行为,不跟随代码形状。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:命令清单
# 依赖安装(一律在 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:库构建产物
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.tscssCodeSplit: false 是刻意的:组件库的样式应该是一次性引入的整体,切成多份只会让消费方难以处理。
附录 C:ThemeProvider 的公开 API
<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"变成一个难查的样式问题。