最近在做一个编辑器项目,编辑器的一般分为三个部分:左侧图层菜单,中间画布,右侧属性配置栏。三者共同操作并维护同一个树形结构的数据,树中的每个节点代表画布中的一个组件,这个组件可能是普通组件或是一个组件组,并不重要。
很明显这是数据驱动的,作为开发者大多数工作都是操作数据,页面渲染交给React
。这也正是使用React
的开发模式,只是对于数据不复杂的应用来说,这个体会可能并不明显。
遇到的问题
这个编辑器项目我是半路加入的,这是一个没有使用状态管理库,通过context
和useReducer
组合管理状态的项目。
我们都知道React
生态中大多数的状态管理方案的思路都是不可变数据。
然而熟悉代码时我注意到了一个比较严重的问题:在大量逻辑中直接操作了本应不可变的数据(图层树数据),这显然会带来风险,我就浅啰嗦一下直接操作数据的隐患:
- 使用
console
打印调试时,不能保证打印的数据就是我想要的数据,有可能在某处被改变了。 useEffect
、useMemo
的依赖项中加入树中某个属性,但是在数据更新后并没有按照预期触发,比如我“订阅”了tree.children
,结果我是直接往children
中push
节点,即使context
的value
更新了,tree.children
却还是之前的数组。- 某一处修改树的操作没有同步更新到
context
中,在React
渲染或某逻辑中访问到了已经改变的数据,造成我们不期望的现象。 - 如果有一天编辑器需要加入撤销修改的功能,一般的做法是
context
更新value
后,将此时value
的快照存到历史栈中,但事实上很可能会出问题,我们无法保证context
知道所有属性的变化。
上面提到的问题,先不论会对开发者造成多大的心智负担,打造出的系统必定包含很多bug并且不易维护的。
怎么解决
我们一般会通过两种方式解决问题:
- 不直接操作原本的数据,手动克隆,但是树结构的层级深时,一层一层手动克隆是很痛苦的,但直接深克隆整个树又会带来不必要的性能损耗。
- 使用
immutable
、immer
这样的库,这就很棒了,既保证了不可变数据的原则,又大大降低了开发者的心智负担。
但此时我们要做的不是从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
,当然给context
的value
传值时,可以直接传get
到的数据或使用toJS
,这样其他地方的读取就不用改了。
const normalData = tree.toJS();
console.log(normalData);
但即使在编写useReducer
的reducer
函数时,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
去更新数据的。
我们一开始的目的就是避免直接操作数据,这就需要我们把每个数据操作都改成通过
immer
的produce
操作,但我们并不希望在业务逻辑层过多的调用produce
。那么我们就需要把这些动作都放在context
的reducer
中,业务逻辑层只需要给reducer
提任务。
但这又带来一个问题,这么做导致我们会在reducer
中设置五花八门的action.type
,随着项目不断迭代完善,会让reducer
变得庞大且杂乱,同事建议我根据一些规则拆分reducer
,但其实除了庞大杂乱的reducer
,我也不喜欢在写逻辑时跳来跳去的,拆分设置一个reducer
层,逻辑都集中放在这里会让我在写逻辑时有种割裂感,在每一个逻辑函数中总会有部分逻辑需要我跳到reducer
层去看。
怎么优化
React
的调度器给了我灵感,在调度器中,只需要给scheduleCallback
传入一个优先级和一个回调函数,调度器就会根据这个优先级在适当的时间调用回调函数,也就是说我不需要在调度器中实现任何我想执行的逻辑,我只需要把这些逻辑提供给它。
把这个思路放在这个项目中:我不需要在reducer
中实现具体的逻辑,我只需要把这些逻辑提供给它,它给我提供我可以直接操作的数据即可。
转变成代码:
对于reducer
,我需要做的是递归树,在满足条件的节点上触发回调,回调中调用我给reducer
传入的callback
,并且传入可以直接操作的immer
的draft
数据:
在业务逻辑层,我需要做的是给reducer
下任务,并把我想执行的逻辑传给它,此时我传入的callback
中接收的参数都是draft
数据,可以随意操作:
如此一来,我们改变了直接操作数据的行为,但又没有改变之前写逻辑的方式,并且写逻辑也没有割裂感,都写在了直接触发的地方,便于阅读和编码。
这里说一下上面提到的不用immutable
的另一个原因,如果我们用immutable
通过这种方式编写callback
,就需要通过set
操作,不如immer
的直接操作来的自然。
写在最后
当然并不是所有的任务都这么实现,只是对于一些不想放在reducer
里的逻辑,我会这么做。
这种方式有点绕不是很直观,项目中其他成员用起来需要有一定的理解成本,但我觉得在JavaScript中,回调函数是一个常见的编码模式,用习惯了之后是好用的。
之前做的项目都没有遇到复杂的数据,在这个项目中我才深刻体会到不可变数据的魅力,以及immutable
和immer
这样的工具库的意义有多大。
这篇总结了最近对于重构编辑器项目数据层的一些想法,目的就是解决问题的同时,还要尽量减少重构的动作和不影响开发体验。
欢迎大家提出建议。
如果你觉得我写的还不错,欢迎关注我的公众号【HxY码迹】。