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

6,719 阅读7分钟

大家好,我卡颂。

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

  • 性能之争

  • API设计之争

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

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

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

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

本文将介绍一款名为legendapp的状态管理库,他与其他状态管理库设计理念上有很大不同。

React中合理使用legendapp,可以极大提升应用的运行时性能。

但本文的目的并不仅仅是介绍一个状态管理库,而是与你一起感受随着性能提高,框架灵活性发生的变化

欢迎加入人类高质量前端框架研究群,带飞

React的性能优化

React性能确实不算太好,这是不争的事实。原因在于React自顶向下的更新机制。

每次状态更新,React都会从根组件开始深度优先遍历整棵组件树。

既然遍历方式是固定的,那么如何优化性能呢?答案是寻找遍历时可以跳过的子树

什么样的子树可以跳过遍历呢?显然是没有发生变化的子树

React中,变化主要由下面3个要素造成:

  • state

  • props

  • context

他们都可能改变UI,或者触发useEffect

所以,一棵子树中如果存在上述3个要素的改变,可能会发生变化,也就不能跳过遍历。

变化的角度,我们再来看看React中的性能优化API,对于下面2个:

  • useMemo

  • useCallback

他们的本质是 —— 减少props的变化。

对于下面2个:

  • PureComponent

  • React.memo

他们的本质是 —— 让比较props的方式从全等比较变为浅比较

状态管理库能做的优化

了解了React的性能优化,我们再来看看状态管理库能为性能优化做些什么呢。

性能瓶颈主要发生在更新时,所以性能优化的方向主要有两个:

  • 减少不必要的更新

  • 减少每次更新时要遍历的子树

Redux语境下的useSelector走的就是第一条路。

对于后一条路,减少更新时遍历的子树通常意味着减少上文介绍的3要素的变化

PS:黄玄开发的React Forget,是一个可以产生等效于useMemo、useCallback代码的编译器,目的就是减少三要素中props的变化。

状态管理库在这方面能发挥的地方很有限,因为不管状态管理库如何巧妙的封装,也无法掩盖他操作的其实是一个React状态这一事实。

比如,虽然MobxReact带来了细粒度更新,但并不能带来与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中包含名为countstate,且每秒发生变化,则更新时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

这是如何办到的呢?

legendapp源码中,useObservable方法代码如下:

function useObservable(initialValue) {
    return React.useMemo(() => {
      // ...一套类似Vue的细粒度更新机制
    }, []);
}

通过包裹依赖项为空的React.useMemouseObservable返回的实际是个永远不会变的值

既然返回的不是state,那Counter组件中就不包含3要素(statepropscontext)中的任何一个,当然不会render了。

我们将这个思路推广开,如果整个应用中所有状态都通过useObservable定义,那不就意味着整个应用都不存在state,那么更新时整棵组件树不都能跳过了么?

也就是说,legendappReact原有更新机制基础上,实现了一套基于细粒度更新的完整更新流程,最大限度摆脱React的影响。

legendapp的原理

接下来我们再聊聊legendapp状态更新的实现。

在传统的React例子中:

function Counter() {
  const [count, setCount] = useState(1)

  useInterval(() => {
    setCount(v => v + 1)
  }, 1000)

  return <div>Count: {count}</div>
}

count变化,造成Counter组件renderrendercount是新的值,所以返回的divcount是新的值。

而在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提高了运行时性能,但也引入了如ComputedShow等新的API

你是愿意框架更灵活、有更多想象力,还是愿意牺牲灵活性,获得更高的性能?

这就是本文想表达的性能与易用性的取舍

总结

用过Solid.js的同学会发现,引入legendappReactAPI上已经无限接近Solid.js了。

事实上,当Solid.js选择结合React细粒度更新,并在性能上作出优化的那一刻起,就决定了他的最终形态就是如此。

legendapp + React已经在运行时做到了很高的性能,如果想进一步优化,一个可行的方向是编译时优化

如果朝着这个路子继续前进,在不舍弃虚拟DOM的情况下,就会与Vue3无限接近。

如果更极端点,舍弃了虚拟DOM,那么就会与Svelte无限接近。

每个框架都在性能与灵活性上作出了取舍,以讨好他们的目标受众。