陌上人如玉
公子世无双

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)】
   · 简单用法要简单:`<Select options={x} />` 一行能用
   · 复杂用法要能展开:需要自定义渲染时有「逃生口」(`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: <Select options={opts} />
   v2: <Select options={opts} renderOption={fn} />                    // 要自定义选项样式
   v3: <Select options={opts} renderOption={fn} renderTrigger={fn} /> // 连带要改触发器
   v4: <Select ... groupBy={fn} filterable filterFn={fn} virtual />   // 又要分组/筛选/虚拟滚动
   ...
   ⭐ 问题:每个新需求都加一个 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 <input value={value} disabled={disabled} onChange={(e) => handleChange(e.target.value)} />;
}
⭐⭐⭐⭐ 三条「受控/非受控」的铁律(每条都有真实事故):

① 【判据必须是 `value !== undefined`,不能用 truthy】
   if (value) isControlled = true;                        // ❌ value="" 会被判成非受控
   ⭐ 后果:`<Input value="" />` 变成非受控 → 用户能输入,但 React 认为它「没值」
     (这是 React 官方当初报的那个著名警告的来源)

② 【不能「中途切换」受控状态】
   ❌ 首次 `<Input />`(非受控),后一次 `<Input value="a" />`(受控)
   → React 会告警:「A component is changing an uncontrolled input to be controlled」
   ⭐ 因为 `input` 的 `value`/`defaultValue` 语义不同,
     从「DOM 管理」切到「React 管理」时,DOM 里的值会被 React 覆盖/丢失
   ✅ 保证「始终传 value」或「始终不传」

③ 【`defaultValue` 只在首次挂载生效】
   ❌ 后续改 `defaultValue` 想更新输入框 → 无效
   ✅ 需要程序化更新就用「受控」;或者在非受控下用 `key` 强制重建
     (⭐ `<Input key={userId} defaultValue={user.name} />` 是常见手法)
// ⭐ 一个「既有受控又有非受控」的完整可复用实现(含开发期警告)
function useControllable<T>(
  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}> 必须放在 <Dialog> 内部使用`);
  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 (
    <DialogContext.Provider value={ctx}>
      {open ? children : null}
    </DialogContext.Provider>
  );
}

Dialog.Title = function DialogTitle({ children }: { children: React.ReactNode }) {
  const { titleId } = useDialogContext('Dialog.Title');
  return <h2 id={titleId}>{children}</h2>;      // ⭐ 自动挂上 id(供 aria-labelledby 用)
};

Dialog.Body = function DialogBody({ children }: { children: React.ReactNode }) {
  useDialogContext('Dialog.Body');
  return <div className="dialog-body">{children}</div>;
};

// 使用(结构完全由使用者决定)
<Dialog open={open} onOpenChange={setOpen}>
  <Dialog.Title>确认删除</Dialog.Title>
  <Dialog.Body>删除后不可恢复,确定继续?</Dialog.Body>
</Dialog>
⭐⭐ 复合组件的三个「必须做对」的点:

① 【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 (
    <DialogActionsContext.Provider value={actions}>
      <DialogStateContext.Provider value={state}>{children}</DialogStateContext.Provider>
    </DialogActionsContext.Provider>
  );
}
// ⭐ 效果:「关闭按钮」只消费 actions → 打开/关闭状态变化时它【不】重渲染

五、asChild 与多态组件

// ✅ asChild:不额外包一层 DOM,把 props 合并到子元素上(Radix 的 Slot 模式)
interface SlotProps {
  children: React.ReactElement;
}

function Slot({ children, ...slotProps }: SlotProps & Record<string, unknown>) {
  const child = Children.only(children) as React.ReactElement<Record<string, unknown>>;

  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<unknown> }).ref,
      slotProps.ref as React.Ref<unknown> | undefined
    ),
  };

  return cloneElement(child, mergedProps);
}

function composeHandlers<E>(childHandler?: (e: E) => void, slotHandler?: (e: E) => void) {
  return (e: E) => {
    childHandler?.(e);
    // ⭐ 如果子元素调用了 preventDefault,就不再执行外层逻辑(尊重子元素的决定)
    if (!(e as { defaultPrevented?: boolean })?.defaultPrevented) slotHandler?.(e);
  };
}

function composeRefs<T>(...refs: Array<React.Ref<T> | undefined>) {
  return (node: T | null) => {
    for (const ref of refs) {
      if (typeof ref === 'function') ref(node);
      else if (ref) (ref as React.MutableRefObject<T | null>).current = node;
    }
  };
}

// 使用:<Button asChild><a href="/x">链接</a></Button>
// → 渲染出的是 <a>(不是「按钮里套链接」),但样式来自 Button
// ✅ 多态组件(as prop):保留「语义标签」的灵活性
type PolymorphicProps<E extends React.ElementType, P> = P & {
  as?: E;
} & Omit<React.ComponentPropsWithoutRef<E>, keyof P | 'as'>;

function Text<E extends React.ElementType = 'span'>({
  as, children, ...rest
}: PolymorphicProps<E, { tone?: 'normal' | 'muted' }>) {
  const Component = (as ?? 'span') as React.ElementType;
  return <Component {...rest}>{children}</Component>;
}

// 使用:类型会自动推断出 <a> 的属性
<Text as="a" href="/x" tone="muted">链接</Text>     // ✅ href 有类型
<Text as="h1" tone="normal">标题</Text>             // ✅
<Text as="a" onClick={(e) => e.currentTarget.href}> // ✅ e 的类型是 HTMLAnchorElement
⭐⭐ 这两个模式解决的是「同一个问题」:让组件「不侵入布局/语义」。

   · **`asChild`**——「我不想多一层 DOM」:
     按钮希望是 `<a>`、Tooltip 希望挂在任意元素上、链接希望用路由组件
   · **`as` prop**——「我需要不同的语义标签」:
     同样的排版样式,有时是 `h2`、有时是 `div`、有时是 `a`
   ⚠️ 两者都能滥用:`asChild` 需要 `cloneElement`(有些团队不喜欢),
     `as` prop 的 TS 类型很难写对(需要泛型 + `Omit`)
   ⭐ 判据:「**需要多一层 DOM 吗**」→ 用 `asChild` 而不是 `as`;
     「**需要不同标签但可以有包裹层吗**」→ 用 `as`。

六、完整实现:一个「三种消费形态」的 Select(220 行)

// 目标:同一个组件能力,提供「简单配置 / 复合结构 / 无头逻辑」三种用法
// ① <Select options={opts} />                       —— 一行能用
// ② <Select.Root><Select.Trigger/><Select.List/></Select.Root>  —— 结构可定制
// ③ const select = useSelect({ options }) + 自己渲染 —— 完全自定义外观

interface Option<T> { value: T; label: string; disabled?: boolean }

/** ============ 第三层:Headless Hook(所有形态都基于它) ============ */
function useSelect<T>({
  options,
  value: valueProp,
  defaultValue,
  onChange,
  disabled = false,
}: {
  options: Array<Option<T>>;
  value?: T;
  defaultValue?: T;
  onChange?: (value: T) => void;
  disabled?: boolean;
}) {
  const [value, setValue] = useControllable<T | undefined>(valueProp, defaultValue, onChange);
  const [open, setOpen] = useState(false);
  const [highlightIndex, setHighlightIndex] = useState(-1);      // ⭐ 键盘高亮(不等于选中)
  const listboxId = useId();
  const triggerRef = useRef<HTMLButtonElement | null>(null);
  const listRef = useRef<HTMLUListElement | null>(null);

  const selectedIndex = options.findIndex((o) => Object.is(o.value, value));
  const enabledIndices = options.map((o, i) => (o.disabled ? -1 : i)).filter((i) => i >= 0);

  const openList = useCallback(() => {
    if (disabled) return;
    setOpen(true);
    // ⭐ 打开时把高亮定位到「当前选中项」(没有就第一项)
    setHighlightIndex(selectedIndex >= 0 ? selectedIndex : (enabledIndices[0] ?? -1));
  }, [disabled, selectedIndex, enabledIndices]);

  const closeList = useCallback(() => {
    setOpen(false);
    setHighlightIndex(-1);
  }, []);

  const selectAt = useCallback((index: number) => {
    const opt = options[index];
    if (!opt || opt.disabled) return;
    setValue(opt.value);
    closeList();
    triggerRef.current?.focus();                               // ⭐ 选完把焦点还给 trigger
  }, [options, setValue, closeList]);

  /** ⭐⭐ 键盘交互(这是「好 API」与「能用 API」的分界) */
  const onTriggerKeyDown = useCallback((e: React.KeyboardEvent) => {
    switch (e.key) {
      case 'ArrowDown':
      case 'ArrowUp': {
        e.preventDefault();
        if (!open) { openList(); return; }
        const dir = e.key === 'ArrowDown' ? 1 : -1;
        const currentPos = enabledIndices.indexOf(highlightIndex);
        const nextPos = currentPos < 0
          ? 0
          : (currentPos + dir + enabledIndices.length) % enabledIndices.length;
        setHighlightIndex(enabledIndices[nextPos] ?? -1);
        break;
      }
      case 'Enter':
      case ' ': {
        e.preventDefault();
        if (open && highlightIndex >= 0) selectAt(highlightIndex);
        else openList();
        break;
      }
      case 'Escape': {
        if (open) { e.preventDefault(); closeList(); }
        break;
      }
      case 'Home': if (open) { e.preventDefault(); setHighlightIndex(enabledIndices[0] ?? -1); } break;
      case 'End': if (open) { e.preventDefault(); setHighlightIndex(enabledIndices.at(-1) ?? -1); } break;
      case 'Tab': if (open) closeList(); break;
    }
  }, [open, openList, closeList, highlightIndex, enabledIndices, selectAt]);

  // ⭐ 点击外部关闭
  useEffect(() => {
    if (!open) return;
    const onPointerDown = (e: PointerEvent) => {
      const target = e.target as Node;
      if (triggerRef.current?.contains(target) || listRef.current?.contains(target)) return;
      closeList();
    };
    document.addEventListener('pointerdown', onPointerDown, true);
    return () => document.removeEventListener('pointerdown', onPointerDown, true);
  }, [open, closeList]);

  /** ⭐ 返回「无头使用者需要的一切」 */
  return {
    // 状态
    value, open, disabled, options, highlightIndex, listboxId, selectedIndex,
    // 行为
    setValue, openList, closeList, selectAt, setHighlightIndex,
    // 展开到元素上的 props(无头使用者直接 spread)
    getTriggerProps: () => ({
      ref: triggerRef,
      type: 'button' as const,
      role: 'combobox' as const,
      'aria-expanded': open,
      'aria-controls': listboxId,
      'aria-haspopup': 'listbox' as const,
      'aria-disabled': disabled || undefined,
      onKeyDown: onTriggerKeyDown,
      onClick: () => (open ? closeList() : openList()),        // ⭐ 点击切换
    }),
    getListProps: () => ({
      ref: listRef,
      id: listboxId,
      role: 'listbox' as const,
      'aria-activedescendant': highlightIndex >= 0 ? `${listboxId}-opt-${highlightIndex}` : undefined,
    }),
    getOptionProps: (index: number) => ({
      id: `${listboxId}-opt-${index}`,
      role: 'option' as const,
      'aria-selected': Object.is(options[index]?.value, value),
      'aria-disabled': options[index]?.disabled || undefined,
      onPointerEnter: () => !options[index]?.disabled && setHighlightIndex(index),
      onClick: () => selectAt(index),
    }),
  };
}
/** ============ 第二层:复合组件(基于 Hook) ============ */
interface SelectCompoundContextValue<T> {
  select: ReturnType<typeof useSelect<T>>;
}
const SelectCompoundContext = createContext<SelectCompoundContextValue<unknown> | null>(null);

function useSelectCompound<T>(component: string) {
  const ctx = useContext(SelectCompoundContext) as SelectCompoundContextValue<T> | null;
  if (!ctx) throw new Error(`<Select.${component}> 必须放在 <Select.Root> 内部`);
  return ctx.select;
}

function SelectRoot<T>(props: Parameters<typeof useSelect<T>>[0] & { children: React.ReactNode }) {
  const { children, ...rest } = props;
  const select = useSelect<T>(rest);
  const ctx = useMemo(() => ({ select }), [select]);
  return <SelectCompoundContext.Provider value={ctx as never}>{children}</SelectCompoundContext.Provider>;
}

const SelectTrigger = React.forwardRef<HTMLButtonElement, { children?: React.ReactNode; className?: string }>(
  function SelectTrigger({ children, className }, forwardedRef) {
    const select = useSelectCompound<unknown>('Trigger');
    const props = select.getTriggerProps();
    const current = select.options.find((o) => Object.is(o.value, select.value));
    return (
      <button
        {...props}
        ref={(node) => {
          props.ref.current = node;
          if (typeof forwardedRef === 'function') forwardedRef(node);
          else if (forwardedRef) forwardedRef.current = node;
        }}
        className={className}
      >
        {children ?? current?.label ?? '请选择'}
        <span aria-hidden>▾</span>
      </button>
    );
  }
);

function SelectList({ className, renderOption }: {
  className?: string;
  renderOption?: (option: Option<unknown>, state: { selected: boolean; highlighted: boolean }) => React.ReactNode;
}) {
  const select = useSelectCompound<unknown>('List');
  if (!select.open) return null;
  return (
    <ul {...select.getListProps()} className={className} style={{ position: 'absolute', zIndex: 10 }}>
      {select.options.map((opt, i) => {
        const selected = Object.is(opt.value, select.value);
        const highlighted = select.highlightIndex === i;
        return (
          <li
            key={String(opt.value)}
            {...select.getOptionProps(i)}
            style={{
              padding: '6px 10px',
              cursor: opt.disabled ? 'not-allowed' : 'pointer',
              background: highlighted ? '#eef' : undefined,
              opacity: opt.disabled ? 0.5 : 1,
            }}
          >
            {/* ⭐ 逃生口:允许自定义选项渲染 */}
            {renderOption ? renderOption(opt, { selected, highlighted }) : opt.label}
            {selected && <span aria-hidden> ✓</span>}
          </li>
        );
      })}
    </ul>
  );
}
/** ============ 第一层:简单配置式(最常用的形态) ============ */
function SimpleSelect<T>(props: Parameters<typeof useSelect<T>>[0] & { className?: string; placeholder?: string }) {
  return (
    <span style={{ position: 'relative', display: 'inline-block' }}>
      <SelectRoot {...props}>
        <SelectTrigger className={props.className} />
        <SelectList />
      </SelectRoot>
    </span>
  );
}

/** ============ 导出:同一个能力,三种用法 ============ */
export const Select = Object.assign(SimpleSelect, {
  Root: SelectRoot,
  Trigger: SelectTrigger,
  List: SelectList,
  /** ⭐ 无头:给「完全自定义外观」的使用者 */
  useSelect,
});

// ---------- 用法 1:一行 ----------
// <Select options={[{ value: 'a', label: '选项 A' }]} onChange={setV} />

// ---------- 用法 2:复合(结构可定制) ----------
// <Select.Root options={opts} onChange={setV}>
//   <div className="row">
//     <label>城市</label>
//     <Select.Trigger className="input" />
//   </div>
//   <Select.List renderOption={(o, { selected }) => <b>{o.label}{selected ? '★' : ''}</b>} />
// </Select.Root>

// ---------- 用法 3:无头(外观完全自定义) ----------
// function MySelect({ options }) {
//   const s = Select.useSelect({ options });
//   return (
//     <div className="my-own-ui">
//       <div {...s.getTriggerProps()}>当前:{String(s.value)}</div>
//       {s.open && <div {...s.getListProps()}>
//         {s.options.map((o, i) => <div key={String(o.value)} {...s.getOptionProps(i)}>{o.label}</div>)}
//       </div>}
//     </div>
//   );
// }
⭐⭐⭐ 这个设计演示了「可渐进」的核心手法:

   【一个能力,三层暴露】
     · 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 ? <Input /> : <Input value={v} />}                   // ❌
// ✅ 始终同一模式

// ③ 期望改 `defaultValue` 更新输入框
<Input defaultValue={user.name} /> 然后 user 变了            // ⚠️ 不生效
// ✅ 受控,或 `key={user.id}` 强制重建

// ④ `onChange` 只在受控模式触发(非受控时不通知)
if (isControlled) onChange?.(next);                           // ❌
// ✅ 两种模式都通知(否则「非受控 + onChange」这个常见组合就废了)

// ⑤ 复合组件的 Context value 不稳定(每次渲染所有消费者重渲染)
<Ctx.Provider value={{ open, setOpen }}>                      // ❌ 每次新对象
// ✅ useMemo

// ⑥ 复合组件「子组件用在错误位置」时报错信息无用
// ⚠️ 用户看到 "Cannot read properties of null (reading 'open')"
// ✅ `if (!ctx) throw new Error('<Dialog.Title> 必须放在 <Dialog> 内部')`

// ⑦ 用「大量布尔 prop」表达互斥状态(组合爆炸)
<Alert type="info" outlined filled dense elevated rounded />   // ❌ 谁能和谁一起用?
// ✅ 用「枚举 + 变体(variant)」或直接「拆成不同组件」

// ⑧ prop 命名不一致(同一个概念在库里叫三个名字)
<Modal visible /> <Drawer isOpen /> <Popover open />           // ❌
// ✅ 统一 `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<string, unknown>`)
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` 回调,或用受控模式

// ⑲ 无障碍属性「要么全自动、要么全靠用户」
// ⭐ 最好的是「自动关联 + 允许覆盖」:
//    `<Dialog.Title>` 自动生成 id 并由 Dialog 关联 aria-labelledby
//    但用户传了 `id` 就用用户的

// ⑳ 默认值用「对象字面量」写在默认参数里(每次渲染新引用)
function List({ items = [] }) { }                              // ⚠️ 每次新数组(依赖它做 memo 会失效)
// ✅ 把常量提到模块级:const EMPTY: never[] = [];
// ⑬ 的完整写法(多态 + 泛型,类型正确)
type AsProp<E extends React.ElementType> = { as?: E };

type PropsToOmit<E extends React.ElementType, P> = keyof (AsProp<E> & P);

type PolymorphicComponentProps<E extends React.ElementType, P = {}> =
  P &
  AsProp<E> &
  Omit<React.ComponentPropsWithoutRef<E>, PropsToOmit<E, P>>;

function Text<E extends React.ElementType = 'span'>(
  { as, children, ...rest }: PolymorphicComponentProps<E, { tone?: 'normal' | 'muted' }>
) {
  const Component = (as ?? 'span') as React.ElementType;
  return <Component {...rest}>{children}</Component>;
}
// ⑨ 的完整写法(用联合类型表达「必填组合」)
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 而不是改语义」;④ 有反馈——类型即文档、误用有开发期警告、无障碍属性自动关联。

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

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

  4. 「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 泛型类型很难写对」。

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

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

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

  8. 「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 设计

评论 抢沙发

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

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

微信扫一扫

支付宝扫一扫