首页
/ Supabase 仓库中的 React 架构规范:告别布尔 Prop 泛滥,用组合模式构建可维护组件

Supabase 仓库中的 React 架构规范:告别布尔 Prop 泛滥,用组合模式构建可维护组件

2026-09-06 22:26:08作者:薛曦旖Francesca

本篇基于 Supabase 仓库内置的 Vercel 组合模式规则 architecture-avoid-boolean-props.md 展开,讲解为什么布尔型 prop(isThreadisEditing 等)会让组件状态空间指数级膨胀,以及如何用复合组件(Compound Components)加共享 Context 的组合方案替代它。读完后你将掌握一套可直接用于组件重构与代码评审的判定标准:如何识别布尔 prop 泛滥、如何用 Composer.Frame / Composer.Input / Composer.Footer 这类组合 API 重写组件、并理解状态通过 state / actions / meta 三段式接口做依赖注入的底层机制。

一、问题本质:每个布尔 prop 都让状态空间翻倍

该规则在 vercel-composition-patterns 技能清单 中被归为第一优先级(Component Architecture / HIGH),元数据中标记为 impact: CRITICAL,其影响描述是 "prevents unmaintainable component variants"(防止不可维护的组件变体)。

文档给出的反面案例是一个消息发送框 Composer,用四个布尔 prop 控制行为:

function Composer({
  onSubmit,
  isThread,
  channelId,
  isDMThread,
  dmId,
  isEditing,
  isForwarding,
}: Props) {
  return (
    <form>
      <Header />
      <Input />
      {isDMThread ? (
        <AlsoSendToDMField id={dmId} />
      ) : isThread ? (
        <AlsoSendToChannelField id={channelId} />
      ) : null}
      {isEditing ? (
        <EditActions />
      ) : isForwarding ? (
        <ForwardActions />
      ) : (
        <DefaultActions />
      )}
      <Footer onSubmit={onSubmit} />
    </form>
  )
}

这段代码有三个层面的问题:

  1. 状态空间指数膨胀。每新增一个布尔 prop,组件的可能状态数就乘以 2。四个布尔 prop 意味着 16 种组合,而其中不少组合在业务上是非法的(例如 isEditingisForwarding 同时为 true),但类型系统无法阻止调用方传出不可能的组合。
  2. 条件逻辑集中且互相纠缠isDMThreadisThread 共用一段三元表达式,isEditing / isForwarding 又共用另一段——阅读者必须沿着所有分支才能回答"这个组件在某个调用点到底渲染了什么"。
  3. 数据 prop 与行为 prop 混杂channelIddmId 是纯数据,却被布尔 prop 决定"是否生效",prop 之间存在隐式的依赖约束(isThread 为 true 时 channelId 才必须有值),而这类约束在 props 签名中完全不可见。

二、解法:用组合替代条件分支

文档给出的正确写法是把一个单体组件拆成三个显式变体,每个变体声明式地组合它需要的内部件:

// Channel composer
function ChannelComposer() {
  return (
    <Composer.Frame>
      <Composer.Header />
      <Composer.Input />
      <Composer.Footer>
        <Composer.Attachments />
        <Composer.Formatting />
        <Composer.Emojis />
        <Composer.Submit />
      </Composer.Footer>
    </Composer.Frame>
  )
}

// Thread composer - adds "also send to channel" field
function ThreadComposer({ channelId }: { channelId: string }) {
  return (
    <Composer.Frame>
      <Composer.Header />
      <Composer.Input />
      <AlsoSendToChannelField id={channelId} />
      <Composer.Footer>
        <Composer.Formatting />
        <Composer.Emojis />
        <Composer.Submit />
      </Composer.Footer>
    </Composer.Frame>
  )
}

// Edit composer - different footer actions
function EditComposer() {
  return (
    <Composer.Frame>
      <Composer.Input />
      <Composer.Footer>
        <Composer.Formatting />
        <Composer.Emojis />
        <Composer.CancelEdit />
        <Composer.SaveEdit />
      </Composer.Footer>
    </Composer.Frame>
  )
}

对比原文的三元条件链,这个方案的关键转变是:变体之间共享的是内部件(Composer.InputComposer.Footer 等),而不是共享一个带大量开关的父组件。文档的结论性一句话是:"Each variant is explicit about what it renders. We can share internals without sharing a single monolithic parent."(每个变体明确知道自己渲染什么;我们可以共享内部件,而不必共享一个单体的父组件。)

这与同技能包中的 patterns-explicit-variants.md 规则是一体两面:调用方从

<Composer
  isThread
  isEditing={false}
  channelId='abc'
  showAttachments
  showFormatting={false}
/>

变为

<ThreadComposer channelId="abc" />
// 或 <EditMessageComposer messageId="xyz" />
// 或 <ForwardMessageComposer messageId="123" />

前者需要调用方理解六个 prop 的相互约束才能推断渲染结果;后者"代码即文档"——组件名直接说明渲染的是什么,且不存在不可能的状态组合。

三、底层机制:复合组件与共享 Context

变体只是表象,真正让"共享内部件"成立的是复合组件结构。同技能包的 architecture-compound-components.md 给出了内部实现:Composer 不是一个函数,而是一个把子组件挂载为属性的对象,子组件之间通过 Context 而非 props 传递状态:

const ComposerContext = createContext<ComposerContextValue | null>(null)

function ComposerProvider({ children, state, actions, meta }: ProviderProps) {
  return (
    <ComposerContext value={{ state, actions, meta }}>
      {children}
    </ComposerContext>
  )
}

function ComposerInput() {
  const {
    state,
    actions: { update },
    meta: { inputRef },
  } = use(ComposerContext)
  return (
    <TextInput
      ref={inputRef}
      value={state.input}
      onChangeText={(text) => update((s) => ({ ...s, input: text }))}
    />
  )
}

function ComposerSubmit() {
  const { actions: { submit } } = use(ComposerContext)
  return <Button onPress={submit}>Send</Button>
}

// Export as compound component
const Composer = {
  Provider: ComposerProvider,
  Frame: ComposerFrame,
  Input: ComposerInput,
  Submit: ComposerSubmit,
  Header: ComposerHeader,
  Footer: ComposerFooter,
  Attachments: ComposerAttachments,
  Formatting: ComposerFormatting,
  Emojis: ComposerEmojis,
}

消费端因此可以精确拼装任意子集,且不存在隐藏的条件分支:

<Composer.Provider state={state} actions={actions} meta={meta}>
  <Composer.Frame>
    <Composer.Header />
    <Composer.Input />
    <Composer.Footer>
      <Composer.Formatting />
      <Composer.Submit />
    </Composer.Footer>
  </Composer.Frame>
</Composer.Provider>

这里有两点值得注意:

  • Context 承载状态,children 承载结构Frame 只负责把 children 包进 <form>Input / Submit 各自从 Context 取数据——结构组合(谁包含谁)与数据流(谁读写状态)被彻底分开。这也呼应了 patterns-children-over-render-props.md 的结论:静态结构用 children 组合,只有当父组件需要把数据回传给子组件时才使用 render props(例如 <List data={items} renderItem={({ item, index }) => ...} />)。
  • 代码中使用 use(ComposerContext) 而非 useContext。这与技能清单中 react19-no-forwardref 规则一致:React 19 起可用 use() 读取 Context,且 ref 可以作为普通 prop 传递,无需 forwardRef。需要注意该条规则在 SKILL.md 中明确标注 "React 19+ only"——如果项目仍停留在 React 18 或更早版本,应改回 useContext 写法。

四、状态如何被解耦:state / actions / meta 三段式接口

组合模式要避免下一个陷阱:内部件直接调用某个具体 hook(如 useChannelComposerState()),导致 UI 与该状态实现耦合。state-context-interface.md 给出的是通用接口定义——Context 的值由 stateactionsmeta 三部分构成:

interface ComposerState {
  input: string
  attachments: Attachment[]
  isSubmitting: boolean
}

interface ComposerActions {
  update: (updater: (state: ComposerState) => ComposerState) => void
  submit: () => void
}

interface ComposerMeta {
  inputRef: React.RefObject<TextInput>
}

interface ComposerContextValue {
  state: ComposerState
  actions: ComposerActions
  meta: ComposerMeta
}

这样任何 Provider 都可以实现同一接口:一个用 useState 存本地草稿(转发消息场景),另一个用全局同步 hook(频道场景,对应 patterns-explicit-variants.md 中的 ChannelProvider / EditMessageProvider / ForwardMessageProvider 各包一层 Provider 再组合 Composer.Frame 的写法)。UI 组件只消费接口,替换 Provider 即可替换状态实现,而 Composer.Frame / Composer.Input 等 UI 原封不动。Provider 边界同样允许"结构在框外、状态在框内"的用法:按钮与预览组件即使不在 Composer.Frame 内部,只要位于 Provider 内就能调用 submit 或读取 state.input——决定能力的是 Provider 边界,不是视觉嵌套。

五、对照仓库实际代码:布尔"样式变体"与布尔"行为开关"的界限

规则针对的是用布尔 prop 控制组件行为分支的情况。Supabase 仓库自身的 UI 库中有一个值得对照的例子:设计系统组件库的 TreeView 组件packages/ui/src/components/TreeView/TreeView.tsx)也使用了 isSelectedisOpenedisPreview 这类布尔型 prop,但它走的是 class-variance-authority(cva)路线:

const TreeViewItemVariant = cva('group relative transition-colors h-[28px] ...', {
  variants: {
    isSelected: {
      true: 'text-foreground !bg-selection',
      false: '',
    },
    isOpened: {
      true: 'bg-control',
      false: '',
    },
    isPreview: {
      true: 'bg-control text-foreground',
      false: '',
    },
  },
})

从源码结构看,这里的布尔 prop 只是声明式的样式类映射:每个取值对应固定的 Tailwind 类名,不存在 if/else 渲染逻辑、不存在 prop 之间的组合约束,状态空间由 cva 的类型系统显式枚举。这与规则要治理的"isEditing ? <EditActions /> : isForwarding ? <ForwardActions /> : ..."式行为分支有本质区别——后者是运行时条件逻辑的堆叠,前者是编译期穷举的样式变体。可以推断,该规则的适用边界是:布尔 prop 一旦驱动了不同的 DOM 结构、不同的数据流或不同的行为,就应该改用组合或显式变体;若只是穷举样式状态,cva 式的变体 prop 仍是合理选择。

另值得注意的是该文件仍在使用 forwardRefTreeView.tsx 第 47 行)。这与本技能包中 react19- 规则的方向不同——属于存量代码尚未迁移到 React 19 API 的状态,阅读时以当前仓库实际写法为准即可。

六、适用时机与落地检查清单

SKILL.md 的"When to Apply",该规则集适用于以下场景:重构拥有大量布尔 prop 的组件、构建可复用组件库、设计灵活的组件 API、评审组件架构,以及涉及复合组件或 Context Provider 的工作。规则按四个优先级组织:architecture-(HIGH)> state-(MEDIUM)> patterns-(MEDIUM)> react19-(MEDIUM,仅 React 19+),本文涉及的 architecture-avoid-boolean-props 位列其中优先级最高的一条。

在实际评审或重构时,可以按以下顺序自检:

  1. 数一数:组件 props 中有多少布尔值?≥ 3 个且开始出现在三元表达式或 && 链中,即为信号;
  2. 列组合:列出布尔组合,标记业务上非法的组合——非法组合越多,越需要拆分为显式变体组件;
  3. 拆内部件:把渲染片段抽成 Xxx.Frame / Xxx.Input / Xxx.Footer 等无副作用的共享内部件,内部件间状态走 Context;
  4. 建变体:为每种合法用法建一个命名组件(ThreadComposerEditComposer),让"渲染什么"由组件名表达;
  5. 定义接口:Context 值按 state / actions / meta 三段定义,Provider 注入状态实现,使 UI 与具体 hook 解耦、可被多个 Provider 复用。

这套模式的价值最终落在两点:对人类读者,变体代码自文档化,无需在脑中运行条件分支;对 AI 辅助工具,每个组件的渲染结果可从其源码静态确定,显著降低了大规模重构时的推理与出错成本。

登录后查看全文
热门项目推荐
相关项目推荐