精讀《正交的React組件》

NO IMAGE

1 引言

搭配了合適的設計模式的代碼,才可擁有良好的可維護性,The Benefits of Orthogonal React Components 這篇文章就重點介紹了正交性原理。

所謂正交,即模塊之間不會相互影響。想象一個音響的音量與換臺按鈕間如果不是正交關係,控制音量同時可能影響換臺,這樣的設備很難維護:

精讀《正交的React組件》

前端代碼也一樣,UI 與數據處理邏輯分離就是一種符合正交原則的設計,這樣有利於長期代碼質量維護。

2 概述

一個擁有良好正交性的 React App 會按照如下模塊分離設計:

  1. UI 元素(展示型組件)。
  2. 取數邏輯(fetch library, REST or GraphQL)。
  3. 全局狀態管理(redux)。
  4. 持久化(local storage, cookies)。

文中通過兩個例子說明。

讓組件與取數邏輯正交

比如一個展示僱員列表組件 <EmployeesPage>:

import React, { useState } from "react";
import axios from "axios";
import EmployeesList from "./EmployeesList";
function EmployeesPage() {
const [isFetching, setFetching] = useState(false);
const [employees, setEmployees] = useState([]);
useEffect(function fetch() {
(async function() {
setFetching(true);
const response = await axios.get("/employees");
setEmployees(response.data);
setFetching(false);
})();
}, []);
if (isFetching) {
return <div>Fetching employees....</div>;
}
return <EmployeesList employees={employees} />;
}

這樣設計看上去沒問題,但其實違背了正交原則,因為 EmployeesPage 既負責渲染 UI 又關心取數邏輯。正交的寫法如下:

import React, { Suspense } from "react";
import EmployeesList from "./EmployeesList";
function EmployeesPage({ resource }) {
return (
<Suspense fallback={<h1>Fetching employees....</h1>}>
<EmployeesFetch resource={resource} />
</Suspense>
);
}
function EmployeesFetch({ resource }) {
const employees = resource.employees.read();
return <EmployeesList employees={employees} />;
}

Suspense 將 loading 狀態剝離到父級組件,因此子組件只需要關心如何用數據,不需關心如何取數據(以及 loading 態)。

讓組件與滾動監聽正交

比如一個滾動到一定距離就出現 “jump to top” 的組件 <ScrollToTop>,可能會這麼實現:

import React, { useState, useEffect } from "react";
const DISTANCE = 500;
function ScrollToTop() {
const [crossed, setCrossed] = useState(false);
useEffect(function() {
const handler = () => setCrossed(window.scrollY > DISTANCE);
handler();
window.addEventListener("scroll", handler);
return () => window.removeEventListener("scroll", handler);
}, []);
function onClick() {
window.scrollTo({
top: 0,
behavior: "smooth"
});
}
if (!crossed) {
return null;
}
return <button onClick={onClick}>Jump to top</button>;
}

可以看到,在這個組件中,按鈕與滾動狀態判斷邏輯混合在了一起。如果我們將 “滾動到一定距離就渲染 UI” 抽象成通用組件 IfScrollCrossed 呢?

import { useState, useEffect } from "react";
function useScrollDistance(distance) {
const [crossed, setCrossed] = useState(false);
useEffect(
function() {
const handler = () => setCrossed(window.scrollY > distance);
handler();
window.addEventListener("scroll", handler);
return () => window.removeEventListener("scroll", handler);
},
[distance]
);
return crossed;
}
function IfScrollCrossed({ children, distance }) {
const isBottom = useScrollDistance(distance);
return isBottom ? children : null;
}

有了 IfScrollCrossed,我們就能專注寫 “點擊按鈕跳轉到頂部” 這個 UI 組件了:

function onClick() {
window.scrollTo({
top: 0,
behavior: "smooth"
});
}
function JumpToTop() {
return <button onClick={onClick}>Jump to top</button>;
}

最後將他們拼裝在一起:

import React from "react";
// ...
const DISTANCE = 500;
function MyComponent() {
// ...
return (
<IfScrollCrossed distance={DISTANCE}>
<JumpToTop />
</IfScrollCrossed>
);
}

這麼做,我們的 <JumpToTop><IfScrollCrossed> 組件就是正交關係,而且邏輯更清晰。不僅如此,這樣的抽象使 <IfScrollCrossed> 可以被其他場景複用:

import React from "react";
// ...
const DISTANCE_NEWSLETTER = 300;
function OtherComponent() {
// ...
return (
<IfScrollCrossed distance={DISTANCE_NEWSLETTER}>
<SubscribeToNewsletterForm />
</IfScrollCrossed>
);
}

Main 組件

上面例子中,<MyComponent> 就是一個 Main 組件,Main 組件封裝一些髒邏輯,即它要負責不同模塊的組裝,而這些模塊之間不需要知道彼此的存在。

一個應用會存在多個 Main 組件,它們負責拼裝各種作用域下的髒邏輯。

正交設計的好處

  • 容易維護: 正交組件邏輯相互隔離,不用擔心連帶影響,因此可以放心大膽的維護單個組件。
  • 易讀: 由於邏輯分離導致了抽象,因此每個模塊做的事情都相對單一,很容易猜測一個組件做的事情。
  • 可測試: 由於邏輯分離,可以採取逐個擊破的思路進行單測。

權衡

如果不採用正交設計,因為模塊之間的關聯導致應用最終變得難以維護。但如果將正交設計應用到極致,可能會多處許多不必要的抽象,這些抽象的複用僅此一次,造成過度設計。

3 精讀

正交設計一定程度可以理解為合理抽象,完全不抽象與過度抽象都是不可取的,因此列舉了四塊需要抽象的要點:UI 元素、取數邏輯、全局狀態管理、持久化。

全局狀態管理注入到組件,就是一種正交的抽象模式,即組件不用關心數據從哪來,而直接使用數據,而數據管理完全交由數據流層管理。

取數邏輯往往是可能被忽略的一環,無論是像原文中直接關心到 fetch 方法的 UI 組件,還是利用取數工具庫關心了 loading 狀態:

import useSWR from "swr";
function Profile() {
const { data, error } = useSWR("/api/user", fetcher);
if (error) return <div>failed to load</div>;
if (!data) return <div>loading...</div>;
return <div>hello {data.name}!</div>;
}

雖然將取數生命週期封裝到自定義 hook useSWR 中,但 error 信息對 UI 組件來說就是一個髒數據:這讓這個 UI 組件不僅要渲染數據,還要擔心取數是否會失敗,或者是否在 loading 中。

好在 Suspense 模式解決了這個問題:

import { Suspense } from "react";
import useSWR from "swr";
function Profile() {
const { data } = useSWR("/api/user", fetcher, { suspense: true });
return <div>hello, {data.name}</div>;
}
function App() {
return (
<Suspense fallback={<div>loading...</div>}>
<Profile />
</Suspense>
);
}

這樣 <Profile> 只要專注於做數據渲染,而不用擔心 useSWR('/api/user', fetcher, { suspense: true }) 這個取數過程發生了什麼、是否取數失敗、是否在 loading 中。因為取數狀態由 Suspense 管理,而取數是否意外失敗由 ErrorBoundary 管理。

合理的抽象使組件邏輯變得更簡單,從而組件嵌套使用使不用擔心額外影響。尤其在大型項目中,不要擔心正交抽象會使本來就很多的模塊數量再次膨脹,因為相比於維護 100 個相互影響,內部邏輯複雜的模塊,維護 200 個職責清晰,相互隔離的模塊也許會更輕鬆。

4 總結

從正交設計角度來看,Hooks 解決了狀態管理與 UI 分離的問題,Suspense 解決了取數狀態與 UI 分離的問題,ErrorBoundary 解決了異常與 UI 分離的問題。

在你看來,React 還有哪些邏輯需要與 UI 分離?分別使用哪些方法呢?歡迎留言。

討論地址是:精讀《正交的 React 組件》 · Issue #221 · dt-fe/weekly

如果你想參與討論,請 點擊這裡,每週都有新的主題,週末或週一發佈。前端精讀 – 幫你篩選靠譜的內容。

關注 前端精讀微信公眾號

精讀《正交的React組件》

版權聲明:自由轉載-非商用-非衍生-保持署名(創意共享 3.0 許可證

相關文章

深入理解flexgrow、flexshrink、flexbasis

2020年史上最全Vue框架整理從基礎到實戰(二)

雲原生基礎及調研

一文搞懂V8引擎的垃圾回收