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