構(gòu)建規(guī)拿タ福化、高性能绍坝、易于變更的前端架構(gòu)
在大規(guī)模項(xiàng)目中,構(gòu)建高性能且易于更改的前端架構(gòu)并不容易苔悦。
在本指南中轩褐,我們將探討復(fù)雜性如何在由多個(gè)開發(fā)人員和團(tuán)隊(duì)共同完成的前端項(xiàng)目中迅速而悄然地累積。
我們會(huì)研究有效的方式來(lái)避免被復(fù)雜性所淹沒(méi)间坐,不論是在問(wèn)題出現(xiàn)之前灾挨,還是在復(fù)雜性已經(jīng)導(dǎo)致我們自問(wèn)“事情怎么變得這么復(fù)雜了”之后邑退。
前端架構(gòu)是一個(gè)廣泛的話題竹宋,包含多個(gè)方面劳澄。本指南將特別聚焦于組件代碼結(jié)構(gòu),以幫助創(chuàng)建具有韌性蜈七、能夠輕松適應(yīng)變更的前端秒拔。
本指南中的示例基于 React,但所述的底層原則適用于任何基于組件的框架飒硅。
我們將從最開始講起砂缩,探索在任何代碼編寫之前,代碼結(jié)構(gòu)如何受到影響三娩。
常見的思維模型的影響
我們所擁有的思維模型庵芭,或我們?nèi)绾慰创挛铮艽蟪潭壬蠒?huì)影響我們的決策雀监。
在大型代碼庫(kù)中双吆,不同人不斷做出許多決策的總和最終決定了代碼的整體結(jié)構(gòu)。
團(tuán)隊(duì)合作開發(fā)時(shí)会前,明確我們持有的思維模型并期望他人也擁有相同的模型非常重要好乐,因?yàn)槊總€(gè)人通常都有自己隱含的模型。
這就是為什么團(tuán)隊(duì)通常需要共享樣式指南和工具(如 Prettier)瓦宜,以確保大家對(duì)如何保持一致蔚万、事物是什么以及代碼應(yīng)放置在哪里有共同的認(rèn)識(shí)。
這樣做可以極大地簡(jiǎn)化開發(fā)過(guò)程临庇,避免代碼庫(kù)隨著時(shí)間的推移因各自為政而變得難以維護(hù)反璃。
如果你曾參與一個(gè)由許多急于交付的開發(fā)人員進(jìn)行的快速開發(fā)項(xiàng)目,你可能會(huì)發(fā)現(xiàn)假夺,在沒(méi)有適當(dāng)?shù)臏?zhǔn)則下版扩,事情會(huì)很快失控。隨著代碼的增加侄泽,前端的運(yùn)行速度逐漸減慢礁芦,性能逐步惡化。
接下來(lái)的幾個(gè)部分中悼尾,我們將回答以下問(wèn)題:
- 在使用類似 React 的組件模型框架開發(fā)前端應(yīng)用時(shí)柿扣,最常見的思維模型是什么?
- 這些模型如何影響我們組織組件的方式闺魏?
- 這些模型中隱含的權(quán)衡是什么未状?如何將它們顯性化,從而理解復(fù)雜性快速增加的原因析桥?
- 如何在不破壞現(xiàn)有行為的情況下司草,保持前端項(xiàng)目的可維護(hù)性和可變更性艰垂?
組件化思維
React 是目前最受歡迎的基于組件的前端框架÷窈纾“用 React 的方式思考”通常是初學(xué)者接觸的第一篇文章猜憎,它闡述了構(gòu)建前端應(yīng)用時(shí)的關(guān)鍵思維模型。這篇文章之所以好搔课,是因?yàn)槠渲械慕ㄗh適用于任何基于組件的框架胰柑。
其主要原則讓我們?cè)跇?gòu)建組件時(shí)能夠提出以下問(wèn)題:
這個(gè)組件的唯一職責(zé)是什么? 良好的組件 API 設(shè)計(jì)遵循單一職責(zé)原則爬泥,這是組件組合模式的關(guān)鍵柬讨。簡(jiǎn)單和容易是兩個(gè)不同的概念,隨著需求不斷變化和增加袍啡,保持簡(jiǎn)單往往是相當(dāng)困難的踩官,我們將在本指南的后面詳細(xì)討論這一點(diǎn)。
其狀態(tài)的絕對(duì)最小但完整的表示是什么境输? 原則上蔗牡,從最小但完整的狀態(tài)源出發(fā),再?gòu)拇嘶A(chǔ)上派生其他狀態(tài)會(huì)更靈活且簡(jiǎn)單畴嘶,可以避免常見的數(shù)據(jù)同步問(wèn)題蛋逾,例如更新了一個(gè)狀態(tài)卻忘記更新另一個(gè)狀態(tài)。
狀態(tài)應(yīng)該存在哪里窗悯? 狀態(tài)管理是一個(gè)超出本指南范圍的廣泛話題区匣。但一般而言,如果一個(gè)狀態(tài)可以局部化到組件內(nèi)部蒋院,那么就應(yīng)該這樣做亏钩。組件對(duì)全局狀態(tài)的依賴越多,其可復(fù)用性就越低欺旧。提出這個(gè)問(wèn)題有助于我們確定哪些組件應(yīng)依賴哪些狀態(tài)姑丑。
此外,文章中還提供了更多的智慧:
“一個(gè)組件理想情況下應(yīng)只做一件事辞友。如果它變得過(guò)于復(fù)雜栅哀,就應(yīng)該將其分解為更小的子組件〕屏”
這些簡(jiǎn)單留拾、經(jīng)過(guò)實(shí)戰(zhàn)檢驗(yàn)的原則可以有效控制復(fù)雜性,形成了創(chuàng)建組件時(shí)最常見的思維模型鲫尊。
然而痴柔,簡(jiǎn)單并不代表容易。尤其在大型項(xiàng)目和多團(tuán)隊(duì)的協(xié)作環(huán)境中疫向,實(shí)踐中常常不容易做到咳蔚。
成功的項(xiàng)目往往是通過(guò)長(zhǎng)期堅(jiān)持基礎(chǔ)原則豪嚎、避免過(guò)多代價(jià)高昂的錯(cuò)誤而實(shí)現(xiàn)的。
這引出了兩個(gè)問(wèn)題谈火,我們將在后面探討:
- 什么情況會(huì)阻礙這些簡(jiǎn)單原則的應(yīng)用侈询?
- 我們?nèi)绾伪M量減輕這些情況的影響?
在實(shí)踐中堆巧,保持簡(jiǎn)單并不總是那么直接妄荔,我們將在下文中探討原因泼菌。
自上而下與自下而上
在現(xiàn)代框架(如 React)中谍肤,組件是核心抽象單元。構(gòu)建組件主要有兩種方式哗伯』拇В“用 React 的方式思考”中提到:
“你可以自上而下,也可以自下而上構(gòu)建焊刹。也就是說(shuō)系任,你可以從層次結(jié)構(gòu)較高的組件開始構(gòu)建。在簡(jiǎn)單示例中虐块,自上而下更容易俩滥,而在大型項(xiàng)目中,自下而上更容易贺奠,并可以在構(gòu)建過(guò)程中編寫測(cè)試霜旧。”
這條建議很實(shí)用儡率,看似簡(jiǎn)單挂据,比如我們?nèi)菀渍J(rèn)同“單一職責(zé)原則很好”。但在實(shí)際操作中儿普,自上而下和自下而上的思維模式差異很大崎逃。當(dāng)一個(gè)團(tuán)隊(duì)廣泛分享其中一種構(gòu)建方式作為默認(rèn)的思維模型時(shí),最終的代碼結(jié)構(gòu)會(huì)大不相同眉孩。
構(gòu)建自上而下的結(jié)構(gòu)
前文的引用隱含了在簡(jiǎn)單的示例中通過(guò)自上而下的方式來(lái)快速推進(jìn)進(jìn)度的權(quán)衡个绍,相較之下,更適合大項(xiàng)目的自下而上的方式雖然進(jìn)度較慢浪汪,但更具可擴(kuò)展性巴柿。
通常,自上而下的方法更直觀和直接吟宦。在我看來(lái)篮洁,開發(fā)人員在進(jìn)行功能開發(fā)時(shí)常采用的組件結(jié)構(gòu)思維模式正是自上而下的方式。
那么自上而下的方法是什么樣的殃姓?常見的建議是袁波,當(dāng)拿到設(shè)計(jì)圖時(shí)瓦阐,先在 UI 上劃分區(qū)域框,這些區(qū)域框最終將成為組件篷牌。
這為我們創(chuàng)建頂層組件提供了基礎(chǔ)睡蟋。通過(guò)這種方法,我們往往會(huì)從一個(gè)粗粒度的組件開始枷颊,確定看似合適的邊界戳杀,作為起點(diǎn)。
假設(shè)我們獲得了一個(gè)新的管理員儀表盤設(shè)計(jì)夭苗。我們查看設(shè)計(jì)圖信卡,判斷需要構(gòu)建哪些組件。
在設(shè)計(jì)中有一個(gè)新的側(cè)邊導(dǎo)航欄题造。我們?cè)趥?cè)邊欄上劃了一個(gè)框傍菇,并創(chuàng)建了一條任務(wù)說(shuō)明,告訴開發(fā)人員要?jiǎng)?chuàng)建一個(gè) <SideNavigation />
組件界赔。
按照自上而下的方法丢习,我們可能會(huì)考慮這個(gè)組件需要接收什么樣的 props,以及它的渲染方式淮悼。假設(shè)我們從后端 API 獲取導(dǎo)航項(xiàng)列表咐低,然后傳遞給側(cè)邊欄組件。以下是可能的偽代碼:
// 從 API 調(diào)用獲取列表
// 并將其轉(zhuǎn)換成傳遞給導(dǎo)航組件的列表
const navItems = [
{ label: 'Home', to: '/home' },
{ label: 'Dashboards', to: '/dashboards' },
{ label: 'Settings', to: '/settings' },
]
...
<SideNavigation items={navItems} />
到目前為止袜腥,這種自上而下的方法看起來(lái)相當(dāng)直接见擦。我們的意圖是讓組件易于重用,使用方只需傳遞所需的導(dǎo)航項(xiàng)瞧挤,SideNavigation
就會(huì)處理剩下的部分锡宋。
自上而下方法的常見特點(diǎn):
- 我們從最初在設(shè)計(jì)中標(biāo)記的頂層邊界開始構(gòu)建組件。
- 它是一個(gè)單一的抽象特恬,處理所有與側(cè)邊導(dǎo)航欄相關(guān)的內(nèi)容执俩。
- 其 API 通常是“自上而下”的,即由上層傳入數(shù)據(jù)癌刽,組件在內(nèi)部處理役首。
- 通常情況下,我們的組件直接渲染從后端數(shù)據(jù)源獲取的數(shù)據(jù)显拜,因此與這種“自上而下”的數(shù)據(jù)傳遞模式非常契合衡奥。
對(duì)于小型項(xiàng)目,這種方法沒(méi)有什么問(wèn)題远荠。然而矮固,在許多開發(fā)人員希望快速交付的大型代碼庫(kù)中,自上而下的思維模式在規(guī)模上會(huì)迅速變得棘手譬淳。
自上而下方法的問(wèn)題
在實(shí)際操作中档址,自上而下的思維模式傾向于一開始就專注于某個(gè)抽象來(lái)解決眼前的問(wèn)題盹兢。這種方式直觀而簡(jiǎn)潔,也通常會(huì)產(chǎn)生優(yōu)化了最初使用便捷性的 API守伸。
常見的情境如下:項(xiàng)目正在快速開發(fā)中绎秒,你劃定了組件的范圍并合并了新組件。此時(shí)尼摹,出現(xiàn)了一個(gè)新需求见芹,要求更新側(cè)邊導(dǎo)航組件。
這時(shí)蠢涝,事情可能會(huì)迅速變得復(fù)雜玄呛。常見的情況會(huì)導(dǎo)致大而臃腫的組件的產(chǎn)生。
此時(shí)開發(fā)人員有兩個(gè)選擇:
A. 考慮當(dāng)前的抽象是否合理惠赫。如果不合理把鉴,先進(jìn)行拆分故黑,再進(jìn)行新功能開發(fā)儿咱。
B. 添加一個(gè)新的 prop,通過(guò)簡(jiǎn)單的條件判斷來(lái)實(shí)現(xiàn)新功能场晶,寫幾個(gè)測(cè)試覆蓋新 prop 的情況混埠。實(shí)現(xiàn)功能并完成測(cè)試,而且速度快诗轻。
正如 Sandy Mets 所說(shuō):
“現(xiàn)有代碼具有強(qiáng)大的影響力钳宪。它的存在表明它是正確且必要的。代碼代表了我們投入的精力扳炬,我們非常希望保留這些努力的價(jià)值吏颖。不幸的是,代碼越復(fù)雜難懂恨樟,我們?cè)接X(jué)得有壓力去保留它(沉沒(méi)成本謬誤)半醉。”
沉沒(méi)成本謬誤存在劝术,是因?yàn)槲覀兲焐鼉A向于避免損失缩多。如果在有時(shí)間壓力的情況下,選項(xiàng) B 的可能性更大养晋。
在規(guī)某倪海化的項(xiàng)目中,這些小決策的快速積累會(huì)迅速增加組件的復(fù)雜性绳泉。
自上而下方法的典型問(wèn)題示例
以下是一個(gè)簡(jiǎn)單的導(dǎo)航側(cè)邊欄示例逊抡。
設(shè)計(jì)變更出現(xiàn),我們需要為導(dǎo)航項(xiàng)添加圖標(biāo)零酪、不同字體大小冒嫡,以及某些鏈接是導(dǎo)航頁(yè)面跳轉(zhuǎn)而非 SPA 內(nèi)部跳轉(zhuǎn)麦射。
由于我們將導(dǎo)航項(xiàng)列表作為數(shù)組傳遞給側(cè)邊欄組件,每個(gè)新需求都需在對(duì)象中增加額外的屬性灯谣,以區(qū)分不同的導(dǎo)航項(xiàng)類型及其狀態(tài)潜秋。
此時(shí),navItems
對(duì)象的結(jié)構(gòu)可能如下:{ id, to, label, icon, size, type, separator, isSelected }
胎许,其中 type
表示是鏈接還是普通導(dǎo)航項(xiàng)峻呛。
在 <SideNavigation />
組件內(nèi),我們將根據(jù) type
渲染不同的導(dǎo)航項(xiàng)辜窑。簡(jiǎn)單的變更就已顯得有些臃腫钩述。
問(wèn)題
使用自上而下的方法,在需求變化時(shí)往往會(huì)增加 API穆碎,并根據(jù)傳入的數(shù)據(jù)進(jìn)行內(nèi)部邏輯分支牙勘。
“小問(wèn)題可以變大問(wèn)題”
幾周后,又來(lái)了一個(gè)新功能需求:用戶點(diǎn)擊導(dǎo)航項(xiàng)時(shí)所禀,可以切換到其下的子導(dǎo)航方面,還需要返回按鈕返回主導(dǎo)航列表。管理員還需通過(guò)拖拽來(lái)重新排序?qū)Ш巾?xiàng)色徘。
此時(shí)恭金,我們需在列表中引入嵌套導(dǎo)航的概念,并關(guān)聯(lián)父子導(dǎo)航項(xiàng)褂策,有些項(xiàng)可拖拽横腿,有些則不行。
很快斤寂,組件從一個(gè)簡(jiǎn)單的組件演變成了臃腫的代碼耿焊,要求復(fù)雜的配置,同時(shí)運(yùn)行速度緩慢且易出錯(cuò)遍搞。
臃腫組件的增長(zhǎng)
“一切都應(yīng)自上而下構(gòu)建罗侯,除第一次外∥惨郑” —— Alan Perlis
正如我們所見歇父,臃腫的組件往往承擔(dān)了過(guò)多的職責(zé)。它們接收過(guò)多的數(shù)據(jù)或配置選項(xiàng)再愈,通過(guò) props 傳遞榜苫,管理過(guò)多的狀態(tài),渲染過(guò)多的 UI翎冲。
這些組件通常始于簡(jiǎn)單的組件垂睬,隨著需求的自然增加,逐漸演變成臃腫的組件。
以下是臃腫組件導(dǎo)致前端隱性崩潰的其他原因:
過(guò)早抽象驹饺。開發(fā)人員傾向于避免重復(fù)钳枕,因此容易快速抽象出一個(gè)組件,卻忽視了它可能會(huì)過(guò)度承擔(dān)職責(zé)赏壹。
限制跨團(tuán)隊(duì)代碼重用鱼炒。在快速開發(fā)的環(huán)境中,團(tuán)隊(duì)往往會(huì)重復(fù)實(shí)現(xiàn)相似但略有差異的組件蝌借。
增加打包大小昔瞧。在大型應(yīng)用中,優(yōu)化加載和解析的優(yōu)先級(jí)至關(guān)重要菩佑。臃腫的組件限制了這種優(yōu)化的實(shí)現(xiàn)自晰,導(dǎo)致加載和渲染效率降低。
導(dǎo)致運(yùn)行時(shí)性能下降稍坯。在 React 等框架中酬荞,組件的狀態(tài)變化會(huì)引起虛擬 DOM 的更新。臃腫組件讓識(shí)別變化和最小化重渲染的難度加大瞧哟,從而影響性能混巧。
構(gòu)建自底向上
相比自上而下的方法,自底向上往往不那么直觀绢涡,開始時(shí)可能會(huì)更慢牲剃。它的結(jié)果是生成多個(gè)較小、API可復(fù)用的組件雄可,而不是大而全的組件。
在追求快速發(fā)布時(shí)缠犀,這種方法顯得不合常理数苫,因?yàn)閷?shí)際上并不是每個(gè)組件都需要具備可復(fù)用性。然而辨液,構(gòu)建API即使不需要復(fù)用的組件虐急,通常會(huì)帶來(lái)更可讀、可測(cè)試滔迈、易變更止吁、易刪除的組件結(jié)構(gòu)。
沒(méi)有一個(gè)“正確”的分解方式燎悍,關(guān)鍵是以單一職責(zé)原則為大致指導(dǎo)來(lái)管理敬惦。
自底向上的思維模型與自上而下有何不同?
回到我們的例子谈山。采用自底向上的方法俄删,我們?nèi)匀豢赡軙?huì)創(chuàng)建一個(gè)頂級(jí)的<SideNavigation />
組件,但區(qū)別在于我們從不同的角度開始工作。
我們首先識(shí)別出頂級(jí)的<SideNavigation />
組件畴椰,但工作的起點(diǎn)并不在這里臊诊,而是先梳理組成<SideNavigation />
整體功能的底層元素,構(gòu)建這些較小的部分斜脂,并將它們組合在一起抓艳。起初,這種方法可能略顯復(fù)雜帚戳。
這樣做的好處在于壶硅,總體復(fù)雜度分散在多個(gè)具備單一職責(zé)的小組件中,而非集中于一個(gè)單體組件销斟。
自底向上的方法示例
回到側(cè)邊導(dǎo)航的例子庐椒。以下是一個(gè)簡(jiǎn)單的實(shí)現(xiàn):
<SideNavigation>
<NavItem to="/home">Home</NavItem>
<NavItem to="/settings">Settings</NavItem>
</SideNavigation>
在簡(jiǎn)單場(chǎng)景中,這看似普通蚂踊。若需要支持嵌套組的API會(huì)是什么樣子呢约谈?
<SideNavigation>
<Section>
<NavItem to="/home">Home</NavItem>
<NavItem to="/projects">Projects</NavItem>
<Separator />
<NavItem to="/settings">Settings</NavItem>
<LinkItem to="/foo">Foo</NavItem>
</Section>
<NestedGroup>
<NestedSection title="My projects">
<NavItem to="/project-1">Project 1</NavItem>
<NavItem to="/project-2">Project 2</NavItem>
<NavItem to="/project-3">Project 3</NavItem>
<LinkItem to="/foo.com">See documentation</LinkItem>
</NestedSection>
</NestedGroup>
</SideNavigation>
自底向上方法的最終結(jié)果是直觀的。它需要更多前期工作犁钟,因?yàn)槲覀儗⒑?jiǎn)單API的復(fù)雜性封裝在各個(gè)組件后面棱诱,這正是其成為一個(gè)長(zhǎng)期可用和可適應(yīng)方法的原因。
與自上而下方法相比涝动,底層構(gòu)建的好處如下:
- 使用組件的不同團(tuán)隊(duì)只需導(dǎo)入和使用所需的組件迈勋。
- 代碼拆分和異步加載更簡(jiǎn)單,未優(yōu)先顯示給用戶的元素可延后加載醋粟。
- 渲染性能更好且易于管理靡菇,因?yàn)閮H有發(fā)生更新的子樹需要重新渲染。
- 每個(gè)組件可在其導(dǎo)航中承擔(dān)特定職責(zé)米愿,并可獨(dú)立優(yōu)化厦凤,從代碼結(jié)構(gòu)角度來(lái)說(shuō)更具擴(kuò)展性。
- 從一開始便從消費(fèi)者視角出發(fā)育苟,構(gòu)建理想API较鼓,逐步向這一目標(biāo)靠攏。
那么自底向上方法的缺點(diǎn)是什么违柏?
自底向上初期較慢博烂,但從長(zhǎng)遠(yuǎn)看更快,因?yàn)槠涓哌m應(yīng)性漱竖,易于避免過(guò)早抽象禽篱,并隨著時(shí)間推移,在合適時(shí)刻自然形成抽象闲孤。這是防止單體組件蔓延的最佳方法谆级。
對(duì)于整個(gè)代碼庫(kù)中使用的共享組件(如側(cè)邊導(dǎo)航)烤礁,自底向上可能在消費(fèi)端需要更多的工作來(lái)組裝各個(gè)部分。但如我們所見肥照,對(duì)于具有許多共享組件的大型項(xiàng)目脚仔,這是一個(gè)值得做的權(quán)衡。
自底向上的強(qiáng)大之處在于舆绎,我們的模型以“我可以組合哪些簡(jiǎn)單的原語(yǔ)來(lái)實(shí)現(xiàn)目標(biāo)”為前提鲤脏,而不是一開始就帶著特定抽象思維出發(fā)。
“敏捷軟件開發(fā)最重要的經(jīng)驗(yàn)之一就是迭代的價(jià)值吕朵;這在軟件開發(fā)的各個(gè)層面猎醇,包括架構(gòu)設(shè)計(jì)中都適用∨#”
自底向上的方法從長(zhǎng)遠(yuǎn)來(lái)看更適合迭代硫嘶。
接下來(lái)我們總結(jié)一些有助于構(gòu)建這種方式的有效原則:
避免單體組件的策略
平衡單一職責(zé)與DRY(不要重復(fù)自己)
自底向上通常意味著采用組合模式,這意味著在消費(fèi)點(diǎn)可能會(huì)出現(xiàn)一些重復(fù)梧税。
DRY是開發(fā)者早期學(xué)習(xí)到的原則沦疾,減少重復(fù)的代碼讓人有成就感。但在一切變得重復(fù)時(shí)再將其DRY化往往更好第队。
這種方法讓你“隨復(fù)雜性波動(dòng)而動(dòng)”哮塞,隨著項(xiàng)目發(fā)展和需求變化而逐漸抽象,使得在適當(dāng)時(shí)機(jī)為更便于消費(fèi)進(jìn)行抽象成為可能凳谦。
控制反轉(zhuǎn)
理解該原則的簡(jiǎn)單例子是回調(diào)與Promise的區(qū)別忆畅。
回調(diào)中你未必知道函數(shù)會(huì)傳到哪里、被調(diào)用多少次或用何種方式尸执。
Promise將控制權(quán)反轉(zhuǎn)給消費(fèi)者家凯,讓你可以組合邏輯,仿佛值已存在剔交。
// 不知道 onLoaded 會(huì)如何處理傳入的回調(diào)
onLoaded((stuff) => {
doSomething(stuff)
})
// 控制權(quán)歸我們肆饶,可以開始組合邏輯,就像值已存在一樣
onLoaded.then((stuff) => {
doSomething(stuff)
})
在React中岖常,我們可以通過(guò)組件API設(shè)計(jì)來(lái)實(shí)現(xiàn)這一點(diǎn)。
我們可以通過(guò)children或渲染樣式的props暴露“插槽”葫督,在消費(fèi)者一側(cè)實(shí)現(xiàn)控制反轉(zhuǎn)竭鞍。
有時(shí)在這種情況下可能會(huì)抗拒控制反轉(zhuǎn),認(rèn)為這樣消費(fèi)者會(huì)有更多工作量橄镜。但這既是放棄對(duì)未來(lái)的預(yù)測(cè)偎快,也是選擇賦予消費(fèi)者靈活性。
// “自上而下”方式的簡(jiǎn)單按鈕API
<Button isLoading={loading} />
// 控制反轉(zhuǎn)方式
// 提供一個(gè)插槽洽胶,讓消費(fèi)者靈活利用
<Button before={loading ? <LoadingSpinner /> : null} />
第二個(gè)例子既更靈活以應(yīng)對(duì)需求變更晒夹,也更具性能優(yōu)勢(shì)裆馒,因?yàn)?code><LoadingSpinner />無(wú)需成為Button包的依賴。
在這里可以看到自上而下與自底向上的細(xì)微差異丐怯。第一個(gè)例子中我們傳入數(shù)據(jù)讓組件處理喷好,第二個(gè)例子中我們多做一些工作,但最終是一個(gè)更靈活读跷、更高效的方式梗搅。
值得注意的是<Button />
本身可以在底層通過(guò)較小的原語(yǔ)組件來(lái)組合完成。例如我們可以將其進(jìn)一步拆分為Pressable
效览,它適用于按鈕和Link
組件无切,這些可結(jié)合起來(lái)創(chuàng)建類似LinkButton
的元素。這種細(xì)粒度分解通常是設(shè)計(jì)系統(tǒng)庫(kù)的領(lǐng)域丐枉,但對(duì)于面向產(chǎn)品的工程師來(lái)說(shuō)值得記住哆键。
開放以擴(kuò)展
即使在使用組合模式自底向上構(gòu)建時(shí),您仍然希望導(dǎo)出具有可消費(fèi)API的專用組件瘦锹,但這些組件是由更小的原語(yǔ)構(gòu)建而成籍嘹。為了靈活性,您還可以從您的包中暴露組成該專用組件的小構(gòu)建塊沼本。
理想情況下噩峦,您的組件應(yīng)執(zhí)行一項(xiàng)任務(wù)。因此抽兆,對(duì)于預(yù)制抽象识补,消費(fèi)者可以獲取所需的單一功能并將其封裝,以便擴(kuò)展自己的功能辫红∑就浚或者,他們可以直接使用構(gòu)成現(xiàn)有抽象的一些原語(yǔ)贴妻,根據(jù)需要構(gòu)建所需的內(nèi)容切油。
利用 Storybook 驅(qū)動(dòng)的開發(fā)
我們的組件中通常會(huì)管理大量離散的狀態(tài)。狀態(tài)機(jī)庫(kù)正因多種原因而變得越來(lái)越受歡迎。
我們可以在與 Storybook 一起構(gòu)建獨(dú)立 UI 組件時(shí)采用這些模型,確保為組件可能處于的每種狀態(tài)編寫故事终息。
這種前期的工作可以避免您在生產(chǎn)中意識(shí)到忘記實(shí)現(xiàn)良好的錯(cuò)誤狀態(tài)驮履。
這也有助于識(shí)別構(gòu)建所需的所有子組件,確保組件能夠順利搭建。
在獨(dú)立構(gòu)建 UI 組件時(shí)可以自問(wèn)的一些問(wèn)題,以便構(gòu)建出更具韌性的組件:
以下是一些常見情況,需要避免以防止構(gòu)建出不夠韌性的組件:
基于實(shí)際功能命名組件戚宦。 這與單一職責(zé)原則相關(guān)。如果名稱能夠表達(dá)清晰的含義锈嫩,長(zhǎng)名稱并不可怕受楼。
-
避免使用過(guò)于通用的名稱垦搬。 有時(shí)候,組件的名稱可能比實(shí)際功能稍微寬泛艳汽。當(dāng)某些事物的名稱過(guò)于通用時(shí)猴贰,這可能會(huì)向其他開發(fā)者暗示這是一個(gè)處理與X相關(guān)的所有內(nèi)容的抽象。
因此骚灸,當(dāng)新的需求出現(xiàn)時(shí)糟趾,常常會(huì)自然顯現(xiàn)出變更的明顯位置,即使這樣做可能并不合適甚牲。
-
避免包含實(shí)現(xiàn)細(xì)節(jié)的屬性名稱义郑。 尤其對(duì)于 UI 樣式的“葉子”組件。盡可能避免添加像
isSomething
這樣的屬性丈钙,其中something
與內(nèi)部狀態(tài)或特定領(lǐng)域的內(nèi)容相關(guān)非驮,且當(dāng)該屬性傳入時(shí),組件會(huì)執(zhí)行不同的操作雏赦。如果必須這樣做劫笙,屬性名稱最好反映出在消費(fèi)該組件的上下文中它的實(shí)際功能。
例如星岗,如果
isSomething
屬性最終控制諸如填充(padding)之類的內(nèi)容填大,那么該屬性名稱應(yīng)反映出這一點(diǎn),而不是讓組件意識(shí)到與其似乎無(wú)關(guān)的事情俏橘。 -
謹(jǐn)慎通過(guò)屬性配置允华。 這與控制反轉(zhuǎn)有關(guān)。
像
<SideNavigation navItems={items} />
這樣的組件在您確定只會(huì)有一種子類型(并且您絕對(duì)知道這不會(huì)改變A绕)時(shí)靴寂,可以正常工作,因?yàn)樗鼈円部梢园踩剡M(jìn)行類型定義召耘。但如我們所見百炬,這一模式很難在不同團(tuán)隊(duì)和開發(fā)者中擴(kuò)展,尤其是在快速發(fā)布的情況下污它。實(shí)踐中剖踊,它們往往對(duì)變更不夠韌性,并且快速增加復(fù)雜性衫贬。
您通常會(huì)希望擴(kuò)展組件以擁有不同或額外類型的子組件蜜宪,這意味著您需要將更多內(nèi)容添加到這些配置選項(xiàng)或?qū)傩灾校⒃黾臃种н壿嫛?/p>
與其讓消費(fèi)者安排和傳遞對(duì)象祥山,更靈活的方法是同時(shí)導(dǎo)出內(nèi)部子組件,讓消費(fèi)者組合和傳遞組件掉伏。
-
避免在渲染方法中定義組件缝呕。 有時(shí)在一個(gè)組件中包含“輔助”組件是常見的做法澳窑。這些組件會(huì)在每次渲染時(shí)被重新掛載,可能會(huì)導(dǎo)致一些奇怪的錯(cuò)誤供常。
此外摊聋,具有多個(gè)內(nèi)部
renderX
、renderY
方法往往是個(gè)信號(hào)栈暇,表明組件正變得單體化麻裁,這是一個(gè)適合拆解的好候選者。
拆分單體組件
如果可能源祈,盡早并頻繁地進(jìn)行重構(gòu)煎源。識(shí)別出可能變更的組件并積極拆解它們,是在估算中納入的良好策略香缺。
當(dāng)您發(fā)現(xiàn)前端變得過(guò)于復(fù)雜時(shí)手销,您通常有兩個(gè)選擇:
- 重寫代碼并逐步遷移到新組件。
- 逐步拆解現(xiàn)有代碼图张。
深入探討組件重構(gòu)策略超出了本指南的范圍锋拖,但您可以利用現(xiàn)有的一系列經(jīng)過(guò)驗(yàn)證的重構(gòu)模式。
在像 React 這樣的框架中祸轮,“組件”實(shí)際上只是偽裝的函數(shù)兽埃。因此,您可以將“函數(shù)”一詞替換為組件适袜,應(yīng)用于所有現(xiàn)有的可靠重構(gòu)技術(shù)柄错。
以下是一些相關(guān)的示例:
- 移除標(biāo)志參數(shù)
- 用多態(tài)替代條件
- 提升字段
- 重命名變量
- 內(nèi)聯(lián)函數(shù)
結(jié)語(yǔ)
我們?cè)谶@里討論了很多內(nèi)容。讓我們總結(jié)一下本指南的主要要點(diǎn)痪蝇。
我們所擁有的模型影響了我們?cè)谠O(shè)計(jì)和構(gòu)建前端組件時(shí)所做的許多微觀決策鄙陡。使這些決策顯而易見非常有用,因?yàn)樗鼈儠?huì)迅速積累躏啰。這些決策的積累最終決定了可以實(shí)現(xiàn)的功能——無(wú)論是增加還是減少添加新功能或采用新架構(gòu)的摩擦趁矾,從而使我們能夠進(jìn)一步擴(kuò)展。
在構(gòu)建組件時(shí)给僵,自上而下與自底向上的方法可能會(huì)導(dǎo)致在規(guī)模上截然不同的結(jié)果毫捣。自上而下的思維模型通常是構(gòu)建組件時(shí)最直觀的。常見的UI拆解模型是圍繞功能區(qū)域繪制框架帝际,這些框架隨后成為您的組件蔓同。這種功能拆解過(guò)程是自上而下的,通常直接導(dǎo)致創(chuàng)建帶有特定抽象的專用組件蹲诀。需求會(huì)變化斑粱。在經(jīng)過(guò)幾輪迭代后,這些組件很容易迅速變成單體組件脯爪。
自上而下的設(shè)計(jì)和構(gòu)建可能導(dǎo)致單體組件则北。一個(gè)充滿單體組件的代碼庫(kù)會(huì)導(dǎo)致最終的前端架構(gòu)變得緩慢且不夠韌性矿微。單體組件的缺點(diǎn)包括:
- 變更和維護(hù)成本高。
- 變更風(fēng)險(xiǎn)大尚揣。
- 難以在團(tuán)隊(duì)間利用現(xiàn)有工作涌矢。
- 性能較差。
- 增加采用未來(lái)技術(shù)和架構(gòu)的摩擦快骗,這些技術(shù)和架構(gòu)對(duì)于持續(xù)擴(kuò)展前端至關(guān)重要娜庇,例如有效的代碼拆分、團(tuán)隊(duì)間的代碼重用方篮、加載階段名秀、渲染性能等。
通過(guò)理解通常導(dǎo)致過(guò)早抽象或持續(xù)擴(kuò)展的基本模型和環(huán)境恭取,我們可以避免創(chuàng)建單體組件泰偿。
在設(shè)計(jì)組件時(shí),React 更有效地支持自底向上的模型蜈垮。這有效地避免了過(guò)早的抽象耗跛,使我們能夠“隨復(fù)雜性波動(dòng)而動(dòng)”,在合適的時(shí)機(jī)進(jìn)行抽象攒发。這種構(gòu)建方式為實(shí)現(xiàn)組件組合模式提供了更多可能性调塌。意識(shí)到單體組件的真正成本,我們可以將標(biāo)準(zhǔn)重構(gòu)實(shí)踐應(yīng)用于日常產(chǎn)品開發(fā)中惠猿,定期進(jìn)行拆解羔砾。
React 的組件模型是其核心。如今偶妖,幾乎所有前端框架都采用了這一模型姜凄,成為了現(xiàn)代前端應(yīng)用的結(jié)構(gòu)化標(biāo)準(zhǔn)。
聲明式組件模型的影響已經(jīng)擴(kuò)展到了原生移動(dòng)開發(fā)趾访,比如 iOS 的 Swift UI 和 Android 的 Jetpack Compose态秧。正如所有事后看起來(lái)顯而易見的東西一樣,組件組合是一種構(gòu)建前端的絕佳方式扼鞋。
獨(dú)立組件的組合是應(yīng)對(duì)項(xiàng)目擴(kuò)展時(shí)復(fù)雜性快速增長(zhǎng)的主要手段申鱼。它幫助我們將內(nèi)容拆解為易于理解的部分。
本文是《構(gòu)建面向未來(lái)的前端》一文的后續(xù)篇章云头。在上文中捐友,我們探討了導(dǎo)致組件不可組合的原因。
也就是說(shuō)溃槐,出現(xiàn)了單體組件匣砖。這些組件不易組合,隨著時(shí)間推移會(huì)變得緩慢且難以更改。通常會(huì)被重復(fù)使用并在需求更改時(shí)稍加修改脆粥。
本文將深入探討用于拆解組件和設(shè)計(jì)可組合 API 的主要原則砌溺。閱讀完本文后,我們將能夠在構(gòu)建可重用的組件時(shí)有效運(yùn)用這種強(qiáng)大的組合模型变隔。
掌握這些原則后,我們將嘗試設(shè)計(jì)和實(shí)現(xiàn)任何共享組件庫(kù)中的經(jīng)典組件——一個(gè)標(biāo)簽頁(yè)組件蟹倾。理解核心問(wèn)題及我們?cè)诖诉^(guò)程中需做出的權(quán)衡匣缘。
什么是基于組合的 API?
讓我們先看看 HTML鲜棠,這是最早的“聲明式 UI”技術(shù)之一肌厨。一個(gè)常見的示例是原生的 <select>
元素:
<select id="cars" name="cars">
<option value="audi">Audi</option>
<option value="mercedes">Mercedes</option>
</select>
在 React 元素中應(yīng)用這種組合風(fēng)格,稱為“復(fù)合組件”模式豁陆。其核心思想是讓多個(gè)組件協(xié)作柑爸,實(shí)現(xiàn)單一實(shí)體的功能。
在討論 API 時(shí)盒音,我們可以將 props
視為組件的公共 API表鳍,而組件本身則是一個(gè)包的 API。
好的 API 設(shè)計(jì)通常會(huì)隨著時(shí)間的推移在反饋中不斷迭代祥诽。這一挑戰(zhàn)的部分原因在于 API 會(huì)有不同類型的消費(fèi)者譬圣。一部分人只需簡(jiǎn)單用例,另一部分人則需要一定的靈活性雄坪。還有少數(shù)人可能會(huì)需要深入的自定義來(lái)滿足難以預(yù)料的需求厘熟。
對(duì)于許多常用的前端組件,基于組合的 API 是應(yīng)對(duì)這些不可預(yù)見用例和不斷變化需求的良好防御手段维哈。
設(shè)計(jì)可組合的組件
關(guān)鍵問(wèn)題在于绳姨,我們?nèi)绾螌⒔M件分解到合適的層級(jí)?
純粹的自底向上方法可能會(huì)創(chuàng)建太多小組件阔挠,而這些小組件難以使用飘庄。自頂向下的方法(更為常見)則不足以分解組件,導(dǎo)致大而單體的組件接受過(guò)多 props
谒亦,并試圖完成過(guò)多任務(wù)竭宰,難以管理。
當(dāng)我們遇到模棱兩可的問(wèn)題時(shí)份招,不妨從最終用戶出發(fā)切揭,思考我們要解決的問(wèn)題。
在組件 API 設(shè)計(jì)中锁摔,一個(gè)有用的原則是穩(wěn)定依賴原則廓旬。這里有兩個(gè)主要思想:
- 作為組件或包的消費(fèi)者,我們希望依賴那些有很大概率保持穩(wěn)定的事物,以便順利完成工作孕豹。
- 作為組件或包的開發(fā)者涩盾,我們希望封裝那些可能會(huì)發(fā)生變化的事物,以保護(hù)消費(fèi)者免受不穩(wěn)定因素的影響励背。
我們可以將這個(gè)原則應(yīng)用到 Tabs 組件上春霍。只要 Tabs 的概念不發(fā)生根本性變化,就可以相對(duì)安全地圍繞其主要元素來(lái)設(shè)計(jì)組件叶眉。
這種設(shè)計(jì)相較于更抽象的實(shí)體址儒,通常能帶來(lái)更好的體驗(yàn)。在視覺(jué)上衅疙,我們可以想象一個(gè) Tab 列表(點(diǎn)擊以切換內(nèi)容)和基于當(dāng)前選中標(biāo)簽顯示內(nèi)容的區(qū)域莲趣。
Tabs 組件的設(shè)計(jì)
基于上述結(jié)構(gòu),我們的 Tabs API 可能如下所示(與 Reakit 等開源組件庫(kù)中的 API 類似):
import { Tabs, TabsList, Tab, TabPanel } from '@mycoolpackage/tabs'
<Tabs>
<TabsList>
<Tab>first</Tab>
<Tab>second</Tab>
</TabsList>
<TabPanel>hey there</TabPanel>
<TabPanel>friend</TabPanel>
</Tabs>
它看起來(lái)很像 HTML 的 <select>
元素饱溢。各組件協(xié)作完成功能喧伞,分發(fā)狀態(tài)以確保組件間的協(xié)同工作。
需要解決的底層問(wèn)題
組件間的內(nèi)部協(xié)調(diào)
當(dāng)我們將事物拆解為獨(dú)立組件時(shí)绩郎,第一個(gè)問(wèn)題是如何使它們?cè)诒3纸怦畹耐瑫r(shí)協(xié)作潘鲫。為了讓組件既可以作為獨(dú)立子組件重用,又能為共同目標(biāo)而協(xié)同工作嗽上,這些組件需要在后臺(tái)協(xié)調(diào)次舌。
在 Tabs 組件中,TabPanels 的順序嵌入在頂級(jí) Tabs 渲染的元素順序中兽愤。
渲染任意子組件
另一個(gè)問(wèn)題是如何處理包裹組件的任意組件彼念。例如:
<Tabs>
<TabsList>
<CustomTabComponent />
<ToolTip message="cool">
<Tab>Second</Tab>
</ToolTip>
</TabsList>
</Tabs>
因?yàn)?Tabs 和內(nèi)容是根據(jù)子樹中的順序關(guān)聯(lián)的,我們需要跟蹤各個(gè)索引以處理下一個(gè)和上一個(gè)項(xiàng)目的選擇浅萧。同時(shí)逐沙,還需管理焦點(diǎn)和鍵盤導(dǎo)航,這在用戶可在組件間隨意渲染標(biāo)記時(shí)具有挑戰(zhàn)性洼畅。
React context 的使用
通過(guò)共享上下文吩案,子組件可以讀取狀態(tài)信息。這種方法使得管理更靈活帝簇,同時(shí)也避免了繁瑣的克隆方法徘郭。
實(shí)現(xiàn) Tabs 組件
在實(shí)際實(shí)現(xiàn)中,我們可以將每個(gè)組件的狀態(tài)分離到不同的上下文中:
const TabContext = createContext(null)
const TabListContext = createContext(null)
const TabPanelContext = createContext(null)
這些上下文提供數(shù)據(jù)和輔助屬性丧肴,使組件之間互相協(xié)調(diào)以構(gòu)建完整的 Tabs 體驗(yàn)残揉。
測(cè)試我們的組件
對(duì)于這種獨(dú)立組件協(xié)作的情況,通常采用黑盒測(cè)試芋浮。創(chuàng)建測(cè)試用例抱环,將各組件組合起來(lái),測(cè)試主要用例以及消費(fèi)者自定義組件的特殊情況。
可擴(kuò)展性
跨團(tuán)隊(duì)共享代碼:通過(guò)良好的組合 API镇草,使組件在面對(duì)不同需求時(shí)更加靈活眶痰。通過(guò)控制反轉(zhuǎn),組件更容易被擴(kuò)展和復(fù)用梯啤,避免了大量重復(fù)代碼的出現(xiàn)竖伯。
性能:拆分后的獨(dú)立組件更易于代碼分割,按需加載条辟,且 React 可以更精確地處理重渲染黔夭,從而提高運(yùn)行時(shí)性能。
設(shè)計(jì)可組合的 API 需要權(quán)衡羽嫡,并且往往需要付出更多努力來(lái)構(gòu)建真正可重用、可訪問(wèn)的組件肩袍。這也是 Web 組件理念的潛力所在杭棵,將常見組件標(biāo)準(zhǔn)化,避免重復(fù)實(shí)現(xiàn)氛赐。
一路組合構(gòu)建
讓我們回顧一下迄今為止采取的步驟魂爪。我們從一個(gè)頂級(jí)組件開始,從底層向上構(gòu)建艰管。構(gòu)建之前滓侍,我們需要先確定一個(gè)目標(biāo) API,以清晰定義我們要實(shí)現(xiàn)的理想效果牲芋。
我們的 Tabs 組件是由更小撩笆、更靈活的組件組合而成的。這樣的組合模式可以應(yīng)用到應(yīng)用程序的根層級(jí)缸浦。
各功能由不同組件之間的組合關(guān)系構(gòu)成夕冲。應(yīng)用程序則由不同功能之間的關(guān)系構(gòu)成。
這就是“一路組合構(gòu)建”裂逐。
盡管這種說(shuō)法可能有些哲學(xué)意味歹鱼,我們還是回到現(xiàn)實(shí),看看它如何關(guān)聯(lián)到經(jīng)典的軟件工程分層原則卜高。
我們可以通過(guò)分層視角來(lái)理解 React 應(yīng)用中的高級(jí)組合:
基礎(chǔ)層:由設(shè)計(jì)令牌弥姻、常量和變量組成的通用集合,供共享組件庫(kù)使用掺涛。
原始組件:組件庫(kù)中使用基礎(chǔ)層構(gòu)建的原始組件和工具庭敦,幫助構(gòu)建庫(kù)中的組件。例如鸽照,供按鈕和鏈接內(nèi)部使用的可按壓組件螺捐。
共享組件庫(kù):組合共享工具和原始組件,提供常用的 UI 元素,如按鈕定血、選項(xiàng)卡等赔癌。這些組件成為上層的“原始元素”。
產(chǎn)品特定的通用組件改編:例如澜沟,產(chǎn)品中常用的“有機(jī)體”組件灾票,它們可能會(huì)將多個(gè)組件庫(kù)組件包裝在一起,在組織內(nèi)多個(gè)功能中共享茫虽。
產(chǎn)品特定的專用組件:例如刊苍,在我們的產(chǎn)品中,Tabs 組件可能需要調(diào)用 API 來(lái)決定渲染哪些選項(xiàng)卡和內(nèi)容濒析。組件的好處在于我們可以將其封裝成一個(gè) <ProductTabs />
正什,該組件在內(nèi)部使用我們的 Tab 組件。
總結(jié)
我們?cè)诒局改现泻w了很多內(nèi)容号杏。最后婴氮,讓我們回顧一下分解組件和設(shè)計(jì)基于組合的 API 的指導(dǎo)原則:
- 穩(wěn)定依賴原則:創(chuàng)建 API 和組件時(shí)始終考慮最終用戶。依賴于不太可能改變的內(nèi)容盾致,同時(shí)隱藏復(fù)雜的部分主经。
- 單一職責(zé)原則:每個(gè)組件封裝一個(gè)單一關(guān)注點(diǎn)。這樣更易于測(cè)試庭惜、維護(hù)罩驻,更重要的是便于組合。
- 控制反轉(zhuǎn):不要試圖預(yù)見所有未來(lái)的用例护赊,而是賦予使用者自主整合的能力惠遏。
如往常一樣,沒(méi)有銀彈百揭,靈活性總伴隨權(quán)衡爽哎。
關(guān)鍵在于理解你優(yōu)化的目標(biāo)和原因,以便減輕相應(yīng)權(quán)衡器一,或者在資源和時(shí)間有限的情況下课锌,將其作為最優(yōu)選擇接受。
在過(guò)少和過(guò)多靈活性之間保持平衡祈秕。本指南中渺贤,我們優(yōu)化的是一個(gè)能夠在團(tuán)隊(duì)和功能之間靈活復(fù)用的組件。
這種組件的主要權(quán)衡是消費(fèi)者需要進(jìn)行的外部協(xié)調(diào)请毛,以便按照預(yù)期方式使用組件志鞍。
在這種情況下,清晰的指導(dǎo)方針方仿、詳細(xì)的文檔和可復(fù)制的示例代碼能夠幫助減輕這種權(quán)衡固棚,使開發(fā)者的工作更輕松统翩。