大家好,我卡颂。
针对「前端框架」,长期存在着各种纷争。其中争论比较大的是下面两项:
- 性能之争
- API设计之争
比如,各大新兴框架都会掏出benchmark
证明自己优秀的运行时性能,在这些benchmark
中React
通常是垫底的存在。
在API
设计上,Vue
爱好者认为:“更多的API约束了开发者,不会因为团队成员水平的差异造成代码质量较大的差异”。
而React
爱好者则认为:“Vue
大量的API
限制了灵活性,JSX
yyds”。
上述讨论归根结底是框架「性能」与「灵活性」的取舍。
本文将介绍一款名为legendapp[1]的状态管理库,他与其他状态管理库设计理念上有很大不同。
在React
中合理使用legendapp
,可以极大提升应用的运行时性能。
但本文的目的并不仅仅是「介绍一个状态管理库」,而是与你一起感受「随着性能提高,框架灵活性发生的变化」。
React的性能优化
React
性能确实不算太好,这是不争的事实。原因在于React
自顶向下的更新机制。
每次状态更新,React
都会从根组件开始深度优先遍历整棵组件树。
既然遍历方式是固定的,那么如何优化性能呢?答案是「寻找遍历时可以跳过的子树」。
什么样的子树可以跳过遍历呢?显然是「没有发生变化的子树」。
在React
中,「变化」主要由下面3个要素造成:
state
props
context
他们都可能改变UI
,或者触发useEffect
。
所以,一棵子树中如果存在上述3个要素的改变,可能会发生变化,也就不能跳过遍历。
从「变化」的角度,我们再来看看React
中的性能优化API,对于下面2个:
useMemo
useCallback
他们的本质是 —— 减少props
的变化。
对于下面2个:
- PureComponent
- React.memo
他们的本质是 —— 直接告诉React
这个组件比较prop变化的方式由全等比较变为浅比较了。
状态管理库能做的优化
了解了React
的性能优化,我们再来看看状态管理库能为「性能优化」做些什么呢。
性能瓶颈主要发生在更新时,所以性能优化的方向主要有两个:
- 减少不必要的更新
- 减少每次更新时要遍历的子树
像Redux
语境下的useSelector
走的就是第一条路。
对于后一条路,「减少更新时遍历的子树」通常意味着「减少上文介绍的3要素的变化」。
PS:黄玄开发的
React Forget
,是一个「可以产生等效于useMemo、useCallback代码的编译器」,目的就是减少三要素中props
的变化。
状态管理库在这方面能发挥的地方很有限,因为不管状态管理库如何巧妙的封装,也无法掩盖「他操作的其实是一个React状态」这一事实。
比如,虽然Mobx
为React
带来了「细粒度更新」,但并不能带来与Vue
中「细粒度更新」相匹配的性能,因为Mobx
最终触发的是自顶向下的更新。
legendapp的思路
本文要介绍的legendapp
也走的是第二条路,但他的理念蛮特别的 —— 如果减少3要素的数量,那不就能减少3要素的变化么?
举个极端的例子,如果一个庞大的应用中一个状态都没有,那更新时整棵组件树都能被跳过。
下面是个Hook
实现的计数器例子,useInterval
每秒触发一次回调,回调中会触发更新:
function Counter() {
const [count, setCount] = useState(1)
useInterval(() => {
setCount(v => v 1)
}, 1000)
return <div>Count: {count}</div>
}
根据3要素法则,Counter
中包含名为count
的state
,且每秒发生变化,则更新时Counter
不会被跳过(表现为Counter
每秒都会render
)。
下面是使用legendapp
改造的例子:
function Counter() {
const count = useObservable(1)
useInterval(() => {
count.set(v => v 1)
}, 1000)
return <div>Count: {count}</div>
}
在这个例子中,使用legendapp
提供的useObservable
方法定义状态count
。
Counter
只会render
一次,后续即使count
变化,Counter
也不会render
。
在线Demo[2]
这是如何办到的呢?
在legendapp
源码中,useObservable
方法代码如下:
function useObservable(initialValue) {
return React.useMemo(() => {
// ...一套类似Vue的细粒度更新机制
}, []);
}
通过包裹依赖项为空的React.useMemo
,useObservable
返回的实际是个「永远不会变的值」。
既然返回的不是state
,那Counter
组件中就不包含3要素(state
、props
、context
)中的任何一个,当然不会render
了。
我们将这个思路推广开,如果整个应用中所有状态都通过useObservable
定义,那不就意味着整个应用都不存在state
,那么更新时整棵组件树不都能跳过了么?
也就是说,legendapp
在React
原有更新机制基础上,实现了一套基于「细粒度更新」的完整更新流程,最大限度摆脱React
的影响。
legendapp的原理
接下来我们再聊聊legendapp
状态更新的实现。
在传统的React
例子中:
function Counter() {
const [count, setCount] = useState(1)
useInterval(() => {
setCount(v => v 1)
}, 1000)
return <div>Count: {count}</div>
}
count
变化,造成Counter
组件render
,render
时count
是新的值,所以返回的div
中count
是新的值。
而在legendapp
例子中,Counter
只会render
一次,count
如何更新呢?
function Counter() {
const count = useObservable(1)
useInterval(() => {
count.set(v => v 1)
}, 1000)
return <div>Count: {count}</div>
}
实际上,useObservable
返回的count
并不是一个数字,而是一个叫做Text
的组件:
const Text = React.memo(function ({ data }) {
// 省略内部实现
});
在Text
组件中,会监听count
的变化。
当count
变化后,会通过内部定义的useReducer
触发一次React
更新。
虽然React
的更新是自顶向下遍历整棵组件树,但是整个应用中只有Text
组件中存在状态且发生变化,所以除Text
组件外其他子树都会被跳过。
性能与易用性的取舍
现在我们知道在legendapp
中文本节点如何更新。
但JSX
非常灵活,除了文本节点,还有比如:
- 条件语句
如:
代码语言:javascript复制isShow ? <A/> : <B/>
- 自定义属性
如:
代码语言:javascript复制<div className={isFocus ? 'text-blue' : ''}></div>
这些形式的变化该如何监听,并触发更新呢?
为此,legendapp
提供了自定义组件Computed
:
<Computed>
<span
className={showChild.get() ? 'text-blue' : ''}
>
{showChild.get() ? 'true' : 'false'}
</span>
</Computed>
对应的React
语句:
<span className={showChild ? 'text-blue' : ''}>
{showChild ? 'true' : 'false'}
</span>
Computed
相当于一个容器,会监听children
中的状态变化,并触发React
更新。
文本节点对应的Text
组件可以类比为「被Computed包裹的文本内容」:
<Computed>{文本内容}</Computed>
除此之外,还有些更具语意化的标签(本质都是Computed
的封装),比如用于条件语句的Show
:
<Show if={showChild}>
<div>Child element</div>
</Show>
对应的React
语句:
{showChild && (
<div>Child element</div>
)}
还有用于数组遍历的<For/>
组件等。
到这一步你应该发现了,虽然我们利用legendapp
提高了运行时性能,但也引入了如Computed
、Show
等新的API
。
你是愿意框架更灵活、有更多想象力,还是愿意牺牲灵活性,获得更高的性能?
这就是本文想表达的「性能与易用性的取舍」。
总结
用过Solid.js
的同学会发现,引入legendapp
的React
在API
上已经无限接近Solid.js
了。
事实上,当Solid.js
选择结合React
与「细粒度更新」,并在性能上作出优化的那一刻起,就决定了他的最终形态就是如此。
legendapp
React
已经在运行时做到了很高的性能,如果想进一步优化,一个可行的方向是「编译时优化」。
如果朝着这个路子继续前进,在不舍弃「虚拟DOM」的情况下,就会与Vue3
无限接近。
如果更极端点,舍弃了「虚拟DOM」,那么就会与Svelte
无限接近。
每个框架都在性能与灵活性上作出了取舍,以讨好他们的目标受众。
参考资料
[1]
legendapp: https://www.legendapp.com/open-source/state/hooks/
[2]
在线Demo: https://codesandbox.io/s/legend-state-primitives-140tmg