Supabase 前端工程实践:React 组合模式(Composition Patterns)组件设计完全指南
【免费下载链接】supabaseThe Postgres development platform. Supabase gives you a dedicated Postgres database to build your web, mobile, and AI applications.项目地址: https://gitcode.com/GitHub_Trending/supa/supabase
本文基于 Supabase 单仓中内置的 Agent 技能文档vercel-composition-patterns(位于.claude/skills/vercel-composition-patterns/,该目录是指向.agents/skills/的符号链接)展开,系统讲解 8 条 React 组件组合设计规则:消除布尔 prop 膨胀、复合组件与共享 Context、state/actions/meta三段式上下文依赖注入、状态提升、显式变体、children优于 render props,以及 React 19 的ref属性化与use()API。读完本文,你可以在 Supabase 这类大型 React 代码库(apps/studio、packages/ui、packages/ui-patterns)中识别并重构“布尔 prop 失控”的组件,设计出人类与 AI Agent 都能轻松维护的组件 API。
技能文档在仓库中的位置与适用场景
该技能由 Vercel 出品(SKILL.md元数据标注author: vercel、license: MIT、version: 1.0.0),入口文件为 SKILL.md,详细规则拆分存放在rules/子目录下的 8 个文件中。技能的核心主张是:避免布尔 prop 膨胀,改用复合组件(compound components)、状态提升(lifting state)与内部组合(composing internals),使代码库在规模增长时依然对人类和 AI Agent 友好。
SKILL.md明确列出了应当引用这套规则的场景:
- 重构带有大量布尔 prop 的组件;
- 构建可复用的组件库;
- 设计灵活的组件 API;
- 评审组件架构;
- 处理复合组件或 Context Provider。
规则按优先级分为四个类别(引自 SKILL.md 的 “Rule Categories by Priority” 表):
| 优先级 | 类别 | 影响 | 文件前缀 |
|---|---|---|---|
| 1 | 组件架构(Component Architecture) | HIGH | architecture- |
| 2 | 状态管理(State Management) | MEDIUM | state- |
| 3 | 实现模式(Implementation Patterns) | MEDIUM | patterns- |
| 4 | React 19 API | MEDIUM | react19- |
每个规则文件的结构固定:先简述为什么重要,再给出带说明的错误代码示例、正确代码示例,最后补充额外上下文与参考。
架构规则一:消除布尔 prop 膨胀
规则文件 architecture-avoid-boolean-props.md(frontmatter 标注impact: CRITICAL,理由是“防止不可维护的组件变体”)主张:不要添加isThread、isEditing、isDMThread之类的布尔 prop 来定制组件行为。每加一个布尔 prop,可能状态数就翻倍一次,最终形成不可维护的条件逻辑。应改用组合(composition)。
先看典型的错误写法——一个被布尔 prop 支配的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> ) }三个布尔开关(isThread/isDMThread/isEditing/isForwarding)叠加后,调用方无法一眼看出“这个组件此刻到底渲染了什么”,组件内部则是层层嵌套的三元表达式。正确做法是用组合替代条件分支:
// 频道版 Composer function ChannelComposer() { return ( <Composer.Frame> <Composer.Header /> <Composer.Input /> <Composer.Footer> <Composer.Attachments /> <Composer.Formatting /> <Composer.Emojis /> <Composer.Submit /> </Composer.Footer> </Composer.Frame> ) } // 帖子线程版 Composer —— 多一个“同时发送到频道”字段 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> ) } // 编辑版 Composer —— 不同的底部操作 function EditComposer() { return ( <Composer.Frame> <Composer.Input /> <Composer.Footer> <Composer.Formatting /> <Composer.Emojis /> <Composer.CancelEdit /> <Composer.SaveEdit /> </Composer.Footer> </Composer.Frame> ) }要点是:每个变体(variant)显式声明自己渲染什么,内部零件(Header、Input、Formatting……)可以共享,但不需要共享一个单体的父组件。组件树本身成了“文档”,不再需要阅读实现才能知道某个<Composer isThread isEditing />会渲染出什么。
架构规则二:复合组件与共享 Context
规则文件 architecture-compound-components.md(impact: HIGH)要求:把复杂组件结构化为复合组件,子组件通过共享 Context 而非 props 访问公共状态,消费方自由组合自己需要的部分。
错误形态是“单体组件 + render props + 一堆show*开关”:
function Composer({ renderHeader, renderFooter, renderActions, showAttachments, showFormatting, showEmojis, }: Props) { return ( <form> {renderHeader?.()} <Input /> {showAttachments && <Attachments />} {renderFooter ? ( renderFooter() ) : ( <Footer> {showFormatting && <Formatting />} {showEmojis && <Emojis />} {renderActions?.()} </Footer> )} </form> ) }正确形态是“Provider + 若干子组件 + 以对象形式导出”:
const ComposerContext = createContext<ComposerContextValue | null>(null) function ComposerProvider({ children, state, actions, meta }: ProviderProps) { return ( <ComposerContext value={{ state, actions, meta }}> {children} </ComposerContext> ) } function ComposerFrame({ children }: { children: React.ReactNode }) { return <form>{children}</form> } 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> } // 以复合组件形式导出 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>三个关键收益:没有隐藏的条件分支(渲染什么完全由 JSX 结构决定);无 prop drilling(子组件从 Context 取数据,不经过中间层层透传);state、actions、meta由父级 Provider 依赖注入,因此同一套组件结构可以同时被多个不同场景复用。
仓库中的真实印证
从源码结构看,这套“Context + 复合子组件”的形态在 Supabase 仓库的 UI 包中是普遍实践。以 packages/ui-patterns/src/CommandMenu/internal/Context.tsx 为例,CommandContext承载了commandsState、pagesState、queryState、viewState四块共享状态,配套的useCommandContext在 Provider 之外访问时会直接抛出`useCommandContext` must be used within a `CommandProvider`的错误——这正是“共享 Context 定义契约、子组件只消费契约”的落地写法。再看 packages/ui-patterns/src/FilterBar/FilterBarContext.tsx,其FilterBarContextValue类型把“数据”(filters、freeformText、isLoading、error、highlightedConditionPath等)与“操作”(onFilterChange、commitFilters、handleInputChange、handleKeyDown、handleRemoveCondition等十几个 handler)放在同一个 Context value 中供 FilterBar 的各子组件消费,与本文档描述的state + actions契约在结构上完全一致。
状态管理规则三:用通用 Context 接口实现依赖注入
规则文件 state-context-interface.md(impact: HIGH)给出本技能的核心原则:Lift state, compose internals, make state dependency-injectable(提升状态、组合内部、让状态可依赖注入)。具体做法是:为组件 Context 定义一个通用接口,固定为state、actions、meta三段。这个接口是一份契约,任何 Provider 都可以实现它,于是同一套 UI 组件可以工作在全然不同的状态实现之上。
错误示例——UI 与特定状态实现绑死:
function ComposerInput() { // 与一个具体 hook 强耦合 const { input, setInput } = useChannelComposerState() return <TextInput value={input} onChangeText={setInput} /> }正确示例——先定义三段式通用接口:
// 定义任何 Provider 都可以实现的通用接口 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)UI 组件只消费接口,不关心实现:
function ComposerInput() { const { state, actions: { update }, meta, } = use(ComposerContext) // 该组件可以搭配任何实现了该接口的 Provider 工作 return ( <TextInput ref={meta.inputRef} value={state.input} onChangeText={(text) => update((s) => ({ ...s, input: text }))} /> ) }然后让不同场景的 Provider 实现同一个接口——例如“临时表单用本地useState”与“频道用全局同步状态”:
// Provider A:本地状态,服务临时表单 function ForwardMessageProvider({ children }: { children: React.ReactNode }) { const [state, setState] = useState(initialState) const inputRef = useRef(null) const submit = useForwardMessage() return ( <ComposerContext value={{ state, actions: { update: setState, submit }, meta: { inputRef }, }} > {children} </ComposerContext> ) } // Provider B:全局同步状态,服务频道 function ChannelProvider({ channelId, children }: Props) { const { state, update, submit } = useGlobalChannel(channelId) const inputRef = useRef(null) return ( <ComposerContext value={{ state, actions: { update, submit }, meta: { inputRef }, }} > {children} </ComposerContext> ) }同一套组合后的 UI 两边都跑得起来:
// 搭配 ForwardMessageProvider(本地状态) <ForwardMessageProvider> <Composer.Frame> <Composer.Input /> <Composer.Submit /> </Composer.Frame> </ForwardMessageProvider> // 搭配 ChannelProvider(全局同步状态) <ChannelProvider channelId="abc"> <Composer.Frame> <Composer.Input /> <Composer.Submit /> </Composer.Frame> </ChannelProvider>这条规则还特别强调了Provider 边界才是共享状态的作用域,而不是视觉嵌套。位于组件框之外、Provider 之内的组件同样可以读取状态和调用 action:
function ForwardMessageDialog() { return ( <ForwardMessageProvider> <Dialog> {/* Composer 主体 UI */} <Composer.Frame> <Composer.Input placeholder="Add a message, if you'd like." /> <Composer.Footer> <Composer.Formatting /> <Composer.Emojis /> </Composer.Footer> </Composer.Frame> {/* 位于 composer 之外、provider 之内的自定义 UI */} <MessagePreview /> {/* 对话框底部的操作 */} <DialogActions> <CancelButton /> <ForwardButton /> </DialogActions> </Dialog> </ForwardMessageProvider> ) } // 该按钮不在 Composer.Frame 内,但依然可以基于上下文提交! function ForwardButton() { const { actions: { submit }, } = use(ComposerContext) return <Button onPress={submit}>Forward</Button> } // 该预览同样在 Composer.Frame 之外,却能读取 composer 的状态 function MessagePreview() { const { state } = use(ComposerContext) return <Preview message={state.input} attachments={state.attachments} /> }一句话总结规则原文:“The UI is reusable bits you compose together. The state is dependency-injected by the provider. Swap the provider, keep the UI.(UI 是被组合的可复用零件;状态由 Provider 依赖注入。换 Provider,UI 保持不变。)”
状态管理规则四:状态管理与 UI 解耦
规则文件 state-decouple-implementation.md(impact: MEDIUM)把上面的原则落到职责边界上:Provider 应该是唯一知道“状态如何被管理”的地方。UI 组件只消费 Context 接口——它不关心状态来自useState、Zustand 还是服务端同步。
错误示例中,UI 组件直接调用useGlobalChannelState(channelId)与useChannelSync(channelId),组件内部出现了全局状态实现的细节,替换状态方案就要改 UI。
正确示例把实现细节全部收进 Provider:
// Provider 负责全部状态管理细节 function ChannelProvider({ channelId, children, }: { channelId: string children: React.ReactNode }) { const { state, update, submit } = useGlobalChannel(channelId) const inputRef = useRef(null) return ( <Composer.Provider state={state} actions={{ update, submit }} meta={{ inputRef }} > {children} </Composer.Provider> ) } // UI 组件只知道 Context 接口 function ChannelComposer() { return ( <Composer.Frame> <Composer.Header /> <Composer.Input /> <Composer.Footer> <Composer.Submit /> </Composer.Footer> </Composer.Frame> ) } // 用法 function Channel({ channelId }: { channelId: string }) { return ( <ChannelProvider channelId={channelId}> <ChannelComposer /> </ChannelProvider> ) }规则文件随后演示了两个 Provider 复用同一套 UI 的完整对比:ForwardMessageProvider用useState(initialState)+useForwardMessage()提供本地状态,ChannelProvider用useGlobalChannel(channelId)提供全局同步状态——而Composer.Input之所以两边都能用,正是因为“它只依赖 Context 接口,不依赖实现”。
状态管理规则五:把状态提升到 Provider 组件
规则文件 state-lift-state.md(impact: HIGH)解决的问题是:当 dialog 里的按钮、预览组件等“框外兄弟组件”需要读取或触发 composer 的状态时,如何避免 prop drilling 和别扭的 ref 传递。答案是:把状态管理移进专门的 Provider 组件。
文档特意给出了三种常见错误姿势,值得逐一避免:
- 状态困在组件内部:
ForwardMessageComposer自己useState,于是同 dialog 下的MessagePreview、ForwardButton根本拿不到状态——“这个按钮怎么访问 composer 的状态?” - 用
useEffect把状态同步向上:父组件useState('')存input,子组件里useEffect(() => onInputChange(state.input), [state.input])逐次同步——多了一条会抖动的同步链路; - 提交时从 ref 里读状态:
stateRef = useRef(null),按钮onPress={() => submit(stateRef.current)}——把状态当 IO 资源传来传去,时序脆弱。
正确写法(前文 “Provider A” 的完整版):
function ForwardMessageProvider({ children }: { children: React.ReactNode }) { const [state, setState] = useState(initialState) const forwardMessage = useForwardMessage() const inputRef = useRef(null) return ( <Composer.Provider state={state} actions={{ update: setState, submit: forwardMessage }} meta={{ inputRef }} > {children} </Composer.Provider> ) } function ForwardMessageDialog() { return ( <ForwardMessageProvider> <Dialog> <ForwardMessageComposer /> <MessagePreview /> {/* 自定义组件可访问 state 和 actions */} <DialogActions> <CancelButton /> <ForwardButton /> {/* 自定义组件可访问 state 和 actions */} </DialogActions> </Dialog> </ForwardMessageProvider> ) } function ForwardButton() { const { actions } = use(Composer.Context) return <Button onPress={actions.submit}>Forward</Button> }规则原文的 Key insight 概括得很精确:“需要共享状态的组件不必在视觉上嵌套在彼此内部——它们只需要处于同一个 Provider 之内。”
实现规则六:用显式变体替代布尔模式
规则文件 patterns-explicit-variants.md(impact: MEDIUM)主张:不要做一个带很多布尔 prop 的组件,而是创建显式的变体组件,每个变体组合它需要的零件——代码即文档。
对比调用侧的表达力:
// 错误:这个组件到底渲染什么? <Composer isThread isEditing={false} channelId='abc' showAttachments showFormatting={false} /> // 正确:一眼看清渲染内容 <ThreadComposer channelId="abc" /> // 或者 <EditMessageComposer messageId="xyz" /> // 或者 <ForwardMessageComposer messageId="123" />每个变体的实现都是“唯一、显式、自包含”的,同时又复用共享零件:
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> ) }规则文件强调,每个变体因此显式回答了三个问题:它使用什么 Provider/状态、包含哪些 UI 元素、有哪些可用 action——不存在需要推演的布尔组合,也不存在“不可能状态”。
仓库中的真实印证
从源码结构看,“显式变体”在 Supabase UI 包中对应的惯用工具是class-variance-authority(cva)。例如 packages/ui-patterns/src/multi-select/multi-select.tsx 头部即import { cva, VariantProps } from 'class-variance-authority',把尺寸、外观等变体声明为显式的、可枚举的 variant 配置,而不是在组件里散落if (size === 'sm')式的条件分支。这与该规则“用声明式变体取代布尔模式”的思想在实现层面是一致的:一个,管视觉变体,一个,管行为变体。
实现规则七:children 优于 render props
规则文件 patterns-children-over-render-props.md(impact: MEDIUM)指出:组合时优先使用children而不是renderXprop。children 可读性更好、天然可组合,也不需要理解回调签名。
错误形态(render props 使调用侧笨拙且不灵活):
function Composer({ renderHeader, renderFooter, renderActions, }: { renderHeader?: () => React.ReactNode renderFooter?: () => React.ReactNode renderActions?: () => React.ReactNode }) { return ( <form> {renderHeader?.()} <Input /> {renderFooter ? renderFooter() : <DefaultFooter />} {renderActions?.()} </form> ) } // 用法笨拙且不灵活 return ( <Composer renderHeader={() => <CustomHeader />} renderFooter={() => ( <> <Formatting /> <Emojis /> </> )} renderActions={() => <SubmitButton />} /> )正确形态(复合组件 + children,用法灵活):
function ComposerFrame({ children }: { children: React.ReactNode }) { return <form>{children}</form> } function ComposerFooter({ children }: { children: React.ReactNode }) { return <footer className='flex'>{children}</footer> } // 用法灵活 return ( <Composer.Frame> <CustomHeader /> <Composer.Input /> <Composer.Footer> <Composer.Formatting /> <Composer.Emojis /> <SubmitButton /> </Composer.Footer> </Composer.Frame> )规则同时给出了 render props 仍然合适的边界:当父组件需要向子项回传数据或状态时。例如列表渲染:
// 需要把数据回传给渲染函数时,render props 是合适的 <List data={items} renderItem={({ item, index }) => <Item item={item} index={index} />} />判断准则:父组件需要向子项提供数据/状态时用 render props;组合静态结构时用 children。
React 19 API 变更(仅适用 React 19+)
规则文件 react19-no-forwardref.md(impact: MEDIUM)专门提示:若项目还在 React 18 或更早,跳过本条。它包含两处 API 变化:
其一,ref变成普通 prop,不再需要forwardRef包装:
// 错误(React 19 中不再必要) const ComposerInput = forwardRef<TextInput, Props>((props, ref) => { return <TextInput ref={ref} {...props} /> }) // 正确(ref 作为普通 prop) function ComposerInput({ ref, ...props }: Props & { ref?: React.Ref<TextInput> }) { return <TextInput ref={ref} {...props} /> }其二,用use()替代useContext():
// 错误 const value = useContext(MyContext) // 正确 const value = use(MyContext)且use()与useContext()不同,可以条件调用。这也解释了为何本文前述所有示例代码统一使用use(ComposerContext)而非useContext。
适用前提:Supabase 仓库的 React 版本
从仓库根目录的 pnpm-workspace.yaml 可以确认,pnpm catalog 统一锁定了react: ^19.2.6与react-dom: ^19.2.6,而 apps/studio/package.json、packages/ui-patterns/package.json、packages/ui/package.json 中的 react 依赖均以catalog:形式引用该版本。也就是说,该技能中“React 19 only”的规则在 Supabase 仓库内是全量适用的,不需要为版本差异做条件处理。
如何把这些规则用于实际工程
SKILL.md给出的使用方式很直接:按需阅读rules/下的单个规则文件,每个文件都包含“为什么重要 + 错误示例及说明 + 正确示例及说明 + 额外上下文与参考”四段。结合仓库中可查证的实践,可以沉淀出如下落地清单:
- 评审组件 API 时先数布尔 prop:出现
is*、show*一类开关叠加(如isThread+isDMThread+isEditing+isForwarding),优先按“显式变体 + 组合”重构(patterns-explicit-variants.md、architecture-avoid-boolean-props.md); - 复杂组件先定义 Context 契约,再写 Provider:契约固定为
state/actions/meta三段(state-context-interface.md),子组件只从契约取数; - 状态实现只出现在 Provider 内部:
useState、全局 store、服务端同步等细节不外泄到 UI(state-decouple-implementation.md); - 框外组件要读状态时,检查 Provider 边界而不是加 prop:把状态提升到 Provider 后,dialog 内的按钮、预览等“视觉框外”组件直接消费 Context(state-lift-state.md);
- 组合用 children,数据回传才用 render props(patterns-children-over-render-props.md);
- React 19 环境下移除
forwardRef、迁移到use()(react19-no-forwardref.md),Supabase 仓库 catalog 锁定的react: ^19.2.6即满足前提。
这套模式的共同目标是同一个:让组件“渲染什么、状态从哪来、动作怎么触发”都从 API 表面可见,消除隐藏在组件内部的布尔分支与实现耦合,使组件库随业务增长保持可读、可组合、可换实现——这正是 Supabase 这样一个同时维护 Studio 控制台、UI 组件包(packages/ui)与 UI 模式包(packages/ui-patterns)的大型 React 单仓所需要的组件工程纪律。
【免费下载链接】supabaseThe Postgres development platform. Supabase gives you a dedicated Postgres database to build your web, mobile, and AI applications.项目地址: https://gitcode.com/GitHub_Trending/supa/supabase
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考