最近被react的性能问题折腾惨了,在实际项目开发中,组件的深度可能很深很深,而react的更新机制本质上还是一种全量的脏检查,也就是从当前组件开始,把它作为根节点的整棵树都检查一遍,并且在这过程中做diff,中间涉及一些算法,这些算法说来说去还是因为它存在性能问题,需要靠复杂的算法来迎合react这种脏检查带来的坏处。那么,有没有一种办法,可以避免这种脏检查,也就是在整棵树中,我只需要更新其中一个节点即可。Mobx提供了一种创新的方法,就是对组件所需要的数据进行收集,只有当这个数据发生变化的时候,这个组件才需要重新渲染。这里面还涉及到整个项目中所有组件本身的设计问题。本文尝试基于mobx的这种思路,提出一种基于依赖收集的最小化更新组件技术。
React组件性能优化的途径
单纯从优化的途径出发,React组件有以下几种途径可以让开发者进行性能优化:
- PureComponent
- shouldComponentUpdate
- React.memo
其中PureComponent实际上内置了shouldComponentUpdate的特殊逻辑。React.memo针对functional组件,本质上还是差不多,通过对比props来决定是否要更新当前组件。但是注意,shouldComponentUpdate可以控制this.state的变化引起更新,而React.memo无法控制useState带来的更新。
但在实际开发中,我们往往很难简单通过这些手段进行优化,因为state和props具有非常复杂的关系,我们往往找不到准确的控制是否更新的逻辑。
响应式本质
无论是vue还是react,都是响应式视图框架,通过修改数据来达到改变界面的效果。响应式是现代前端框架的基本要求。对于开发者而言,应该透过响应式的表象,看到它的本质——观察者模式/订阅发布模式。vue通过对数据劫持,在发生数据变化时,执行劫持代码中的触发逻辑,触发更新机制。react则是在setState等接口被调用时,触发更新机制。它们本质上都是通过一个方式触发更新。
基于这一理解,我们再看redux,它是一个状态管理器,在和react结合使用时,本质上,它也是订阅发布器。只是库的作者封装了store.subscribe/store.dispatch方法,让很多开发者只看到状态的变化,没有看到订阅发布的过程。这实际上启发我们理解一个核心问题:单向双向数据流也好,immutable或mutable也好,不是react或vue界面更新机制的必要条件,必要条件是触发过程,也就是说,无论是哪种数据流或哪种数据形态,只要数据变化之后,能够触发框架的更新机制,就可以完成更新。
这有什么用呢?
我们不需要redux或vuex,我们可以用mobx了。Mobx和前两者都有巨大的不同,你可以把它当作一个状态管理器,但是,本质上,它不是专门为前端框架们特制的一个状态管理器,它是一个通用的数据模型生成器。当你需要对一个物品/对象进行描述时,可以用Mobx对该物品/对象进行描述,有什么属性,什么方法,都可以定义在Mobx的模型上。而mobx提供了多个方法,可以帮助开发者对这些属性和方法,做更加深入和魔幻的控制,比如让一个属性的值依赖另外一个属性的值,被依赖属性的值发生变化时,该属性的值也自动变化。所以,抛开前端框架来讲,它只是一个用于创建数据的模型生成器。另外,你可以通过它的接口,订阅模型示例上属性的变化,至于界面的更新,则是把框架的更新机制的触发接口丢到这个订阅函数中去。如果你需要一个理解起来更简单的数据模型,可以尝试我写的模式库tyshemo(npm i tyshemo)来做这个响应式的模型对象。
因此,我们在react之外建立的响应式数据体系,可以很轻松的按照观察者模式/订阅发布模式接入到react中。比较简单的一种方式:
// 假如 model 是一个可订阅的对象 function ReactComponent(props) { const [, setState] = useState({}) useEffect(() => { const forceUpdate = () => setState({}) model.subscribe(forceUpdate) return () => model.unsubscribe(forceUpdate) }, []) // .... }
这是最最最简单的实现了,可以看到,我们已经可以用一个react之外的可订阅对象完成react的响应式更新,也就是说,当我们在该组件外更新了model,那么该组件就会被更新。如果我们在多个组件中都做了这个操作,那么这些组件都会随着model上属性的变化而进行更新。
依赖收集
简单说,依赖收集就是你可以知道在一段代码执行过程中,哪些数据被读取了。这种能力在vue的computed属性中体现的尽致淋漓,vue通过执行computed函数,知道该计算属性依赖了哪些其他的属性,那么当被依赖的属性发生变化时,计算属性的计算器就会再执行一次,得到新的值。
怎么实现依赖收集呢?vue3使用了Proxy作为响应式的底层技术,我们简单看下Proxy的依赖收集的实现:
const deps = [] const data = new Proxy({ a: 1, b: 2 }, { get: (target, key) => { deps.push(key) return target[key] }, }) function collect(compute) { deps.length = 0 const computed = compute() const computedDeps = [...deps] deps.length = 0 return [computed, computedDeps] } // 试试看 const [computed, computedDeps] = collect(() => { const { a, b } = data return a + b })
你看,通过对数据拦截,我们可以非常轻松的知道一个计算过程,依赖了那些其他属性。
但是,
有的情况下,在计算时,存在if...else分支,这会导致第一次运行compute时,有些依赖并没有收集到。这是一个非常重要的点,我会在下面详细讲解我的思路/理念。
基于依赖更新组件
一个组件中,使用到了那些数据,可以通过依赖收集收集到,而该组件是否需要更新,只需要看这些依赖是否有更新就可以了,其他属性的变化,我并不关心。比如:
function SomeComponent() { const { a, b } = model // 再做一次上面那段关于用setState订阅model更新组件的活 // ... }
如果按照我们最前面的那段代码来跑,那么model上的任何可能引起更新的属性变化,都会触发该组件更新,但是,实际上,这个组件,只依赖了model的a,b这两个属性,其他属性的变化,其实每必要触发本组件的更新。
在mobx中,提供了一个observer的方法,使用如下:
const TodoView = observer(({ todo }) => (
<li>
<input type="checkbox" checked={todo.finished} onClick={() => todo.toggle()} />
{todo.title}
</li>
))
用observer包裹一个组件,那么这个组件内使用那些store上的属性,就可以被收集到。而正因为收集到了对应的属性,所以,只有当store中的item.todo发生变化时,才更新该组件。而在使用到store的其他组件中,可能又依赖其他属性。总之只有被依赖的属性发生变化时,这个组件才更新。
那要怎么去实现呢?我们只需要揉合前面关于订阅和依赖收集的两段代码,就可以实现了。
const observe = (model) => (InsideComponent) => { return React.memo(function OutsideComponent() { const data = new Proxy(model, { ... }) // ... 省略了一堆 const [vdom, deps] = collect(() => <InsideComponent model={data} />) const hash = getObjectHash(deps) // .. 省略了一堆 useEffect(() => { model.subscribe(deps, forceUpdate) return () => model.unsubscribe(deps, forceUpdate) }, [hash]) return vdom }, () => true) // true表示不更新(它代表新旧props相等,所以不更新) }
中间省略了collect等那些。使用React.memo直接隔绝了组件内部和外部,外部的props不会导致组件内的函数执行。总而言之,通过上面这样一通操作,被observe柯里函数返回的组件,就是一个基于根据依赖model上的对应的属性发生变化时才更新的组件。
条件分支依赖
在前文提到,如果在依赖收集时,如果存在if...else,在第一次收集时,部分依赖没有被收集到,怎么办呢?这里,我想说一个话题,就是,我们常常希望做到准确和全面,但是实际上,大部分情况下,我们只需要覆盖到大部分需求,再对特殊场景进行处理即可。
为什么要讲一些题外话呢?因为,我们在收集依赖时,我们不需要一次收集全部依赖。举个例子:
function SomeComponent({ model }) { const { a } = model if (a) { const { b } = model } else { const { c } = model } // .... }
这个典型的例子,我们再去看看,你会发现,其中的a是关键,而不是b,c,当a为true时,b的变化影响组件的更新,而c不会影响更新。既然如此,为什么我们要订阅c的变化呢?同样的道理,a为false的时候,我们也不需要订阅b的变化。如果我们把全部依赖一次性订阅,那实际上反而会有浪费。
基于这个场景,我们需要动态的订阅和弃订被依赖的属性。当当前收集到的依赖发生变化时,组件函数会重新运行,运行时,我们会收集到新的依赖,当我们发现新的依赖列表和旧的依赖列表不同时,就代表我们要重新订阅了。
结语
本文讲解了一种基于依赖收集的组件更新思路,在具体讲解中,没有把具体的细节都写出来,需要读者自己去实践补充。其中关键点在于:
- 通过React.memo隔绝外部脏检查机制
- 脱离react生态的订阅发布模式数据模型
- 依赖收集的实现
- 坦然面对条件分支的依赖收集
通过这样的处理之后,我自己在项目中发现组件响应变得非常快。或许对于正在寻找方案的你也有借鉴意义。如果你觉得本文对你有帮助,可以在播客下方留言。
2021-05-12 3271
React.memo(,() => true) 之后, 不就没办法接受新的props了吗
要的就是这个效果。组件内完全是自治的,与外部隔离。