useState:柳暗花明 欢迎继续阅读《用动画和实战打开 React Hooks 系列》:
如果你想要直接从这一篇开始学习,那么请克隆我们为你提供的源代码:
git clone -b third-part https://github.com/tuture-dev/covid-19-with-hooks.git git clone -b third-part https://gitee.com/tuture/covid-19-with-hooks.git
在这第三篇文章中,我们将首先来重温一下 useState
。在之前的两篇教程中,我们可以说和 useState
并肩作战了很久,是我们非常“熟悉”的老朋友了。但是回过头来,我们真的足够了解它吗?
一个未解决的问题 你很有可能在使用 useState
的时候遇到过一个问题:通过 Setter 修改状态的时候,怎么读取上一个状态值,并在此基础上修改呢?如果你看文档足够细致,应该会注意到 useState
有一个函数式更新 (Functional Update)的用法,以下面这段计数器(代码来自 React 官网 )为例:
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 函数 。
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 函数有两个必要规则 :
第一点很好判断,其中第二点则是很多新手踩过的坑,对比以下两个函数:
function buy (cart, thing ) { cart.push(thing); return cart; } 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 组件 我们需要稍微调整一下 CountriesChart
组件,使得用户在点击一个国家的数据后,能够展示对应的历史趋势图。打开 src/components/CountriesChart.js
,添加一个 onClick
Prop,并传入 BarChart
中,如下面的代码所示:
src/components/CountriesChart.js 查看完整代码 [tuture-del]function CountriesChart ({ data, dataKey } ) { [tuture-add]function CountriesChart ({ data, dataKey, onClick } ) { return ( <BarChart width={1200 } height={250 } style={{ margin : "auto" }} margin={{ top : 30 , left : 20 , right : 30 }} data={data} [tuture-add] onClick={onClick} > </BarChart> ); } / / ...
在根组件中集成 最后,我们调整根组件,把之前实现的历史趋势图和修改后的 CountriesChart
集成到应用中。打开 src/App.js
,代码如下:
src/App.js 查看完整代码 [tuture-add]import HistoryChartGroup from "./components/HistoryChartGroup" ; function App ( ) { [tuture-add] const [country, setCountry] = useState(null ); [tuture-add] const history = useCoronaAPI(`/historical/${country} ` , { [tuture-add] initialData: {}, [tuture-add] converter: (data ) => data.timeline, [tuture-add] }); [tuture-add] return ( <div className='App' > <h1>COVID-19 </h1> <GlobalStats stats={globalStats} / > <SelectDataKey onChange={(e) => setKey(e.target.value)} /> [tuture-del] <CountriesChart data={countries} dataKey={key} /> [tuture-add] <CountriesChart [tuture-add] data={countries} [tuture-add] dataKey={key} [tuture-add] onClick={(payload) => setCountry(payload.activeLabel)} [tuture-add] /> [tuture-add] [tuture-add] {country ? ( [tuture-add] <> [tuture-add] <h2>History for {country}</h2> [tuture-add] <HistoryChartGroup history={history} / >[tuture-add] </> [tuture-add] ) : ( [tuture-add] <h2>Click on a country to show its history.</ h2>[tuture-add] )} </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
useReducer 使用浅析 首先,我们还是来看下官方介绍的 useReducer
使用方法:
const [state, dispatch] = useReducer(reducer, initialArg, init);
首先我们来看下 useReducer
需要提供哪些参数:
第一个参数 reducer
显然是必须的,它的形式跟 Redux 中的 Reducer 函数完全一致,即 (state, action) => newState
。
第二个参数 initialArg
就是状态的初始值。
第三个参数 init
是一个可选的用于懒初始化 (Lazy Initialization)的函数,这个函数返回初始化后的状态。
返回的 state
(只读状态)和 dispatch
(派发函数)则比较容易理解了。我们来结合一个简单的计数器例子讲解一下:
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 你也许发现,useReducer
和 useState
的使用目的几乎是一样的:定义状态和修改状态的逻辑 。useReducer
使用起来较为繁琐,但如果你的状态管理出现了至少一个以下所列举的问题:
需要维护的状态本身比较复杂,多个状态之间相互依赖
修改状态的过程比较复杂
那么我们就强烈建议你使用 useReducer
了。我们来通过一个实际的案例讲解来感受一下 useReducer
的威力(这次不是无聊的计数器啦)。假设我们要做一个支持撤销 和重做 的编辑器,它的 init
函数和 Reducer 函数分别如下:
function init (initialState ) { return { past: [], present: initialState, future: [], }; } 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
钩子。使用起来非常简单:
const MyContext = React.createContext('hello' );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 查看完整代码 [tuture-del]import React, { useState } from "react" ; [tuture-add]import React, { useReducer } from "react" ; [tuture-add]const initialState = { [tuture-add] key: "cases" , [tuture-add] country: null , [tuture-add] lastDays: { [tuture-add] cases: 30 , [tuture-add] deaths: 30 , [tuture-add] recovered: 30 , [tuture-add] }, [tuture-add]}; [tuture-add] [tuture-add]function reducer (state, action ) { [tuture-add] switch (action.type) { [tuture-add] case "SET_KEY" : [tuture-add] return { ...state, key : action.key }; [tuture-add] case "SET_COUNTRY" : [tuture-add] return { ...state, country : action.country }; [tuture-add] case "SET_LASTDAYS" : [tuture-add] return { [tuture-add] ...state, [tuture-add] lastDays: { ...state.lastDays, [action.key]: action.days }, [tuture-add] }; [tuture-add] default : [tuture-add] return state; [tuture-add] } [tuture-add]} [tuture-add] [tuture-add] [tuture-add]export const AppDispatch = React.createContext(null ); [tuture-add] function App ( ) {[tuture-add] const [state, dispatch] = useReducer(reducer, initialState); [tuture-add] const { key, country, lastDays } = state; [tuture-add] const globalStats = useCoronaAPI("/all" , { initialData: {}, refetchInterval: 5000 , }); [tuture-del] const [key, setKey] = useState("cases" ); const countries = useCoronaAPI(`/countries?sort=${key} ` , { initialData: [], converter: (data ) => data.slice(0 , 10 ), }); [tuture-del] const [country, setCountry] = useState(null ); const history = useCoronaAPI(`/historical/${country} ` , { initialData: {}, converter: (data ) => data.timeline, }); return ( [tuture-del] <div className='App' > [tuture-del] <h1>COVID-19 </h1> [tuture-del] <GlobalStats stats={globalStats} / >[tuture-del] <SelectDataKey onChange={(e) => setKey(e.target.value)} /> [tuture-del] <CountriesChart [tuture-del] data={countries} [tuture-del] dataKey={key} [tuture-del] onClick={(payload) => setCountry(payload.activeLabel)} [tuture-del] /> [tuture-del] [tuture-del] {country ? ( [tuture-del] <> [tuture-del] <h2>History for {country}</h2> [tuture-del] <HistoryChartGroup history={history} / >[tuture-del] </> [tuture-del] ) : ( [tuture-del] <h2>Click on a country to show its history.</ h2>[tuture-del] )} [tuture-del] </div> [tuture-add] <AppDispatch.Provider value={dispatch}> [tuture-add] <div className='App'> [tuture-add] <h1>COVID-19</ h1>[tuture-add] <GlobalStats stats={globalStats} /> [tuture-add] <SelectDataKey /> [tuture-add] <CountriesChart data={countries} dataKey={key} /> [tuture-add] [tuture-add] {country ? ( [tuture-add] <> [tuture-add] <h2>History for {country}</h2> [tuture-add] <HistoryChartGroup history={history} lastDays={lastDays} / >[tuture-add] </> [tuture-add] ) : ( [tuture-add] <h2>Click on a country to show its history.</ h2>[tuture-add] )} [tuture-add] </div> [tuture-add] </ AppDispatch.Provider> ); } export default App;
我们来一一分析上面的代码变化:
首先定义了整个应用的初始状态 initialState
,这个是后面 useReducer
钩子所需要的
然后我们定义了 Reducer 函数,主要响应三个 Action:SET_KEY
、SET_COUNTRY
和 SET_LASTDAYS
,分别用于修改数据指标、国家和过去天数这三个状态
定义了 AppDispatch
这个 Context,用来向子组件传递 dispatch
调用 useReducer
钩子,获取到状态 state
和分发函数 dispatch
最后在渲染时用 AppDispatch.Provider
将整个应用包裹起来,传入 dispatch
,使子组件都能获取得到
在子组件中通过 Dispatch 修改状态 现在子组件的所有状态都已经提取到了根组件中,而子组件唯一要做的就是在响应用户事件时通过 dispatch
去修改中心状态。思路非常简单:
先通过 useContext
获取到 App
组件传下来的 dispatch
调用 dispatch
,发起相应的动作(Action)
OK,让我们开始动手吧。打开 src/components/CountriesChart.js
,修改代码如下:
src/components/CountriesChart.js 查看完整代码 [tuture-del]import React from "react" ; [tuture-add]import React, { useContext } from "react" ; [tuture-add]import { AppDispatch } from "../App" ; [tuture-add] [tuture-add]function CountriesChart ({ data, dataKey } ) { [tuture-add] const dispatch = useContext(AppDispatch); [tuture-add] [tuture-add] function onClick (payload = {} ) { [tuture-add] if (payload.activeLabel) { [tuture-add] dispatch({ type : "SET_COUNTRY" , country : payload.activeLabel }); [tuture-add] } [tuture-add] } [tuture-del]function CountriesChart ({ data, dataKey, onClick } ) { return ( ); } export default CountriesChart;
按照同样的思路,我们来修改 src/components/HistoryChartGroup.js
组件:
src/components/HistoryChartGroup.js 查看完整代码 [tuture-del]import React, { useState } from "react" ; [tuture-add]import React, { useContext } from "react" ; import HistoryChart from "./HistoryChart" ;import { transformHistory } from "../utils" ;[tuture-add]import { AppDispatch } from "../App" ; [tuture-del]function HistoryChartGroup ({ history = {} } ) { [tuture-del] const [lastDays, setLastDays] = useState({ [tuture-del] cases: 30 , [tuture-del] deaths: 30 , [tuture-del] recovered: 30 , [tuture-del] }); [tuture-add]function HistoryChartGroup ({ history = {}, lastDays = {} } ) { [tuture-add] const dispatch = useContext(AppDispatch); function handleLastDaysChange (e, key ) { [tuture-del] setLastDays((prev ) => ({ ...prev, [key]: e.target.value })); [tuture-add] dispatch({ type : "SET_LASTDAYS" , key, days : e.target.value }); } return ( ); } export default HistoryChartGroup;
最后一公里,修改 src/components/SelectDataKey.js
:
src/components/SelectDataKey.js 查看完整代码 [tuture-del]import React from "react" ; [tuture-add]import React, { useContext } from "react" ; [tuture-add]import { AppDispatch } from "../App" ; [tuture-add] [tuture-add]function SelectDataKey ( ) { [tuture-add] const dispatch = useContext(AppDispatch); [tuture-add] [tuture-add] function onChange (e ) { [tuture-add] dispatch({ type : "SET_KEY" , key : e.target.value }); [tuture-add] } [tuture-del]function SelectDataKey ({ onChange } ) { 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。
聊到这里,我想你心里已经有自己的答案了。如果你想要分享的话,记得在评论区留言哦~
参考资料
Sarah Drasner:Understanding the Almighty Reducer
Kingsley Silas:Getting to Know the useReducer React Hook
Kpax Qin:Redux状态管理之痛点、分析与改良
方应杭:尽量使用 useReducer,不要使用 useState(译文)
张一鸣:CEO 要避免”理性的自负”, 这错误盖茨、乔布斯都犯过