在 React 的学习过程中,有一个大 boss 拦路虎。他不仅概念多,理解起来困难,使用起来也很麻烦,他给 React 学习者带来了巨大的痛苦。因此他臭名昭著。有许多前端开发者因为讨厌他而放弃了 React。但怪就怪在,很多大佬会觉得这个方案非常厉害。
他就是 redux.
在刚开始的时候,redux 几乎是 React 项目中的唯一状态管理方案,为了解决他的一系列问题,基于 redux 又发展出来许多技术方案,例如 redux-thunk,redux-saga,dva 等,这又无形中增加了大量的学习成本。
正是由于他臭名昭著,以致于在 react hooks 出来之后,大家都在积极探索如何在项目中寻找替代 redux 的状态管理方案。最后他才开始逐渐淡化。许多项目开始放弃使用 redux,寻找其他的替代品,例如,基于数据劫持的 Mobx,使用更简单的 zustand,官方团队推出的 Recoil,以及我自己封装 Moz
Moz 对 TS 的支持非常完善,能自动推导出返回类型,无需额外定义,小型轻量,学习成本低,欢迎大家给我点个 star
但是,如果想要成为一名资深的 React 使用者,redux 始终是我们绕不开的点。react hooks 的底层实现也大量借鉴了 redux 的思路,可能你在使用层面看到的是 useState,但是底层实现里还是 redux,react hooks 也提供了一个与 redux 概念几乎一样的 hook
useReducer
如果你不去封装一些底层库,可能会很少在项目中使用到他,因此有的人在学习过程中会忽视他的重要性。但是他的思想在大型项目中非常有用。我们借助一个场景来逐渐了解他。
场景
在许多的编辑器项目中,例如富文本编辑器,MD 编辑器,思维导图编辑器,低代码平台编辑器,代码编辑器...
我们会遇到一个非常常规的需求:撤销:向后撤销、向前撤销,ctrl + z shift + ctrl + z。作为使用者,相信大家都非常熟悉。但是作为开发者,要如何基于 React 实现这个功能呢?
这里的关键就在于,我们要回溯之前的状态,因此一个常规的思路就是,我在内存中,把每一次操作之后,对应的状态以快照的形式存起来。例如,我们编辑一篇文章
state1: 今天 state2: 今天天 state3: 今天天气 state4: 今天天气不 state5: 今天天气不错 state5: 今天天气不错!
这样存起来之后,你想要撤回到前一步的状态,就非常简单。因为都存在那里,我们只需要找出来就可以了。但是当文章内容变得越来越多,越来越多的时候,问题就出现了。
存储空间里,冗余的信息太多了。导致了越到后面,对存储空间的消耗就越大,但是带来的收益又非常低。因此,这种思路只适合编辑内容比较小的项目,无法运用在文章内容的编辑里,因为开发者无法预测用户一篇文章到底有多少字
此时我们需要转换思维。一个新的思路就是,我们只存储当前操作的内容,然后根据上一个完整的内容去整合出最新内容
例如,完整的内容我们初始化为
state: ''
一个操作内容我们记录为
action: { type: '添加', content: '今天' }
这样,我们就可以结合 state 与 action,整合出来最新的 state
state = state + action.content
当你再继续输入的时候,我们用同样的办法结合现在的 state 与 新的 action,整合最新的 state
// 上次整合的结果 state = '今天' action = { type: '添加', content: '天气' }
整合结果
state = '今天天气'
再次输入一次操作
action = { type: '添加', content: '不行' }
整合结果
state = '今天天气不行'
你发现写错了,因此你需要撤销一个步骤,此时,有两种思路,一种是我们用同样的方式记录你的撤销操作,然后根据操作类型去你刚才存的新增 action 类型列表里找到你要撤销的内容,用最新的状态减去操作内容即可
// 此时就只有一个操作类型,没有对应的数据 action = { type: '撤销' } state = state - preAction.content
也可以不用记录这次撤销操作,而是直接减也行,这根据你的需求来定。
如果你理解了这个场景,那么你也就理解了 redux,接下来,我们来学习一下 useReducer 的基础语法,他与 redux 几乎一模一样。
useReducer
在上面的场景中,我们需要记录一个操作,这个操作我们称之为 action. 在 action 中,我们往往会包括该操作的具体方式,以及对应的具体内容
action = { type: 'add', content: 'hello world' }
执行 action 的操作,我们通常称之为 dispatch
我们还需要一个根据 action 整合最新状态内容的聚合方式,在 redux 中,我们称之为 reducer
因此,useReducer 的语法为
const [state, dispatch] = useReducer(reducer, initialArg, init?)
initialArg 表示状态的初始值
init 是一个需要返回初始状态的初始化函数。如果未指定,那么初始状态就设定为 initialArg,如果指定了 init,初始状态将会采用 init(initialArg) 的执行结果
在使用层面,我们只需要想办法定义好 action 的具体内容和 reducer 的具体聚合方式,然后使用 dispatch 去执行 action 即可
dispatch({ type: 'add', content: 'hello world' })
我们使用一个简单的案例来了解他们的具体使用
image.png
具体的需求是,当你点击按钮时,字符串中的数字会增加。
我们首先考虑初始状态,将其设定为 18 岁
{age: 18}
然后,目前只有一种改变方式:增加岁数,因此,我们设定 action 表示增加 1 岁,代码表示具体为
action = { type: 'increment', age: 1 }
通常我们会在更复杂的操作场景中,将 action.type 设置为 increment/age,更贴近语义
我们要根据 state 与 action,集合出最新的 state,因此聚合的方式定义为
function reducer(state, action) { if (action.type === 'increment') { return { age: state.age + action.age } } }
最后在点击时,执行 action
onClick = () => { dispatch({ type: 'increment', age: 1 }) }
完整代码为
import { useReducer } from 'react'; function reducer(state, action) { if (action.type === 'increment') { return { age: state.age + action.age } } } export default function Counter() { const [state, dispatch] = useReducer(reducer, { age: 18 }); return ( <> <button onClick = () => { dispatch({ type: 'increment', age: 1 }) }> Increment age </button> <p>Hello! You are {state.age}.</p> </> ); }
稍微复杂一点的案例
初始时有一个列表,在 input 中,我们可以新增列表,具体的操作如下图所示。
scroll.gif
首先,我们要约定初始状态,他包括一个列表,还需要存储输入的内容。因此他至少应该有两个字段
state = { draft: '', todos: [] }
由于初始时,列表已经存在,因此我们可以约定一个方式去自己创造列表数据
function createInitialState(username) { const initialTodos = []; for (let i = 0; i < 50; i++) { initialTodos.push({ id: i, text: username + "'s task #" + (i + 1) }); } return { draft: '', todos: initialTodos, }; }
此时的操作有两个,一个是更改存储的草稿内容。一个是新增一项更改列表,因此我们设计 action 为
{ type: 'changed_draft', nextDraft: e.target.value } // 内容从草稿状态中获取即可 { type: 'added_todo' }
reducer 则为
function reducer(state, action) { switch (action.type) { case 'changed_draft': { return { draft: action.nextDraft, todos: state.todos, }; }; case 'added_todo': { return { draft: '', todos: [{ id: state.todos.length, text: state.draft }, ...state.todos] } } } throw Error('Unknown action: ' + action.type); }
完整代码如下
import { useReducer } from 'react'; function createInitialState(username) { const initialTodos = []; for (let i = 0; i < 50; i++) { initialTodos.push({ id: i, text: username + "'s task #" + (i + 1) }); } return { draft: '', todos: initialTodos, }; } function reducer(state, action) { switch (action.type) { case 'changed_draft': { return { draft: action.nextDraft, todos: state.todos, }; }; case 'added_todo': { return { draft: '', todos: [{ id: state.todos.length, text: state.draft }, ...state.todos] } } } throw Error('Unknown action: ' + action.type); } export default function TodoList({ username }) { const [state, dispatch] = useReducer( reducer, username, createInitialState ); return ( <> <input value={state.draft} onChange={e => { dispatch({ type: 'changed_draft', nextDraft: e.target.value }) }} /> <button onClick={() => { dispatch({ type: 'added_todo' }); }}>Add</button> <ul> {state.todos.map(item => ( <li key={item.id}> {item.text} </li> ))} </ul> </> ); }
变化
这个时候,你基本上已经掌握了 useReducer,但是这个解决方案是可以应对大型项目的。因此刚才我们讲的每一个点都有可能变得更加复杂。
当 action 变得更多更复杂时,我们并不想自己去手写完整的 action 内容,因此这个时候就有一种方式,写一个函数,去创建 action,以简化 action 的使用
function createAction(age) { return { type: 'increment/age', age: age } }
这个创建 action 的方法,我们称之为 actionCreator
当状态变得更复杂时,他有非常多的 key 值,每一个 key 可能都是对应一个页面的数据,因此我们会单独新起一个或者多个模块来管理这些复杂的 state,我们称这个单独的模块为数据中心 Store
当状态变得更加复杂,那么 reducer 的内部逻辑也会变得更加复杂,因此我们也会根据实际情况将 reducer 进行拆分,分散在不同的模块中去管理,最后再将他们合并在一起,因此就会引入一个新的概念合并 reducer combineReducers
因此,useReducer 能够结合 useContext 完成更复杂的状态管理。
注意事项
useState 就是基于 useReducer 实现而来,因此 dispatch 与 setState 有几乎相同的表现。他是一个异步行为,当为什么调用 dispatch 时,如果直接访问 state 的数据,无法拿到最新的 state 数据
function handleClick() { console.log(state.age); // 18 dispatch({ type: 'incremented_age' }); // Request a re-render with 19 console.log(state.age); // Still 18! setTimeout(() => { console.log(state.age); // Also 18! }, 5000); }
当 state 数据变得复杂时,在 reducer 中,我们可以使用展开运算符来聚合数据,这里一定要返回一个新的数据,而不要基于之前的 state 去做修改
function reducer(state, action) { switch (action.type) { case 'incremented_age': { return { ...state, // Don't forget this! age: state.age + 1 }; }
总结
useReducer 由于使用比较繁琐,因此在应用层面我们会很少使用到他,但是,当你能力变得越来越强,需要封装一个功能更为强大的状态管理工具时,或者解决大型项目中的特定场景时,你一定会需要到它。因此在后面的学习中,我们还需要结合 useContext 进一步学习 redux.