☰
前端组件库工程化实战:从设计令牌到无障碍的完整质量体系
2026/10/29 5:53:42 网站建设 项目流程

如果你的项目代号里敢放一个英文单词“impeccable”,那你基本上等于给自己挖了一个大坑——因为从立项那天起,每一个提测版本、每一次走查会议、每一轮发版,都会拿这个词来鞭打你的良心。我去年年底干的一件事,就是顶着这个代号,做了一套面向业务团队的组件库工程。不是那种自嗨的demo轮子,而是要在真实项目里被几十个页面、十几个开发、多套视觉规范反复蹂躏的那种。今天这篇就把这个项目从头到脚拆开聊,包括为什么用这个词当代号、怎么把"无可挑剔"翻译成可执行的技术动作、以及我踩过的那些坑。

这篇文章适合两类人看:一类是想自己搭组件库但不知道质量红线画在哪里的前端工程师;另一类是已经在做组件库或设计系统,但苦于"做完能用"和"做得干净"之间差距的人。我尽量把每一条决策背后的理由讲清楚,而不是只贴代码。

1. 项目起源:把"无可挑剔"拆成可执行的验收维度

1.1 一句话讲清楚这个项目在做什么

"impeccable"在这个项目里不是一个形容词,而是一套验收标准。我给它下的定义是:在任何使用场景下,组件的功能、视觉、交互、无障碍与文档都保持一致的高水准,且不依赖使用者额外修补。

听起来很玄,但落到工程上就变成了很具体的检查点。我最初把它拆成了五个维度:功能正确性(输入输出是否符合预期)、视觉精确性(间距、圆角、色值是否与设计稿完全对齐)、交互完整性(loading、禁用、hover、focus等状态是否全部覆盖)、无障碍达标率(键盘可达、读屏可读、对比度合格)、文档时效性(示例与API注释是否与代码同步)。

这种拆法帮我躲开了一个经典误区:很多人一说"质量高"就开始堆测试覆盖率,但覆盖率到90%以上后,真正拖垮体验的往往是视觉细节和无障碍问题。所以我从一开始就没把测试覆盖率当成唯一指标,而是把五条线并排立起来,任何一条不过都不能进发布分支。

1.2 为什么不用现成组件库,非要自己造

这也是我被问得最多的问题。团队里不是没有方案,Ant Design、Element Plus都很成熟,直接用不香吗?我的判断是:方案的适配成本要放在"使用密度"里看。团队要做的是高频业务组件,比如数据表格、筛选表单、批量操作条,这些组合类组件在通用库里往往需要写大量覆盖代码和hack,改样式还要跟框架的样式变量体系较劲。

自研组件库不是要重造按钮和输入框,而是要把业务里反复出现的"组合模式"沉淀成一等公民。用"impeccable"做代号其实也是在提醒自己:既然选择自研,那品质基准必须能够对标成熟开源库,否则没有复用的意义。

1.3 三条设计红线,定调之后不许改

项目启动会上我立了三条红线,后面所有评审都围绕这三条展开。

第一条是单一事实来源。设计稿里的所有色值、字号、间距、阴影必须先落到设计令牌(design token)里,组件代码里不允许出现任何硬编码的魔法值。这样做的好处很直接:暗色模式、品牌换肤、甚至不同子品牌的色系切换,都只需要改令牌映射,不需要翻组件源码。

第二条是无障碍不后置。每一个组件的原型评审阶段就必须过一遍键盘操作流程和读屏语义草案,不能等开发完了再回来补ARIA属性。说句不好听的,"都做完再补A11y"基本等于永远不做,因为补起来太痛,通常会被优先级挤掉。

第三条是可复现大于人眼。所有验证动作必须能在CI里自动执行:单测、类型检查、视觉回归、包体积监控,都不能靠"我本地测过了"来背书。人眼只能作为最终走查兜底,不能作为质量门禁。

2. 整体设计:先搭骨架再往里填东西

2.1 组件分层:把"基础件"和"业务件"分开管理

这套组件库的分层我参考了设计系统的通用做法,但做了简化:底层是基础组件(Button、Input、Checkbox这类原子组件),上层是业务复合组件(FilterBar、DataTable这类从业务里提炼出来的分子组件)。第一版我只承诺做扎实底层,业务层只从"impeccable内部"自身沉淀,不开放给外部直接扩展。

这样分层的理由很实际。基础组件讲究稳定和克制,API要小而准;业务组件恰恰相反,需要一定的开放度和较强的容错性。如果把它们混在一个目录里,很容易出现"基础组件为了满足业务场景越做越重"的失控状态。分完层之后,两边的验收标准也自然分化:基础件看通用性,业务件看覆盖度。

2.2 设计令牌:把视觉规范变成代码里唯一可信源

设计令牌是整个项目里最值得认真做的事。我没有直接照搬任何一套开源token体系,而是按自己项目的视觉语言重新整理了一套,用CSS变量实现,前缀都带--im避免污染。

核心的令牌体系我分成了四组:色彩令牌(语义色+中性色+状态色)、字体令牌(字号梯度+字重+行高)、空间令牌(间距基数+半径梯度)、阴影令牌(层级阴影+焦点环)。举个例子,间距基数是4px,常用间距就是4、8、12、16、24、32这么上来的,组件里只用var(--im-space-4)这类变量,而不是写死数字。

这套东西的价值在几个星期后真正体现出来了——设计师提了一版新配色,说要适配某个大客户的主题色。我改了不到20行的令牌映射,全局组件就自动跟着换了。如果当时图省事在组件里硬编码色值,这会是一个通宵的活儿。

2.3 目录结构与模块边界

项目用的是pnpm workspace,包结构长这样:

packages/ tokens/ # 设计令牌,独立发布 components/ # 组件源码 docs/ # 文档与示例 test-utils/ # 测试辅助工具

组件源码内部再按foundation(基础样式逻辑)、components(组件实现)、hooks(共用逻辑)、utils(工具函数)划分。这个结构不复杂,但边界要清晰:tokens被所有包依赖,但不允许反向依赖任何组件;components包内部允许互相引用,但跨包的引用只能通过主入口导出,不允许直接文件路径引用。

模块边界不清是组件库后期腐化的头号原因。我在code review里卡得很死,只要出现"为了让这个组件不往外暴露内部类型就let it go"的情况,一律打回。

3. 工具链与工程化配置:把质量约束变成自动化

3.1 构建工具选型:为什么用Vite而不是webpack

构建方案我选了Vite,主要原因不是它新,而是库模式(library mode)对组件库的产出形态支持得很干净。用Vite打包,既能输出ESM也能输出CJS,还天然支持sideEffects标记,配合使用方按需加载时tree shaking效果稳定。之前用webpack做组件库的时候,经常要为了externals和css提取写一堆体积不小的配置,Vite这边原生就好。

配置片段大概是:

// vite.config.ts import { defineConfig } from 'vite'; import react from '@vitejs/plugin-react'; import dts from 'vite-plugin-dts'; export default defineConfig({ plugins: [react(), dts({ tsconfigPath: './tsconfig.build.json' })], build: { lib: { entry: 'src/index.ts', formats: ['es', 'cjs'], fileName: 'index', }, rollupOptions: { external: ['react', 'react-dom', 'react/jsx-runtime'], output: { preserveModules: true, preserveModulesRoot: 'src', }, }, }, });

这里的preserveModules: true是关键。它让每个组件都保持独立的模块文件,而不是把所有东西揉进一个巨大的bundle里。使用方用哪个组件就引哪个文件,路径提示和按需加载都自然了。

3.2 类型检查与Lint:把宽松变成一种罪过

既然这个项目叫"impeccable",我的态度是严格模式本来就该是默认值。TypeScript的strict: true开好,noUncheckedIndexedAccess也开,宁可写着的时候多处理点undefined,也别让潜在错误流到用户手上。

ESLint这边,我用了typescript-eslint的recommended集合再加一些自定义规则,重点管两类问题:一类是导入顺序和循环依赖,另一类是组件props里不允许出现any。循环依赖在组件库里特别恶心,父组件引用子组件,子组件又引用父组件的类型,看似编译没问题,但会导致运行时undefined和构建输出的不可控。

lint-staged和husky也配了。提交之前自动跑prettier、eslint、tsc的差异检查,实际上这个"差异检查"比全量检查更符合人体工学,不然改一个组件要全量lint两分钟,人都坏了。

3.3 测试与文档配套:先铺好轮子再写轮子

测试这边我选了Vitest配合Testing Library和jest-dom,因为和Vite同构,配置成本低,跑起来也快。无障碍层面用vitest-axe做自动化检测,视觉回归用Storybook的test-runner加regression插件。这样一套下来,单测、A11y、视觉diff理论上都能在一条命令里跑完。

文档侧直接压在了Storybook上,MDX写示例和说明。我当时的判断是组件库的文档必须和组件源码存在同一仓库,任何"文档单独建站"的尝试最后都会因为维护成本变成文档和组件两套真相。Storybook够用,团队内部查看也方便,还能一键发布到站点上。

4. Button组件实操:一个零件从设计到落地的完整打磨

4.1 props API:宁可少给几个,也别多给一打

我不知道你们怎么看待组件API设计,我见过很多组件库的Button写了二十几个props,看起来很强,实际使用者根本记不住。imbec—不不,impeccable里的Button,我一开始就给自己做了减法,只保留几个真正必要的维度。

export interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> { variant?: 'solid' | 'outline' | 'ghost'; size?: 'sm' | 'md' | 'lg'; loading?: boolean; block?: boolean; }

variant管视觉风格,size管尺寸,loading是异步场景的标配,block管全宽。其他所有语义属性比如disabled、aria-label都是直接从原生HTMLAttributes继承的,不需要重复声明。这样写的好处是类型就是文档,使用者看到接口就知道这个组件的边界在哪里,不会被一堆语义模糊的props绕晕。

4.2 loading状态:不是转个圈那么简单

Button的loading是个看着简单实际上坑很多的点。很多实现只做了一件事:显示转圈。我在这个项目里给它定了三条规定:

第一,loading时自动把按钮置为disabled,避免用户重复点击;第二,用aria-busy="true"告诉读屏软件按钮正在忙;第三,保留一个独立的aria-label规则,如果外部没传自定义label,就用"正在加载"替代原文本,而不是让读屏软件读一个"正在加载中按钮正在运转"这种缝合怪。

还有一个小细节是loading态的样式变化。转圈图标用的是CSS动画画出来的圆环,而不是引一个icon库,因为就这一个图标单独引库太亏,而且CSS实现的尺寸和颜色都跟令牌联动,不需要额外维护一套icon颜色。

4.3 样式实现:CSS变量接令牌,不做runtime style

组件的样式我强烈建议用纯CSS或CSS-in-JS的静态提取方案,不要搞运行时生成。我这边的Button样式直接用一层普通的CSS文件,然后用var(--im-*)去接令牌。

.im-btn { display: inline-flex; align-items: center; justify-content: center; gap: var(--im-space-2); height: var(--im-btn-height); padding-inline: var(--im-space-4); border-radius: var(--im-radius-sm); font-size: var(--im-font-size-md); font-weight: var(--im-font-weight-medium); transition: background-color 0.15s ease, box-shadow 0.15s ease; } .im-btn:focus-visible { outline: none; box-shadow: 0 0 0 2px var(--im-color-bg-canvas), 0 0 0 4px var(--im-color-focus-ring); }

焦点环这行值得展开说:我用双层box-shadow做聚焦态而不是改outline,因为outline走不了圆角半径。外层画焦点色,内层用背景色挖出一个间隙,视觉上就像给按钮套了一个干净的聚焦圈,这在深色背景和浅色背景上通用的。

4.4 暗色模式:令牌改一层,全局都跟走

暗色模式是我在项目中期补的,因为设计评审时突然加入了这个需求。好在前期的令牌体系撑得住,我只给[data-theme='dark']覆盖了一套颜色映射表达式:

:root { --im-color-bg-canvas: #ffffff; --im-color-text-primary: #171717; } [data-theme='dark'] { --im-color-bg-canvas: #0f0f0f; --im-color-text-primary: #e5e5e5; }

暗色模式的坑主要是色相偏移带来的对比度问题,而不是简单"黑底白字"。我当时专门设置了一个对比度的抽查清单,色值从浅色换到深色后,用axe的色盲模拟跑一遍,确保所有文字和背景的对比度都达到4.5:1以上。做这个检查的时候发现幽灵按钮在暗色下边框对比度只有2.1,差点漏掉。

5. 测试与无障碍:从"应该能用"到"我一定知道它能用"

5.1 单测:测试用户行为,而不是测试实现细节

测试这一Part我特别想强调:写测试的人最常犯的错是"测试自己怎么写代码的",而不是"测试用户怎么用产品的"。我要求团队用Testing Library的user-event模拟真实交互,而不是用fireEvent直接触发React合成事件。

user-event会真实地经历用户的击键序列,会触发focus、keydown、click这些完整链路。举个例子,测Button loading不重复提交:

it('加载状态下不会重复触发点击', async () => { const user = userEvent.setup(); const onSubmit = vi.fn(); render(<Button loading={false} onClick={onSubmit}>保存</Button>); await user.click(screen.getByRole('button', { name: '保存' })); expect(onSubmit).toHaveBeenCalledTimes(1); rerender(<Button loading onClick={onSubmit}>保存</Button>); await user.click(screen.getByRole('button', { name: '保存' })); expect(onSubmit).toHaveBeenCalledTimes(1); });

在rerender之前set test component到loading态,点击后回调次数不变,这个测试把"loading置灰防重复提交"的承诺钉死了。这种测试每天跑一百遍也不会有心理负担,因为它没有挂在任何实现细节上,只检查行为。

5.2 无障碍:从一开始就跑合规检查

对无障碍我设了一个硬规则:每个组件的测试文件里必须有一条跑vitest-axe的用例,握在手里之后才允许merge。比如Checkbox:

it('不包含无障碍违规项', async () => { const { container } = render( <Checkbox aria-label="同意用户协议">同意用户协议</Checkbox> ); const results = await axe(container); expect(results).toHaveNoViolations(); });

跑完这个才知道,很多自以为是的语义其实都是错的。比如给纯装饰图标设aria-label,读屏软件会多读一遍莫名其妙的内容;再比如用div模拟按钮但是忘了加role="button"和键盘事件,axe会直接报出来。这些错误在人工review时很难一一看全,但自动检查可以在每次提交的时候快速地过一遍。

5.3 视觉回归:把像素级偏差挡在merge之前

视觉回归一开始我比较抗拒,因为配置成本高、维护成本也不低。后来还是上了,因为真实的教训摆在那:有一次我改了个间距token,换了全局的spacing基数,结果Stories里看起来一切正常,但页面上某处间距差了2px,这个视觉抖动在开发阶段根本注意不到,却在UI走查的时候被设计师当场抓包。

配置上用的Storybook test-runner +reg-cli或者类似的snapshot diff方案:每次跑都会对Stories里的每个案例截图,和基线比对,一旦有超过阈值(比如0.01%的像素差异)就直接失败。唯一麻烦的是,动效案例会让截图不稳定,所以我在Stories里给每一个动效加了play函数里的等待,或者直接用chromatic这种专业平台托管。这些都是可以自动化掉的。

6. 发布与版本维护:把质量一直维持到用户手里

6.1 版本管理:用changesets,别手动改package.json

组件库比普通应用更需要在版本上谨慎,因为使用方可能成千上万。我用changesets来管理版本和变更日志,好处是它可以追踪到每个包的影响范围,一个PR里如果改动了多个packages下的文件,它要求开发者按包去描述变更级别。

流程是这样:开发分支上跑pnpm changeset,选择变更级别(patch/minor/major),写清楚是什么变更;合并到main后,CI里再跑changeset version,自动更新各包的版本号和CHANGELOG;版本更新完再跑一遍完整测试,确认无误后发布。这样发布记录不再是"我随手改了个版本号,忘了写CHANGELOG",而是每一次变更都有迹可循。

6.2 发布门禁:合并之前必须全绿

我在CI里设了一个强门禁清单,任何一个失败都会阻断merge:

检查项覆盖范围失败阈值
类型检查全量tsc任何error
LintESlint + Stylelint任何error
单元测试Vitest全量任何失败
A11y检查axe用例任何violation
视觉回归Storybook截图差异超0.01%
构建产物Vite build任何warning

这个表看起来严,但实跑下来每个检查都在几分钟内完成,成本远低于把问题留到发布后再返工。也是这个门禁逼着我修了很多之前"反正能用"就算了的问题,比如Stylelint里面挂了某个组件的hex色值最低长度设置,这类细节在人工review里很难坚持,机器却可以一直盯。

6.3 文档同步:不做"第二份真相"

文档和组件脱节是组件库腐烂最快的地方。我的对策很土但很有效:文档里的示例代码不是手写的一大坨字符串,而是直接从Stories源文件import过来的。Storybook里的<Canvas>区块引用的就是组件Demo源码,只要组件改了,文档示例跟着改,不存在"文档还在展示旧API"的错位。

在API注释这一层也做了小动作:接口注释里写明每一个props的可选值、默认值和变更记录,发布时通过typedoc生成API页,这样从设计师、前端开发到文档阅读者,所有人看到的接口都是代码里唯一的注释。用了半年之后回头再看,这可能是维持项目"无可挑剔"观感的最省力杠杆。

7. 踩坑实录与自查清单:这些坑没踩过的别说你做过

7.1 类型层面的几个"无解"问题

组件库涉及React的ref转发时,老版本的React.forwardRef很容易写出"类型能跑但props丢失"的代码。我建议新项目直接评估React 19,ref作为prop传会让类型干净很多。如果还在React 18,要用forwardRef,记得给泛型传两个参数而不是让let TS自动推断,不然组件的props和ref为any,意图就全丢了。

另一个常见坑是组件props继承ButtonHTMLAttributes之后,想把type属性默认值设为'button'而不是'submit'——我见过线上表单被组件库Button默认点击提交,从而触发了刷新,页面还找不出原因。这个要在类型声明阶段就直接把默认值写进接口注释和实现两边,并加一条测试守护。

7.2 CSS层级和样式冲突:组件库最伤的瘟疫

组件库的样式混进使用方的项目后,很容易被业务全局样式覆盖。我的对策是两部分:一是所有样式都挂在组件类名下,不用裸标签选择器,也尽量不用后代选择器穿透;二是所有关键视觉属性都通过CSS变量引用,业务方要改风格就改变量,不要直接改类名。

这里还踩过一个具体坑:在使用方的globally样式里,他们给button写了一个border: none和background: transparent,结果我的Button不出边框了。排查之后才知道,这是他们项目里一个旧reset样式导致的。所以我把Button的所有关键样式都提高了一层优先级,统一塞进:where(.im-btn)里,这样既压低了自身的特异度,又不会被裸标签级别的规则干扰。

7.3 包体积和tree shaking:组件库不能一个人胖死全公司

组件库的体积膨胀是个"温水煮青蛙"的过程。每加一个工具函数感觉就几KB,但加几十个之后,首屏体积就开始让人肉疼。我在CI里塞了一个budget检查:每个包的gzip体积超过阈值就报警,比如tokens包小于5KB,components的按需单组件gzip小于1KB。

优化手段其实不复杂:所有工具函数都从各自的模块文件导出,别搞一个把所有工具都收进来的utils/index.ts;依赖external化,不把react打包进产物;开发自己的工具函数时,开头就先考虑sideEffects: false的标记是否成立。这些动作单项看着都小,合在一起能压掉三分之一的体积。

7.4 验收自查清单(干活前打印一份)

我在项目最后整理了一份自查清单,每次发布前逐项打勾,也放在这里:

检查项操作通过标准
API完整性对照设计稿检查props所有状态有对应API,无计划外props
可访问性axe跑全量0 violation
键盘操作Tab走一遍焦点可见、顺序合理、无死循环
暗色模式切theme跑全部Stories无对比度低于4.5:1的文本
视觉回归跑基线diff差异接近0
构建产物用产物写一个demo应用按需引入正常,无css丢失
文档同步抽查3个文档页示例和API与源码一致
变更日志查看CHANGELOG每个条目有清晰描述

经过这一轮,我最大的体会是:"无可挑剔"从来没有某个瞬间能达到,它是一个需要不断往回看的循环。每个版本发出去之后,总会有新的使用场景从你没想过的地方冒出来,然后你要回过头把那条线再收紧一点。这个过程说不上轻松,但看着项目里每一处细节都经得起追问、每一个组件都能在真实页面里立得住的时候,还是会觉得当初给自己挖的这个坑,值了。

最后再分享一个小小的实用技巧:如果你也想干类似的事,别一开始就铺太大的摊子。挑一个业务里使用频次最高的组件,比如Button或者Input,把它按上面这套标准完整打磨一遍——测试、无障碍、视觉回归、文档全配齐——先跑通整个质量闭环。等这个样板立住了,后面再做其他组件就有模板可抄了。一个"无可挑剔"的Button,比一百个"凑合能用"的组件,更能撑起整个项目的水准。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询