可擴(kuò)展的前端 -- 常見模式
(給前端大學(xué)加星標(biāo),提升前端技能.)
譯者:唐江洪
譯文:https://github.com/mcuking/blog/issues/60
作者:Talysson de Oliveira
https://blog.codeminer42.com/scalable-frontend-2-common-patterns-d2f28aef0714
@唐江洪,網(wǎng)易云音樂前端工程師,三年前端經(jīng)驗的杭漂,目前專注在前端工程方面研究不能自拔
正文從這開始~~
讓我們繼續(xù)討論前端可擴(kuò)展性!在可擴(kuò)展的前端 -- 架構(gòu)基礎(chǔ)中,我們僅在概念上討論了前端應(yīng)用程序中的架構(gòu)基礎(chǔ)。現(xiàn)在,我們將動手操作實(shí)際代碼。
常見模式
如可擴(kuò)展的前端 -- 架構(gòu)基礎(chǔ)所述,我們?nèi)绾螌?shí)現(xiàn)架構(gòu)?與我們過去的做法有什么不同?我們?nèi)绾螌⑺羞@些與依賴注入結(jié)合起來?
不管你使用哪個庫來抽象 view 或管理 state,前端應(yīng)用程序中都有重復(fù)出現(xiàn)的模式。在這里,我們將討論其中的一部分,因此請系緊安全帶!
譯者解讀:結(jié)合上篇文章分成的四層:application 層、domain 層、 infrastructure 層、view 層。下面講解的內(nèi)容中:用例屬于 application 層的核心概念,實(shí)體/值對象/聚合屬于 domain 層核心概念,Repositories 屬于 infrastructure 核心概念。
用例(Use Case)
我們選擇用例作為第一種模式,因為在架構(gòu)方面,它們是我們與軟件進(jìn)行交互的方式。用例說明了我們的應(yīng)用程序的頂層功能;它是我們功能的秘訣;application 層的主要模塊。他們定義了應(yīng)用程序本身。
用例通常也稱為 interactors,它們負(fù)責(zé)在其他層之間執(zhí)行交互。它們:
由 view 層調(diào)用,
應(yīng)用它們的算法,
使 domain 和 infrastructure 層交互而無需關(guān)心它們在內(nèi)部的工作方式,并且,
將結(jié)果狀態(tài)返回到 view 層。結(jié)果狀態(tài)用來表明用例是成功還是失敗,原因是內(nèi)部錯誤、失敗的驗證、前提條件等。
知道結(jié)果狀態(tài)很有用,因為它有助于確定要為結(jié)果發(fā)出什么 action,從而允許 UI 中包含更豐富的消息,以便用戶知道故障下出了什么問題。但是有一個重要的細(xì)節(jié):結(jié)果狀態(tài)的邏輯應(yīng)該在用例之內(nèi),而不是 view 層--因為知道這一點(diǎn)不是 view 層的責(zé)任。這意味著 view 層不應(yīng)從用例中接收通用錯誤對象,而應(yīng)使用 if 語句來找出失敗的原因--例如檢查 error.message 屬性或 instanceof 以查詢錯誤的類。
這給我們帶來了一個棘手的事實(shí):從用例返回 promise 可能不是最佳的設(shè)計決策,因為 promise 只有兩個可能的結(jié)果:成功或失敗,這就要求我們在條件語句來發(fā)現(xiàn) catch() 語句中失敗的原因。是否意味著我們應(yīng)該跳過軟件中的 promise?不!完全可以從其他部分返回 promise,例如 actions、repositories、services。克服此限制的一種簡單方法是對用例的每種可能結(jié)果狀態(tài)進(jìn)行回調(diào)。
用例的另一個重要特征是,即使在只有單個入口點(diǎn)的前端,它們也應(yīng)該來遵循分層之間的邊界,不用知道哪個入口點(diǎn)在調(diào)用它們。這意味著我們不應(yīng)該修改用例內(nèi)的瀏覽器全局變量,特定 DOM 的值或任何其他低級對象。例如:我們不應(yīng)該將 />元素的實(shí)例作為參數(shù),然后再讀取其值;view 層應(yīng)該是負(fù)責(zé)提取該值并將其傳遞給用例。
沒有什么比一個例子更清楚地表達(dá)一個概念了:
createUser.js
exportdefault({ validateUser, userRepository })=>async(userData,{ onSuccess, onError, onValidationError })=>{if(!validateUser(userData)){return onValidationError(newError('Invalid user'));}try{const user =await userRepository.add(userData);onSuccess(user);}catch(error){onError(error);}};
userAction.js
const createUserAction = userData =>(dispatch, getState, container)=>{container.createUser(userData,{// notice that we don't add conditionals to emit any of these actionsonSuccess: user => dispatch(createUserSuccessAction(user)),onError: error => dispatch(createUserErrorAction(error)),onValidationError: error => dispatch(createUserValidationErrorAction(error))});};
本示例使用 Redux 和 Redux-Thunk。容器將作為 thunk 的第三個參數(shù)注入。
請注意,在 userAction 中,我們不會對 createUser 用例的響應(yīng)進(jìn)行任何斷言;我們相信用例將為每個結(jié)果調(diào)用正確的回調(diào)。另外,即使 userData 對象中的值來自 HTML 輸入,用例對此也不了解。它僅接收提取的數(shù)據(jù)并將其轉(zhuǎn)發(fā)。
就是這樣!用例不能做的更多。你能看到現(xiàn)在測試它們有多容易嗎?我們可以簡單地注入所需功能的模擬依賴項,并測試我們的用例是否針對每種情況調(diào)用了正確的回調(diào)。
實(shí)體、值對象和聚合(Entities, value objects, and aggregates)
實(shí)體是我們 domain 層的核心:它們代表了我們軟件所處理的概念。假設(shè)我們正在構(gòu)建博客引擎應(yīng)用程序,在這種情況下,如果我們的引擎允許,我們可能會有一個 User 實(shí)體,Article 實(shí)體,甚至還有 Comment 實(shí)體。因此,實(shí)體只是保存數(shù)據(jù)和這些概念的行為的對象,而不用考慮技術(shù)實(shí)現(xiàn)。實(shí)體不應(yīng)被視為 Active Record 設(shè)計模式的模型或?qū)崿F(xiàn);他們對數(shù)據(jù)庫、AJAX 或持久數(shù)據(jù)一無所知。它們只是代表概念和圍繞該概念的業(yè)務(wù)規(guī)則。
因此,如果我們博客引擎的用戶在評論有關(guān)暴力的文章時有年齡限制,我們會有一個 user.isMajor()方法,該方法將在 article.canBeCommentedBy(user)內(nèi)部調(diào)用,以某種方式將年齡分類規(guī)則保留在 user 對象內(nèi),并將年齡限制規(guī)則保留在 article 對象內(nèi)。AddCommentToArticle 用例是將用戶實(shí)例傳遞給 article.canBeCommentedBy,而用例則是在它們之間執(zhí)行 interaction 的地方。
有一種方法可以識別代碼庫中某物是否為實(shí)體:如果一個對象代表一個 domain 概念并且它具有標(biāo)識符屬性(例如,id 或文檔編號),則它是一個實(shí)體。此標(biāo)識符的存在很重要,因為它是區(qū)分實(shí)體和值對象的原因。
盡管實(shí)體具有標(biāo)識符屬性,但值對象的身份由其所有屬性的值組合而成。混亂?考慮一個顏色對象。當(dāng)用對象表示顏色時,我們通常不給該對象一個 ID。我們給它提供紅色,綠色和藍(lán)色的值,這三個屬性結(jié)合在一起可以識別該對象。現(xiàn)在,如果我們更改紅色屬性的值,我們可以說它代表了另一種顏色,但是用 id 標(biāo)識的用戶卻不會發(fā)生同樣的情況。如果我們更改用戶的 name 屬性的值但保留相同的 ID,則表示它仍然是同一用戶,對嗎?
在本節(jié)的開頭,我們說過在實(shí)體中使用方法以及給定實(shí)體的業(yè)務(wù)規(guī)則和行為是很普遍的。但是在前端,將業(yè)務(wù)規(guī)則作為實(shí)體對象的方法并不總是很好。考慮一下函數(shù)式編程:我們沒有實(shí)例方法,或者 this, 可變性--這是一種使用普通 JavaScript 對象而不是自定義類的實(shí)例的,很好兼容單向數(shù)據(jù)流的范例。那么在使用函數(shù)式編程時,實(shí)體中具有方法是否有意義?當(dāng)然沒有。那么我們?nèi)绾蝿?chuàng)建具有此類限制的實(shí)體?我們采用函數(shù)式方式!
我們將不使用帶有 user.isMajor() 實(shí)例方法的 User 類,而是使用一個名為 User 的模塊,該模塊導(dǎo)出 isMajor(user) 函數(shù),該函數(shù)會返回具有用戶屬性的對象,就像 User 類的 this。該參數(shù)不必是特定類的實(shí)例,只要它具有與用戶相同的屬性即可。這很重要:屬性(用戶實(shí)體的預(yù)期參數(shù))應(yīng)以某種方式形式化。你可以在具有工廠功能的純 JavaScript 中進(jìn)行操作,也可以使用 Flow 或 TypeScript 更明確地進(jìn)行操作。
為了更容易理解,我們看下前后對比。
使用類實(shí)現(xiàn)的實(shí)體
// User.jsexportdefaultclassUser{static LEGAL_AGE =21;constructor({ id, age }){this.id = id;this.age = age;}isMajor(){returnthis.age >=User.LEGAL_AGE;}}// usageimportUserfrom'./User.js';const user =newUser({ id:42, age:21});user.isMajor();// true// if spread, loses the reference for the classconst user2 ={...user, age:20};user2.isMajor();// Error: user2.isMajor is not a function
使用函數(shù)實(shí)現(xiàn)的實(shí)體
// User.jsconst LEGAL_AGE =21;exportconst isMajor = user =>{return user.age >= LEGAL_AGE;};// this is a user factoryexportconst create = userAttributes =>({id: userAttributes.id,age: userAttributes.age});// usageimport*asUserfrom'./User.js';const user =User.create({ id:42, age:21});User.isMajor(user);// true// no problem if it's spreadconst user2 ={...user, age:20};User.isMajor(user2);// false
當(dāng)與 Redux 之類的狀態(tài)管理器打交道時,越容易支持 immutable(不變性)就越好,因此無法展開對象來進(jìn)行淺拷貝并不是一件好事。使用函數(shù)式方式會強(qiáng)制解耦,并且我們可以展開對象。
所有這些規(guī)則都適用于值對象,但它們還有另一個重要性:它們有助于減少實(shí)體的膨脹。通常,實(shí)體中有很多彼此不直接相關(guān)的屬性,這可能表明我們可以提取其中一些屬性給值對象。舉例來說,假設(shè)我們有一個椅子實(shí)體,其屬性有 id,cushionType,cushionColor,legsCount,legsColor 和 legsMaterial。注意到 cushionType 和 cushionColor 與 legsCount,legsColor 和 legsMaterial 不相關(guān),因此在提取了一些值對象之后,我們的椅子將減少為三個屬性:id,cushion 和 legs。現(xiàn)在,我們可以繼續(xù)為 cushion 和 legs 添加屬性,而不會使椅子變得更繁冗。


但是,僅從實(shí)體中提取值對象并不總是足夠的。你會發(fā)現(xiàn),通常會有與次要實(shí)體相關(guān)聯(lián)的實(shí)體,其中主要概念由第一個實(shí)體表示,依賴于這些次要實(shí)體作為一個整體,而僅存在這些次要實(shí)體是沒有意義的。現(xiàn)在你的腦海中肯定會有些混亂,所以讓我們清除一下。
想一下購物車。購物車可以由購物車實(shí)體表示,該實(shí)體將由訂單項組成,而訂單項又是實(shí)體,因為它們具有自己的 ID。訂單項只能通過主要實(shí)體購物車對象進(jìn)行交互。想知道特定產(chǎn)品是否在購物車內(nèi)?調(diào)用 cart.hasProduct(product) 方法,而不是像 cart.lineItems.find(...) 那樣直接訪問 lineItems 屬性。對象之間的這種關(guān)系稱為聚合,給定聚合的主要實(shí)體(在本例中為 cart 對象)稱為聚合根。代表聚合及其所有組件概念的實(shí)體只能通過購物車進(jìn)行訪問,但聚合內(nèi)部的實(shí)體從外部引用對象是可以的。我們甚至可以說,在單個實(shí)體能夠代表整個概念的情況下,該實(shí)體也是由單個實(shí)體及其值對象(如果有)組成的聚合。因此,當(dāng)我們說“聚合”時,從現(xiàn)在開始,你必須將其解釋為適當(dāng)?shù)木酆虾蛦我粚?shí)體聚合。

外部無法訪問聚合的內(nèi)部實(shí)體,但是次要實(shí)體可以從聚合外部訪問事物,例如 products。
在我們的代碼庫中具有明確定義的實(shí)體,集合和值對象,并以領(lǐng)域?qū)<胰绾我盟鼈儊砻赡芊浅S袃r值(無雙關(guān)語)。因此,在將代碼丟到其他地方之前,請始終注意是否可以使用它們來抽象一些東西。另外,請務(wù)必了解實(shí)體和聚合,因為它對下一種模式很有用!
Repositories
你是否注意到我們還沒有談?wù)摮志没兀靠紤]這一點(diǎn)很重要,因為它會強(qiáng)制執(zhí)行我們從一開始就講過的話:持久化是實(shí)現(xiàn)細(xì)節(jié),是次要關(guān)注點(diǎn)。只要在軟件中將負(fù)責(zé)處理的部分合理地封裝并且不影響其余代碼,將內(nèi)容持久化到哪里就沒什么關(guān)系。在大多數(shù)基于分層的架構(gòu)中,這就是 repository 的職責(zé),該 repository 位于 infrastructure 層內(nèi)。
Repositories 是用于持久化和讀取實(shí)體的對象,因此它們應(yīng)實(shí)現(xiàn)使它們感覺像集合的方法。如果你有 article 對象并希望保留它,則可能有一個帶有 add(article) 方法的 ArticleRepository,該方法將文章作為參數(shù),將其保留在某個地方,然后返回帶有附加的僅保留屬性(如 id)的文章副本。
我說過我們會有一個 ArticleRepository,但是我們?nèi)绾纬志没渌麑ο竽兀课覀兪欠駪?yīng)該使用其他 repository 來持久存儲用戶?我們應(yīng)該有多少個 repository,它們應(yīng)該有多少顆粒度?冷靜下來,規(guī)則并不難掌握。你還記得聚合嗎?那是我們切入的地方。根據(jù)經(jīng)驗一般是為代碼庫的每個聚合提供一個 repository。我們也可以為次要實(shí)體創(chuàng)建 repository,但僅在需要時才可以。
好吧,好吧,這聽起來很像后端談話。那么,repository 在前端做什么?我們那里沒有數(shù)據(jù)庫!這就是要注意的問題:停止將 repository 與數(shù)據(jù)庫相關(guān)聯(lián)。repository 與整個持久性有關(guān),而不僅僅是數(shù)據(jù)庫。在前端,repository 處理數(shù)據(jù)源,例如 HTTP API,LocalStorage,IndexedDB 等。在上一個示例中,我們的 ArticleRepository.add 方法將 Article 實(shí)體作為輸入,將其轉(zhuǎn)換為 API 期望的 JSON 格式,對 API 進(jìn)行 AJAX 調(diào)用,然后將 JSON 響應(yīng)映射回 Article 實(shí)體的實(shí)例。
很高興注意到,例如,如果 API 仍在開發(fā)中,我們可以通過實(shí)現(xiàn)一個名為 LocalStorageArticleRepository 的 ArticleRepository 來模擬它,該 ArticleRepository 與 LocalStorage 而不是與 API 交互。當(dāng) API 準(zhǔn)備就緒時,我們?nèi)缓髣?chuàng)建另一個稱為 AjaxArticleRepository 的實(shí)現(xiàn),從而替換 LocalStorage 實(shí)現(xiàn)--只要它們都共享相同的接口,并注入通用名稱即可,而不需要展示底層技術(shù),例如 articleRepository。
我們在這里使用“接口”一詞來表示對象應(yīng)實(shí)現(xiàn)的一組方法和屬性,因此請不要將其與圖形用戶界面(也稱為 GUI)混淆。如果你使用的是純 JavaScript,則接口僅是概念性的;它們是虛構(gòu)的,因為該語言不支持接口的顯式聲明,但是如果你使用的是 TypeScript 或 Flow,則它們可以是顯性的。

Services
這是最后一種模式,不是偶然。正是在這里,因為它應(yīng)該被視為“最后的資源”。如果你無法將概念適用于上述任何一種模式,則只有在那時才考慮創(chuàng)建服務(wù)。在代碼庫中,任何可重用的代碼被拋出到所謂的“服務(wù)對象”中是很普遍的,它不過是一堆沒有封裝概念的可重用邏輯。始終要意識到這一點(diǎn),不要讓這種情況在你的代碼庫中發(fā)生,并且要避免創(chuàng)建服務(wù)而不是用例的沖動,因為它們不是一回事。
簡而言之:服務(wù)是一個對象,它實(shí)現(xiàn)了領(lǐng)域?qū)ο笾胁贿m合的過程。例如,支付網(wǎng)關(guān)。
讓我們想象一下,我們正在建立一個電子商務(wù),并且需要與支付網(wǎng)關(guān)的外部 API 交互以獲取購買的授權(quán)令牌。付款網(wǎng)關(guān)不是一個領(lǐng)域概念,因此非常適合 PaymentService。向其中添加不會透露技術(shù)細(xì)節(jié)的方法,例如 API 響應(yīng)的格式,然后你將擁有一個通用對象,可以很好地封裝你的軟件和支付網(wǎng)關(guān)之間的交互。
就是這樣,這里不是秘密。嘗試使你的領(lǐng)域概念適應(yīng)上述模式,如果它們不起作用,則僅考慮提供服務(wù)。它對代碼庫的所有層都很重要!
文件組織
許多開發(fā)人員誤解了架構(gòu)和文件組織之間的區(qū)別,認(rèn)為后者定義了應(yīng)用程序的架構(gòu)。甚至擁有良好的文件組織,應(yīng)用程序就可以很好地擴(kuò)展,這完全是一種誤導(dǎo)。即使是最完美的文件組織,你仍然可能在代碼庫中遇到性能和可維護(hù)性問題,因此這是本文的最后主題。讓我們揭開文件組織的神秘面紗,以及如何將其與架構(gòu)結(jié)合使用以實(shí)現(xiàn)可讀且可維護(hù)的項目結(jié)構(gòu)。
基本上,文件組織是你從視覺上分離應(yīng)用程序各部分的方式,而架構(gòu)是從概念上分離應(yīng)用程序的方式。你可以很好地保持相同的架構(gòu),并且在文件組織方案時仍然可以有多個選擇。但是,最好是組織文件以反映架構(gòu)的各個層次,并幫助代碼庫的讀者,以便他們僅查看文件樹即可了解會發(fā)生什么。
沒有完美的文件組織,因此請根據(jù)你的喜好和需求進(jìn)行明智的選擇。但是,有兩種方法對突出本文討論的層特別有用。讓我們看看它們中的每一個。
第一個是最簡單的,它包括將 src 文件夾的根分為幾層,然后是架構(gòu)的概念。例如:
.|-- src||-- app|||-- user||||--CreateUser.js|||-- article||||--GetArticle.js||-- domain|||-- user||||-- index.js||-- infra|||-- common||||-- httpService.js|||-- user||||--UserRepository.js|||-- article||||--ArticleRepository.js||-- store|||-- index.js|||-- user||||-- index.js||-- view|||-- ui||||--Button.js||||--Input.js|||-- user||||--CreateUserPage.js||||--UserForm.js|||-- article||||--ArticlePage.js||||--Article.js
當(dāng)這種文件組織與 React 和 Redux 配合使用時,通常會看到諸如 components, containers, reducers, actions 等文件夾。我們傾向于更進(jìn)一步,將相似的職責(zé)分組在同一文件夾中。例如,我們的 components 和 containers 都將在 view 文件夾中,而 actions 和 reducers 將在 store 文件夾中,因為它們遵循將出于相同原因而改變的事物收集在一起的規(guī)則。以下是該文件組織的立場:
你不應(yīng)該通過文件夾來反映技術(shù)角色,例如“controllers”,“components”,“helpers”等;
實(shí)體位于 domain/ 文件夾中,其中“ concept”是實(shí)體所在的集合的名稱,并通過 domain / /index.js 文件導(dǎo)出;
只要不會引起耦合,就可以在同一層的概念之間導(dǎo)入文件。
我們的第二個選擇包括按功能分隔 src 文件夾的根。假設(shè)我們正在處理文章和用戶;在這種情況下,我們將有兩個功能文件夾來組織它們,然后是第三個文件夾,用于處理諸如通用 Button 組件之類的常見事物,甚至是僅用于 UI 組件的功能文件夾:
.|-- src||-- common|||-- infra||||-- httpService.js|||-- view||||--Button.js||||--Input.js||-- article|||-- app||||--GetArticle.js|||-- domain||||--Article.js|||-- infra||||--ArticleRepository.js|||-- store||||-- index.js|||-- view||||--ArticlePage.js||||--ArticleForm.js||-- user|||-- app||||--CreateUser.js|||-- domain||||--User.js|||-- infra||||--UserRepository.js|||-- store||||-- index.js|||-- view||||--UserPage.js||||--UserForm.js
該組織的立場與第一個組織的立場基本相同。對于這兩種情況,你都應(yīng)將 dependencies container 保留在 src 文件夾的根目錄中。
同樣,這些選項可能無法滿足你的需求,可能不是你理想的文件組織方式。因此,請花一些時間來移動文件和文件夾,直到獲得可以更輕松地找到所需工件為止。這是找出最適合你們團(tuán)隊的最佳方法。請注意,僅將代碼分成文件夾不會使你的應(yīng)用程序更易于維護(hù)!你必須保持相同的心態(tài),同時在代碼中分離職責(zé)。
接下來
哇!很多內(nèi)容,對不對?沒關(guān)系,我們在這里談到了很多模式,所以不要一口氣讀懂所有這些內(nèi)容。隨時重新閱讀并檢查該系列的第一篇文章和我們的示例,直到你對體系結(jié)構(gòu)及其實(shí)現(xiàn)的輪廓感到更滿意為止。
在下一篇文章中,我們還將討論實(shí)際示例,但將完全集中在狀態(tài)管理上。
如果你想看到此架構(gòu)的實(shí)際實(shí)現(xiàn),請查看此示例博客引擎應(yīng)用程序的代碼,點(diǎn)擊查看。請記住,沒有什么是一成不變的,在以后的文章中,我們還會討論一些模式。
推薦閱讀鏈接
Mark Seemann — Functional architecture — The pits of success
Scott Wlaschin — Functional Design Patterns
分享前端好文,點(diǎn)亮?在看?
