陌上人如玉
公子世无双

1.11 组件 API 设计

1.11 组件 API 设计

一句话:好 API 的标准是「可预测、可组合、可渐进」——落到 React 上就是三件事:受控/非受控双模式(value + defaultValue)、用组合(children/Slots/Context)代替配置炸弹、以及用「多态 + 泛型 + Headless」保留扩展空间。

一、四条判据(先有标准再谈技巧)

⭐⭐ 评价一个组件 API 的四条判据(对不上任何一条,用户就会骂):

① 【可预测(Predictable)】
   · 命名一致:`onXxx` 是回调、`isXxx`/`hasXxx` 是布尔、`defaultXxx` 是初值
   · 同类组件的行为一致:所有输入类组件都支持 `value`/`onChange`/`disabled`
   · 不搞惊喜:不要「传了 A 就必须传 B」这种隐藏契约

② 【可组合(Composable)】
   · 能和其他组件拼:`children` 透传、`className`/`style` 能合并
   · 能按需替换内部结构(而不是「要么全用我的,要么别用」)
   · ⭐ 判据:用户想做一件你没预料到的事时,**能不能不改你的源码就做到**

③ 【可渐进(Progressive)】
   · 简单用法要简单:`
   v2:  // 连带要改触发器
   v4:  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}> 必须放在  内部使用`);
  return ctx;
}

function Dialog({ open, onOpenChange, children }: {
  open: boolean;
  onOpenChange: (v: boolean) => void;
  children: React.ReactNode;
}) {
  const titleId = useId();
  // ⭐ 用 useMemo 稳定 Context value(否则每次渲染所有消费者都重渲染,见 5.9)
  const ctx = useMemo(() => ({ open, setOpen: onOpenChange, titleId }), [open, onOpenChange, titleId]);
  return (
    
      {open ? children : null}
    
  );
}

Dialog.Title = function DialogTitle({ children }: { children: React.ReactNode }) {
  const { titleId } = useDialogContext('Dialog.Title');
  return 

{children}

; // ⭐ 自动挂上 id(供 aria-labelledby 用) }; Dialog.Body = function DialogBody({ children }: { children: React.ReactNode }) { useDialogContext('Dialog.Body'); return
{children}
; }; // 使用(结构完全由使用者决定) 确认删除 删除后不可恢复,确定继续?
⭐⭐ 复合组件的三个「必须做对」的点:

① 【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 行)

// 目标:同一个组件能力,提供「简单配置 / 复合结构 / 无头逻辑」三种用法
// ① 

// ---------- 用法 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;
}

八、面试延伸

  1. 「好的组件 API 应该满足什么?」

四条判据:① 可预测——命名一致(onXxx 回调、isXxx 布尔、defaultXxx 初值)、同类组件行为一致、不搞隐藏契约;② 可组合——children/Slots/Context 而不是「配置炸弹」,⭐ 判据是「用户想做你没预料到的事时,能不能不改你的源码就做到」;③ 可渐进——简单用法一行能用、复杂用法有逃生口(renderXxx/Headless Hook)、版本演进「加 prop 而不是改语义」;④ 有反馈——类型即文档、误用有开发期警告、无障碍属性自动关联。

  1. 「受控和非受控怎么设计?有哪些坑?」

标准形态是「value + defaultValue + onChange」三件套,判据必须是 value !== undefined(❌ 用 truthy 会让 value=""、value={0} 被判成非受控)。三条铁律:① 判据不能用 truthy;② 不能中途切换受控状态(否则 React 警告且 DOM 值会丢失);③ defaultValue 只在首次挂载生效(要程序化更新就用受控,或用 key 强制重建)。另外还有一条容易漏的:onChange 在两种模式下都要通知(否则「非受控 + onChange」这个常见组合就废了)。实践上把逻辑抽成 useControllable 之类的 Hook,保证两份路径行为一致。

  1. 「复合组件(Compound Components)怎么实现?注意什么?」

实现是「父组件用 Context 共享状态,子组件通过 Context 读取」,结构交给使用者。三个要点:① Context value 必须稳定(useMemo),否则每次渲染所有消费者重渲染;⭐ 更彻底的做法是「把状态与 setter 拆成两个 Context」——setter 用 ref 保持引用稳定,于是「只消费 setter 的组件」永不因状态变化重渲染;② 子组件用在错误父级外要尽早报错(抛出「<Dialog.Title> 必须放在 <Dialog> 内部」这类明确错误);③ 自动关联无障碍属性(如 Dialog.Title 自动生成 id 并由 Dialog 透传给 aria-labelledby),把易错的事做掉。

  1. 「asChild 和 as prop 有什么区别?什么时候用哪个?」

两者都是「让组件不侵入 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 泛型类型很难写对」。

  1. 「什么是『配置炸弹』?怎么避免?」

指「每来一个新需求就往组件上加一个 prop」,最后 API 表面变成几十个 prop 的组合,而「哪些能一起用、哪些互斥」变成没人说得清的隐规则(<Alert type="info" outlined filled dense elevated rounded />)。避免方式(按优先级):① 把「结构」交给 children(复合组件)——用户自己决定层级;② 把「渲染」交给 render props 或 asChild;③ ⭐ 把「逻辑」抽成 Headless Hook(用户想完全自定义外观时不必 fork);④ 只有「数据/行为开关」才留成 prop,且要保持正交(互不耦合)。另外「互斥的布尔 prop」应该改成枚举 + 变体(variant)。

  1. 「怎么让组件 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 内核 + 复合外壳 + 便利封装」的组织方式。

  1. 「组件库怎么做版本演进而不破坏用户?」

三条策略:① 只加不改——新需求优先「加新 prop」而不是「改旧 prop 的语义」(改名/改语义是最难排查的破坏性变更);② deprecated 流程——旧 prop 保留但标 @deprecated(IDE 会划掉)+ 开发期 console 警告(提示替代方案),下个大版本再删;③ 给逃生口——className/style/分部位的 classNames/renderXxx/Headless Hook,让用户「不改源码也能做到想做的一切」(⭐ 这是减少「用户来提需求 → 你被迫加 prop」的关键)。另外「必填组合」用联合类型表达(而不是只写在文档里),让类型系统帮你传达契约。

  1. 「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)与 as prop(换标签)解决不同问题;避免「配置炸弹」的办法是「结构给 children、渲染给 render props、逻辑抽 Headless Hook、只把数据/开关留成 prop」;可渐进的关键手法是「一个能力三层暴露」(Headless Hook → 复合组件 → 简单配置),三层共享同一份逻辑;版本演进只加不改 + deprecated 警告 + 给足逃生口。

赞(0) 打赏
未经允许不得转载:陌上寒 » 1.11 组件 API 设计

评论 抢沙发

觉得文章有用就打赏一下文章作者

非常感谢你的打赏,我们将继续给力更多优质内容,让我们一起创建更加美好的网络世界!

微信扫一扫

支付宝扫一扫