Supabase 仓库中的 React 组合式架构实践:用显式组件变体消除布尔 Prop 的"模式爆炸"
本篇基于 Supabase monorepo 中 .claude/skills/vercel-composition-patterns/ 技能包里的规则文件 patterns-explicit-variants.md,讲解"创建显式组件变体(Explicit Component Variants)"这一 React 组合式架构模式:为什么一个组件塞满 isThread、isEditing 这类布尔 prop 会让状态组合指数爆炸,以及如何拆成多个各自自包含的变体组件(ThreadComposer、EditMessageComposer、ForwardMessageComposer),并借助共享的复合组件零件与 Provider 状态注入,做到"代码即文档"。读完后你可以掌握一套可落地的组件拆分策略,并能对照仓库中的配套规则文件验证整套模式的完整调用链。
一、这条规则在技能体系中的定位
.claude/skills/vercel-composition-patterns/ 是随仓库分发的一个 Claude Code 技能(skill),由 SKILL.md 定义其触发条件与规则优先级,其 frontmatter 声明的用途是:
- 重构带有大量布尔 prop 的组件;
- 构建可复用的组件库;
- 设计灵活的组件 API;
- 组件架构评审;
- 涉及复合组件(compound components)、render props、context provider 的任务。
技能内按优先级把规则分为四类(摘自 SKILL.md 的 Quick Reference 表格):
| 优先级 | 类别 | 影响级别 | 文件前缀 |
|---|---|---|---|
| 1 | 组件架构 | HIGH | architecture- |
| 2 | 状态管理 | MEDIUM | state- |
| 3 | 实现模式 | MEDIUM | patterns- |
| 4 | React 19 API | MEDIUM | react19- |
patterns-explicit-variants 属于第 3 类"实现模式",frontmatter 中标注的影响级别为 MEDIUM,一句话收益描述是 "self-documenting code, no hidden conditionals"(自文档化的代码、没有隐藏的条件分支)。它并不是孤立规则:上游动机来自 architecture-avoid-boolean-props.md(标注 CRITICAL 级别,指出每加一个布尔 prop 就把可能状态翻倍),构建材料来自 architecture-compound-components.md(提供 Composer.Frame、Composer.Input 等共享零件),状态来源则由 state-lift-state.md、state-context-interface.md、state-decouple-implementation.md 三条规则共同支撑。本文以 explicit-variants 规则为主体,把这条调用链完整展开。
二、问题侧写:一个组件、一堆布尔模式
规则文档首先给出"错误"示范——一个名为 Composer 的组件被多个布尔 prop 控制行为:
// What does this component actually render?
<Composer
isThread
isEditing={false}
channelId='abc'
showAttachments
showFormatting={false}
/>
问题在于:调用方读完这段 JSX 后无法立刻回答"这个组件到底渲染了什么"。每个布尔 prop 都会让组件内部可能出现的 UI 状态组合翻倍,isThread × isEditing × showAttachments × showFormatting 已经构成 16 种排列,而其中相当一部分在业务上是不可能状态(impossible states)——比如"同时处于编辑模式和转发模式"根本不该存在,却仍然需要调用方用 prop 组合去规避。配套的 architecture-avoid-boolean-props.md 用更大的例子量化了这个爆炸:一个带 isThread、isDMThread、isEditing、isForwarding 的 Composer 内部必须写成层层嵌套的三目运算:
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>
)
}
组件名不再表达它做什么,而是被迫承载所有分支逻辑。
三、正确姿势:显式变体组件
patterns-explicit-variants 的核心主张是:不要造一个带很多布尔 prop 的组件,而是为每种模式创建显式的变体组件,每个变体只组装它需要的零件。规则文档给出的调用侧对比:
// Immediately clear what this renders
<ThreadComposer channelId="abc" />
// Or
<EditMessageComposer messageId="xyz" />
// Or
<ForwardMessageComposer messageId="123" />
变体名本身就是模式描述——ThreadComposer 只会在频道线程里发送,EditMessageComposer 只做编辑,ForwardMessageComposer 只做转发。没有任何布尔组合需要调用方去理解,也没有"编辑 + 转发"这种非法组合可以出现。
三个变体的完整实现
规则文档给出的实现代码(原文完整保留,这也是本文的骨架):
function ThreadComposer({ channelId }: { channelId: string }) {
return (
<ThreadProvider channelId={channelId}>
<Composer.Frame>
<Composer.Input />
<AlsoSendToChannelField channelId={channelId} />
<Composer.Footer>
<Composer.Formatting />
<Composer.Emojis />
<Composer.Submit />
</Composer.Footer>
</Composer.Frame>
</ThreadProvider>
)
}
function EditMessageComposer({ messageId }: { messageId: string }) {
return (
<EditMessageProvider messageId={messageId}>
<Composer.Frame>
<Composer.Input />
<Composer.Footer>
<Composer.Formatting />
<Composer.Emojis />
<Composer.CancelEdit />
<Composer.SaveEdit />
</Composer.Footer>
</Composer.Frame>
</EditMessageProvider>
)
}
function ForwardMessageComposer({ messageId }: { messageId: string }) {
return (
<ForwardMessageProvider messageId={messageId}>
<Composer.Frame>
<Composer.Input placeholder="Add a message, if you'd like." />
<Composer.Footer>
<Composer.Formatting />
<Composer.Emojis />
<Composer.Mentions />
</Composer.Footer>
</Composer.Frame>
</ForwardMessageProvider>
)
}
对照三个实现,可以读出显式变体的三重"自文档化":
- 显式的状态来源:
ThreadComposer用ThreadProvider channelId(线程上下文),EditMessageComposer用EditMessageProvider messageId(编辑上下文),ForwardMessageComposer用ForwardMessageProvider(转发上下文)。每个变体用哪一个 Provider,在组件签名层一目了然。 - 显式的 UI 元素:
ThreadComposer多了一个AlsoSendToChannelField("同时也发到频道"的字段),EditMessageComposer没有附件/提及入口,ForwardMessageComposer用了带占位符的Composer.Input和Composer.Mentions。每个变体包含什么、不含什么,直接写在 JSX 里。 - 显式的可用动作:底部动作区(Footer)是各变体差异最集中的地方——线程场景是
Formatting + Emojis + Submit,编辑场景换成CancelEdit + SaveEdit,转发场景是Formatting + Emojis + Mentions。动作集合的差异不再由条件分支决定,而是由"这个变体写了哪些零件"决定。
规则文档对此的总结是:每个实现都是独特的、显式的、自包含的(unique, explicit and self-contained),但它们可以各自复用共享零件。
四、共享零件从哪来:与复合组件规则的衔接
上面三个变体反复出现的 Composer.Frame、Composer.Input、Composer.Footer、Composer.Formatting 等零件,正是姊妹规则 architecture-compound-components.md 定义的结构:一个由共享 Context 支撑的复合组件集合。该规则给出的导出形态是:
// Export as compound component
const Composer = {
Provider: ComposerProvider,
Frame: ComposerFrame,
Input: ComposerInput,
Submit: ComposerSubmit,
Header: ComposerHeader,
Footer: ComposerFooter,
Attachments: ComposerAttachments,
Formatting: ComposerFormatting,
Emojis: ComposerEmojis,
}
关键在于:这些子组件不接受行为相关的 props,它们通过 Context 取状态。以 ComposerInput 为例:
function ComposerInput() {
const {
state,
actions: { update },
meta: { inputRef },
} = use(ComposerContext)
return (
<TextInput
ref={inputRef}
value={state.input}
onChangeText={(text) => update((s) => ({ ...s, input: text }))}
/>
)
}
这就解释了 explicit-variants 规则里"每个实现都是独特的,却能用共享零件"的可行性机制:ThreadComposer 与 EditMessageComposer 各自包了一层不同的 Provider,注入的是不同的 state/actions/meta,但内部的 Composer.Input 是同一个组件——它只依赖 Context 接口,不依赖具体实现。这正是 state-decouple-implementation.md 强调的"Provider 是唯一知道状态如何管理的地方"。
五、状态注入链:三条 state- 规则如何托底变体
显式变体中每个 XxxProvider(ThreadProvider / EditMessageProvider / ForwardMessageProvider)背后是一套完整的状态管理约定,仓库中的三条 state- 规则给出了标准答案:
1. 状态上提到 Provider(state-lift-state.md)
状态不留在 UI 组件内部,而是提升到 Provider,让视觉层级之外的兄弟组件也能读写状态。文档中 ForwardMessageDialog 的例子说明:ForwardButton 和 MessagePreview 位于 Composer.Frame 之外,但因为处在 ForwardMessageProvider 之内,依然可以直接消费 actions.submit 和 state.input:
// This button lives OUTSIDE Composer.Frame but can still submit based on its context!
function ForwardButton() {
const {
actions: { submit },
} = use(ComposerContext)
return <Button onPress={submit}>Forward</Button>
}
// This preview lives OUTSIDE Composer.Frame but can read composer's state!
function MessagePreview() {
const { state } = use(ComposerContext)
return <Preview message={state.input} attachments={state.attachments} />
}
这条规则同时点名的反模式值得在改造变体时一并避开:用 onInputChange 回调 + useEffect 把状态逐层同步给父组件、或者用 stateRef 在提交时从 ref 里读状态——两者都是状态没有上提留下的补丁。
2. 通用接口即依赖注入契约(state-context-interface.md)
Context 值被定义为三段式通用接口:
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
}
const ComposerContext = createContext<ComposerContextValue | null>(null)
任何 Provider 只要实现这个 { state, actions, meta } 契约,同一套 UI 零件就能工作。文档给出两个对偶实现:ForwardMessageProvider 用本地 useState(临时表单),ChannelProvider 用全局同步状态 useGlobalChannel(channelId)(频道实时同步)——同一份 Composer.Frame / Composer.Input / Composer.Submit 组合在两种 Provider 下都能跑。这正是 explicit-variants 能够成立的类型层基础:变体之间"共享零件、注入不同状态"不会破坏类型。
3. UI 与状态实现解耦(state-decouple-implementation.md)
UI 组件消费 Context 接口,不关心状态来自 useState、Zustand 还是服务端同步。反例是一个 ChannelComposer 直接调用 useGlobalChannelState(channelId) 与 useChannelSync(channelId)——UI 被具体实现绑架;正例是把 useGlobalChannel 收进 ChannelProvider,ChannelComposer 退化为纯零件组装:
function ChannelComposer() {
return (
<Composer.Frame>
<Composer.Header />
<Composer.Input />
<Composer.Footer>
<Composer.Submit />
</Composer.Footer>
</Composer.Frame>
)
}
从源码结构看,这套约定下新增一个变体的成本被压缩到"选 Provider + 排零件"两步,不需要动任何共享零件的代码。
六、相邻实现模式的取舍:children 与 render props
在把巨型组件拆成变体的过程中,还会遇到"内部区块怎么自定义"的问题,patterns-children-over-render-props.md 给出了配套答案:用 children 做结构组合,用 render props 做数据回传。
错误示范是 renderHeader / renderFooter / renderActions 一组回调 props——调用侧必须理解每个回调的签名,且嵌套层级很难读:
return (
<Composer
renderHeader={() => <CustomHeader />}
renderFooter={() => (
<>
<Formatting />
<Emojis />
</>
)}
renderActions={() => <SubmitButton />}
/>
)
正确示范是复合组件 + children:
return (
<Composer.Frame>
<CustomHeader />
<Composer.Input />
<Composer.Footer>
<Composer.Formatting />
<Composer.Emojis />
<SubmitButton />
</Composer.Footer>
</Composer.Frame>
)
规则同时保留了 render props 的合法边界——当父组件需要向子层传递数据或状态时(如 <List data={items} renderItem={({ item, index }) => ...} />),render props 仍是合适的工具。简言之:静态结构组合用 children,数据回调用 render props。
另外,若项目处于 React 19+,react19-no-forwardref.md 提醒两个 API 细节:ref 已是一等 prop(不再需要 forwardRef 包裹),Context 消费用 use() 替代 useContext(),且 use() 允许条件调用。文中所有 use(ComposerContext) 的写法均基于此。
七、变体拆分实操清单
结合规则文档正文与配套规则,把"从布尔组件到显式变体"的迁移整理为可执行步骤:
- 列出布尔矩阵:把目标组件的所有
isXxx/showXxxprops 列出来,枚举哪些组合在业务上真实存在。文档中Composer的isThread+isEditing+showAttachments+showFormatting就是典型起点。 - 按"真实组合"命名变体:每个真实存在的模式独立成一个组件,名称直接表达模式(
ThreadComposer而非Composer mode="thread")。 - 抽取共享零件:把各变体中重复的 UI 片段(输入框、底部动作条、格式化工具)抽成复合组件子件,经由共享 Context 取状态而不是接收行为 props。
- 为每个变体指定 Provider:状态的创建、同步与提交逻辑收进各自 Provider,Context 值遵循
{ state, actions, meta }通用接口,保证零件可被不同 Provider 驱动。 - 验收标准:调用侧 JSX 里不再出现任何需要"推理"的布尔 prop;每个变体内部无不可能状态;共享零件不 import 任何具体状态实现。
规则文档对此的最终陈述是:No boolean prop combinations to reason about. No impossible states.(没有需要推理的布尔 prop 组合,没有不可能的状态。)
八、适用前提与说明
- 本文主体是 patterns-explicit-variants.md,它是随 Supabase monorepo 分发的 agent 技能规则文件(位于
.claude/skills/vercel-composition-patterns/,frontmatter 标注 author 为 vercel、version 1.0.0),用于在仓库内做 React 组件重构、组件库设计时的模式指导;示例代码(Composer 系列)是技能文档自带的教学性示例,并非 supabase 应用内的具体源码模块。 - 规则中标注的 MEDIUM 影响级别是技能包内部的优先级分级,供 agent 检索与参考,不代表对任何项目通用强制。
- 涉及
use()、ref-as-prop 的写法仅适用于 React 19+,React 18 及以下应沿用useContext()与forwardRef。 - 仓库为只读资源,以上内容均可通过直接阅读
.claude/skills/vercel-composition-patterns/目录下的规则文件进行验证与延伸。
atomcodeClaude Code 的开源替代方案。连接任意大模型,编辑代码,运行命令,自动验证 — 全自动执行。用 Rust 构建,极致性能。 | An open-source alternative to Claude Code. Connect any LLM, edit code, run commands, and verify changes — autonomously. Built in Rust for speed. Get StartedRust0629
MiniCPM5-2BMiniCPM5-2B 是一款面向端侧、本地部署和资源受限场景的 2B 稠密 Transformer,能够达到同尺寸开源模型 SOTA 水平。Markdown00
GLM-5.3GLM-5.3 与 GLM-5.2 使用相同的基座模型——所有提升均来自后训练。与 GLM-5.2 相比,它在复杂编程和长程任务上的表现显著提升。Jinja00
HivisionIDPhotos⚡️HivisionIDPhotos: a lightweight and efficient AI ID photos tools. 一个轻量级的AI证件照制作算法。Python07
DragonOSDragonOS is an operating system developed from scratch using Rust, with Linux compatibility. It is designed for **Serverless** scenarios. 使用Rust从0自研内核,具有Linux兼容性的操作系统,面向云计算Serverless场景而设计。Rust00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00