6

前端框架:性能与灵活性的取舍

 1 year ago
source link: https://www.51cto.com/article/720380.html
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.
neoserver,ios ssh client
88b579296761b7cf630517161cea4e53e469aa.png

大家好,我卡颂。

针对「前端框架」,长期存在着各种纷争。其中争论比较大的是下面两项:

  • API设计之争

比如,各大新兴框架都会掏出benchmark​证明自己优秀的运行时性能,在这些benchmark中React通常是垫底的存在。

在API​设计上,Vue爱好者认为:“更多的API约束了开发者,不会因为团队成员水平的差异造成代码质量较大的差异”。

而React​爱好者则认为:“Vue​大量的API​限制了灵活性,JSX yyds”。

上述讨论归根结底是框架「性能」与「灵活性」的取舍。

本文将介绍一款名为[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这个组件没有变化,你不用再去检查上述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)

  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(() => {
      
    }, []);
}

通过包裹依赖项为空的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非常灵活,除了文本节点,还有比如:

isShow ? <A/> : <B/>
  • 自定义属性
<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。


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK