前端框架:性能与灵活性的取舍
2022-10-10 18:58:51来源:魔术师卡颂
大家好,我卡颂。
针对「前端框架」,长期存在着各种纷争。其中争论比较大的是下面两项:
(相关资料图)
性能之争API设计之争比如,各大新兴框架都会掏出benchmark证明自己优秀的运行时性能,在这些benchmark中React通常是垫底的存在。
在API设计上,Vue爱好者认为:“更多的API约束了开发者,不会因为团队成员水平的差异造成代码质量较大的差异”。
而React爱好者则认为:“Vue大量的API限制了灵活性,JSXyyds”。
上述讨论归根结底是框架「性能」与「灵活性」的取舍。
本文将介绍一款名为[1]的状态管理库,他与其他状态管理库设计理念上有很大不同。
在React中合理使用legendapp,可以极大提升应用的运行时性能。
但本文的目的并不仅仅是「介绍一个状态管理库」,而是与你一起感受「随着性能提高,框架灵活性发生的变化」。
React的性能优化React性能确实不算太好,这是不争的事实。原因在于React自顶向下的更新机制。
每次状态更新,React都会从根组件开始深度优先遍历整棵组件树。
既然遍历方式是固定的,那么如何优化性能呢?答案是「寻找遍历时可以跳过的子树」。
什么样的子树可以跳过遍历呢?显然是「没有发生变化的子树」。
在React中,「变化」主要由下面3个要素造成:
statepropscontext他们都可能改变UI,或者触发useEffect。
所以,一棵子树中如果存在上述3个要素的改变,可能会发生变化,也就不能跳过遍历。
从「变化」的角度,我们再来看看React中的性能优化API,对于下面2个:
useMemouseCallback他们的本质是 —— 减少props的变化。
对于下面2个:
PureComponentReact.memo他们的本质是 —— 直接告诉React这个组件没有变化,你不用再去检查上述3个要素了。
状态管理库能做的优化了解了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) returnCount: {count}}
根据3要素法则,Counter中包含名为count的state,且每秒发生变化,则更新时Counter不会被跳过(表现为Counter每秒都会render)。
下面是使用legendapp改造的例子:
function Counter() { const count = useObservable(1) useInterval(() => { count.set(v => v + 1) }, 1000) returnCount: {count}}
在这个例子中,使用legendapp提供的useObservable方法定义状态count。
Counter只会render一次,后续即使count变化,Counter也不会render。
在线Demo[2]。
这是如何办到的呢?
在legendapp源码中,useObservable方法代码如下:
function useObservable(initialValue) { return React.useMemo(() => { }, []);}
通过包裹依赖项为空的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) returnCount: {count}}
count变化,造成Counter组件render,render时count是新的值,所以返回的div中count是新的值。
而在legendapp例子中,Counter只会render一次,count如何更新呢?
function Counter() { const count = useObservable(1) useInterval(() => { count.set(v => v + 1) }, 1000) returnCount: {count}}
实际上,useObservable返回的count并不是一个数字,而是一个叫做Text的组件:
const Text = React.memo(function ({ data }) { });
在Text组件中,会监听count的变化。
当count变化后,会通过内部定义的useReducer触发一次React更新。
虽然React的更新是自顶向下遍历整棵组件树,但是整个应用中只有Text组件中存在状态且发生变化,所以除Text组件外其他子树都会被跳过。
性能与易用性的取舍现在我们知道在legendapp中文本节点如何更新。
但JSX非常灵活,除了文本节点,还有比如:
条件语句如:
isShow ? :自定义属性
如:
这些形式的变化该如何监听,并触发更新呢?
为此,legendapp提供了自定义组件Computed:
{showChild.get() ? "true" : "false"}
对应的React语句:
{showChild ? "true" : "false"}
Computed相当于一个容器,会监听children中的状态变化,并触发React更新。
文本节点对应的Text组件可以类比为「被Computed包裹的文本内容」:
{文本内容}
除此之外,还有些更具语意化的标签(本质都是Computed的封装),比如用于条件语句的Show:
Child element
对应的React语句:
{showChild && (Child element)}
还有用于数组遍历的
到这一步你应该发现了,虽然我们利用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。