若是您以为咱们写得还不错,记得 点赞 + 关注 + 评论 三连,鼓励咱们写出更好的教程💪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。
聊到这里,我想你内心已经有本身的答案了。若是你想要分享的话,记得在评论区留言哦~
想要学习更多精彩的实战技术教程?来图雀社区逛逛吧。