前端组件设计实践:抽象边界、受控状态与组合 API
组件设计里最耗人的判断,不是怎么写,是要不要抽、抽到哪一层——这道题我几乎每次动笔前都要先过一遍。
同样两个列表页,摆在你面前:一种做法是各写各的,接受一点重复;另一种是马上提炼一个统一的列表组件,把分页、筛选、空态、加载态全收进去。哪种对?答案不在"要不要复用",而在这两个页面的相似是不是稳定、后面会不会分叉。判断错了方向,后面无论怎么改代码都在还债。
我自己交过最贵的一次学费,就是把一个业务表格越抽越"大"。起点很朴素,只想复用分页和 loading。然后加筛选、加导出、加批量操作、加权限按钮、加空态文案、加固定列、加接口请求,最后 props 多到没人敢动。它确实复用进了十几个页面,可每个页面都被它的抽象反过来限制。经过那一遭,"通用组件"这四个字在我这儿是要打个问号的。
能用不代表可维护,区别在第二次复用
组件第一次写出来往往都挺好用,因为它只服务一个页面、一个场景、一个需求。它该管什么、不该管什么,是自己隐含定下的,没人挑战。
真正的考验在第二次、第三次复用。这时的典型剧情是这样的:
- 新场景需要一个额外参数
- 旧交互要兼容新的样式
- 一个组件开始同时承担多个职责
- props 越加越多
- 内部状态越来越难预测
组件没坏,但它在变重。更麻烦的是,这些越来越重的组件常常还挂着"通用组件"的名字,于是后来的业务只能继续往里塞参数——名字骗了所有人。
所以判断一个组件好不好,别看它第一次多顺手,要看它扛过第二次复用之后有没有变形。
该不该抽:三次法则,和"能力小组件"优先
回到最前面那道判断题。我现在的取舍很简单:**重复出现三次以上、且用法稳定,才沉淀成组件;只有两个场景时,先忍着重复。**保留一点重复,通常比抽一个不成熟的组件便宜得多,因为重复是显式的、好删的,而错误的抽象是隐式的、会绑住一堆调用方的。
过早抽象最典型的产物长这样:
1<DataList 2 columns={columns} 3 showFilter={true} 4 filterSlot={filterSlot} 5 loading={loading} 6 emptyText="暂无数据" 7 showToolbar={true} 8 toolbarActions={toolbarActions} 9 pagination={pagination} 10 rowActions={rowActions} 11 customHeader={customHeader} 12/>
参数越堆越多,说白了是在用 props 模拟不同页面。这不是复用,是在造另一个配置系统,而且是没人维护文档的那种。
我的另一条经验是:先抽"能力小组件",别一上来抽"业务大组件"。先沉淀 EmptyState、ConfirmDialog、ToolbarButton、Pagination 这种,等多个页面的组合方式稳定了,再谈上层组件。小组件抽错了好替换,大组件抽错了通常绑着一片页面一起遭殃。
比起用一个 DataList 收全部,我更愿意让页面自己拼装:
1function ProductListPage() { 2 const { data, loading } = useProducts(); 3 4 return ( 5 <div> 6 <ProductFilter /> 7 {loading ? <Spinner /> : null} 8 {data.length === 0 ? ( 9 <EmptyState text="暂无商品" /> 10 ) : ( 11 <ProductTable rows={data} /> 12 )} 13 <Pagination {...pagination} /> 14 </div> 15 ); 16}
看起来"没那么复用",但每个小块该管什么都清楚,页面想加个导出按钮、想换个筛选布局,直接在这一层改,不用去动一个所有人共用的大组件。等到三四个列表页拼下来的骨架真的长得一模一样了,再把这个骨架抽成模板也不迟——那时这套划分是被现实验证过的,不是我拍脑袋想出来的。
好组件通常只解决一个明确问题
抽出来之后,怎么算抽对了?稳定的组件有个共同点:职责单一,只解决一个明确问题。
- Button 只负责按钮语义和样式
- Modal 只负责弹层显示与关闭
- Table 只负责表格展示,不掺业务筛选逻辑
- Upload 只负责选择和上传文件,不决定业务归属
职责清楚,后面每个判断都会变简单:这个需求该不该进组件,这个参数是不是越界了,这段逻辑放外层还是内层。一个克制的按钮组件是这样的:
1type ButtonProps = { 2 variant?: "primary" | "secondary" | "danger"; 3 disabled?: boolean; 4 loading?: boolean; 5 children: React.ReactNode; 6 onClick?: () => void; 7}; 8 9export function Button({ variant = "primary", loading, disabled, children, onClick }: ButtonProps) { 10 return ( 11 <button 12 className={`btn btn-${variant}`} 13 disabled={disabled || loading} 14 onClick={onClick} 15 > 16 {loading ? "处理中..." : children} 17 </button> 18 ); 19}
它不关心"保存用户"还是"删除文章",只关心按钮本身的状态和样式。反过来,一个叫 CommonButton 的组件如果里面判断了订单、文章、客户三种业务状态,它早就不通用了。组件通不通用,不看文件名,看它依不依赖具体业务概念。
展示和业务,最好是两个组件
很多组件难复用,根子在它既管 UI 又管业务。比如一个"用户列表组件"里同时干了:请求用户列表、处理分页、判断权限、渲染表格、控制删除弹窗、调删除接口。这组件在当前页面可能很顺,可换个页面只想复用表格展示时,就被接口、权限、弹窗一并绑死了。
拆成容器加展示会清爽很多:
1function UserListContainer() { 2 const { data, loading } = useUsers(); 3 4 return ( 5 <UserTable 6 users={data} 7 loading={loading} 8 onEdit={openEditDialog} 9 onDelete={confirmDelete} 10 /> 11 ); 12}
UserListContainer 管业务数据,UserTable 管展示。以后别的页面要展示用户表格,不必背上原来的请求逻辑。这个拆法还顺带救了测试:展示组件用固定 props 做快照或交互测试,不用起 mock server:
1test("空列表展示空态", () => { 2 render(<UserTable users={[]} loading={false} />); 3 expect(screen.getByText("暂无数据")).toBeInTheDocument(); 4});
容器组件则专注请求、权限、状态流转,测试时把 useUsers 这个 hook mock 掉就行。两类逻辑揉在一起,你想测一个表格的空态,都得先把一整套请求链路搭起来,测试往往又慢又脆。展示和业务分开之后,测试能不能好写,其实是这条职责划得清不清的一面镜子——难测,通常就是揉在一起了。
props 要表达意图,别用一堆布尔值
props 不是越灵活越好。一个常见的坑是暴露太多布尔:
1<Dialog showFooter showCancel showConfirm danger large centered />
布尔一多,组合状态就爆炸,后面根本说不清哪些组合是合法的。large 且 centered 且 danger 到底长什么样,没人敢保证。
更好的是用明确的模式表达意图:
1type DialogVariant = "default" | "danger"; 2type DialogSize = "sm" | "md" | "lg";
或者把复杂区域交给组合:
1<Dialog> 2 <Dialog.Header>删除文章</Dialog.Header> 3 <Dialog.Body>删除后不可恢复,确认继续吗?</Dialog.Body> 4 <Dialog.Footer> 5 <Button variant="secondary">取消</Button> 6 <Button variant="danger">删除</Button> 7 </Dialog.Footer> 8</Dialog>
组合式 API 比一个巨大的配置对象更接近 UI 本身,也更好扩展。配置对象不是不能用,表格列、表单 schema 这类结构化场景就很适合。但一旦配置里开始塞函数、插槽、权限、请求、展示条件,就该警惕了:你多半正在用 JSON 写一个小框架。组件 API 服务当前问题就好,别把未来所有可能性提前装进去。
受控还是非受控,状态该交给谁管要一开始就想清楚
组件可以有内部状态,但得分清哪些该由外部控制。Modal 是否打开,通常该父组件说了算:
1<Modal open={open} onOpenChange={setOpen}> 2 ... 3</Modal>
这样父组件能根据路由、权限、提交结果决定弹窗状态。弹窗要是完全自己维护开关,外部想插手就会很别扭。适合藏在组件内部的,是纯 UI 细节:hover、临时动画、输入法组合态;涉及业务流程、数据提交、跨组件同步的,别藏。
有些组件想同时支持受控和非受控,比如 Tabs:
1type TabsProps = { 2 value?: string; 3 defaultValue?: string; 4 onValueChange?: (value: string) => void; 5};
这模式灵活,但实现时最怕"双源状态"。传了 value 就是受控,组件内部只能通过回调通知外部,不能再自作主张改最终状态;否则父子各存一份当前值,迟早不同步。我写这类组件会先抽一个很小的受控状态工具,把判断逻辑钉死:
1function useControllableState<T>({ 2 value, 3 defaultValue, 4 onChange, 5}: { 6 value?: T 7 defaultValue: T 8 onChange?: (value: T) => void 9}) { 10 const [innerValue, setInnerValue] = React.useState(defaultValue) 11 const isControlled = value !== undefined 12 const current = isControlled ? value : innerValue 13 14 const setValue = React.useCallback( 15 (next: T) => { 16 if (!isControlled) { 17 setInnerValue(next) 18 } 19 onChange?.(next) 20 }, 21 [isControlled, onChange] 22 ) 23 24 return [current, setValue] as const 25}
Tabs、Switch、Accordion 都走同一套规则:传了 value 就受控、只发 onChange;没传才用内部状态。省得每个组件重写一遍半受控逻辑,也避开"外部传了 value 内部又 setState"这类同步 bug。还有一处容易漏的规则:defaultValue 只在初始化生效,不该在每次 props 变化时重置内部状态。不少人以为改 defaultValue 能改当前值,但按 React 的组件语义,真要控当前值就该传 value。
别忘了给基础组件留一条透传的口子
还有一个基础组件特别容易漏、后来又特别难补的设计:ref 和原生属性的透传。一个封装得很干净的 Input,如果不往下透传 ref 和 ...rest,等哪天需要给它挂 onBlur、autoFocus、aria-*,或者要拿到 DOM 节点做聚焦、做定位,就会发现无路可走,只能回头改组件签名,而这时它可能已经被几十处引用了。
所以基础组件我一般一开始就留好这条口子:
1import React from "react"; 2 3type InputProps = React.InputHTMLAttributes<HTMLInputElement> & { 4 invalid?: boolean; 5}; 6 7export const Input = React.forwardRef<HTMLInputElement, InputProps>( 8 function Input({ invalid, className, ...rest }, ref) { 9 return ( 10 <input 11 ref={ref} 12 className={`input ${invalid ? "input-error" : ""} ${className ?? ""}`} 13 {...rest} 14 /> 15 ); 16 } 17);
forwardRef 让外部能拿到真实节点,...rest 让原生属性自然穿透,className 留给调用方补样式。这几样都是基础组件的"预留接口"——不预留,将来每加一个需求都要动组件本身;预留了,调用方大多数扩展都能在外部完成,不必回来碰你的核心逻辑。这条只对真正的基础组件成立,业务组件反而不该无脑透传,否则又分不清谁该管什么了。
设计系统不是组件仓库
团队做组件库时有个通病:把所有组件都往里扔,最后组件库变成第二个业务仓库。有价值的设计系统,先沉淀的应该是稳定的设计 token、基础组件和交互约定,而不是攒数量。设计 token 这一层尤其值得先立住——把颜色、间距、圆角、字号收成一组变量,基础组件都引这组变量,将来换主题、调规范就是改 token,不必逐个组件去动:
1:root { 2 --color-primary: #2563eb; 3 --color-danger: #dc2626; 4 --radius-md: 6px; 5 --space-2: 8px; 6}
组件本身我会分成三类:
- 基础组件:按钮、输入框、弹窗、提示
- 业务组件:用户选择器、组织树、权限面板
- 页面片段:某页专用的卡片、筛选区
只有基础组件适合进真正的公共层。业务组件可以共享,但要承认它带业务语义;页面片段就留在页面附近,别硬包装成通用能力。硬把业务组件塞进公共库,等于把业务的易变性注入了本该稳定的地基。
回到那道判断题
绕了一圈,还是回到最前面那个"要不要抽"。两个页面相似,不代表抽象已经稳定;三个页面都在用,也不代表这个组件就该继续加参数。真正值得抽的,是那些职责清楚、变化方向稳定、调用方不必理解内部业务流程的东西。
好组件不是"参数最多",也不是"复用次数最多",而是职责清楚、行为稳定、扩展方式自然。组件设计最难的是克制:别过早抽象,别用 props 模拟整个业务系统,别让展示组件背上业务流程。真正好用的组件,往往只解决一个明确问题,再靠组合去适应变化。那张越抽越大的表格,最后我是拆成一个纯展示的 Table 加几个能力小组件,才算把它从"没人敢改"救回来的。