若是您以爲咱們寫得還不錯,記得 點贊 + 關注 + 評論 三連,鼓勵咱們寫出更好的教程💪html
隨着應用狀態愈來愈複雜,咱們迫切須要狀態與數據流管理的解決方案。熟悉 React 開發的同窗必定據說過 Redux,而在這篇文章中,咱們將經過 useReducer + useContext 的組合實現一個簡易版的 Redux。首先,咱們將帶你從新認識「老朋友」useState,並藉此引出這篇文章的主角:Reducer 函數與 useReducer 鉤子,並經過實戰一步步帶你理清數據流和狀態管理的基本思想。react
歡迎繼續閱讀《用動畫和實戰打開 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
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;
複製代碼
這裏咱們使用了 Recharts 的 AreaChart 組件來繪製歷史趨勢圖,而後在圖表下方添加了一個範圍拖動條,可以讓用戶選擇查看過去 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
組件,使得用戶在點擊一個國家的數據後,可以展現對應的歷史趨勢圖。打開 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,咱們能夠本身輕鬆地實現一個輕量級的狀態管理解決方案。
在以前咱們說過,這篇文章將經過 React Hooks 來實現一個輕量級的、相似 Redux 的狀態管理模型。不過在此以前,咱們先簡單地過一遍 Redux 的基本思想(熟悉的同窗能夠直接跳過哈)。
以前,應用的狀態(例如咱們應用中當前國家、歷史數據等等)散落在各個組件中,大概就像這樣:
能夠看到,每一個組件都有本身的 State(狀態)和 State Setter(狀態修改函數),這意味着跨組件的狀態讀取和修改是至關麻煩的。而 Redux 的核心思想之一就是將狀態放到惟一的全局對象(通常稱爲 Store)中,而修改狀態則是調用對應的 Reducer 函數去更新 Store 中的狀態,大概就像這樣:
上面這個動畫描述的是組件 A 改變 B 和 C 中狀態的過程:
提示
這篇教程不會詳細地講解 Redux,想要深刻學習的同窗能夠閱讀咱們的《Redux 包教包會》系列教程。
首先,咱們仍是來看下官方介紹的 useReducer
使用方法:
const [state, dispatch] = useReducer(reducer, initialArg, init);
複製代碼
首先咱們來看下 useReducer
須要提供哪些參數:
reducer
顯然是必須的,它的形式跟 Redux 中的 Reducer 函數徹底一致,即 (state, action) => newState
。initialArg
就是狀態的初始值。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 函數,它的兩個參數 state
和 action
分別是當前狀態和 dispatch
派發的動做。這裏的動做就是普通的 JavaScript 對象,用來表示修改狀態的操做,注意 type
是必需要有的屬性,表明動做的類型。而後咱們根據 action
的類型返回相應修改後的新狀態。
而後在 Counter
組件中,咱們經過 useReducer
鉤子獲取到了狀態和 dispatch
函數,而後把這個狀態渲染出來。在按鈕 button
的 onClick
回調函數中,咱們經過 dispatch
一個類型爲 increment
的 Action 去更新狀態。
天哪,爲何一個簡單的計數器都搞得這麼複雜!簡簡單單一個 useState
不就搞定了嗎?
你也許發現,useReducer
和 useState
的使用目的幾乎是同樣的:定義狀態和修改狀態的邏輯。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
去寫,會不會很複雜?
如今狀態的獲取和修改都已經經過 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;
}
}
複製代碼
這一次咱們按照自頂向下的順序,先在根組件 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;
複製代碼
咱們來一一分析上面的代碼變化:
initialState
,這個是後面 useReducer
鉤子所須要的SET_KEY
、SET_COUNTRY
和 SET_LASTDAYS
,分別用於修改數據指標、國家和過去天數這三個狀態AppDispatch
這個 Context,用來向子組件傳遞 dispatch
useReducer
鉤子,獲取到狀態 state
和分發函數 dispatch
AppDispatch.Provider
將整個應用包裹起來,傳入 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。
聽到有些聲音說有了 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。
聊到這裏,我想你內心已經有本身的答案了。若是你想要分享的話,記得在評論區留言哦~
想要學習更多精彩的實戰技術教程?來圖雀社區逛逛吧。