使用既定的 UI 模式模組化 React 應用程式
儘管既定的 UI 模式已證明在解決 UI 設計中的複雜問題上很有效,但在前端開發領域卻常常未被充分利用。本文探討了將既定的 UI 建構模式應用於 React 世界,並透過重構歷程的程式碼範例來展示其優點。重點在於分層架構如何協助組織 React 應用程式,以提高回應能力和因應未來的變更。
2023 年 2 月 16 日
儘管我放了 React 應用程式,但並不存在 React 應用程式這種東西。我的意思是,有使用 JavaScript 或 TypeScript 編寫的前端應用程式,碰巧使用 React 作為它們的檢視。然而,我認為稱它們為 React 應用程式並不公平,就像我們不會稱 Java EE 應用程式為 JSP 應用程式一樣。
通常,人們會將不同的東西擠進 React 元件或掛勾中,以使應用程式正常運作。如果應用程式很小或大多沒有太多商業邏輯,這種較不組織化的結構並非問題。然而,由於在許多情況下,更多的商業邏輯轉移到前端,這種元件中的一切都會顯示問題。更具體地說,理解這種類型程式碼的難度相對較高,而且程式碼修改的風險也增加了。
在本文中,我想討論一些模式和技術,你可以使用它們將你的「React 應用程式」重新塑造成常規的應用程式,並且只使用 React 作為其檢視(你甚至可以在不花費太多力氣的情況下將這些檢視換成另一個檢視函式庫)。
這裡的關鍵點是,你應該分析程式碼的每個部分在應用程式中扮演什麼角色(即使在表面上,它們可能打包在同一個檔案中)。將檢視從非檢視邏輯中分離出來,再根據它們的責任進一步分割非檢視邏輯,並將它們放在正確的地方。
這種分離的好處是,它允許你在基礎領域邏輯中進行變更,而不用過於擔心表面檢視,反之亦然。此外,它可以增加領域邏輯在其他地方的可重複使用性,因為它們未與任何其他部分耦合。
React 是用於建構檢視的謙遜函式庫
很容易忘記 React 在其核心是一個函式庫(不是一個架構),它可以幫助你建立使用者介面。
在此脈絡中,強調 React 是 JavaScript 函式庫,專注於網頁開發的特定方面,即 UI 元件,並在應用程式的設計及其整體結構方面提供充裕的自由度。
用於建立使用者介面的 JavaScript 函式庫
-- React 首頁
聽起來很簡單。但我看過很多案例,人們會在使用資料的地方撰寫資料擷取、重塑邏輯。例如,在 React 元件中擷取資料,就在渲染正上方的 useEffect
區塊中,或是在從伺服器端取得回應後執行資料對應/轉換。
useEffect(() => { fetch("https://address.service/api") .then((res) => res.json()) .then((data) => { const addresses = data.map((item) => ({ street: item.streetName, address: item.streetAddress, postcode: item.postCode, })); setAddresses(addresses); }); }, []); // the actual rendering...
或許是因為前端世界尚未有通用的標準,或只是不良的程式設計習慣。前端應用程式不應與一般軟體應用程式有太大的不同。在前端世界中,你仍然會使用關注點分離原則來安排程式碼結構。所有已證實有用的設計模式仍然適用。
歡迎來到真實世界的 React 應用程式
大多數開發人員都對 React 的簡潔性印象深刻,以及使用者介面可以表示為將資料對應到 DOM 的純函式的概念。在某種程度上,的確如此。
但是,當開發人員需要向後端發送網路要求或執行頁面導覽時,他們就會開始遇到困難,因為這些副作用會讓元件不那麼「純粹」。一旦你考慮這些不同的狀態(全域狀態或區域狀態),事情就會迅速變得複雜,而且使用者介面的黑暗面就會浮現。
除了使用者介面
React 本身不太在意計算或商業邏輯放在哪裡,這很合理,因為它只是一個用於建置使用者介面的函式庫。除了檢視層之外,前端應用程式還有其他部分。為了讓應用程式正常運作,你需要路由器、本機儲存、不同層級的快取、網路要求、第三方整合、第三方登入、安全性、記錄、效能調整等。
有了這些額外的內容,嘗試將所有內容都塞進 React 元件或掛勾中通常不是個好主意。原因是將概念混在一起通常會導致更多混淆。一開始,元件會設定一些訂單狀態的網路要求,然後有一些邏輯可以修剪字串開頭的空白,然後導覽到其他地方。讀者必須不斷重設他們的邏輯流程,並在不同層級的細節之間來回跳躍。
將所有程式碼打包到元件中可能會在小型的應用程式中運作,例如待辦事項或單一表單應用程式。然而,一旦達到某個層級,了解此類應用程式的努力將會顯著增加。更不用說新增新功能或修復現有缺陷了。
如果我們可以將不同的關注點分隔到具有結構的檔案或資料夾中,則了解應用程式的精神負擔將會大幅降低。而且你一次只需要專注於一件事。幸運的是,有一些經過充分驗證的模式可以追溯到網路時代之前。這些設計原則和模式已獲得充分的探索和討論,以解決常見的使用者介面問題,但是在桌面 GUI 應用程式背景下。
Martin Fowler 對檢視模型資料分層概念有很好的摘要。
整體而言,我發現這是一種對許多應用程式有效的模組化形式,而且我經常使用並鼓勵使用。它最大的優點是,它允許我透過相對獨立思考這三個主題(即檢視、模型、資料)來增加我的專注力。
分層架構已用於應對大型 GUI 應用程式的挑戰,我們當然可以在「React 應用程式」中使用這些已建立的前端組織模式。
React 應用程式的演進
對於小型或一次性專案,您可能會發現所有邏輯都只是寫在 React 元件內部。您總共可能只會看到一個或幾個元件。程式碼看起來很像 HTML,只有一些變數或狀態用於使頁面「動態」。有些可能會在元件呈示後,在 useEffect
上傳送要求以擷取資料。
隨著應用程式成長,越來越多的程式碼會新增到程式碼庫中。若沒有適當的方式來組織它們,程式碼庫很快就會變成無法維護的狀態,這表示即使新增小型功能也可能很耗時,因為開發人員需要更多時間來閱讀程式碼。
因此,我將列出一些步驟,有助於減輕可維護性的問題。這通常需要多一點的努力,但為您的應用程式建立結構是值得的。讓我們快速檢閱這些步驟,以建立可擴充的前端應用程式。
單一元件應用程式
它幾乎可以稱為單一元件應用程式

圖 1:單一元件應用程式
但很快地,您就會發現單一元件需要花很多時間才能讀懂正在發生什麼事。例如,有邏輯會反覆運算清單並產生每個項目。此外,還有一些邏輯是用於使用第三方元件,只有少數幾個 組態 程式碼,除了其他邏輯之外。
多重元件應用程式
您決定將元件拆分成數個元件,這些結構反映結果 HTML 上發生的事是一個好主意,而且有助於您一次專注於一個元件。

圖 2:多重元件應用程式
隨著應用程式的擴充,除了檢視之外,還有像是傳送網路要求、將資料轉換成不同形狀以供檢視使用,以及收集資料傳回伺服器等事項。將這些程式碼放在元件中並不妥當,因為它們並非真正與使用者介面相關。此外,有些元件有太多內部狀態。
使用鉤子進行狀態管理
將此邏輯拆分到不同的位置會是個更好的點子。很幸運地,在 React 中,你可以定義自己的勾子。這是共用這些狀態以及狀態變更時邏輯的絕佳方式。

圖 3:使用勾子進行狀態管理
太棒了!你已經從單一元件應用程式中萃取出許多元素,並擁有幾個純粹的呈現元件和一些可重複使用的勾子,讓其他元件具有狀態。唯一的問題是,在勾子中,除了副作用和狀態管理之外,有些邏輯似乎不屬於狀態管理,而是純粹的計算。
商業模式浮現
因此,你開始意識到將此邏輯萃取出另一個位置可以為你帶來許多好處。例如,透過此拆分,邏輯可以具有內聚性且獨立於任何檢視。接著,你萃取出幾個網域物件。
這些簡單的物件可以處理資料對應(從一種格式到另一種格式)、檢查 null 值並視需要使用後備值。此外,隨著這些網域物件數量的增加,你會發現需要一些繼承或多型才能讓事情更簡潔。因此,你將從其他地方找到許多有用的設計模式套用在此處的前端應用程式中。

圖 4:商業模式
分層前端應用程式
應用程式持續演進,然後你會發現一些模式浮現。有許多物件不屬於任何使用者介面,而且它們也不在乎基礎資料是來自遠端服務、本機儲存或快取。然後,你想要將它們拆分到不同的層級。以下是關於層級拆分的詳細說明 簡報網域資料分層。

圖 5:分層前端應用程式
上述的演進過程是一個高階概觀,你應該對如何建構程式碼結構或至少應該朝哪個方向前進有所了解。不過,在你將理論套用在應用程式中之前,有許多細節需要考慮。
在以下各節中,我將引導你了解從實際專案中萃取出的功能,以展示我認為對大型前端應用程式有用的所有模式和設計原則。
付款功能的導入
我使用一個過度簡化的線上訂購應用程式作為起點。在此應用程式中,客戶可以挑選一些產品並將它們加入訂單,然後他們需要選擇一種付款方式才能繼續。

圖 6:付款區段
這些付款方式選項是在伺服器端設定,來自不同國家的客戶可能會看到其他選項。例如,Apple Pay 可能只在某些國家/地區流行。單選按鈕是資料驅動的 - 從後端服務擷取的任何內容都會浮出水面。唯一的例外是,當沒有傳回已設定的付款方式時,我們不會顯示任何內容,並預設將其視為「以現金支付」。
為求簡潔,我將略過實際付款流程,並專注於Payment
元件。假設在閱讀 React hello world 文件和搜尋幾個 stackoverflow 之後,你提出了類似這樣的程式碼
src/Payment.tsx…
export const Payment = ({ amount }: { amount: number }) => { const [paymentMethods, setPaymentMethods] = useState<LocalPaymentMethod[]>( [] ); useEffect(() => { const fetchPaymentMethods = async () => { const url = "https://online-ordering.com/api/payment-methods"; const response = await fetch(url); const methods: RemotePaymentMethod[] = await response.json(); if (methods.length > 0) { const extended: LocalPaymentMethod[] = methods.map((method) => ({ provider: method.name, label: `Pay with ${method.name}`, })); extended.push({ provider: "cash", label: "Pay in cash" }); setPaymentMethods(extended); } else { setPaymentMethods([]); } }; fetchPaymentMethods(); }, []); return ( <div> <h3>Payment</h3> <div> {paymentMethods.map((method) => ( <label key={method.provider}> <input type="radio" name="payment" value={method.provider} defaultChecked={method.provider === "cash"} /> <span>{method.label}</span> </label> ))} </div> <button>${amount}</button> </div> ); };
上述程式碼相當典型。你可能在入門教學的某個地方看過它。而且它並非一定不好。但是,正如我們上面提到的,程式碼將不同的考量因素全部混在單一元件中,使得閱讀起來有點困難。
初始實作的問題
我想處理的第一個問題是元件有多麼「繁忙」。我的意思是,Payment
處理不同的東西,而且由於你必須在閱讀時在腦中切換脈絡,因此程式碼很難閱讀。
為了進行任何變更,你必須了解如何初始化網路要求、如何將資料對應到元件可以理解的當地格式、如何呈現每種付款方式,以及Payment
元件本身的呈現邏輯。
src/Payment.tsx…
export const Payment = ({ amount }: { amount: number }) => { const [paymentMethods, setPaymentMethods] = useState<LocalPaymentMethod[]>( [] ); useEffect(() => { const fetchPaymentMethods = async () => { const url = "https://online-ordering.com/api/payment-methods"; const response = await fetch(url); const methods: RemotePaymentMethod[] = await response.json(); if (methods.length > 0) { const extended: LocalPaymentMethod[] = methods.map((method) => ({ provider: method.name, label: `Pay with ${method.name}`, })); extended.push({ provider: "cash", label: "Pay in cash" }); setPaymentMethods(extended); } else { setPaymentMethods([]); } }; fetchPaymentMethods(); }, []); return ( <div> <h3>Payment</h3> <div> {paymentMethods.map((method) => ( <label key={method.provider}> <input type="radio" name="payment" value={method.provider} defaultChecked={method.provider === "cash"} /> <span>{method.label}</span> </label> ))} </div> <button>${amount}</button> </div> ); };
對於這個簡單的範例,在這個階段這不是一個大問題。但是,隨著程式碼變得更大、更複雜,我們需要對它們進行一些重構。
將檢視和非檢視程式碼分開到不同的位置是一種良好的做法。原因是,一般來說,檢視比非檢視邏輯更常變更。此外,由於它們處理應用程式的不同面向,因此將它們分開可以讓你專注於特定獨立模組,在實作新功能時更易於管理。
檢視與非檢視程式碼的分離
在 React 中,我們可以使用自訂勾子來維護元件的狀態,同時讓元件本身或多或少保持無狀態。我們可以使用usePaymentMethods
的函式(前綴use
是 React 中的慣例,表示函式是一個勾子,並在其中處理一些狀態)
src/Payment.tsx…
const usePaymentMethods = () => {
const [paymentMethods, setPaymentMethods] = useState<LocalPaymentMethod[]>(
[]
);
useEffect(() => {
const fetchPaymentMethods = async () => {
const url = "https://online-ordering.com/api/payment-methods";
const response = await fetch(url);
const methods: RemotePaymentMethod[] = await response.json();
if (methods.length > 0) {
const extended: LocalPaymentMethod[] = methods.map((method) => ({
provider: method.name,
label: `Pay with ${method.name}`,
}));
extended.push({ provider: "cash", label: "Pay in cash" });
setPaymentMethods(extended);
} else {
setPaymentMethods([]);
}
};
fetchPaymentMethods();
}, []);
return {
paymentMethods,
};
};
這會傳回一個 paymentMethods
陣列(類型為 LocalPaymentMethod
),做為內部狀態,並準備好用於 渲染。因此,Payment
中的邏輯可以簡化為
src/Payment.tsx…
export const Payment = ({ amount }: { amount: number }) => {
const { paymentMethods } = usePaymentMethods();
return (
<div>
<h3>Payment</h3>
<div>
{paymentMethods.map((method) => (
<label key={method.provider}>
<input
type="radio"
name="payment"
value={method.provider}
defaultChecked={method.provider === "cash"}
/>
<span>{method.label}</span>
</label>
))}
</div>
<button>${amount}</button>
</div>
);
};
這有助於減輕 Payment
元件的負擔。不過,如果你查看用於反覆運算 paymentMethods
的區塊,這裡似乎缺少一個概念。換句話說,這個區塊值得擁有自己的元件。理想情況下,我們希望每個元件只專注於一件事。
透過萃取子元件來分離檢視
此外,如果我們能讓元件成為純函數,表示給定任何輸入,輸出都是確定的,這將有助於我們撰寫測試、了解程式碼,甚至在其他地方重複使用元件。畢竟,元件越小,重複使用的可能性就越高。
我們可以再次使用
src/Payment.tsx…
const PaymentMethods = ({ paymentMethods, }: { paymentMethods: LocalPaymentMethod[]; }) => ( <> {paymentMethods.map((method) => ( <label key={method.provider}> <input type="radio" name="payment" value={method.provider} defaultChecked={method.provider === "cash"} /> <span>{method.label}</span> </label> ))} </> );
Payment
元件可以直接使用 PaymentMethods
,因此可以簡化如下
src/Payment.tsx…
export const Payment = ({ amount }: { amount: number }) => {
const { paymentMethods } = usePaymentMethods();
return (
<div>
<h3>Payment</h3>
<PaymentMethods paymentMethods={paymentMethods} />
<button>${amount}</button>
</div>
);
};
請注意,PaymentMethods
是沒有任何狀態的純函數(純元件)。它基本上是一個字串格式化函數。
資料建模以封裝邏輯
到目前為止,我們所做的變更都是關於將檢視和非檢視程式碼拆分到不同的地方。這很有效。掛鉤處理資料擷取和重新塑形。Payment
和 PaymentMethods
都相對較小且易於理解。
不過,如果你仔細觀察,仍然有改進的空間。首先,在純函數元件 PaymentMethods
中,我們有一些邏輯要檢查是否應預設勾選付款方式
src/Payment.tsx…
const PaymentMethods = ({
paymentMethods,
}: {
paymentMethods: LocalPaymentMethod[];
}) => (
<>
{paymentMethods.map((method) => (
<label key={method.provider}>
<input
type="radio"
name="payment"
value={method.provider}
defaultChecked={method.provider === "cash"}
/>
<span>{method.label}</span>
</label>
))}
</>
);
檢視中的這些測試陳述可以被視為邏輯外洩,而且它們逐漸會散佈在不同的地方,並讓修改變得更加困難。
潛在邏輯外洩的另一個點是在我們擷取資料的資料轉換中
src/Payment.tsx…
const usePaymentMethods = () => { const [paymentMethods, setPaymentMethods] = useState<LocalPaymentMethod[]>( [] ); useEffect(() => { const fetchPaymentMethods = async () => { const url = "https://online-ordering.com/api/payment-methods"; const response = await fetch(url); const methods: RemotePaymentMethod[] = await response.json(); if (methods.length > 0) { const extended: LocalPaymentMethod[] = methods.map((method) => ({ provider: method.name, label: `Pay with ${method.name}`, })); extended.push({ provider: "cash", label: "Pay in cash" }); setPaymentMethods(extended); } else { setPaymentMethods([]); } }; fetchPaymentMethods(); }, []); return { paymentMethods, }; };
請注意 methods.map
內部的匿名函數會靜默地執行轉換,而且這個邏輯,以及上述的 method.provider === "cash"
,可以提取到一個類別中。
我們可以有一個 PaymentMethod
類別,將資料和行為集中到一個地方
src/PaymentMethod.ts…
class PaymentMethod {
private remotePaymentMethod: RemotePaymentMethod;
constructor(remotePaymentMethod: RemotePaymentMethod) {
this.remotePaymentMethod = remotePaymentMethod;
}
get provider() {
return this.remotePaymentMethod.name;
}
get label() {
if(this.provider === 'cash') {
return `Pay in ${this.provider}`
}
return `Pay with ${this.provider}`;
}
get isDefaultMethod() {
return this.provider === "cash";
}
}
有了這個類別,我就可以定義預設的現金付款方式
const payInCash = new PaymentMethod({ name: "cash" });
在轉換期間,也就是從遠端服務擷取付款方式後,我可以在原地建構 PaymentMethod
物件。或者甚至提取一個稱為 convertPaymentMethods
的小函數
src/usePaymentMethods.ts…
const convertPaymentMethods = (methods: RemotePaymentMethod[]) => {
if (methods.length === 0) {
return [];
}
const extended: PaymentMethod[] = methods.map(
(method) => new PaymentMethod(method)
);
extended.push(payInCash);
return extended;
};
此外,在 PaymentMethods
元件中,我們不再使用 method.provider === "cash"
進行檢查,而是呼叫 getter
src/PaymentMethods.tsx…
export const PaymentMethods = ({ options }: { options: PaymentMethod[] }) => (
<>
{options.map((method) => (
<label key={method.provider}>
<input
type="radio"
name="payment"
value={method.provider}
defaultChecked={method.isDefaultMethod}
/>
<span>{method.label}</span>
</label>
))}
</>
);
現在我們將我們的 Payment
元件重新結構為許多較小的部分,這些部分會一起運作來完成工作。

圖 7:重新調整的 Payment,包含更多可輕鬆組合的部分
新結構的優點
- 類別會將所有邏輯封裝在付款方式中。它是一個網域物件,不包含任何與 UI 相關的資訊。因此,在這裡測試和修改邏輯會比嵌入在檢視中容易得多。
- 新的萃取元件
PaymentMethods
是純函數,而且僅依賴網域物件陣列,這使得它非常容易在其他地方進行測試和重複使用。我們可能需要傳入onSelect
回呼,但即使在這種情況下,它也是純函數,而且不必觸及任何外部狀態。 - 功能的每個部分都很清楚。如果出現新的需求,我們可以導航到正確的地方,而不用讀取所有程式碼。
我必須讓本文中的範例足夠複雜,才能萃取出許多模式。所有這些模式和原則都在於協助簡化我們的程式碼修改。
新需求:捐贈給慈善機構
讓我們透過對應用程式進行一些進一步的變更來探討此處的理論。新的需求是,我們希望為客戶提供一個選項,讓他們可以隨訂單捐贈一小筆金額作為慈善小費。
例如,如果訂單金額為 19.80 美元,我們會詢問他們是否願意捐贈 0.20 美元。如果使用者同意捐贈,我們會在按鈕上顯示總金額。

圖 8:捐贈給慈善機構
在進行任何變更之前,讓我們快速檢視目前的程式碼結構。我比較喜歡將不同的部分放在各自的資料夾中,這樣當它變大的時候,我就可以輕鬆導航。
src ├── App.tsx ├── components │ ├── Payment.tsx │ └── PaymentMethods.tsx ├── hooks │ └── usePaymentMethods.ts ├── models │ └── PaymentMethod.ts └── types.ts
App.tsx
是主要入口,它使用 Payment
元件,而 Payment
使用 PaymentMethods
來呈現不同的付款選項。掛勾 usePaymentMethods
負責從遠端服務擷取資料,然後將其轉換為 PaymentMethod
網域物件,用於保留 標籤
和 isDefaultChecked
旗標。
內部狀態:同意捐贈
要在 Payment
中進行這些變更,我們需要一個布林值狀態 agreeToDonate
來表示使用者是否選取頁面上的核取方塊。
src/Payment.tsx…
const [agreeToDonate, setAgreeToDonate] = useState<boolean>(false); const { total, tip } = useMemo( () => ({ total: agreeToDonate ? Math.floor(amount + 1) : amount, tip: parseFloat((Math.floor(amount + 1) - amount).toPrecision(10)), }), [amount, agreeToDonate] );
函數 Math.floor
會將數字向下捨入,因此當使用者選取 agreeToDonate
時,我們可以取得正確的金額,而向上捨入的值與原始金額之間的差額會指定給 tip
。
至於檢視,JSX 將會是一個核取方塊加上一個簡短的說明
src/Payment.tsx…
return ( <div> <h3>Payment</h3> <PaymentMethods options={paymentMethods} /> <div> <label> <input type="checkbox" onChange={handleChange} checked={agreeToDonate} /> <p> {agreeToDonate ? "Thanks for your donation." : `I would like to donate $${tip} to charity.`} </p> </label> </div> <button>${total}</button> </div> );
有了這些新的變更,我們的程式碼又開始處理多件事。保持警覺,注意檢視程式碼和非檢視程式碼可能會混在一起,這一點很重要。如果你發現任何不必要的混用,請找出將它們分開的方法。
請注意,這不是一成不變的規則。對於小型且緊密的元件,保持所有內容井然有序,這樣你就不必在多個地方尋找才能了解整體行為。通常,你應該注意避免元件檔案過大而難以理解。
萃取一個鉤子來救援
這裡我們需要一個物件來計算小費和金額,每當使用者改變主意時,物件應該傳回更新後的金額和小費。
所以聽起來我們需要一個物件
- 將原始金額作為輸入
- 每當
agreeToDonate
變更時,傳回total
和tip
。
聽起來這又是一個自訂 Hook 的絕佳場合,對吧?
src/hooks/useRoundUp.ts…
export const useRoundUp = (amount: number) => {
const [agreeToDonate, setAgreeToDonate] = useState<boolean>(false);
const {total, tip} = useMemo(
() => ({
total: agreeToDonate ? Math.floor(amount + 1) : amount,
tip: parseFloat((Math.floor(amount + 1) - amount).toPrecision(10)),
}),
[amount, agreeToDonate]
);
const updateAgreeToDonate = () => {
setAgreeToDonate((agreeToDonate) => !agreeToDonate);
};
return {
total,
tip,
agreeToDonate,
updateAgreeToDonate,
};
};
在檢視中,我們可以使用初始 amount
來呼叫這個 Hook,並讓所有這些狀態在外部定義。updateAgreeToDonate
函式可以更新 Hook 中的值並觸發重新渲染。
src/components/Payment.tsx…
export const Payment = ({ amount }: { amount: number }) => { const { paymentMethods } = usePaymentMethods(); const { total, tip, agreeToDonate, updateAgreeToDonate } = useRoundUp(amount); return ( <div> <h3>Payment</h3> <PaymentMethods options={paymentMethods} /> <div> <label> <input type="checkbox" onChange={updateAgreeToDonate} checked={agreeToDonate} /> <p>{formatCheckboxLabel(agreeToDonate, tip)}</p> </label> </div> <button>${total}</button> </div> ); };
請注意,我們也可以將訊息格式化部分萃取到輔助函式 formatCheckboxLabel
中,以簡化元件中的程式碼。
const formatCheckboxLabel = (agreeToDonate: boolean, tip: number) => { return agreeToDonate ? "Thanks for your donation." : `I would like to donate $${tip} to charity.`; };
而且 Payment
元件可以簡化很多 - 狀態現在完全由 Hook useRoundUp
管理。
你可以將 Hook 想像成一個狀態機,它在 UI 中發生某些變更時位於檢視的背後,例如核取方塊變更事件。事件將傳送到狀態機以產生新的狀態,而新的狀態將觸發重新渲染。
因此,這裡的模式是我們應該將狀態管理從元件中移開,並嘗試使其成為一個呈現函式(這樣就可以像這些卑微的公用程式函式一樣輕鬆地進行測試和重複使用)。React Hook 被設計為從不同元件中分享可重複使用的邏輯,但我發現即使只使用一次也有好處,因為它可以幫助你專注於在元件中進行渲染,並將狀態和資料保存在 Hook 中。
隨著捐款核取方塊變得更加獨立,我們可以將它移到它自己的純函式元件中。
src/components/DonationCheckbox.tsx…
const DonationCheckbox = ({ onChange, checked, content, }: DonationCheckboxProps) => { return ( <div> <label> <input type="checkbox" onChange={onChange} checked={checked} /> <p>{content}</p> </label> </div> ); };
在 Payment
中,由於 React 中的宣告式 UI,像閱讀一段卑微的 HTML 程式碼一樣閱讀程式碼非常簡單。
src/components/Payment.tsx…
export const Payment = ({ amount }: { amount: number }) => {
const { paymentMethods } = usePaymentMethods();
const { total, tip, agreeToDonate, updateAgreeToDonate } = useRoundUp(amount);
return (
<div>
<h3>Payment</h3>
<PaymentMethods options={paymentMethods} />
<DonationCheckbox
onChange={updateAgreeToDonate}
checked={agreeToDonate}
content={formatCheckboxLabel(agreeToDonate, tip)}
/>
<button>${total}</button>
</div>
);
};
此時,我們的程式碼結構開始類似於下方的圖表。請注意不同的部分如何專注於自己的任務,並共同使流程運作。

圖 9:重構後的付款與捐款
有關四捨五入邏輯的更多變更
到目前為止,四捨五入看起來不錯,隨著業務擴展到其他國家,它會帶來新的需求。相同的邏輯不適用於日本市場,因為 0.1 日圓作為捐款太小,而且需要四捨五入到日圓最近的百位數。而對於丹麥,則需要四捨五入到最近的十位數。
這聽起來像是一個容易解決的問題。我所需要的只是一個傳遞到 Payment
元件中的 countryCode
,對吧?
<Payment amount={3312} countryCode="JP" />;
而且由於所有邏輯現在都定義在 useRoundUp
鉤子中,我也可以將 countryCode
傳遞到鉤子中。
const useRoundUp = (amount: number, countryCode: string) => { //... const { total, tip } = useMemo( () => ({ total: agreeToDonate ? countryCode === "JP" ? Math.floor(amount / 100 + 1) * 100 : Math.floor(amount + 1) : amount, //... }), [amount, agreeToDonate, countryCode] ); //... };
你會注意到,隨著 useEffect
區塊中新增新的 countryCode
,if-else 條件會持續下去。而對於 getTipMessage
,我們需要相同的 if-else 檢查,因為不同的國家可能會使用其他貨幣符號(預設為美元符號)。
const formatCheckboxLabel = ( agreeToDonate: boolean, tip: number, countryCode: string ) => { const currencySign = countryCode === "JP" ? "¥" : "$"; return agreeToDonate ? "Thanks for your donation." : `I would like to donate ${currencySign}${tip} to charity.`; };
我們還需要變更的最後一件事是按鈕上的貨幣符號。
<button> {countryCode === "JP" ? "¥" : "$"} {total} </button>;
散彈槍手術問題
這種情況是我們在許多地方(特別是在 React 應用程式中)看到的著名的「散彈槍手術」問題。這基本上表示,每當我們需要修改程式碼來修正錯誤或新增新功能時,我們都必須觸及多個模組。而且,的確,在有這麼多變更的情況下更容易出錯,特別是在你的測試不足夠時。

圖 10:散彈槍手術問題
如上圖所示,有色線條表示跨越多個檔案的國家代碼檢查分支。在檢視中,我們需要針對不同的國家代碼執行不同的操作,而在鉤子中,我們需要類似的分支。而且每當我們需要新增新的國家代碼時,我們都必須觸及所有這些部分。
例如,如果我們將丹麥視為業務擴展的新國家,我們最終會在許多地方看到類似以下的程式碼
const currencySignMap = { JP: "¥", DK: "Kr.", AU: "$", }; const getCurrencySign = (countryCode: CountryCode) => currencySignMap[countryCode];
解決分支散落在不同位置問題的一種可能解決方案是使用多型來取代這些開關案例或表格查詢邏輯。我們可以在這些屬性上使用 Extract Class,然後使用 Replace Conditional with Polymorphism。
多型性來救援
我們可以做的第一件事是檢查所有變異,看看需要提取到類別中的內容。例如,不同的國家有不同的貨幣符號,因此可以將 getCurrencySign
提取到公開介面中。此外,各國可能採用不同的進位演算法,因此 getRoundUpAmount
和 getTip
可以轉到介面。
export interface PaymentStrategy { getRoundUpAmount(amount: number): number; getTip(amount: number): number; }
策略介面的具體實作會像下列程式碼片段:PaymentStrategyAU
。
export class PaymentStrategyAU implements PaymentStrategy {
get currencySign(): string {
return "$";
}
getRoundUpAmount(amount: number): number {
return Math.floor(amount + 1);
}
getTip(amount: number): number {
return parseFloat((this.getRoundUpAmount(amount) - amount).toPrecision(10));
}
}
請注意,介面和類別與 UI 無直接關係。此邏輯可以在應用程式的其他位置共用,甚至移至後端服務(例如,如果後端是用 Node 編寫的)。
我們可以為每個國家建立子類別,每個子類別都有特定國家的進位邏輯。但是,由於函式是 JavaScript 中的一等公民,我們可以將進位演算法傳遞到策略實作中,以減少沒有子類別的程式碼開銷。而且,因為我們只有一個介面的實作,所以我們可以使用 Inline Class 來減少單一實作介面。
src/models/CountryPayment.ts…
export class CountryPayment {
private readonly _currencySign: string;
private readonly algorithm: RoundUpStrategy;
public constructor(currencySign: string, roundUpAlgorithm: RoundUpStrategy) {
this._currencySign = currencySign;
this.algorithm = roundUpAlgorithm;
}
get currencySign(): string {
return this._currencySign;
}
getRoundUpAmount(amount: number): number {
return this.algorithm(amount);
}
getTip(amount: number): number {
return calculateTipFor(this.getRoundUpAmount.bind(this))(amount);
}
}
如下所示,現在它們不再依賴於元件和掛勾中的分散邏輯,而只依賴於單一類別 PaymentStrategy
。在執行階段,我們可以輕鬆地將一個 PaymentStrategy
的執行個體替換為另一個(紅色、綠色和藍色方塊表示 PaymentStrategy
類別的不同執行個體)。

圖 11:提取類別來封裝邏輯
而 useRoundUp
掛勾,程式碼可以簡化為
src/hooks/useRoundUp.ts…
export const useRoundUp = (amount: number, strategy: PaymentStrategy) => { const [agreeToDonate, setAgreeToDonate] = useState<boolean>(false); const { total, tip } = useMemo( () => ({ total: agreeToDonate ? strategy.getRoundUpAmount(amount) : amount, tip: strategy.getTip(amount), }), [agreeToDonate, amount, strategy] ); const updateAgreeToDonate = () => { setAgreeToDonate((agreeToDonate) => !agreeToDonate); }; return { total, tip, agreeToDonate, updateAgreeToDonate, }; };
在 Payment
元件中,我們將策略從 props
傳遞到掛勾
src/components/Payment.tsx…
export const Payment = ({ amount, strategy = new PaymentStrategy("$", roundUpToNearestInteger), }: { amount: number; strategy?: PaymentStrategy; }) => { const { paymentMethods } = usePaymentMethods(); const { total, tip, agreeToDonate, updateAgreeToDonate } = useRoundUp( amount, strategy ); return ( <div> <h3>Payment</h3> <PaymentMethods options={paymentMethods} /> <DonationCheckbox onChange={updateAgreeToDonate} checked={agreeToDonate} content={formatCheckboxLabel(agreeToDonate, tip, strategy)} /> <button>{formatButtonLabel(strategy, total)}</button> </div> ); };
然後我進行一些清理,以提取幾個用於產生標籤的輔助函式
src/utils.ts…
export const formatCheckboxLabel = ( agreeToDonate: boolean, tip: number, strategy: CountryPayment ) => { return agreeToDonate ? "Thanks for your donation." : `I would like to donate ${strategy.currencySign}${tip} to charity.`; };
我希望你已經注意到,我們正在嘗試直接將非檢視程式碼提取到不同的位置,或抽象出新的機制來將其重新整理成更具模組化的形式。
你可以這樣想:React 視圖只是非視圖程式碼的消費者之一。例如,如果你要建立一個新的介面,可能是使用 Vue 或甚至命令列工具,你可以使用多少程式碼來重複使用目前的實作?
進一步推動設計:萃取網路用戶端
如果我保持這種「關注點分離」的心態(用於分割視圖和非視圖邏輯,或更廣泛地將不同的責任分割到自己的函式/類別/物件中),下一步就是做一些事情來減輕 usePaymentMethods
鉤子中的混合。
目前,那個鉤子沒有太多程式碼。如果我加入錯誤處理和重試等內容,它很容易膨脹。而且,鉤子是 React 的概念,你無法直接在你的下一個精緻的 Vue 視圖中重複使用它,對吧?
src/hooks/usePaymentMethods.ts…
export const usePaymentMethods = () => {
const [paymentMethods, setPaymentMethods] = useState<PaymentMethod[]>(
[]
);
useEffect(() => {
const fetchPaymentMethods = async () => {
const url = "https://online-ordering.com/api/payment-methods";
const response = await fetch(url);
const methods: RemotePaymentMethod[] = await response.json();
setPaymentMethods(convertPaymentMethods(methods));
};
fetchPaymentMethods();
}, []);
return {
paymentMethods,
};
};
我已將 convertPaymentMethods
提取到這裡,作為一個全域函式。我想將擷取邏輯移到一個獨立的函式中,這樣我就可以使用像 React Query 這樣的函式庫來處理所有與網路相關的麻煩。
src/hooks/usePaymentMethods.ts…
const fetchPaymentMethods = async () => {
const response = await fetch("https://5a2f495fa871f00012678d70.mockapi.io/api/payment-methods?countryCode=AU");
const methods: RemotePaymentMethod[] = await response.json();
return convertPaymentMethods(methods)
}
這個小類別做了兩件事,擷取和轉換。它就像一個 反腐敗層(或一個閘道 [1]),可以確保我們對 PaymentMethod
結構的變更僅限於一個檔案。這種分割的好處是,這個類別可以在任何需要時使用,即使是在後端服務中,就像我們上面看到的 策略 物件一樣。
而對於 usePaymentMethods
鉤子,現在的程式碼非常簡單
src/hooks/usePaymentMethods.ts…
export const usePaymentMethods = () => {
const [paymentMethods, setPaymentMethods] = useState<PaymentMethod[]>(
[]
);
useEffect(() => {
fetchPaymentMethods().then(methods => setPaymentMethods(methods))
}, []);
return {
paymentMethods,
};
};
我們的類別圖表已變更為類似於以下內容。我們已將大部分程式碼移到非視圖相關的檔案中,這些檔案可以在其他地方使用。

圖 12:更細緻的分割使每個部分的責任更明確
擁有這些層級的優點
如上所示,這些層級為我們帶來了許多優點
- 增強的可維護性:透過將元件分為不同的部分,可以更輕鬆地找出並修復程式碼特定部分的缺陷。這可以節省時間,並降低在進行變更時引入新錯誤的風險。
- 增加模組化:分層結構更具模組化,這可以讓重複使用程式碼和建立新功能變得更容易。即使在每一層中,以視圖為例,也傾向於更具可組成性。
- 增強可讀性:了解和遵循程式碼邏輯變得容易許多。這對於閱讀和使用程式碼的其他開發人員來說特別有幫助。這是對程式碼庫進行變更的核心。
- 提升可擴充性:透過降低每個單獨模組的複雜度,應用程式通常會更具可擴充性,因為更容易新增新功能或進行變更,而不會影響整個系統。這對於預期會隨著時間演進的大型複雜應用程式來說,特別重要。
- 移轉到其他技術堆疊:如果我們必須(即使在大部分專案中非常不可能),我們可以在不變更底層模型和邏輯的情況下,取代檢視層。這一切都是因為網域邏輯封裝在純 JavaScript(或 TypeScript)程式碼中,而且不知道檢視的存在。
結論
建置 React 應用程式,或使用 React 作為檢視的前端應用程式,不應視為一種新的軟體類型。大部分建置傳統使用者介面的模式和原則仍然適用。甚至在後端建構無頭服務的模式,在前端領域也是有效的。我們可以在前端使用圖層,並讓使用者介面盡可能精簡,將邏輯匯入支援模型層,並將資料存取匯入另一個圖層。
在前端應用程式中擁有這些圖層的好處是,你只需要了解一個部分,而不用擔心其他部分。此外,透過提升可重複使用性,變更現有程式碼會比以前相對容易管理。
致謝
感謝 Andy Marks 和 Hannah Bourke 審查草稿版本,並更正我的文法和語言問題。
感謝 Cam Jackson 提供詳細技術審查,並針對文章結構提出很棒的建議。
感謝我的榜樣 Martin Fowler 指導我了解所有技術細節,並讓這篇文章可以在這個網站上發布。
註腳
1: Gateway 是封裝對外部系統或資源存取的物件。當你不想將所有採用邏輯分散到你的程式碼庫中時,它很有用,而且當外部系統變更時,在一個地方變更會更容易。
重大修訂
2023 年 2 月 16 日:發布文章的其餘部分
2023 年 2 月 14 日:發布新需求部分的第一部分
2023 年 2 月 08 日:發布第二部分:付款功能簡介。
2023 年 2 月 07 日:發布至 React 應用程式的演進