Supabase 前端工程实践:React 组合模式(Composition Patterns)组件设计完全指南

发布时间:2026/9/7 5:02:32
Supabase 前端工程实践:React 组合模式(Composition Patterns)组件设计完全指南 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 ArchitectureHIGHarchitecture-2状态管理State ManagementMEDIUMstate-3实现模式Implementation PatternsMEDIUMpatterns-4React 19 APIMEDIUMreact19-每个规则文件的结构固定先简述为什么重要再给出带说明的错误代码示例、正确代码示例最后补充额外上下文与参考。架构规则一消除布尔 prop 膨胀规则文件 architecture-avoid-boolean-props.mdfrontmatter 标注impact: CRITICAL理由是“防止不可维护的组件变体”主张不要添加isThread、isEditing、isDMThread之类的布尔 prop 来定制组件行为。每加一个布尔 prop可能状态数就翻倍一次最终形成不可维护的条件逻辑。应改用组合composition。先看典型的错误写法——一个被布尔 prop 支配的Composerfunction 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.mdimpact: 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 createContextComposerContextValue | 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.mdimpact: 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.RefObjectTextInput } interface ComposerContextValue { state: ComposerState actions: ComposerActions meta: ComposerMeta } const ComposerContext createContextComposerContextValue | 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 channelIdabc Composer.Frame Composer.Input / Composer.Submit / /Composer.Frame /ChannelProvider这条规则还特别强调了Provider 边界才是共享状态的作用域而不是视觉嵌套。位于组件框之外、Provider 之内的组件同样可以读取状态和调用 actionfunction ForwardMessageDialog() { return ( ForwardMessageProvider Dialog {/* Composer 主体 UI */} Composer.Frame Composer.Input placeholderAdd a message, if youd 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 依赖注入。换 ProviderUI 保持不变。”状态管理规则四状态管理与 UI 解耦规则文件 state-decouple-implementation.mdimpact: 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.mdimpact: 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.mdimpact: MEDIUM主张不要做一个带很多布尔 prop 的组件而是创建显式的变体组件每个变体组合它需要的零件——代码即文档。对比调用侧的表达力// 错误这个组件到底渲染什么 Composer isThread isEditing{false} channelIdabc showAttachments showFormatting{false} / // 正确一眼看清渲染内容 ThreadComposer channelIdabc / // 或者 EditMessageComposer messageIdxyz / // 或者 ForwardMessageComposer messageId123 /每个变体的实现都是“唯一、显式、自包含”的同时又复用共享零件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 placeholderAdd a message, if youd like. / Composer.Footer Composer.Formatting / Composer.Emojis / Composer.Mentions / /Composer.Footer /Composer.Frame /ForwardMessageProvider ) }规则文件强调每个变体因此显式回答了三个问题它使用什么 Provider/状态、包含哪些 UI 元素、有哪些可用 action——不存在需要推演的布尔组合也不存在“不可能状态”。仓库中的真实印证从源码结构看“显式变体”在 Supabase UI 包中对应的惯用工具是class-variance-authoritycva。例如 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.mdimpact: 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 classNameflex{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.mdimpact: MEDIUM专门提示若项目还在 React 18 或更早跳过本条。它包含两处 API 变化其一ref变成普通 prop不再需要forwardRef包装// 错误React 19 中不再必要 const ComposerInput forwardRefTextInput, Props((props, ref) { return TextInput ref{ref} {...props} / }) // 正确ref 作为普通 prop function ComposerInput({ ref, ...props }: Props { ref?: React.RefTextInput }) { 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*一类开关叠加如isThreadisDMThreadisEditingisForwarding优先按“显式变体 组合”重构patterns-explicit-variants.md、architecture-avoid-boolean-props.md复杂组件先定义 Context 契约再写 Provider契约固定为state/actions/meta三段state-context-interface.md子组件只从契约取数状态实现只出现在 Provider 内部useState、全局 store、服务端同步等细节不外泄到 UIstate-decouple-implementation.md框外组件要读状态时检查 Provider 边界而不是加 prop把状态提升到 Provider 后dialog 内的按钮、预览等“视觉框外”组件直接消费 Contextstate-lift-state.md组合用 children数据回传才用 render propspatterns-children-over-render-props.mdReact 19 环境下移除forwardRef、迁移到use()react19-no-forwardref.mdSupabase 仓库 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),仅供参考