今天早上,我的毕设来到了这样一个需求:引导用户创建一个实例,而这个引导过程会根据用户的选择而有两种不同的路径共五种状态。所以如何比较优雅地管理这些状态便成了一个重要的问题。
将这五个状态以 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的响应式特性导致第一次写出了“使用类控制”的无法使用的结构。好在最后还是回到文档查找到了最合适的解决方案,这个切片的思想值得我牢记!
