Skip to main content

组件 API 设计原则

组件 API 一旦发布就很难改。设计时要考虑的不是「现在好不好用」,而是「半年后会不会想删掉自己」。

一、一致比好用更重要​

一致性有三个层面。第一是命名一致:同类组件的属性命名要统一,比如都用 value / defaultValue / onChange,不要这个组件叫 value、那个叫 val、另一个叫 model。第二是受控行为一致:要么都支持受控、要么都不支持,不要混着来。第三是默认值一致:同类的「空态」「初始态」表现要一样,用户才不会在每个组件上重新猜一遍。

一致性比正确性更早被感知——一个属性名写错用户会愣一下,但十个组件命名风格各异,用户会觉得整个库「不好用」却说不清为什么。

层面不一致的表现一致的做法
命名val / value / model 混用统一 value / defaultValue / onChange
受控有的支持受控有的不支持同类组件行为统一
默认值空态表现各不相同统一的空态与初始态

二、少而完整的接口面​

「最小惊讶原则」是说默认值要符合大多数场景,让用户不用配就能用;复杂能力通过组合而不是堆参数来提供。参数越多不代表越灵活——参数组合爆炸之后,没人知道哪种组合是合法的,文档也写不清,最后每个使用者都在试。

能用组合解决的(children、插槽、render props),就不要加配置项。一个组件该暴露的是「真正需要外部决定的东西」,而不是「内部实现的所有旋钮」。

三、两种模式的取舍​

一个组件最好两种都支持:外部传 value 就是受控(真值在外部),不传就用内部 state 自己管(非受控)。最怕的是半受控——传了 value 又在内部维护一份状态,两边不同步时会出现「输入被吞掉」「值莫名其妙回退」这类极难复现的 bug。判定方法:组件内部不应该同时存在「来自 props 的值」和「自己维护的副本」。

function Input({ value, defaultValue, onChange }) {
const isControlled = value !== undefined
const [inner, setInner] = useState(defaultValue ?? '')
const current = isControlled ? value : inner

const handleChange = (e) => {
if (!isControlled) setInner(e.target.value)
onChange?.(e.target.value)
}
return <input value={current} onChange={handleChange} />
}

切换时的常见 bug 是:一开始是非受控,后来父组件传了 value,组件没感知到「受控标志变了」,继续用内部 state,导致输入不更新。处理方式是把「是否受控」当成派生状态,每次渲染都重新判定,而不是只在挂载时记一次。

// 错:半受控——只在首次渲染判定是否受控,后续父组件传 value 也不同步
function BadInput({ value, defaultValue }) {
const [inner, setInner] = useState(defaultValue ?? '')
// value 变化时仍用 inner,输入被「吞掉」
const current = value !== undefined ? value : inner
return <input value={current} onChange={(e) => setInner(e.target.value)} />
}
受控真值在父组件每次变化都回调父组件可随时回写非受控真值在组件内部只在需要时取值写法简单,但外部改不动半受控(危险)两边各存一份真值输入被吞、值回退最难排查的一类 bug
图:三种取值模式——两边同时维护真值,问题一定会在某个边界上冒出来

四、回调命名与参数​

回调要返回足够信息:原始事件对象、当前值、以及在列表场景下带上索引。信息不够,业务方只能自己去查、去重新算,体验很差。命名用 on + 动词或名词(onChange / onSelect / onSubmit),语义清楚,不要 invent 自己的词表。

异步场景要给出 pending / 取消的能力:一个提交按钮在请求中要有 loading 态,也要能在组件卸载时取消未完成的请求,避免回调打到已卸载的组件上。

受控与非受控的判定必须在每次渲染时重算,只在首次判定就会变成「传了 value 也不生效」:

function Input({ value, defaultValue, onChange }) {
const [inner, setInner] = useState(defaultValue ?? '')

// 正确:每次渲染都用「有没有传 value」判定当前是否受控
const isControlled = value !== undefined
const current = isControlled ? value : inner

return <input value={current} onChange={(e) => {
if (!isControlled) setInner(e.target.value)
onChange?.(e.target.value) // 受控与否都通知,父组件决定要不要用
}} />
}

五、给未来留的余地​

组件一旦被多处使用,API 就变成了承诺。可演进的做法有三条:留扩展点(children / 插槽 / render props,让业务能覆盖你没想到的形态)、破坏性变更走大版本并附迁移指南、废弃期双写(旧 API 保留一段时间并打警告)。直接删属性是最伤的一种升级,它把成本全部转嫁给使用方。

// 用插槽留扩展点:业务能覆盖默认的底部按钮区
function Dialog({ footer, children }) {
return (
<div role="dialog">
<div>{children}</div>
<div>{footer ?? <DefaultFooter />}</div>
</div>
)
}

接口面一旦开始按需求累加,很快就会出现这种没人敢删的参数:

// 错:每个需求加一个开关,最终没人说得清组合起来是什么行为
<Table
striped bordered hoverable
showHeader showFooter showToolbar
headerFixed firstColFixed lastColFixed
compactMode darkMode printMode
/>

// 更好的做法:按场景收敛成少数几个语义化取值
<Table variant="report" density="compact" />

六、参数、默认值与回调的三处取舍​

参数越多不代表越灵活。组合爆炸之后没人知道哪种组合是合法的,复杂能力应该靠组合而不是靠开关。

默认值也不是「按最安全的方式设」就行,它应该符合大多数场景,让用户不用配就能用。

回调返回得越少也不等于越简洁——回调要给出足够信息(事件对象、当前值、索引),否则业务方只能自己再去查一遍。

好的 API 让人猜得到,不好的 API 让人查文档。

想找现成组件对照 API 设计,Component Gallery 收集了大量真实库的接口,比凭空设计省事。