当前位置: 首页 > 科技观察

前端框架:性能与灵活性的权衡

时间:2023-03-15 10:20:11 科技观察

大家好,我是Kason。对于“前端框架”,长期以来一直存在着各种争议。其中,争议最大的是以下两个:性能之争和API设计之争。比如各大新兴框架都会拿出benchmarks来证明自己出色的运行时性能,而React通常在这些benchmarks中垫底。在API设计方面,Vue爱好者认为:“更多的API约束了开发者,不会因为团队成员水平的不同而导致代码质量有较大差异。”而React爱好者认为:“Vue大量的API限制了灵活性,JSXyyds”。归根结底,以上讨论是框架在“性能”和“灵活性”之间的权衡。本文将介绍一个名为[1]的状态管理库,它在设计理念上与其他状态管理库有很大不同。在React中合理使用legendapp可以大大提高应用程序的运行时性能。但这篇文章的目的不只是“介绍一个状态管理库”,而是要和大家一起体验“随着性能的提高,框架在灵活性上的变化”。React性能优化React性能确实不是很好,这是不争的事实。原因是React的自上而下的更新机制。每次状态更新时,React都会从根组件开始深度优先遍历整个组件树。既然遍历方式是固定的,那么如何优化性能呢?答案是“找到遍历时可以跳过的子树”。什么样的子树可以跳过遍历?显然是“没有改变的子树”。在React中,“改变”主要是由以下三个要素引起的:statepropscontext,它们都可能改变UI,或者触发useEffect。因此,如果一个子树中的上述三个元素有变化,则可能发生变化,不能跳过遍历。我们从“变”的角度来看一下React中的性能优化API。对于下面两个:useMemouseCallback,它们的本质都是为了减少props的变化。对于下面两个:PureComponentReact.memo,它们的本质是——直接告诉React这个组件没有变化,你不需要检查上面三个元素。状态管理库能做的优化了解了React的性能优化之后,我们来看看状态管理库能为“性能优化”做些什么。性能瓶颈主要出现在更新时,所以性能优化主要有两个方向:减少不必要的更新和减少每次更新时要遍历的子树,像Redux上下文中的useSelector就是第一种方式。对于后一种路径,“减少更新时遍历的子树”通常意味着“减少上面介绍的三个元素的变化”。PS:黄轩开发的ReactForget是一个“可以生成相当于useMemo和useCallback代码的编译器”,目的是减少三个元素之间props的变化。状态管理库在这方面能起到的作用非常有限,因为无论状态管理库封装得多么巧妙,都掩盖不了“它其实就是一个React状态”的事实。例如,虽然Mobx为React带来了“细粒度更新”,但它并没有带来匹配Vue中“细粒度更新”的性能,因为Mobx最终会触发自上而下的更新。legendapp的思路本文要介绍的legendapp也是走的第二条路,但是它的思路比较特别——如果减少3个元素的数量,那不就可以减少3个元素的变化吗?举个极端的例子,如果在一个庞大的应用中没有状态,更新时可以跳过整个组件树。以下是Hook实现的计数器示例。useInterval每秒触发一次回调,回调中触发更新:functionCounter(){const[count,setCount]=useState(1)useInterval(()=>{setCount(v=>v+1)},1000)return

Count:{count}
}根据3个元素的规则,Counter包含一个名为count的state,它每秒变化一次,所以更新时不会跳过Counter(性能是计数器将每秒渲染一次)。以下是使用legendapp修改的示例:functionCounter(){constcount=useObservable(1)useInterval(()=>{count.set(v=>v+1)},1000)return
Count:{count}
}在此示例中,状态计数是使用legendapp提供的useObservable方法定义的。Counter只会渲染一次,即使后面count发生变化,Counter也不会渲染。在线演示[2]。这怎么可能?在legendapp的源码中,useObservable方法的代码如下:useObservable实际上返回一个“永不改变的值”。由于返回的不是state,Counter组件不包含3个元素(state,props,context)中的任何一个,当然不会渲染。让我们推广这个想法。如果整个应用中的所有状态都由useObservable来定义,那岂不是整个应用中就没有状态了,那么更新的时候就不能跳过整个组件树吗?也就是说,legendapp基于React原有的更新机制,实现了一个完整的基于“细粒度更新”的更新流程,最大程度的摆脱了React的影响。legendapp的原理接下来说说legendapp状态更新的实现。在传统的React示例中:functionCounter(){const[count,setCount]=useState(1)useInterval(()=>{setCount(v=>v+1)},1000)return
Count:{count}
}count发生变化,导致Counter组件渲染,而渲染时count是新的值,所以返回的div中的count就是新值。在legendapp的例子中,Counter只会渲染一次,如何更新计数?functionCounter(){constcount=useObservable(1)useInterval(()=>{count.set(v=>v+1)},1000)return
Count:{count}
}实际上,useObservable返回的计数不是一个数字,而是一个名为Text的组件:constText=React.memo(function({data}){});在Text组件中,会监听count的变化。当计数发生变化时,将通过内部定义的useReducer触发React更新。React的更新虽然从上到下遍历了整个组件树,但是整个应用中只有Text组件存在和变化,所以除了Text组件之外的其他子树都会被跳过。性能和可用性之间的权衡现在我们知道了legendapp中文本节点是如何更新的。但是JSX非常灵活。除了文本节点,还有:条件语句如:isShow?:自定义属性如:
如何监听这些表单的变化并触发更新?为此,legendapp提供了一个自定义组件Computed:{showChild.get()?'true':'false'}对应的React语句:{showChild?'true':'false'}Computed相当于一个容器会监听子进程的状态变化并触发React更新。文本节点对应的Text组件可以类比为“Computed包裹的文本内容”:asShow对于条件语句:
子元素
对应的React语句:{showChild&&(
Childelement
)}还有数组遍历等组件,此时你应该已经发现,虽然我们使用legendapp来提升运行时性能,但我们也引入了新的API,如Computed,Show等,你是否愿意拥有一个更灵活的框架和更多的想象力,还是愿意牺牲灵活性来换取更高的性能?这就是本文想要表达的“性能与易用性之间的权衡”。综上所述,用过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]在线演示:https://codesandbox.io/s/legend-state-primitives-140tmg。