React 4 種狀態型別及 N 種狀態管理

破曉L發表於2021-12-15

React 是一個檢視層框架,其核心思想是 UI = f(state),即「UI 是 state 的投影」,state 自上而下流動,整個 React 元件樹由 state 驅動。當一個 React 應用程式足夠複雜,元件巢狀足夠深時,元件樹中的狀態流動會變得難以控制(例如你如何跟蹤父節點的 state 流動到葉子節點時產生的變化)。這時我們就需要對 state 進行管理,在進行狀態管理的同時,還需要分清 React 應用中有哪些狀態型別,方便制定出最適合的狀態管理方案。

狀態型別

React 應用程式狀態從巨集觀意義上講可以分為兩類:

  1. 客戶端狀態 Client State:多數用於控制客戶端的 UI 展示,如下文將介紹到的 Local state、Feature state、Application state 都屬於客戶端狀態。
  2. 服務端狀態 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 表單,它包含兩個欄位 skillyears ,預設情況下,Form 表單的提交按鈕處於禁用狀態,僅當兩個輸入都有值時該按鈕才變為啟用狀態,請注意 skillyears 都是必需的,以便我們可以計算 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 設定為應用狀態,這樣做的目的以便於 ThemeThemeSelector 也能輕鬆的訪問(它們也需要訪問 theme,但它們有可能巢狀於其他元件之中)。

如果某些屬性許多元件都需要,並且可能需要從遠端元件進行更新,那麼我們可能必須將其設定為應用程式狀態。

伺服器狀態 - Server state

伺服器狀態可以理解為介面狀態,服務端狀態有以下特點:儲存在遠端,本地無法直接控制、需要非同步 API 來查詢和更新、可能在不知情的情況下,被另一個請求方更改了資料,導致資料不同步等。

現有的狀態管理庫(如 Mobx、Redux 等)適用於管理客戶端狀態,但它們並不關心客戶端是如何非同步請求遠端資料的,所以他們並不適合處理非同步的、來自服務端的狀態。

要識別伺服器狀態,您必須考慮資料更改的頻率以及這些資料的來源。如果它或多或少是靜態的,那麼我們應該避免從服務端獲取,只需將其儲存到客戶端並將其作為全域性變數傳遞。

狀態管理

隨著 React 生態不斷壯大,社群中提供了多種方式來解決狀態管理,有基於 Flux 思想的 Redux、Zustand 以及 React 自帶的 useReducer + Context;有基於原子化的 Recoil、Jotail 等;還有響應式方案的代表 Mobx,在這裡不過多針對介紹,我們來了解一些最常用的。

Hooks 是狀態管理的主要機制

useStateuseReducer 是大多數人用來管理本地狀態的方式,useState 實際上是 React 中重新渲染的主要機制,這也是為什麼大多數狀態管理庫都在底層使用它的原因。如果深入研究,你會發現這些不同的第三方庫提供的 Custom Hooks 都依賴於預設的 useStateuseReduceruseffect,下面是官網提供的一個 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,而是自行約定將 ContactsChat 兩個元件通過 props.left/props.right 傳入,被傳遞的元件同父元件組成了更加複雜的元件,雖然被組合在一起,但各個成員元件都具有單一職責。

client-server 方式

這種方式體現在 ApolloReactQuery 中,基本上都是在應用程式中新增一個 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...

相關文章