当前位置: 首页 > Web前端 > JavaScript

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

时间:2023-03-27 00:43:47 JavaScript

大家好,我是Kason。对于前端框架,长期以来一直存在各种争议。其中,争议最大的是以下两个:性能之争和API设计之争。例如,所有主要的新兴框架都会拿出基准来证明其出色的运行时性能。React通常在这些基准测试中垫底。在API设计方面,Vue爱好者认为:“更多的API约束了开发者,不会因为团队成员水平的不同而导致代码质量有较大差异。”而React粉丝认为:“Vue的大型API限制了灵活性,JSXyyds”。上面的讨论归结为框架性能和灵活性之间的权衡。本文将介绍一个名为legendapp的状态管理库,它在设计理念上与其他状态管理库有很大不同。在React中合理使用legendapp可以大大提高应用程序的运行时性能。但是这篇文章的目的不仅仅是介绍一个状态管理库,而是要和大家一起感受框架随着性能的提升在灵活性上的变化。欢迎加入人类优质前端框架群。React的性能优化不是很好已经是不争的事实。原因是React的自上而下的更新机制。每次状态更新时,React都会从根组件开始深度优先遍历整个组件树。既然遍历方式是固定的,那么如何优化性能呢?答案是找到遍历时可以跳过的子树。什么样的子树可以跳过遍历?显然没有改变的子树。在React中,变化主要是由以下三个元素引起的:statepropscontext,它们都可能改变UI,或者触发useEffect。因此,如果一个子树中的上述三个元素有变化,则可能发生变化,不能跳过遍历。我们从变化的角度来看一下React中的性能优化API。对于下面两个:useMemouseCallback,它们的本质都是为了减少props的变化。对于下面两个:PureComponentReact.memo,其实质是将props的比较方式从全等比较改为浅比较。状态管理库可以做的优化了解了React的性能优化之后,我们来看看状态管理库可以做哪些性能优化。性能瓶颈主要出现在更新的时候,所以性能优化主要有两个方向:减少不必要的更新和减少每次更新要遍历的子树,比如Redux上下文中的useSelector就是第一种方式。对于后一种路径,减少更新时遍历的子树通常意味着减少上面介绍的三个元素的变化。PS:黄轩开发的ReactForget是一个编译器,可以生成相当于useMemo和useCallback的代码。目的是为了减少三要素之间道具的变化。状态管理库在这方面能起到的作用非常有限,因为无论状态管理库封装得多么巧妙,都掩盖不了它操作的其实是一个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只会渲染一次,即使后面计数发生变化,Counter也不会渲染。在线演示如何工作?在legendapp源码中,useObservable方法的代码如下:functionuseObservable(initialValue){returnReact.useMemo(()=>{//...一套类似于Vue的细粒度更新机制},[]);}通过包依赖项为空的React.useMemo,useObservable实际上返回了一个永远不会改变的值。由于返回的不是state,Counter组件不包含这三个元素(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相当于一个container,会监听children的状态变化,触发React更新。文本节点对应的Text组件可以类比为Computed包裹的文本内容:{textcontent}此外,还有一些比较语义化的标签(本质上是对Computed的包裹),比如Showfor条件语句:
Childelement
对应的React语句:{showChild&&(
Childelement
)}也有用数组遍历等组件到这里,你应该已经发现了,虽然我们使用legendapp来提升运行时性能,但是我们也引入了新的API,比如Computed和Show。您是愿意拥有更灵活的框架和更多的想象力,还是愿意牺牲灵活性来换取更高的性能?这就是本文想要表达的性能与易用性之间的权衡。综上所述,用过Solid.js的同学会发现legendapp引入的React在API方面与Solid.js无限接近。事实上,Solid.js选择结合React和细粒度更新,优化性能的那一刻,就决定了它最终的形态。legendapp+React实现了运行时的高性能。如果要进一步优化,一个可行的方向是编译期优化。如果你沿着这条路走下去,在不放弃虚拟DOM的情况下,你将无限接近Vue3。如果再极端一点,放弃虚拟DOM,那么就会无限接近Svelte。每个框架都会牺牲性能和灵活性来取悦目标受众。