如何使用immer优化复杂数据应用

912 阅读7分钟

最近在做一个编辑器项目,编辑器的一般分为三个部分:左侧图层菜单,中间画布,右侧属性配置栏。三者共同操作并维护同一个树形结构的数据,树中的每个节点代表画布中的一个组件,这个组件可能是普通组件或是一个组件组,并不重要。

很明显这是数据驱动的,作为开发者大多数工作都是操作数据,页面渲染交给React。这也正是使用React的开发模式,只是对于数据不复杂的应用来说,这个体会可能并不明显。

遇到的问题

这个编辑器项目我是半路加入的,这是一个没有使用状态管理库,通过contextuseReducer组合管理状态的项目。

我们都知道React生态中大多数的状态管理方案的思路都是不可变数据。

然而熟悉代码时我注意到了一个比较严重的问题:在大量逻辑中直接操作了本应不可变的数据(图层树数据),这显然会带来风险,我就浅啰嗦一下直接操作数据的隐患:

  1. 使用console打印调试时,不能保证打印的数据就是我想要的数据,有可能在某处被改变了。
  2. useEffectuseMemo的依赖项中加入树中某个属性,但是在数据更新后并没有按照预期触发,比如我“订阅”了tree.children,结果我是直接往childrenpush节点,即使contextvalue更新了,tree.children却还是之前的数组。
  3. 某一处修改树的操作没有同步更新到context中,在React渲染或某逻辑中访问到了已经改变的数据,造成我们不期望的现象。
  4. 如果有一天编辑器需要加入撤销修改的功能,一般的做法是context更新value后,将此时value的快照存到历史栈中,但事实上很可能会出问题,我们无法保证context知道所有属性的变化。

上面提到的问题,先不论会对开发者造成多大的心智负担,打造出的系统必定包含很多bug并且不易维护的。

怎么解决

我们一般会通过两种方式解决问题:

  1. 不直接操作原本的数据,手动克隆,但是树结构的层级深时,一层一层手动克隆是很痛苦的,但直接深克隆整个树又会带来不必要的性能损耗。
  2. 使用immutableimmer这样的库,这就很棒了,既保证了不可变数据的原则,又大大降低了开发者的心智负担。

但此时我们要做的不是从0开始写一个项目,我们要改造现有项目,这就需要考虑改造成本,分别看看这两个库的特点。

immutable和immer选择哪个

首先是immutable,直接构造不可变数据,通过get/set来读取操作数据。

import { Map, fromJS, List } from 'immutable';

const tree = Map({
  children: [{id: 1}],
  id: 0,
});
const newTree = tree.setIn(['children', '0', 'id'], 2);
console.log(newTree.getIn(['children', '0']))

我们很难把项目中所有取值操作都改成get,当然给contextvalue传值时,可以直接传get到的数据或使用toJS,这样其他地方的读取就不用改了。

const normalData = tree.toJS();
console.log(normalData);

但即使在编写useReducerreducer函数时,get/set的方式肯定是不如直接操作方便的,并且还有另外一个类似的原因,后面再提。

再来看看immer,不直接构造不可变数据,在改变数据时,通过produce完成。

import produce from 'immer';

const tree = {
  children: [{ id: 1 }],
  id: 0,
};
const newTree = produce(tree, (draft) => {
  draft.children[0].id = 2
})
console.log(newTree);

不仅在produce中可以直接操作数据,返回的新对象也可以直接读取,不过不可以直接修改,因为返回的新数据是被自动“冻结”的,但我们可以通过setAutoFreeze(false)来放开这个限制。

仅仅是这样,我就已经偏向immer了,我可以像这样编写reducer

import React, {useCallback, useReducer, createContext} from "react"
import produce from "immer"

const Context = createContext()

function reducer(state, action) {
  switch (action.type) {
    case 'test':
      return produce(state, draft => {
        // do something
      });

    default:
      break;
  }
}
const ContextProvider = ({ children }) => {
  const [tree, dispatch] = useReducer(reducer, { children: [], id: 0});
  return <Context.Provider value={{ tree }}>{children}</Context.Provider>;
};

不仅如此,当不按照上面的方式给produce传参,给它传入一个函数时,它会返回一个函数,我们可以把返回的函数作为reducer函数,直接看代码,这段代码来自immer官网:

import React, {useCallback, useReducer} from "react"
import produce from "immer"

const TodoList = () => {
    const [todos, dispatch] = useReducer(
        produce((draft, action) => {
            switch (action.type) {
                case "toggle":
                    const todo = draft.find(todo => todo.id === action.id)
                    todo.done = !todo.done
                    break
                case "add":
                    draft.push({
                        id: action.id,
                        title: "A new todo",
                        done: false
                    })
                    break
                default:
                    break
            }
        }),
        [
            /* initial todos */
        ]
    )

    const handleToggle = useCallback(id => {
        dispatch({
            type: "toggle",
            id
        })
    }, [])

    const handleAdd = useCallback(() => {
        dispatch({
            type: "add",
            id: "todo_" + Math.random()
        })
    }, [])

    // etc
}

我们不需要在每个action.type分支里调用produce,写起来和没用immer差不多。

选择immer后又遇到什么问题

在数据层面(指context里),我们在reducer处理数据,这没什么问题,但现状是项目中有很多逻辑是直接操作数据,然后提交给reducer去更新数据的。

image.png 我们一开始的目的就是避免直接操作数据,这就需要我们把每个数据操作都改成通过immerproduce操作,但我们并不希望在业务逻辑层过多的调用produce。那么我们就需要把这些动作都放在contextreducer中,业务逻辑层只需要给reducer提任务。

但这又带来一个问题,这么做导致我们会在reducer中设置五花八门的action.type,随着项目不断迭代完善,会让reducer变得庞大且杂乱,同事建议我根据一些规则拆分reducer,但其实除了庞大杂乱的reducer,我也不喜欢在写逻辑时跳来跳去的,拆分设置一个reducer层,逻辑都集中放在这里会让我在写逻辑时有种割裂感,在每一个逻辑函数中总会有部分逻辑需要我跳到reducer层去看。

怎么优化

React的调度器给了我灵感,在调度器中,只需要给scheduleCallback传入一个优先级和一个回调函数,调度器就会根据这个优先级在适当的时间调用回调函数,也就是说我不需要在调度器中实现任何我想执行的逻辑,我只需要把这些逻辑提供给它。

image.png

把这个思路放在这个项目中:我不需要在reducer中实现具体的逻辑,我只需要把这些逻辑提供给它,它给我提供我可以直接操作的数据即可。

转变成代码:

对于reducer,我需要做的是递归树,在满足条件的节点上触发回调,回调中调用我给reducer传入的callback,并且传入可以直接操作的immerdraft数据:

image.png

在业务逻辑层,我需要做的是给reducer下任务,并把我想执行的逻辑传给它,此时我传入的callback中接收的参数都是draft数据,可以随意操作:

image.png

如此一来,我们改变了直接操作数据的行为,但又没有改变之前写逻辑的方式,并且写逻辑也没有割裂感,都写在了直接触发的地方,便于阅读和编码。

这里说一下上面提到的不用immutable的另一个原因,如果我们用immutable通过这种方式编写callback,就需要通过set操作,不如immer的直接操作来的自然。

写在最后

当然并不是所有的任务都这么实现,只是对于一些不想放在reducer里的逻辑,我会这么做。

这种方式有点绕不是很直观,项目中其他成员用起来需要有一定的理解成本,但我觉得在JavaScript中,回调函数是一个常见的编码模式,用习惯了之后是好用的。

之前做的项目都没有遇到复杂的数据,在这个项目中我才深刻体会到不可变数据的魅力,以及immutableimmer这样的工具库的意义有多大。

这篇总结了最近对于重构编辑器项目数据层的一些想法,目的就是解决问题的同时,还要尽量减少重构的动作和不影响开发体验。

欢迎大家提出建议。

如果你觉得我写的还不错,欢迎关注我的公众号【HxY码迹】。