原文地址:https://yuzi.dev/posts/frontend/react-reducer
今天早上,我的毕设来到了这样一个需求:引导用户创建一个实例,而这个引导过程会根据用户的选择而有两种不同的路径共五种状态。所以如何比较优雅地管理这些状态便成了一个重要的问题。
将这五个状态以 0,1,2,3,4 来表示的话,第一条路径就是[0,1,2,4]
第二条就是[0,2,3,4]
(不考虑返回操作),如图:
牛刀小试:使用类
对于状态机的需求,我第一个想到了使用一个类创建的对象来管理这些状态,话不多说就之间开干!
首先使用 enum 定义一下状态:
export enum Steps {
StartUp,
ChooseTemplate,
SetInstanceConfigure,
UploadZip,
Finish,
}
随后定义一个类来操纵状态:
export class StepState {
private current = 0
private steps: Steps[] = [Steps.StartUp]
setCreateType(createType: 'template' | 'custom') {
if (createType === 'template') {
this.steps.push(
Steps.ChooseTemplate,
Steps.SetInstanceConfigure,
Steps.Finish,
)
} else {
this.steps.push(Steps.SetInstanceConfigure, Steps.UploadZip, Steps.Finish)
}
this.current = 1
}
get currentStep() {
return this.steps[this.current]
}
next() {
this.current++
}
}
然后扔进组件里:
export default function QuickStart() {
const stepState = new StepState()
const currentStep = useMemo(
() => stepState.currentStep,
[stepState.currentStep],
)
const handleSetCreateType = (type: 'template' | 'custom') => {
stepState.setCreateType(type)
}
switch (currentStep) {
case Steps.StartUp:
return <StartUp handleSetCreateType={handleSetCreateType} />
case Steps.ChooseTemplate:
return <div>选择模板</div>
case Steps.SetInstanceConfigure:
return <div>设置实例配置</div>
default:
return <div>未知错误</div>
}
}
然而结果是,点击了按钮后并没有反应。
为什么?因为这个数据并不是响应式的,我们调用我们自己类的方法改变了对象的值,React 是不知道的(useMemo
,useEffect
的 dependencies 是只能监听响应式数据的,普通数据放进去照样监听不到),所以页面没有任何变化。
所以解决方法的是转入 React 的响应式生态,才能被监听到。
useState
? useImmer
?
最直接的方法是放进 useState 中,这样数据就是响应式的了,但是有一个问题是,useState 更新数据时是替换,如果我们使用解构赋值进行浅拷贝的话,类的方法就会尽数丢失。当然我们也可以用手动管理原型链的方法进行完全拷贝,但是感觉又不够优雅。
这时,我想到了 React 官方教程里的库Immer
,它类似 Vue3,使用Proxy
来进行代理原始数据,使得我们达到可以直接‘修改’数据而保持响应式的效果。那么,我把useImmer
套在我创建的对象中如何呢?
const [stepState, updateStepState] = useImmer(new StepState())
...
const handleSetCreateType = (type: 'template' | 'custom') => {
updateStepState((draft) => {
draft.setCreateType(type)
})
}
然后直接来了个报错:
通过报错可以看到,对于我们这种复杂的有函数的对象,Immer 也是无能为力的。
useReducer
和useImmerReducer
在use-immer
库的介绍页,它的下方还介绍了另一个 hook:useImmerReducer
,我一看,这用法不是和 vuex 很像吗?它写着它基于useReducer
这个 React hook,立马翻开官方文档查看。
简而言之,这就是 React 提供的一个可以用于状态管理的短小精悍的 hook,而我在学习 React 时居然漏掉或者说是跳过了它!
它的简单用法如下:
import { useReducer } from 'react';
function reducer(state, action) {
if (action.type === 'incremented_age') {
return {
age: state.age + 1
};
}
throw Error('Unknown action.');
}
export default function Counter() {
const [state, dispatch] = useReducer(reducer, { age: 42 });
return (
<>
<button onClick={() => {
dispatch({ type: 'incremented_age' })
}}>
Increment age
</button>
<p>Hello! You are {state.age}.</p>
</>
);
}
reducer 是一个函数,接受两个参数 state 和 action,state 是在调用 dispatch 时自动传入,action 则是 dispatch 的参数,他的返回值即作为新的 state。
但是要注意,reducer 传进来的 state 是只读的,正如同它的名字“切片”和 React 的不可变哲学一样,reducer 必须返回一个新的对象作为新的 state 切片!
function reducer(state, action) {
if (action.type === 'incremented_age') {
state.age++;
}
return state; //错误示例,这样什么也不会发生!数据不会变化。
throw Error('Unknown action.');
}
那么上文提到的useImmerReducer
就是来解决这个问题的,利用 Proxy,reducer 可以直接修改 state(在 immer 里叫 draft)的属性的值,而不必返回新切片(immer 帮我们做了),所以我们可以重构我们的代码如下:
export interface StepState {
current: number
steps: Steps[]
}
function stepReducer(
draft: StepState,
action:
| { type: 'setCreateType'; payload: 'template' | 'custom' }
| { type: 'next' },
) {
switch (action.type) {
case 'setCreateType':
if (action.payload === 'template') {
draft.steps.push(
Steps.ChooseTemplate,
Steps.SetInstanceConfigure,
Steps.Finish,
)
} else {
draft.steps.push(
Steps.SetInstanceConfigure,
Steps.UploadZip,
Steps.Finish,
)
}
return void draft.current++
case 'next':
return void draft.current++
default:
throw new Error('未知类型')
}
}
export default function QuickStart() {
const [stepState, stepDispatch] = useImmerReducer(stepReducer, {
current: 0,
steps: [Steps.StartUp],
} as StepState)
const currentStep = useMemo(
() => stepState.steps[stepState.current],
[stepState],
)
const handleSetCreateType = (type: 'template' | 'custom') => {
stepDispatch({ type: 'setCreateType', payload: type })
}
switch (currentStep) {
case Steps.StartUp:
return <StartUp handleSetCreateType={handleSetCreateType} />
case Steps.ChooseTemplate:
return <div>选择模板</div>
case Steps.SetInstanceConfigure:
return <div>设置实例配置</div>
default:
return <div>未知错误</div>
}
}
这样,问题完美解决。
总结
回顾整个问题解决的步骤,我学到了很多。首先我有把代码写得优雅而不是乱糊的意识,值得肯定。但是我在想解决思路的时候没有意识到React的响应式特性导致第一次写出了“使用类控制”的无法使用的结构。好在最后还是回到文档查找到了最合适的解决方案,这个切片的思想值得我牢记!