用動畫和實戰打開 React Hooks(三):useReducer 和 useContext

本文由圖雀社區成員 mRc 寫做而成,歡迎加入圖雀社區,一塊兒創做精彩的免費技術教程,予力編程行業發展。css

若是您以爲咱們寫得還不錯,記得 點贊 + 關注 + 評論 三連,鼓勵咱們寫出更好的教程💪html

隨着應用狀態愈來愈複雜,咱們迫切須要狀態與數據流管理的解決方案。熟悉 React 開發的同窗必定據說過 Redux,而在這篇文章中,咱們將經過 useReducer + useContext 的組合實現一個簡易版的 Redux。首先,咱們將帶你從新認識「老朋友」useState,並藉此引出這篇文章的主角:Reducer 函數與 useReducer 鉤子,並經過實戰一步步帶你理清數據流和狀態管理的基本思想。react

useState:柳暗花明

歡迎繼續閱讀《用動畫和實戰打開 React Hooks 系列》:git

若是你想要直接從這一篇開始學習,那麼請克隆咱們爲你提供的源代碼:github

git clone -b third-part https://github.com/tuture-dev/covid-19-with-hooks.git

# 若是你訪問 GitHub 不流暢,咱們還提供了 Gitee 地址
git clone -b third-part https://gitee.com/tuture/covid-19-with-hooks.git
複製代碼

在這第三篇文章中,咱們將首先來重溫一下 useState 。在以前的兩篇教程中,咱們能夠說和 useState 並肩做戰了好久,是咱們很是「熟悉」的老朋友了。可是回過頭來,咱們真的足夠了解它嗎?編程

一個未解決的問題

你頗有可能在使用 useState 的時候遇到過一個問題:經過 Setter 修改狀態的時候,怎麼讀取上一個狀態值,並在此基礎上修改呢?若是你看文檔足夠細緻,應該會注意到 useState 有一個函數式更新(Functional Update)的用法,如下面這段計數器(代碼來自 React 官網)爲例:redux

function Counter({initialCount}) {
  const [count, setCount] = useState(initialCount);
  return (
    <> Count: {count} <button onClick={() => setCount(initialCount)}>Reset</button> <button onClick={() => setCount(prevCount => prevCount - 1)}>-</button> <button onClick={() => setCount(prevCount => prevCount + 1)}>+</button> </> ); } 複製代碼

能夠看到,咱們傳入 setCount 的是一個函數,它的參數是以前的狀態,返回的是新的狀態。熟悉 Redux 的朋友立刻就指出來了:這其實就是一個 Reducer 函數api

Reducer 函數的前生今世

Redux 文檔裏面已經詳細地闡述了 Reducer 函數,可是咱們這裏將先回歸最基礎的概念,暫時忘掉框架相關的知識。在學習 JavaScript 基礎時,你應該接觸過數組的 reduce 方法,它能夠用一種至關炫酷的方式實現數組求和:數組

const nums = [1, 2, 3]
const value = nums.reduce((acc, next) => acc + next, 0)
複製代碼

其中 reduce 的第一個參數 (acc, next) => acc + next 就是一個 Reducer 函數。從表面上來看,這個函數接受一個狀態的累積值 acc 和新的值 next,而後返回更新事後的累積值 acc + next。從更深層次來講,Reducer 函數有兩個必要規則bash

  • 只返回一個值
  • 不修改輸入值,而是返回新的值

第一點很好判斷,其中第二點則是不少新手踩過的坑,對比如下兩個函數:

// 不是 Reducer 函數!
function buy(cart, thing) {
  cart.push(thing);
  return cart;
}

// 正宗的 Reducer 函數
function buy(cart, thing) {
  return cart.concat(thing);
}
複製代碼

上面的函數調用了數組的 push 方法,會就地修改輸入的 cart 參數(是否 return 都無所謂了),違反了 Reducer 第二條規則,而下面的函數經過數組的 concat 方法返回了一個全新的數組,避免了直接修改 cart

咱們回過頭來看以前 useState 的函數式更新寫法:

setCount(prevCount => prevCount + 1);
複製代碼

是否是一個很標準的 Reducer?

最熟悉的陌生人

咱們在前兩篇教程中大量地使用了 useState,你可能就此認爲 useState 應該是最底層的元素了。但實際上在 React 的源碼中,useState 的實現使用了 useReducer(本文的主角,下面會講到)。在 React 源碼中有這麼一個關鍵的函數 basicStateReducer(去掉了源碼中的 Flow 類型定義):

function basicStateReducer(state, action) {
  return typeof action === 'function' ? action(state) : action;
}
複製代碼

因而,當咱們經過 setCount(prevCount => prevCount + 1) 改變狀態時,傳入的 action 就是一個 Reducer 函數,而後調用該函數並傳入當前的 state,獲得更新後的狀態。而咱們以前經過傳入具體的值修改狀態時(例如 setCount(5)),因爲不是函數,因此直接取傳入的值做爲更新後的狀態。

提示

這裏選取的是 React v16.13.1 的源碼,可是總體的實現應該已經趨於穩定,原理上不會相差太多。

聽上去仍是有點迷迷糊糊?又到了咱們的動畫環節。首先,咱們傳入的 action 是一個具體的值:

當傳入 Setter 的是一個 Reducer 函數的時候:

是否是一會兒就豁然開朗了?

實戰環節

這一步要寫的代碼比較多(可自行復制粘貼哈),咱們要實現以下圖所示的歷史趨勢圖展現效果:

注意到咱們展現了三個歷史趨勢(確診病例 Cases、死亡病例 Deaths 和治癒病例 Recovered),而且每張歷史趨勢圖能夠調節過去的天數(從 0 到 30 天)。

實現歷史趨勢圖

首先,讓咱們來實現歷史曲線圖 HistoryChart 組件。建立 src/components/HistoryChart.js 組件,代碼以下:

// src/components/HistoryChart.js
import React from "react";
import {
  AreaChart,
  CartesianGrid,
  XAxis,
  YAxis,
  Tooltip,
  Area,
} from "recharts";

const TITLE2COLOR = {
  Cases: "#D0021B",
  Deaths: "#4A4A4A",
  Recovered: "#09C79C",
};

function HistoryChart({ title, data, lastDays, onLastDaysChange }) {
  const colorKey = `color${title}`;
  const color = TITLE2COLOR[title];

  return (
    <div>
      <AreaChart
        width={400}
        height={150}
        data={data.slice(-lastDays)}
        margin={{ top: 10, right: 30, left: 10, bottom: 0 }}
      >
        <defs>
          <linearGradient id={colorKey} x1='0' y1='0' x2='0' y2='1'>
            <stop offset='5%' stopColor={color} stopOpacity={0.8} />
            <stop offset='95%' stopColor={color} stopOpacity={0} />
          </linearGradient>
        </defs>
        <XAxis dataKey='time' />
        <YAxis />
        <CartesianGrid strokeDasharray='3 3' />
        <Tooltip />
        <Area
          type='monotone'
          dataKey='number'
          stroke={color}
          fillOpacity={1}
          fill={`url(#${colorKey})`}
        />
      </AreaChart>
      <h3>{title}</h3>
      <input
        type='range'
        min='1'
        max='30'
        value={lastDays}
        onChange={onLastDaysChange}
      />
      Last {lastDays} days
    </div>
  );
}

export default HistoryChart;
複製代碼

這裏咱們使用了 RechartsAreaChart 組件來繪製歷史趨勢圖,而後在圖表下方添加了一個範圍拖動條,可以讓用戶選擇查看過去 1 到 30 天的歷史趨勢。

HistoryChart 組件包含如下 Props:

  • title 是圖表標題
  • data 就是繪製圖表須要的歷史數據
  • lastDays 是顯示過去 N 天的數據,能夠經過 data.slice(-lastDays) 進行選擇
  • onLastDaysChange 是用戶經過 input 修改處理過去 N 天時的事件處理函數

接着,咱們須要一個輔助函數來對歷史數據進行一些轉換處理。NovelCOVID 19 API 返回的歷史數據是一個對象:

{
  "3/28/20": 81999,
  "3/29/20": 82122
}
複製代碼

爲了可以適應 Recharts 的數據格式,咱們但願轉換成數組格式:

[
  {
    time: "3/28/20",
    number: 81999
  },
  {
    time: "3/29/20",
    number: 82122
  }
]
複製代碼

這個能夠經過 Object.entries 很方便地進行轉換。咱們建立 src/utils.js 文件,實現 transformHistory 函數,代碼以下:

// src/utils.js
export function transformHistory(timeline = {}) {
  return Object.entries(timeline).map((entry) => {
    const [time, number] = entry;
    return { time, number };
  });
}
複製代碼

接着咱們來實現歷史趨勢圖組 HistoryChartGroup,包含三個圖表:確診病例(Cases)、死亡人數(Deaths)和治癒病例(Recovered)。建立 src/components/HistoryChartGroup.js,代碼以下:

// src/components/HistoryChartGroup.js
import React, { useState } from "react";

import HistoryChart from "./HistoryChart";
import { transformHistory } from "../utils";

function HistoryChartGroup({ history = {} }) {
  const [lastDays, setLastDays] = useState({
    cases: 30,
    deaths: 30,
    recovered: 30,
  });

  function handleLastDaysChange(e, key) {
    setLastDays((prev) => ({ ...prev, [key]: e.target.value }));
  }

  return (
    <div className='history-group'>
      <HistoryChart
        title='Cases'
        data={transformHistory(history.cases)}
        lastDays={lastDays.cases}
        onLastDaysChange={(e) => handleLastDaysChange(e, "cases")}
      />
      <HistoryChart
        title='Deaths'
        data={transformHistory(history.deaths)}
        lastDays={lastDays.deaths}
        onLastDaysChange={(e) => handleLastDaysChange(e, "deaths")}
      />
      <HistoryChart
        title='Recovered'
        data={transformHistory(history.recovered)}
        lastDays={lastDays.recovered}
        onLastDaysChange={(e) => handleLastDaysChange(e, "recovered")}
      />
    </div>
  );
}

export default HistoryChartGroup;
複製代碼

調整 CountriesChart 組件

咱們須要稍微調整一下 CountriesChart 組件,使得用戶在點擊一個國家的數據後,可以展現對應的歷史趨勢圖。打開 src/components/CountriesChart.js,添加一個 onClick Prop,並傳入 BarChart 中,以下面的代碼所示:

// src/components/CountriesChart.js
// ...

function CountriesChart({ data, dataKey, onClick }) {
  return (
    <BarChart width={1200} height={250} style={{ margin: "auto" }} margin={{ top: 30, left: 20, right: 30 }} data={data} onClick={onClick} > // ... </BarChart>
  );
}

// ...
複製代碼

在根組件中集成

最後,咱們調整根組件,把以前實現的歷史趨勢圖和修改後的 CountriesChart 集成到應用中。打開 src/App.js,代碼以下:

// src/App.js
// ...
import HistoryChartGroup from "./components/HistoryChartGroup";

function App() {
  // ...

  const [country, setCountry] = useState(null);
  const history = useCoronaAPI(`/historical/${country}`, {
    initialData: {},
    converter: (data) => data.timeline,
  });

  return (
    <div className='App'>
      <h1>COVID-19</h1>
      <GlobalStats stats={globalStats} />
      <SelectDataKey onChange={(e) => setKey(e.target.value)} />
      <CountriesChart
        data={countries}
        dataKey={key}
        onClick={(payload) => setCountry(payload.activeLabel)}
      />

      {country ? (
        <>
          <h2>History for {country}</h2>
          <HistoryChartGroup history={history} />
        </>
      ) : (
        <h2>Click on a country to show its history.</h2>
      )}
    </div>
  );
}

export default App;
複製代碼

成功

寫完以後開啓項目,點擊直方圖中的任意一個國家,就會展現該國家的歷史趨勢圖(累計確診、死亡病例、治癒病例),咱們還能夠隨意調節過去的天數。

雖然如今咱們的應用已經初步成型,但回過頭來看代碼,發現組件的狀態和修改狀態的邏輯散落在各個組件中,後面維護和實現新功能時無疑會遇到很大的困難,這時候就須要作專門的狀態管理了。熟悉 React 開發的同窗必定知道 Redux 或者 MobX 這樣的庫,不過藉助 React Hooks,咱們能夠本身輕鬆地實現一個輕量級的狀態管理解決方案。

useReducer + useContext:呼風喚雨

在以前咱們說過,這篇文章將經過 React Hooks 來實現一個輕量級的、相似 Redux 的狀態管理模型。不過在此以前,咱們先簡單地過一遍 Redux 的基本思想(熟悉的同窗能夠直接跳過哈)。

Redux 基本思想

以前,應用的狀態(例如咱們應用中當前國家、歷史數據等等)散落在各個組件中,大概就像這樣:

能夠看到,每一個組件都有本身的 State(狀態)和 State Setter(狀態修改函數),這意味着跨組件的狀態讀取和修改是至關麻煩的。而 Redux 的核心思想之一就是將狀態放到惟一的全局對象(通常稱爲 Store)中,而修改狀態則是調用對應的 Reducer 函數去更新 Store 中的狀態,大概就像這樣:

上面這個動畫描述的是組件 A 改變 B 和 C 中狀態的過程:

  • 三個組件掛載時,從 Store 中獲取並訂閱相應的狀態數據並展現(注意是只讀的,不能直接修改)
  • 用戶點擊組件 A,觸發事件監聽函數
  • 監聽函數中派發(Dispatch)對應的動做(Action),傳入 Reducer 函數
  • Reducer 函數返回更新後的狀態,並以此更新 Store
  • 因爲組件 B 和 C 訂閱了 Store 的狀態,因此從新獲取更新後的狀態並調整 UI

提示

這篇教程不會詳細地講解 Redux,想要深刻學習的同窗能夠閱讀咱們的《Redux 包教包會》系列教程。

useReducer 使用淺析

首先,咱們仍是來看下官方介紹的 useReducer 使用方法:

const [state, dispatch] = useReducer(reducer, initialArg, init);
複製代碼

首先咱們來看下 useReducer 須要提供哪些參數:

  1. 第一個參數 reducer 顯然是必須的,它的形式跟 Redux 中的 Reducer 函數徹底一致,即 (state, action) => newState
  2. 第二個參數 initialArg 就是狀態的初始值。
  3. 第三個參數 init 是一個可選的用於懶初始化(Lazy Initialization)的函數,這個函數返回初始化後的狀態。

返回的 state(只讀狀態)和 dispatch(派發函數)則比較容易理解了。咱們來結合一個簡單的計數器例子講解一下:

// Reducer 函數
function reducer(state, action) {
  switch (action.type) {
    case 'increment':
      return { count: state.count + 1 };
    default:
      throw new Error();
  }
}

function Counter() {
  const [state, dispatch] = useReducer(reducer, { count: 0 });
  return (
    <> Count: {state.count} <button onClick={() => dispatch({ type: 'increment' })}>+</button> </> ); } 複製代碼

咱們首先關注一下 Reducer 函數,它的兩個參數 stateaction 分別是當前狀態和 dispatch 派發的動做。這裏的動做就是普通的 JavaScript 對象,用來表示修改狀態的操做,注意 type 是必需要有的屬性,表明動做的類型。而後咱們根據 action 的類型返回相應修改後的新狀態。

而後在 Counter 組件中,咱們經過 useReducer 鉤子獲取到了狀態和 dispatch 函數,而後把這個狀態渲染出來。在按鈕 buttononClick 回調函數中,咱們經過 dispatch 一個類型爲 increment 的 Action 去更新狀態。

天哪,爲何一個簡單的計數器都搞得這麼複雜!簡簡單單一個 useState 不就搞定了嗎?

何時該用 useReducer

你也許發現,useReduceruseState 的使用目的幾乎是同樣的:定義狀態和修改狀態的邏輯useReducer 使用起來較爲繁瑣,但若是你的狀態管理出現了至少一個如下所列舉的問題:

  • 須要維護的狀態自己比較複雜,多個狀態之間相互依賴
  • 修改狀態的過程比較複雜

那麼咱們就強烈建議你使用 useReducer 了。咱們來經過一個實際的案例講解來感覺一下 useReducer 的威力(此次不是無聊的計數器啦)。假設咱們要作一個支持撤銷重作的編輯器,它的 init 函數和 Reducer 函數分別以下:

// 用於懶初始化的函數
function init(initialState) {
  return {
    past: [],
    present: initialState,
    future: [],
  };
}

// Reducer 函數
function reducer(state, action) {
  const { past, future, present } = state;
  switch (action.type) {
    case 'UNDO':
      return {
        past: past.slice(0, past.length - 1),
        present: past[past.length - 1],
        future: [present, ...future],
      };
    case 'REDO':
      return {
        past: [...past, present],
        present: future[0],
        future: future.slice(1),
      };
    default:
      return state;
  }
}
複製代碼

試試看用 useState 去寫,會不會很複雜?

useContext 使用淺析

如今狀態的獲取和修改都已經經過 useReducer 搞定了,那麼只差一個問題:怎麼讓全部組件都能獲取到 dispatch 函數呢?

在 Hooks 誕生以前,React 已經有了在組件樹中共享數據的解決方案:Context。在類組件中,咱們能夠經過 Class.contextType 屬性獲取到最近的 Context Provider,那麼在函數式組件中,咱們該怎麼獲取呢?答案就是 useContext 鉤子。使用起來很是簡單:

// 在某個文件中定義 MyContext
const MyContext = React.createContext('hello');

// 在函數式組件中獲取 Context
function Component() {
  const value = useContext(MyContext);
  // ...
}
複製代碼

經過 useContext ,咱們就能夠輕鬆地讓全部組件都能獲取到 dispatch 函數了!

實戰環節

設計中心狀態

好的,讓咱們開始用 useReducer + useContext 的組合來重構應用的狀態管理。按照狀態中心化的原則,咱們把整個應用的狀態提取到一個全局對象中。初步設計(TypeScript 類型定義)以下:

type AppState {
  // 數據指標類別
  key: "cases" | "deaths" | "recovered";

  // 當前國家
  country: string | null;

  // 過去天數
  lastDays: {
    cases: number;
    deaths: number;
    recovered: number;
  }
}
複製代碼

在根組件中定義 Reducer 和 Dispatch Context

這一次咱們按照自頂向下的順序,先在根組件 App 中配置好全部須要的 Reducer 以及 Dispatch 上下文。打開 src/App.js ,修改代碼以下:

// src/App.js
import React, { useReducer } from "react";

// ...

const initialState = {
  key: "cases",
  country: null,
  lastDays: {
    cases: 30,
    deaths: 30,
    recovered: 30,
  },
};

function reducer(state, action) {
  switch (action.type) {
    case "SET_KEY":
      return { ...state, key: action.key };
    case "SET_COUNTRY":
      return { ...state, country: action.country };
    case "SET_LASTDAYS":
      return {
        ...state,
        lastDays: { ...state.lastDays, [action.key]: action.days },
      };
    default:
      return state;
  }
}

// 用於傳遞 dispatch 的 React Context
export const AppDispatch = React.createContext(null);

function App() {
  const [state, dispatch] = useReducer(reducer, initialState);
  const { key, country, lastDays } = state;

  const globalStats = useCoronaAPI("/all", {
    initialData: {},
    refetchInterval: 5000,
  });

  const countries = useCoronaAPI(`/countries?sort=${key}`, {
    initialData: [],
    converter: (data) => data.slice(0, 10),
  });

  const history = useCoronaAPI(`/historical/${country}`, {
    initialData: {},
    converter: (data) => data.timeline,
  });

  return (
    <AppDispatch.Provider value={dispatch}>
      <div className='App'>
        <h1>COVID-19</h1>
        <GlobalStats stats={globalStats} />
        <SelectDataKey />
        <CountriesChart data={countries} dataKey={key} />

        {country ? (
          <>
            <h2>History for {country}</h2>
            <HistoryChartGroup history={history} lastDays={lastDays} />
          </>
        ) : (
          <h2>Click on a country to show its history.</h2>
        )}
      </div>
    </AppDispatch.Provider>
  );
}

export default App;
複製代碼

咱們來一一分析上面的代碼變化:

  1. 首先定義了整個應用的初始狀態 initialState,這個是後面 useReducer 鉤子所須要的
  2. 而後咱們定義了 Reducer 函數,主要響應三個 Action:SET_KEYSET_COUNTRYSET_LASTDAYS ,分別用於修改數據指標、國家和過去天數這三個狀態
  3. 定義了 AppDispatch 這個 Context,用來向子組件傳遞 dispatch
  4. 調用 useReducer 鉤子,獲取到狀態 state 和分發函數 dispatch
  5. 最後在渲染時用 AppDispatch.Provider 將整個應用包裹起來,傳入 dispatch ,使子組件都能獲取獲得

在子組件中經過 Dispatch 修改狀態

如今子組件的全部狀態都已經提取到了根組件中,而子組件惟一要作的就是在響應用戶事件時經過 dispatch 去修改中心狀態。思路很是簡單:

  • 先經過 useContext 獲取到 App 組件傳下來的 dispatch
  • 調用 dispatch ,發起相應的動做(Action)

OK,讓咱們開始動手吧。打開 src/components/CountriesChart.js ,修改代碼以下:

// src/components/CountriesChart.js
import React, { useContext } from "react";
// ...
import { AppDispatch } from "../App";

function CountriesChart({ data, dataKey }) {
  const dispatch = useContext(AppDispatch);

  function onClick(payload = {}) {
    if (payload.activeLabel) {
      dispatch({ type: "SET_COUNTRY", country: payload.activeLabel });
    }
  }

  return (
    // ...
  );
}

export default CountriesChart;
複製代碼

按照一樣的思路,咱們來修改 src/components/HistoryChartGroup.js 組件:

// src/components/HistoryChartGroup.js
import React, { useContext } from "react";

import HistoryChart from "./HistoryChart";
import { transformHistory } from "../utils";
import { AppDispatch } from "../App";

function HistoryChartGroup({ history = {}, lastDays = {} }) {
  const dispatch = useContext(AppDispatch);

  function handleLastDaysChange(e, key) {
    dispatch({ type: "SET_LASTDAYS", key, days: e.target.value });
  }

  return (
    // ...
  );
}

export default HistoryChartGroup;
複製代碼

最後一千米,修改 src/components/SelectDataKey.js

// src/components/SelectDataKey.js
import React, { useContext } from "react";
import { AppDispatch } from "../App";

function SelectDataKey() {
  const dispatch = useContext(AppDispatch);

  function onChange(e) {
    dispatch({ type: "SET_KEY", key: e.target.value });
  }

  return (
    // ...
  );
}

export default SelectDataKey;
複製代碼

重構完成,把項目跑起來,應該會發現和上一步的功能分絕不差。

提示

若是你熟悉 Redux,會發現咱們的重構存在一個小小的遺憾:子組件只能經過傳遞 Props 的方式獲取根組件 App 中的 state 。一個變通之計是經過把 state 也裝進 Context 來解決,但若是遇到這種需求,筆者仍是建議直接使用 Redux。

Redux 還有用嗎:Control 與 Context 之爭

聽到有些聲音說有了 React Hooks,都不須要 Redux 了。那 Redux 到底還有用嗎?

在回答這個問題以前,請容許我先胡思亂想一波。React Hooks 確實強大得可怕,特別是經過優秀的第三方自定義 Hooks 庫,幾乎能讓每一個組件都能遊刃有餘地處理複雜的業務邏輯。反觀 Redux,它的核心思想就是將狀態和修改狀態的操做所有集中起來進行。

有沒有發現,這其實恰好對應了兩種管理學思想 Context 和 Control?

管理者須要 Context,not Control。—— 字節跳動創始人和 CEO 張一鳴

Control 就是將權力集中起來,員工們只需有條不紊地按照 CEO 的決策執行相應的任務,就像 Redux 中的全局 Store 是」惟一的真相來源「(Single Source of Truth),全部狀態和數據流的更新必須通過 Store;而 Context 就是給予各部門、各層級足夠的決策權,由於他們所擁有的上下文更充足,專業度也更好,就像 React 中響應特定邏輯的組件具備更充足的上下文,而且能夠藉助 Hooks 」自給自足「地執行任務,而無需依賴全局的 Store。

聊到這裏,我想你內心已經有本身的答案了。若是你想要分享的話,記得在評論區留言哦~

參考資料

想要學習更多精彩的實戰技術教程?來圖雀社區逛逛吧。

相關文章
相關標籤/搜索