
一句话:好 API 的标准是「可预测、可组合、可渐进」——落到 React 上就是三件事:受控/非受控双模式(
value+defaultValue)、用组合(children/Slots/Context)代替配置炸弹、以及用「多态 + 泛型 + Headless」保留扩展空间。
一、四条判据(先有标准再谈技巧)
⭐⭐ 评价一个组件 API 的四条判据(对不上任何一条,用户就会骂):
① 【可预测(Predictable)】
· 命名一致:`onXxx` 是回调、`isXxx`/`hasXxx` 是布尔、`defaultXxx` 是初值
· 同类组件的行为一致:所有输入类组件都支持 `value`/`onChange`/`disabled`
· 不搞惊喜:不要「传了 A 就必须传 B」这种隐藏契约
② 【可组合(Composable)】
· 能和其他组件拼:`children` 透传、`className`/`style` 能合并
· 能按需替换内部结构(而不是「要么全用我的,要么别用」)
· ⭐ 判据:用户想做一件你没预料到的事时,**能不能不改你的源码就做到**
③ 【可渐进(Progressive)】
· 简单用法要简单:`` 一行能用
· 复杂用法要能展开:需要自定义渲染时有「逃生口」(`renderOption`/Headless Hook)
· 版本演进不破坏:加 prop 而不是改 prop 语义
④ 【有反馈(Feedback)】
· 类型即文档:泛型能推断出 `value` 的类型
· 开发期警告:误用(如同时传 `value` 与 `defaultValue`)要有提示
· 无障碍:键盘、`aria-*`、焦点
二、五种 API 模式与它们的适用边界
| 模式 | 形态 | 适合 | 代价 |
|---|---|---|---|
| 配置式 | <Table columns={...} data={...} /> |
结构固定、变化少(表格、图表) | ⚠️ 需求一变就加 prop → 「配置炸弹」 |
| 组合式(children) | <Dialog><Dialog.Title/><Dialog.Body/></Dialog> |
结构需要自定义(对话框、卡片、布局) | 需要 Context 共享状态 |
| Render Props / Slots | <List renderItem={(x) => ...} /> 或 asChild |
只替换「某一小块」 | 嵌套回调可读性差 |
| 受控 / 非受控 | value/onChange + defaultValue |
表单类组件(几乎都该支持) | 两套路径要同步维护 |
| Headless(无头) | const s = useSelect() + 自己渲染 |
需要完全不同外观(设计系统、多端) | 使用者门槛高 |
⭐⭐⭐ 「配置炸弹」是怎么长出来的(每个 UI 库都经历过):
v1:
v2: // 要自定义选项样式
v3: // 连带要改触发器
v4: // 又要分组/筛选/虚拟滚动
...
⭐ 问题:每个新需求都加一个 prop,最终 api 表面变成几十个 prop 的组合,
「哪些能一起用、哪些互斥」变成没人说得清的隐规则。
✅ 解法(按优先级):
① 把「结构」交给 children(组合式)——用户自己决定层级
② 把「渲染」交给 render props 或 `asChild`
③ 把「逻辑」抽成 Headless Hook
④ 只有「数据/行为开关」才留成 prop,且要「正交」(互不耦合)
三、受控 / 非受控:同一个组件的两条路
// ✅ 标准形态(所有输入类组件都该这样)
interface FieldProps {
/** 受控值(传了它就是受控) */
value?: string;
/** 非受控初值(只在首次挂载生效) */
defaultValue?: string;
/** 受控变化回调 */
onChange?: (value: string) => void;
disabled?: boolean;
}
function Field({ value: valueProp, defaultValue = '', onChange, disabled }: FieldProps) {
const isControlled = valueProp !== undefined; // ⭐ 判据:value 传了就是受控
const [innerValue, setInnerValue] = useState(defaultValue);
const value = isControlled ? valueProp : innerValue;
const handleChange = (next: string) => {
if (!isControlled) setInnerValue(next); // ⭐ 非受控时自己维护
onChange?.(next); // ⭐ 两种模式都通知
};
return handleChange(e.target.value)} />;
}
⭐⭐⭐⭐ 三条「受控/非受控」的铁律(每条都有真实事故):
① 【判据必须是 `value !== undefined`,不能用 truthy】
if (value) isControlled = true; // ❌ value="" 会被判成非受控
⭐ 后果:`` 变成非受控 → 用户能输入,但 React 认为它「没值」
(这是 React 官方当初报的那个著名警告的来源)
② 【不能「中途切换」受控状态】
❌ 首次 ``(非受控),后一次 ``(受控)
→ React 会告警:「A component is changing an uncontrolled input to be controlled」
⭐ 因为 `input` 的 `value`/`defaultValue` 语义不同,
从「DOM 管理」切到「React 管理」时,DOM 里的值会被 React 覆盖/丢失
✅ 保证「始终传 value」或「始终不传」
③ 【`defaultValue` 只在首次挂载生效】
❌ 后续改 `defaultValue` 想更新输入框 → 无效
✅ 需要程序化更新就用「受控」;或者在非受控下用 `key` 强制重建
(⭐ `` 是常见手法)
// ⭐ 一个「既有受控又有非受控」的完整可复用实现(含开发期警告)
function useControllable(
valueProp: T | undefined,
defaultValue: T,
onChange?: (next: T) => void
): [T, (next: T) => void] {
const isControlled = valueProp !== undefined;
const [inner, setInner] = useState(defaultValue);
if (process.env.NODE_ENV !== 'production') {
const wasControlled = useRef(isControlled);
useEffect(() => {
if (wasControlled.current !== isControlled) {
console.warn(
'[useControllable] 受控状态发生了变化(受控 ↔ 非受控)。' +
'请保持一致,否则 DOM 中的值会丢失。'
);
wasControlled.current = isControlled;
}
}, [isControlled]);
}
const value = isControlled ? valueProp : inner;
const setValue = useCallback(
(next: T) => {
if (!isControlled) setInner(next);
onChange?.(next);
},
[isControlled, onChange]
);
return [value, setValue];
}
四、复合组件:用 Context 共享状态
// ✅ 复合组件(Compound Components):结构由使用者决定,状态由父级共享
const DialogContext = createContext<{
open: boolean;
setOpen: (v: boolean) => void;
titleId: string;
} | null>(null);
function useDialogContext(component: string) {
const ctx = useContext(DialogContext);
// ⭐ 开发期提示「子组件被用在了错的父组件外面」
if (!ctx) throw new Error(`<${component}> 必须放在
⭐⭐ 复合组件的三个「必须做对」的点:
① 【Context value 必须稳定】
不稳定的 value → 每次渲染所有消费者都重渲染(见 5.9 与 12.8)。
✅ `useMemo` + 「把 setter 单独放进另一个 Context」(setter 引用天然稳定)
② 【子组件要在「错误的父级」外被使用时尽早报错】
`useDialogContext` 里 throw 一个「明确说清该放哪里」的错误,
比「undefined is not a function」友好 100 倍。
③ 【自动关联无障碍属性】
`Dialog.Title` 自动生成 id、`Dialog` 把它透传给容器的 `aria-labelledby`
→ ⭐ 用户不用手写 id 关联(这是「好 API」的体现:把易错的事做掉)。
// ⭐ 进阶:把「状态」与「设置函数」拆到两个 Context(避免不必要的重渲染)
const DialogStateContext = createContext<{ open: boolean; titleId: string } | null>(null);
const DialogActionsContext = createContext<{ setOpen: (v: boolean) => void } | null>(null);
function DialogRoot({ open, onOpenChange, children }: DialogProps) {
const titleId = useId();
const state = useMemo(() => ({ open, titleId }), [open, titleId]);
// ⭐ setter 的引用用 ref 保持稳定 → 这个 Context 永不变化 → 只消费它的组件永不重渲染
const onOpenChangeRef = useRef(onOpenChange);
onOpenChangeRef.current = onOpenChange;
const actions = useMemo(() => ({ setOpen: (v: boolean) => onOpenChangeRef.current(v) }), []);
return (
{children}
);
}
// ⭐ 效果:「关闭按钮」只消费 actions → 打开/关闭状态变化时它【不】重渲染
五、asChild 与多态组件
// ✅ asChild:不额外包一层 DOM,把 props 合并到子元素上(Radix 的 Slot 模式)
interface SlotProps {
children: React.ReactElement;
}
function Slot({ children, ...slotProps }: SlotProps & Record) {
const child = Children.only(children) as React.ReactElement>;
const mergedProps = {
...slotProps,
...child.props,
// ⭐ className 合并(而不是覆盖)
className: [slotProps.className, child.props.className].filter(Boolean).join(' ') || undefined,
// ⭐ style 合并
style: { ...(slotProps.style as object), ...(child.props.style as object) },
// ⭐ 事件处理:两个都调用(先子后父)
onClick: composeHandlers(child.props.onClick as ((e: unknown) => void) | undefined,
slotProps.onClick as ((e: unknown) => void) | undefined),
// ⭐ ref 合并(React 19 起 ref 是普通 prop)
ref: composeRefs(
(child as { ref?: React.Ref }).ref,
slotProps.ref as React.Ref | undefined
),
};
return cloneElement(child, mergedProps);
}
function composeHandlers(childHandler?: (e: E) => void, slotHandler?: (e: E) => void) {
return (e: E) => {
childHandler?.(e);
// ⭐ 如果子元素调用了 preventDefault,就不再执行外层逻辑(尊重子元素的决定)
if (!(e as { defaultPrevented?: boolean })?.defaultPrevented) slotHandler?.(e);
};
}
function composeRefs(...refs: Array | undefined>) {
return (node: T | null) => {
for (const ref of refs) {
if (typeof ref === 'function') ref(node);
else if (ref) (ref as React.MutableRefObject).current = node;
}
};
}
// 使用:
// → 渲染出的是 (不是「按钮里套链接」),但样式来自 Button
// ✅ 多态组件(as prop):保留「语义标签」的灵活性
type PolymorphicProps = P & {
as?: E;
} & Omit, keyof P | 'as'>;
function Text({
as, children, ...rest
}: PolymorphicProps) {
const Component = (as ?? 'span') as React.ElementType;
return {children} ;
}
// 使用:类型会自动推断出 的属性
链接 // ✅ href 有类型
标题 // ✅
e.currentTarget.href}> // ✅ e 的类型是 HTMLAnchorElement
⭐⭐ 这两个模式解决的是「同一个问题」:让组件「不侵入布局/语义」。
· **`asChild`**——「我不想多一层 DOM」:
按钮希望是 ``、Tooltip 希望挂在任意元素上、链接希望用路由组件
· **`as` prop**——「我需要不同的语义标签」:
同样的排版样式,有时是 `h2`、有时是 `div`、有时是 `a`
⚠️ 两者都能滥用:`asChild` 需要 `cloneElement`(有些团队不喜欢),
`as` prop 的 TS 类型很难写对(需要泛型 + `Omit`)
⭐ 判据:「**需要多一层 DOM 吗**」→ 用 `asChild` 而不是 `as`;
「**需要不同标签但可以有包裹层吗**」→ 用 `as`。
六、完整实现:一个「三种消费形态」的 Select(220 行)
// 目标:同一个组件能力,提供「简单配置 / 复合结构 / 无头逻辑」三种用法
// ① —— 一行能用
// ② —— 结构可定制
// ③ const select = useSelect({ options }) + 自己渲染 —— 完全自定义外观
interface Option { value: T; label: string; disabled?: boolean }
/** ============ 第三层:Headless Hook(所有形态都基于它) ============ */
function useSelect({
options,
value: valueProp,
defaultValue,
onChange,
disabled = false,
}: {
options: Array
/** ============ 第二层:复合组件(基于 Hook) ============ */
interface SelectCompoundContextValue {
select: ReturnType>;
}
const SelectCompoundContext = createContext | null>(null);
function useSelectCompound(component: string) {
const ctx = useContext(SelectCompoundContext) as SelectCompoundContextValue | null;
if (!ctx) throw new Error(` 必须放在 内部`);
return ctx.select;
}
function SelectRoot(props: Parameters>[0] & { children: React.ReactNode }) {
const { children, ...rest } = props;
const select = useSelect(rest);
const ctx = useMemo(() => ({ select }), [select]);
return {children} ;
}
const SelectTrigger = React.forwardRef(
function SelectTrigger({ children, className }, forwardedRef) {
const select = useSelectCompound('Trigger');
const props = select.getTriggerProps();
const current = select.options.find((o) => Object.is(o.value, select.value));
return (
);
}
);
function SelectList({ className, renderOption }: {
className?: string;
renderOption?: (option: Option, state: { selected: boolean; highlighted: boolean }) => React.ReactNode;
}) {
const select = useSelectCompound('List');
if (!select.open) return null;
return (
{select.options.map((opt, i) => {
const selected = Object.is(opt.value, select.value);
const highlighted = select.highlightIndex === i;
return (
-
{/* ⭐ 逃生口:允许自定义选项渲染 */}
{renderOption ? renderOption(opt, { selected, highlighted }) : opt.label}
{selected && ✓}
);
})}
);
}
/** ============ 第一层:简单配置式(最常用的形态) ============ */
function SimpleSelect(props: Parameters>[0] & { className?: string; placeholder?: string }) {
return (
);
}
/** ============ 导出:同一个能力,三种用法 ============ */
export const Select = Object.assign(SimpleSelect, {
Root: SelectRoot,
Trigger: SelectTrigger,
List: SelectList,
/** ⭐ 无头:给「完全自定义外观」的使用者 */
useSelect,
});
// ---------- 用法 1:一行 ----------
//
// ---------- 用法 2:复合(结构可定制) ----------
//
//
//
//
//
// {o.label}{selected ? '★' : ''}} />
//
// ---------- 用法 3:无头(外观完全自定义) ----------
// function MySelect({ options }) {
// const s = Select.useSelect({ options });
// return (
//
// 当前:{String(s.value)}
// {s.open &&
// {s.options.map((o, i) => {o.label})}
// }
//
// );
// }
⭐⭐⭐ 这个设计演示了「可渐进」的核心手法:
【一个能力,三层暴露】
· Headless Hook(`useSelect`)→ 所有逻辑 + 无障碍 + 键盘
· 复合组件(`Select.Root/Trigger/List`)→ 结构可定制
· 简单配置(`Select`)→ 一行能用
⭐ 好处:
① 复杂需求的用户能用 Hook 完全自定义(不必 fork 源码)
② 中间需求的用户能改结构但复用行为
③ 简单需求的用户零成本
④ 三层共享同一份逻辑 → 「键盘行为」只有一处实现(不会出现
「简单版能用键盘、复合版不行」这类不一致)
⭐ 这就是 Material UI / Radix / Ark UI 等库的组织方式:
**Headless 内核 + 复合组件外壳 + 便利封装**。
七、本篇特有的坑
// ① 受控判据用 truthy(value="" / value={0} / value={false} 会被判成非受控)
const isControlled = !!value; // ❌
const isControlled = value !== undefined; // ✅
// ② 中途切换受控/非受控(DOM 里的值会被 React 覆盖)
{loading ? : } // ❌
// ✅ 始终同一模式
// ③ 期望改 `defaultValue` 更新输入框
然后 user 变了 // ⚠️ 不生效
// ✅ 受控,或 `key={user.id}` 强制重建
// ④ `onChange` 只在受控模式触发(非受控时不通知)
if (isControlled) onChange?.(next); // ❌
// ✅ 两种模式都通知(否则「非受控 + onChange」这个常见组合就废了)
// ⑤ 复合组件的 Context value 不稳定(每次渲染所有消费者重渲染)
// ❌ 每次新对象
// ✅ useMemo
// ⑥ 复合组件「子组件用在错误位置」时报错信息无用
// ⚠️ 用户看到 "Cannot read properties of null (reading 'open')"
// ✅ `if (!ctx) throw new Error(' 必须放在 内部')`
// ⑦ 用「大量布尔 prop」表达互斥状态(组合爆炸)
// ❌ 谁能和谁一起用?
// ✅ 用「枚举 + 变体(variant)」或直接「拆成不同组件」
// ⑧ prop 命名不一致(同一个概念在库里叫三个名字)
// ❌
// ✅ 统一 `open` / `onOpenChange`
// ⑨ 「必填组合」没有在类型层面表达
// ⚠️ 「传了 `options` 就必须传 `getOptionLabel`」只写在文档里
// ✅ 用「联合类型」表达:
type Props =
| { options: string[]; getOptionLabel?: never }
| { options: object[]; getOptionLabel: (o: object) => string };
// ⑩ ref 转发思路过时(React 19 起 ref 是普通 prop)
const Input = React.forwardRef(...) // ⚠️ React 19 不再需要
function Input({ ref, ...rest }) { ... } // ✅ React 19 写法
// ⭐ 但要注意「同时要兼容 React 18」时仍需 forwardRef
// ⑪ `asChild` 的 props 合并顺序写错(子元素的 props 应该「优先」)
const merged = { ...child.props, ...slotProps }; // ❌ slot 覆盖了子元素
const merged = { ...slotProps, ...child.props }; // ✅(但 className/style 要合并)
// ⑫ `asChild` 忘了合并 className/style(结果是「二选一」)
// ✅ 拼接字符串 / 展开对象合并
// ⑬ 多态组件的 TS 类型偷懒(用 `any`/`Record`)
function Text({ as: C = 'span', ...rest }: { as?: any } & any) // ❌ 用户失去类型
// ✅ 泛型 + Omit(见实现)
// ⑭ `children` 用「函数」时忘了处理「子元素不是单个元素」
Children.only(children) // ⚠️ 多个 child 时会抛错
// ✅ 明确文档「只接受单个元素」,并在开发期给出友好提示
// ⑮ 组件的「受控 + 非受控」两套代码路径不同步(行为不一致)
// ✅ 抽出 `useControllable` 这类统一 Hook(一份逻辑两条路)
// ⑯ 新增 prop 时改了「同名 prop 的语义」(破坏性变更)
// ⚠️ 用户升级后静默行为变化(最难排查)
// ✅ 加新 prop + 旧 prop 标记 deprecated + 开发期警告,下个大版本再删
// ⑰ 没有「逃生口」(用户必须 fork 才能改一点样式)
// ✅ 提供 `className`/`style`/`classNames`(分部位)/`renderXxx`/Headless Hook
// ⑱ 「组件内部状态」无法被外部读取(用户想做联动只能猜)
// ✅ 提供 `onXxxChange` 回调,或用受控模式
// ⑲ 无障碍属性「要么全自动、要么全靠用户」
// ⭐ 最好的是「自动关联 + 允许覆盖」:
// `` 自动生成 id 并由 Dialog 关联 aria-labelledby
// 但用户传了 `id` 就用用户的
// ⑳ 默认值用「对象字面量」写在默认参数里(每次渲染新引用)
function List({ items = [] }) { } // ⚠️ 每次新数组(依赖它做 memo 会失效)
// ✅ 把常量提到模块级:const EMPTY: never[] = [];
// ⑬ 的完整写法(多态 + 泛型,类型正确)
type AsProp = { as?: E };
type PropsToOmit = keyof (AsProp & P);
type PolymorphicComponentProps =
P &
AsProp &
Omit, PropsToOmit>;
function Text(
{ as, children, ...rest }: PolymorphicComponentProps
) {
const Component = (as ?? 'span') as React.ElementType;
return {children} ;
}
// ⑨ 的完整写法(用联合类型表达「必填组合」)
type OptionProps =
| {
/** 简单用法:只有标签、值就是标签 */
options: string[];
getOptionLabel?: never;
}
| {
/** 复杂用法:对象选项 + 取值函数 */
options: Array<{ id: string; name: string }>;
getOptionLabel: (o: { id: string; name: string }) => string;
};
function SmartSelect(props: OptionProps & { onChange: (v: string) => void }) {
// 类型收窄后,两分支各自安全
if (props.getOptionLabel) {
// ...
}
return null;
}
八、面试延伸
- 「好的组件 API 应该满足什么?」
四条判据:① 可预测——命名一致(onXxx 回调、isXxx 布尔、defaultXxx 初值)、同类组件行为一致、不搞隐藏契约;② 可组合——children/Slots/Context 而不是「配置炸弹」,⭐ 判据是「用户想做你没预料到的事时,能不能不改你的源码就做到」;③ 可渐进——简单用法一行能用、复杂用法有逃生口(renderXxx/Headless Hook)、版本演进「加 prop 而不是改语义」;④ 有反馈——类型即文档、误用有开发期警告、无障碍属性自动关联。
- 「受控和非受控怎么设计?有哪些坑?」
标准形态是「value + defaultValue + onChange」三件套,判据必须是 value !== undefined(❌ 用 truthy 会让 value=""、value={0} 被判成非受控)。三条铁律:① 判据不能用 truthy;② 不能中途切换受控状态(否则 React 警告且 DOM 值会丢失);③ defaultValue 只在首次挂载生效(要程序化更新就用受控,或用 key 强制重建)。另外还有一条容易漏的:onChange 在两种模式下都要通知(否则「非受控 + onChange」这个常见组合就废了)。实践上把逻辑抽成 useControllable 之类的 Hook,保证两份路径行为一致。
- 「复合组件(Compound Components)怎么实现?注意什么?」
实现是「父组件用 Context 共享状态,子组件通过 Context 读取」,结构交给使用者。三个要点:① Context value 必须稳定(useMemo),否则每次渲染所有消费者重渲染;⭐ 更彻底的做法是「把状态与 setter 拆成两个 Context」——setter 用 ref 保持引用稳定,于是「只消费 setter 的组件」永不因状态变化重渲染;② 子组件用在错误父级外要尽早报错(抛出「<Dialog.Title> 必须放在 <Dialog> 内部」这类明确错误);③ 自动关联无障碍属性(如 Dialog.Title 自动生成 id 并由 Dialog 透传给 aria-labelledby),把易错的事做掉。
- 「
asChild和asprop 有什么区别?什么时候用哪个?」
两者都是「让组件不侵入 DOM 结构与语义」,但解决不同问题:asChild(Radix 的 Slot 模式)——不额外包一层 DOM,把 props 合并到子元素上(如 <Button asChild><a href="/x">链接</a></Button> 最终渲染出的是 <a>)。实现要点是「className/style 要合并而不是覆盖、事件处理要组合(先子后父,且尊重子元素的 preventDefault)、ref 要合并」。as prop——可以有包裹层,但要换语义标签(<Text as="h2">)。判据:「需要多一层 DOM 吗」→ 用 asChild;「需要不同标签但可以有包裹层吗」→ 用 as。两者的代价分别是「cloneElement 的团队偏好」与「TS 泛型类型很难写对」。
- 「什么是『配置炸弹』?怎么避免?」
指「每来一个新需求就往组件上加一个 prop」,最后 API 表面变成几十个 prop 的组合,而「哪些能一起用、哪些互斥」变成没人说得清的隐规则(<Alert type="info" outlined filled dense elevated rounded />)。避免方式(按优先级):① 把「结构」交给 children(复合组件)——用户自己决定层级;② 把「渲染」交给 render props 或 asChild;③ ⭐ 把「逻辑」抽成 Headless Hook(用户想完全自定义外观时不必 fork);④ 只有「数据/行为开关」才留成 prop,且要保持正交(互不耦合)。另外「互斥的布尔 prop」应该改成枚举 + 变体(variant)。
- 「怎么让组件 API『可渐进』?举一个具体设计。」
核心手法是「一个能力、三层暴露」——以 Select 为例:① Headless Hook(useSelect)——包含全部逻辑、键盘交互、无障碍 props,返回 getTriggerProps/getListProps/getOptionProps 供使用者展开到自己的 DOM 上;② 复合组件(Select.Root/Trigger/List)——基于 Hook 实现,结构可定制(还能传 renderOption 换渲染);③ 简单配置(<Select options={...} />)——一行能用。三个好处:复杂需求能完全自定义(不必 fork)、中间需求能改结构但复用行为、简单需求零成本;⭐ 而且三层共享同一份逻辑,不会出现「简单版支持键盘、复合版不支持」这类不一致。Material UI / Radix / Ark UI 都是「Headless 内核 + 复合外壳 + 便利封装」的组织方式。
- 「组件库怎么做版本演进而不破坏用户?」
三条策略:① 只加不改——新需求优先「加新 prop」而不是「改旧 prop 的语义」(改名/改语义是最难排查的破坏性变更);② deprecated 流程——旧 prop 保留但标 @deprecated(IDE 会划掉)+ 开发期 console 警告(提示替代方案),下个大版本再删;③ 给逃生口——className/style/分部位的 classNames/renderXxx/Headless Hook,让用户「不改源码也能做到想做的一切」(⭐ 这是减少「用户来提需求 → 你被迫加 prop」的关键)。另外「必填组合」用联合类型表达(而不是只写在文档里),让类型系统帮你传达契约。
- 「TypeScript 下写组件 API,有哪些值得做的?」
四点:① 泛型组件——function Select<T>(props: { options: Array<Option<T>>; value?: T; onChange?: (v: T) => void }),让 value/onChange 的类型自动跟随 options;② 多态组件的正确类型——P & { as?: E } & Omit<React.ComponentPropsWithoutRef<E>, keyof P | 'as'>(不然用户传 as="a" 就没有 href 的类型);③ 用联合类型表达「必填组合」/「互斥 prop」(如 { options: string[]; getOptionLabel?: never } | { options: Obj[]; getOptionLabel: Fn });④ satisfies/const 泛型保持字面量类型(如变体名)。⭐ 一个实用判据:「类型能不能当文档用」——如果用户必须读源码或文档才知道怎么传,类型就没做到位。
一句话速记
好 API 的四条判据是「可预测(命名一致)、可组合(children 而非配置炸弹)、可渐进(有逃生口)、有反馈(类型 + 警告 + 无障碍)」;受控/非受控的标准形态是
value+defaultValue+onChange,判据必须value !== undefined(不能用 truthy),且不能中途切换、defaultValue只在首次挂载生效、两种模式都要触发onChange;复合组件用 Context 共享状态 + value 必须useMemo(更彻底是把「状态」与「setter」拆成两个 Context,让 setter 消费者永不重渲染)+ 错误位置要尽早报错 + 自动关联aria-*;asChild(不包 DOM,合并 className/style/事件/ref)与asprop(换标签)解决不同问题;避免「配置炸弹」的办法是「结构给 children、渲染给 render props、逻辑抽 Headless Hook、只把数据/开关留成 prop」;可渐进的关键手法是「一个能力三层暴露」(Headless Hook → 复合组件 → 简单配置),三层共享同一份逻辑;版本演进只加不改 + deprecated 警告 + 给足逃生口。



最新评论
读过书不知道欧·亨利的人少。教科书上选文有
这小生活不错呀
不错,必须顶一下!
看着你还在坚持,很好
看来忙了也没时间更新博客了
NIce。学习了。。。。
网站不错!!!!
简洁实用,好文章!