踐:用復(fù)合組件與共享 Context 構(gòu)建可靈活組合的 React 組件)
Supabase 前端工程實(shí)踐用復(fù)合組件與共享 Context 構(gòu)建可靈活組合的 React 組件【免費(fèi)下載鏈接】supabaseThe Postgres development platform. Supabase gives you a dedicated Postgres database to build your web, mobile, and AI applications.項(xiàng)目地址: https://gitcode.com/GitHub_Trending/supa/supabase本篇指南圍繞 Supabase 倉庫內(nèi)沉淀的一條高優(yōu)先級 React 組件架構(gòu)規(guī)則——復(fù)合組件模式——展開如何通過“共享 Context 獨(dú)立子組件 顯式組合”的方式重構(gòu)臃腫的大型組件消除 render props 與布爾開關(guān)帶來的復(fù)雜度。讀完本文你可以掌握該模式的核心結(jié)構(gòu)、配套的狀態(tài)依賴注入接口設(shè)計(jì)并在 Supabase 自己的 UI 組件庫源碼中找到這一模式的真實(shí)落地范例。模式解決的問題單體組件的失控原始規(guī)則文檔首先給出了一個(gè)典型的反面示例——一個(gè)承擔(dān)過多職責(zé)的單體組件它同時(shí)接收renderHeader、renderFooter、renderActions等 render props以及showAttachments、showFormatting、showEmojis等布爾開關(guān)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 ) }這個(gè)寫法的問題在于組件內(nèi)部用一連串條件分支替消費(fèi)者做布局決策消費(fèi)者只能通過“隱藏的邏輯開關(guān)”間接影響結(jié)構(gòu)每新增一個(gè)可選區(qū)域就要增加一個(gè) prop。規(guī)則文檔將這種模式標(biāo)記為“Incorrect (monolithic component with render props)”即“帶 render props 的單體組件”應(yīng)當(dāng)被重構(gòu)。復(fù)合組件模式給出的答案只有兩句話把復(fù)雜組件拆成一組共享 Context 的復(fù)合子組件每個(gè)子組件通過 Context 而非 props 訪問共享狀態(tài)消費(fèi)者按需組合compose自己需要的部件。核心結(jié)構(gòu)Provider、Frame 與子組件規(guī)則文檔給出的正確實(shí)現(xiàn)由三層構(gòu)成共享 Context一個(gè)null初始值的 Context類型承載state、actions、meta三類數(shù)據(jù)Provider由外部注入依賴負(fù)責(zé)把這三類數(shù)據(jù)掛到 Context 上子組件每一個(gè)都是獨(dú)立函數(shù)組件通過use(ComposerContext)讀取自己需要的部分互不依賴。完整實(shí)現(xiàn)摘自原始規(guī)則文檔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 } // Export as compound component const Composer { Provider: ComposerProvider, Frame: ComposerFrame, Input: ComposerInput, Submit: ComposerSubmit, Header: ComposerHeader, Footer: ComposerFooter, Attachments: ComposerAttachments, Formatting: ComposerFormatting, Emojis: ComposerEmojis, }注意最后一步所有子組件被掛載到一個(gè)對象上以Composer.Frame、Composer.Input這樣的命名空間形式對外導(dǎo)出。這是復(fù)合組件compound component的標(biāo)志性 API 形態(tài)——類型系統(tǒng)和 IDE 自動(dòng)補(bǔ)全都能直接反映“這個(gè)組件家族里有哪些可用的塊”。消費(fèi)者視角的顯式組合使用方得到的是一棵完全受自己控制的 JSX 樹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原始規(guī)則文檔對這一形態(tài)的總結(jié)值得逐字記住“消費(fèi)者顯式地組合自己恰好需要的東西沒有隱藏的條件分支No hidden conditionalsstate、actions、meta 由父級 Provider 依賴注入因此同一套組件結(jié)構(gòu)可以在多處復(fù)用?!边@正是對單體版本三大痛點(diǎn)的直接回答布局控制權(quán)交還消費(fèi)者Composer.Footer里放什么、放幾個(gè)Composer.Formatting和Composer.Submit由消費(fèi)者決定組件內(nèi)部不再有任何showX X /的分支依賴注入實(shí)現(xiàn)復(fù)用同一套 UI 結(jié)構(gòu)可以掛在不同的 Provider 之上從而適配本地狀態(tài)、全局同步狀態(tài)等完全不同的數(shù)據(jù)源prop 不再層層下鉆子組件之間的協(xié)作比如 Submit 讀取 input 的 ref全部走 Context父組件無需把 ref、回調(diào)在子組件之間穿梭傳遞。狀態(tài)接口設(shè)計(jì)state / actions / meta 三段式契約單靠“拆組件 Context”還不夠真正讓這套模式可復(fù)用的是配套的通用 Context 接口規(guī)則。該規(guī)則要求把 Context 的值定義為一個(gè)“任何 Provider 都可以實(shí)現(xiàn)”的通用接口分為三段// 任何 Provider 都可以實(shí)現(xiàn)的通用接口 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)三段各司其職state只讀的共享數(shù)據(jù)快照輸入文本、附件列表、提交狀態(tài)actions對外的行為契約update采用updater函數(shù)簽名使 Provider 既可以包裝useState也可以包裝全局 store消費(fèi)方無感知meta不適合歸入業(yè)務(wù)狀態(tài)的技術(shù)性元數(shù)據(jù)典型如inputRef。關(guān)鍵在于UI 組件消費(fèi)的是接口而不是某個(gè)具體狀態(tài)實(shí)現(xiàn)。錯(cuò)誤寫法是讓ComposerInput直接調(diào)用useChannelComposerState()把 UI 綁死在特定 hook 上正確寫法是上面接口化之后的use(ComposerContext)。由此同一套Composer.Frame組合可以無縫切換 Provider// Provider A本地狀態(tài)用于臨時(shí)表單 ForwardMessageProvider Composer.Frame Composer.Input / Composer.Submit / /Composer.Frame /ForwardMessageProvider // Provider B全局同步狀態(tài)用于頻道消息 ChannelProvider channelIdabc Composer.Frame Composer.Input / Composer.Submit / /Composer.Frame /ChannelProvider該規(guī)則還點(diǎn)明了 Provider 邊界與視覺嵌套的區(qū)別“真正重要的是 Provider 邊界而不是視覺嵌套”。只要位于 Provider 內(nèi)部組件就能訪問共享狀態(tài)哪怕它在視覺上處于Composer.Frame之外——例如對話框底部的ForwardButton依然可以調(diào)用actions.submit消息預(yù)覽區(qū)可以讀取state.input做實(shí)時(shí)預(yù)覽。這是把狀態(tài)提升到 Providerlift state之后的直接收益同一 Skill 目錄下的 state-lift-state 規(guī)則 對此有獨(dú)立論述。Supabase 組件庫中的真實(shí)落地Menu 復(fù)合組件該模式在 Supabase 倉庫自身的前端代碼中就有完整實(shí)例。Supabase 的 UI 組件庫packages/ui中的 Menu 組件 正是按“Provider 共享 Context 子組件掛載”的復(fù)合組件結(jié)構(gòu)實(shí)現(xiàn)的。MenuContext.tsx 定義了共享 Context 與消費(fèi)輔助 hookinterface ContextProps { type: text | pills | border } // Make sure the shape of the default value passed to // createContext matches the shape that the consumers expect! const MenuContext createContextContextProps({ type: text, }) export const MenuContextProvider (props: Provider) { const { type } props const value { type } return MenuContext.Provider value{value}{props.children}/MenuContext.Provider } // context helper to avoid using a consumer component export const useMenuContext () { const context useContext(MenuContext) if (context undefined) { throw new Error(MenuContext must be used within a MenuContextProvider.) } return context }這個(gè)實(shí)現(xiàn)里有兩處值得對照規(guī)則文檔注意的細(xì)節(jié)Context 默認(rèn)值形狀與消費(fèi)方期望一致源碼注釋明確強(qiáng)調(diào)“確保傳給createContext的默認(rèn)值形狀與消費(fèi)方期望的形狀匹配”MenuContext.tsx L13-L17。規(guī)則文檔采用的createContextComposerContextValue | null(null)是“null 強(qiáng)制 Provider”風(fēng)格而 Menu 采用“合理默認(rèn)值”風(fēng)格二者都是合法選擇但默認(rèn)值形狀錯(cuò)誤例如默認(rèn)給{}會(huì)導(dǎo)致消費(fèi)方解構(gòu)出undefined的類型與運(yùn)行時(shí)陷阱——這一點(diǎn)在 Supabase 的 IconContext.tsx 中被再次強(qiáng)調(diào)其默認(rèn)值{ contextSize: small, className: }完整覆蓋了ContextValue的所有字段輔助 hook 做邊界守衛(wèi)useMenuContext在 Context 缺失時(shí)拋出明確錯(cuò)誤MenuContext must be used within a MenuContextProvider.把“忘記包裹 Provider”這類配置錯(cuò)誤提前暴露而不是在渲染時(shí)靜默退化。再看組合方 Menu.tsx根組件負(fù)責(zé)渲染語義化的nav rolemenu結(jié)構(gòu)并包裹 ProviderMenu.tsx L17-L32而Item與Group子組件各自通過useMenuContext()讀取type用它驅(qū)動(dòng)class-variance-authority的變體樣式Menu.tsx L101-L107export function Item({ children, icon, active, onClick, style, className }: ItemProps) { const { type } useMenuContext() return ( li rolemenuitem className{cn(outline-hidden, menuItemVariants({ type, active }), className)} ...最后通過把子組件掛載到根組件上來完成復(fù)合組件導(dǎo)出Menu.tsx L155-L156Menu.Item Item Menu.Group Group export default Menu這與規(guī)則文檔中const Composer { Provider, Frame, Input, ... }的對象字面量寫法等價(jià)只是采用了“給函數(shù)組件動(dòng)態(tài)附加屬性”的更傳統(tǒng)寫法最終對外 API 形態(tài)一致Menu typepillsMenu.ItemMenu.Group。從源碼結(jié)構(gòu)看Menu 是一個(gè)輕量版示范它的 Context 只承載type一個(gè)樣式維度而非完整的 state/actions 契約因此子組件無需行為協(xié)作但當(dāng)需要跨子組件共享行為如submit讀取inputRef時(shí)就應(yīng)當(dāng)升級到三段式接口。這也印證了SKILL.md 中對規(guī)則的分層定位復(fù)合組件屬于最高優(yōu)先級的 Component Architecture 類HIGH 影響而三段式 Context 接口、狀態(tài)提升屬于 State Management 類MEDIUM兩者疊加才能支撐完整場景。React 19 適配use() 與 ref-as-prop規(guī)則文檔中的示例使用了use(ComposerContext)而非useContext這不是風(fēng)格偏好而是 React 19 API 的適配要求。同目錄的 react19-no-forwardref 規(guī)則 說明了兩點(diǎn)僅限 React 19React 18 及更早版本不適用// React 19use() 取代 useContext()且 use() 可以條件調(diào)用 const value use(MyContext) // React 19ref 是普通 prop不再需要 forwardRef 包裝 function ComposerInput({ ref, ...props }: Props { ref?: React.RefTextInput }) { return TextInput ref{ref} {...props} / }復(fù)合組件中meta.inputRef這類“把 ref 放入 Context”的用法在 React 19 下更加順暢子組件可以直接把 ref 當(dāng)普通 prop 透傳給底層輸入框不需要任何forwardRef包裝層。如果你的代碼庫仍在 React 18同樣的模式可用useContext與forwardRef等價(jià)實(shí)現(xiàn)架構(gòu)層面沒有任何差異。模式取舍與落地檢查清單回到規(guī)則文檔的 frontmatter該模式被標(biāo)注為impact: HIGH收益描述為 “enables flexible composition without prop drilling”實(shí)現(xiàn)靈活組合無需 prop 下鉆。落地時(shí)可以用如下清單自檢組件內(nèi)是否出現(xiàn)showX X /分支或renderXprops有則說明布局決策泄漏進(jìn)了組件內(nèi)部應(yīng)拆為復(fù)合子組件把選擇權(quán)交還消費(fèi)者的 JSX 樹是否存在跨層級傳遞 ref/回調(diào)的 prop 鏈把這類“技術(shù)元數(shù)據(jù)”收斂進(jìn) Provider 注入的meta行為收斂進(jìn)actions數(shù)據(jù)快照收斂進(jìn)stateContext 是否有 Provider 邊界守衛(wèi)參照 MenuContext.tsx 的拋錯(cuò)式 hook 或null初始值 斷言避免靜默失敗子組件是否通過命名空間導(dǎo)出Composer.Frame/Menu.Item這類點(diǎn)號 API 是復(fù)合組件對外的契約也是可讀文檔本身是否需要同一 UI 對接多種狀態(tài)源若是嚴(yán)格按接口規(guī)則定義ComposerContextValue讓 Provider 而非 UI 組件承載狀態(tài)實(shí)現(xiàn)的差異——“換 ProviderUI 不動(dòng)”。需要說明的前提本文所引用的規(guī)則文件位于.claude/skills/vercel-composition-patterns/目錄是倉庫為 AI 編碼代理與開發(fā)者共同維護(hù)的 React 組合模式規(guī)范其 frontmatter 標(biāo)注 author 為 vercel、MIT license、version 1.0.0Menu、Icon等組件則是 Supabase 前端Studio 與 UI 組件庫正在使用的真實(shí)實(shí)現(xiàn)二者相互印證了這套模式在該代碼庫中的實(shí)際地位?!久赓M(fèi)下載鏈接】supabaseThe Postgres development platform. Supabase gives you a dedicated Postgres database to build your web, mobile, and AI applications.項(xiàng)目地址: https://gitcode.com/GitHub_Trending/supa/supabase創(chuàng)作聲明:本文部分內(nèi)容由AI輔助生成(AIGC),僅供參考