LOADING

加载过慢请开启缓存 浏览器默认开启

在React中使用简单的状态管理

2023/12/2 React

原文地址: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 也是无能为力的。

useReduceruseImmerReducer

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的响应式特性导致第一次写出了“使用类控制”的无法使用的结构。好在最后还是回到文档查找到了最合适的解决方案,这个切片的思想值得我牢记!