React 是一個檢視層框架,其核心思想是 UI = f(state),即「UI 是 state 的投影」,state 自上而下流動,整個 React 元件樹由 state 驅動。當一個 React 應用程式足夠複雜,元件巢狀足夠深時,元件樹中的狀態流動會變得難以控制(例如你如何跟蹤父節點的 state 流動到葉子節點時產生的變化)。這時我們就需要對 state 進行管理,在進行狀態管理的同時,還需要分清 React 應用中有哪些狀態型別,方便制定出最適合的狀態管理方案。
狀態型別
React 應用程式狀態從巨集觀意義上講可以分為兩類:
- 客戶端狀態 Client State:多數用於控制客戶端的 UI 展示,如下文將介紹到的 Local state、Feature state、Application state 都屬於客戶端狀態。
- 服務端狀態 Server State:客戶端通過非同步請求獲得的資料。
本地狀態 - Local state
僅存在於單個元件中的狀態,我們可以稱之為「本地」或「UI」狀態。
通常它可以幫助我們管理使用者介面互動,例如顯示和隱藏內容或啟用和禁用按鈕,它還經常在我們等待時控制渲染的內容。考慮以下示例:
function Text() {
const [viewMore, setViewMore] = useState(false);
return (
<Fragment>
<p>
React makes it painless to create interactive UIs.
{
viewMore && <span>
Design simple views for each state in your application.
</span>
}
</p>
<button onClick={() => setViewMore(true)}>read more</button>
</Fragment>
)
}
viewMore
是一種僅對這個特定元件有意義的狀態,它的作用是僅控制此處文字的可見性。
在此示例中, viewMore
對應用程式的其他元件對都是沒用的,因此,您不必將此狀態洩漏到 Text
元件之外。
特徵狀態 - Feature state
組合兩個或多個元件,這些元件需要知道相同的資訊,我們將這種狀態定義為「特徵」狀態。
可以說,每一個非本地性的狀態都屬於這一類。然而,並非每個特徵狀態都是相同的,我們將在本文中進一步瞭解這一點。
特徵狀態的一個很好的例子是表單狀態,它看起來有點像上面描述的 UI 狀態,但它結合了多個輸入,管理多個元件。
import { useState } from "react";
const Skill = ({ onChange }) => (
<label>
技能:
<input type="text" onChange={(e) => onChange(e.target.value)} />
</label>
);
const Years = ({ onChange }) => (
<label>
工齡:
<input type="text" onChange={(e) => onChange(e.target.value)} />
</label>
);
export default function Form() {
const [skill, setSkill] = useState("");
const [years, setYears] = useState("");
const isFormReady = skill !== "" && years !== "";
return (
<form onSubmit={() => alert("提及成功")}>
<Skill onChange={setSkill} /> <br />
<Years onChange={setYears} />
<button disabled={!isFormReady}>submit</button>
</form>
);
}
這裡我們有一個 Form 表單,它包含兩個欄位 skill
和 years
,預設情況下,Form 表單的提交按鈕處於禁用狀態,僅當兩個輸入都有值時該按鈕才變為啟用狀態,請注意 skill
和 years
都是必需的,以便我們可以計算 isFormReady
的值。Form 是實現此類邏輯的最佳場所,因為它包含了所有相關聯的元素。
此示例體現了特徵狀態和應用狀態之間的一個界限,在這裡也可以使用 Redux 進行狀態管理,但是這不是一種好的做法,我們應該在它被提升併成為應用程式狀態之前更早地識別特徵狀態。
應用狀態 - Application state
應用程式狀態是引導使用者整體體驗的狀態,這可能是授權狀態、配置檔案資料或全域性樣式主題。在應用程式的任何地方都可能需要它。下面是一個簡單的示例:
import React, { useContext, useState } from "react";
const ThemeContext = React.createContext();
const Theme = ({ onChange }) => {
const { theme } = useContext(ThemeContext);
return `Theme: ${theme}`;
}
const ThemeSelector = () => {
const { theme, toggleTheme } = useContext(ThemeContext);
return (
<select value={theme} onChange={toggleTheme}>
<option value="light">light</option>
<option value="dark">dark</option>
</select>
);
}
export default function App() {
const [theme, setTheme] = useState("light");
const toggleTheme = () => setTheme(theme === "light" ? "dark" : "light");
const themeStyle = {
background: theme === "light" ? "#fff" : "#b9b9b9"
};
return (
<ThemeContext.Provider value={{ theme, toggleTheme }}>
<div className="App">
<header style={themeStyle}>
<Theme />
</header>
<footer style={themeStyle}>
<ThemeSelector />
</footer>
</div>
</ThemeContext.Provider>
);
}
這個例子中,有一個頁頭和一個頁尾,他們都需要知道當前應用程式的主題。我們通過使用 Context API 將 theme
設定為應用狀態,這樣做的目的以便於 Theme
和 ThemeSelector
也能輕鬆的訪問(它們也需要訪問 theme
,但它們有可能巢狀於其他元件之中)。
如果某些屬性許多元件都需要,並且可能需要從遠端元件進行更新,那麼我們可能必須將其設定為應用程式狀態。
伺服器狀態 - Server state
伺服器狀態可以理解為介面狀態,服務端狀態有以下特點:儲存在遠端,本地無法直接控制、需要非同步 API 來查詢和更新、可能在不知情的情況下,被另一個請求方更改了資料,導致資料不同步等。
現有的狀態管理庫(如 Mobx、Redux 等)適用於管理客戶端狀態,但它們並不關心客戶端是如何非同步請求遠端資料的,所以他們並不適合處理非同步的、來自服務端的狀態。
要識別伺服器狀態,您必須考慮資料更改的頻率以及這些資料的來源。如果它或多或少是靜態的,那麼我們應該避免從服務端獲取,只需將其儲存到客戶端並將其作為全域性變數傳遞。
狀態管理
隨著 React 生態不斷壯大,社群中提供了多種方式來解決狀態管理,有基於 Flux 思想的 Redux、Zustand 以及 React 自帶的 useReducer + Context;有基於原子化的 Recoil、Jotail 等;還有響應式方案的代表 Mobx,在這裡不過多針對介紹,我們來了解一些最常用的。
Hooks 是狀態管理的主要機制
useState
或 useReducer
是大多數人用來管理本地狀態的方式,useState
實際上是 React 中重新渲染的主要機制,這也是為什麼大多數狀態管理庫都在底層使用它的原因。如果深入研究,你會發現這些不同的第三方庫提供的 Custom Hooks 都依賴於預設的 useState
、useReducer
和 useffect
,下面是官網提供的一個 useReducer
的簡單示例:
const initialState = {count: 0};
function reducer(state, action) {
switch (action.type) {
case 'increment':
return {count: state.count + 1};
case 'decrement':
return {count: state.count - 1};
default:
throw new Error();
}
}
function Counter() {
const [state, dispatch] = useReducer(reducer, initialState);
return (
<>
Count: {state.count}
<button onClick={() => dispatch({type: 'decrement'})}>-</button>
<button onClick={() => dispatch({type: 'increment'})}>+</button>
</>
);
}
狀態提升 - Lifting State Up
在 React 中,將多個元件中需要共享的 state 向上移動到它們的最近共同父元件中,便可實現共享 state,這就是所謂的「狀態提升」。
回顧一下本文開頭的 view-more
示例(本地狀態來控制文字的可見性)。但是,如果有一個新的要求,我們有一個全域性的「展開文字」按鈕,然後我們必須把這個狀態提升,然後通過 props
傳遞下去。
// viewMore 是一個 local state
function Component() {
const [viewMore, setViewMore] = useState(false);
return (
<Fragment>
<p>Text... { viewMore && <span>More text ...</span>}</p>
<button onClick={() => setViewMore(true)}>read more</button>
</Fragment>
);
}
// 將 viewMore 提升為元件的 feature state
function Component({ viewMore }) {
return (
<p>Text... { viewMore && <span>More text ...</span>}</p>
);
}
當您看到一個區域性狀態變數變成一個 props
時,我們就可以視其為狀態提升。這種方式的需要注意在 prop drilling
方面的找到平衡,您也不希望有許多隻是負責將 props
傳遞給他們子元件的「中間人」元件。
使用 Context API
Context API 提供了一種通過元件樹傳遞資料的方法,而無需在每個級別手動向下傳遞 props。
在典型的 React 應用程式中,資料通過 props 自上而下(父級到子級)傳遞,但這種做法對於某些型別的 props 而言是極其繁瑣的(例如:地區偏好,UI 主題),這些屬性是應用程式中許多元件都需要的。Context 提供了一種在元件之間共享此類值的方式,而不必顯式地通過元件樹的逐層傳遞 props。
Context 設計目的是為了共享那些對於一個元件樹而言是「全域性」的資料,例如當前認證的使用者、主題或首選語言,因此 Context 主要應用於應用狀態的管理(見應用狀態示例)。
如果你只是想避免層層傳遞一些屬性,元件組合(component composition)有時候是一個比 context 更好的解決方案。
組合模式 - Composition mode
Context API 適合管理應用狀態,如果想避免 Props Drilling 等問題,可以採用組合模式。組合是一種通過將各元件聯合在一起以建立更大元件的方式,組合不僅具有多變的靈活性和可重用性,還具有單一職責的特性,組合也是 React 的核心。 看一個官網的示例:
function SplitPane(props) {
return (
<div className="SplitPane">
<div className="SplitPane-left">
{props.left}
</div>
<div className="SplitPane-right">
{props.right}
</div>
</div>
);
}
function App() {
return (
<SplitPane
left={
<Contacts />
}
right={
<Chat />
} />
);
}
示例中沒有使用 children
,而是自行約定將 Contacts
和 Chat
兩個元件通過 props.left/props.right
傳入,被傳遞的元件同父元件組成了更加複雜的元件,雖然被組合在一起,但各個成員元件都具有單一職責。
client-server 方式
這種方式體現在 Apollo 和 ReactQuery 中,基本上都是在應用程式中新增一個 layer/client
並由其負責從外部源請求/快取資料。
它們帶有一些不錯的 hooks,對於客戶端來說,這種方式看起來很像使用本地狀態,它消除了資料儲存和獲取的繁雜性。下面是 Apollo 和 ReactQuery 的簡單示例:
// Apollo
import { useQuery, gql } from '@apollo/client';
// 在這個例子中,我們使用了 GraphQL
const EXCHANGE_RATES = gql`
query GetExchangeRates {
rates(currency: "USD") {
currency
rate
}
}
`;
function ExchangeRates() {
const { loading, error, data } = useQuery(EXCHANGE_RATES);
if (loading) return <p>Loading...</p>;
if (error) return <p>Error :(</p>;
return data.rates.map(({ currency, rate }) => (
<div key={currency}>
<p>
{currency}: {rate}
</p>
</div>
));
}
// ReactQuery
import React from "react";
import ReactDOM from "react-dom";
import { QueryClient, QueryClientProvider, useQuery } from "react-query";
const queryClient = new QueryClient();
export default function App() {
return (
<QueryClientProvider client={queryClient}>
<Example />
</QueryClientProvider>
);
}
function Example() {
const { isLoading, error, data, isFetching } = useQuery("repoData", () =>
fetch(
"https://api.github.com/repos/tannerlinsley/react-query"
).then((res) => res.json())
);
if (isLoading) return "Loading...";
if (error) return "An error has occurred: " + error.message;
return (
<div>
<h1>{data.name}</h1>
<p>{data.description}</p>
<strong>? {data.subscribers_count}</strong>{" "}
<strong>✨ {data.stargazers_count}</strong>{" "}
<strong>? {data.forks_count}</strong>
<div>{isFetching ? "Updating..." : ""}</div>
</div>
);
}
const rootElement = document.getElementById("root");
ReactDOM.render(<App />, rootElement);
從例子中我們發現,前端負責指定如何獲取資料,其餘的都取決於 Apollo/ReactQuery 客戶端,包括前端需要的 loading
和一個 error
兩種狀態都由後端提供並管理。這使得前端擁有一個狀態,但實際上允許在後端管理該狀態,是一種有趣的結合。
結論
狀態管理很複雜,狀態管理沒有最好的方案,只有最合適的方案。
關於第三方狀態管理庫推薦閱讀 56 個 NPM 包解決 16 個 React 問題 裡的小節。
參考:
https://krasimirtsonev.com/bl...
https://www.sytone.me/b-syste...