多年后再次写 React,一些感想

react_foundation_logo.png

这是一篇草稿很久一直没有发的文章,一年多前换了工作,去了宇宙厂,在使用 React 过程中有一些感想。

在上家公司最后的一段时间里,我一直在做一些框架的开发工作。百度有自己研发的一个前端框架 San,团队还一起为这个框架写了一本书 《高性能MVVM框架的设计与实现——San》 (豆瓣)。团队的生产环境项目没有用 React 的,但内部项目里,因为可以自由选择框架,所以多少还是写过 React 。

没有记错的话,上次用 React 写项目好像还是在 2019 年。那个时候,React hooks 还没有出,所以大家写的还都是 React Class Component。到了今天,应该已经很少有人写 Class Component 了。代码里所有的组件都是直接用 hooks 来写了。相比 Class Component,hooks 固然有非常多的好处,比如:

  • Hooks 可以在函数组件中直接使用状态和生命周期相关功能,无需编写冗长的 class、constructor、this 绑定等。

  • 状态逻辑集中在相关的 Hooks 中,不再分散在多个生命周期方法里,使得组件逻辑更直观。这点和 Vue 的 Composition API 是类似的。进而由于逻辑集中在了一起,更方便抽出来复用了。

    composition-api-after.ZXskY_32.png

该说不说,初一上手写 React Hooks 体感会很好,会觉得自己在写很“高级”的代码,新概念。但,一旦写的时间长一些,问题就出来了,我们拿这么一段代码举例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
import React, { useState, useEffect } from "react";

function Counter() {
// 声明一个 state 变量 count,初始值为 0
const [count, setCount] = useState(0);

// 副作用:更新页面标题
useEffect(() => {
document.title = `你点击了 ${count} 次`;
}, [count]); // 依赖 count,只有 count 改变时才执行

return (
<div>
<p>你点击了 {count} 次</p>
<button onClick={() => setCount(count + 1)}>
点我 +1
</button>
</div>
);
}

export default Counter;
  1. 整体写法其实与 JavaScript 逻辑是相悖的,是反直觉的。

    首先大前提就是每个组件更新时,Counter 函数都会重新执行一遍,看上去非常函数式编程非常简洁优雅。

    但是,每次 Counter 执行,里面的 hooks 相关函数 useXXX 等也一定都会调用一遍,但我们又要想办法做状态的持久化,所以这些 hooks 函数里面隐含了一个复杂的概念,就是这些函数返回的内容跟组件实例是绑定的,会复用,会保存状态,这可太不函数式了。

    进而,如果我们要修改数据,需要在 Counter 函数内部调用 useState 返回的 setCount 变量,然后它修改的是隐含在 useState 背后的状态,这叫函数式?

    其实如果重新审视 hooks 代码,所有涉及到 hooks 的函数调用,比如上面代码中的 useState 和 useEffect 都只需要执行一次。React 关于 hooks 的隐含约束,比如调用顺序不能变,不能有条件执行定,都映射了这一逻辑。

    为什么不把 useState 和 useEffect 调用移出 Counter 呢?真正需要在组件更新时调用的其实只有 return 回去的部分。

  2. useMemo 的使用其实是很迷的,需要非常精细的控制子组件是否更新。

    比如下面的代码,意图很清晰,想要用 memo 阻止子组件更新。但这里每次 Component 更新的时候,子组件 MemoItem 还是会更新一遍,这是因为传给子组件的 value 没有使用 memo 包裹,每次 Component 更新,传入的 value 变量都是一个新创建的数组。

    1
    2
    3
    4
    5
    6
    7
    8
    const Item = () => <div> ... </div>
    const MemoItem = React.memo(Item)
    const Component = () => {
    const onClick = useCallback(() => {
    /* do something */
    }, []);
    return <MemoItem onClick={onClick} value={[1,2,3]}/>
    };

    想要阻止子组件更新,开发者需要非常精细的控制子组件接收到的参数,上面举的例子还比较清晰,但实际生产环境中会有各种各样的奇怪场景,让事情变得非常复杂。


很多人说 Vue 适合初级选手,React 适合高级选手,因为 React 能够对子组件更新有更多的调整,达到更好的性能。这句话的结论前半句是没错的,确实 React 框架本身做的足够少,给了更多的空间到开发者。但这真的可以有更好的性能吗?实际开发生产环境中,真正能时刻调整 useMemo 的使用严格控制子组件更新的人有多少呢,即便你可以,你的同事们都可以么?只要团队中有一个人破坏了 render 链路上的每一环,就会导致整体的崩坏。

其实我们优化了半天,做的只是一件事:让子组件仅在需要更新的时候才执行 render 函数。手动优化再多,达到的可能是其他框架随便就已经默认做到的事情。


我们来看下关于子组件的更新,React 是如何做的:

  1. 父组件更新是,默认行为是所有子组件全部更新。需要手动用 memo 来组织子组件的更新。
  2. 数据与组件之间的绑定关系是非常粗粒度的,只要数据有一点变化,统统更新。比如父组件的 data 有三个 key:A,B 和 C,分别给三个子组件使用。三个子组件分别消费 A,B 和 C。当数据 A 变化时,理想情况下应当只更新一个子组件,但 React 不会这么精细,而是全量更新。

这一更新机制一方面使得 React 的核心逻辑非常简洁,但也使得框架本身在数据发生变化时不能精细化的控制那些组件需要更新,哪些不需要更新,这个工作交给了开发者。但我认为,组件是否需要更新,这是框架该做的事情。


那么其他框架是如何做的呢?Vue 的数据依赖收集做的非常精细,会具体到某个字段。当整个数据对象中的某一项发生变化时,Vue 可以知道依赖这一项的组件都有哪些,从而只更新这些组件,甚至不需要更新这些组件的子组件。

image.png


上面这些对比当然只是设计理念的问题,有人喜欢有人不喜欢。但我个人觉得 Vue 的这种方式给了框架更多的优化空间,框架团队可以在性能优化上做更多事情。框架要考虑兼容性,一旦框架做的太少,社区的用法就会五花八门,导致后续的优化迭代变难。比如 Vue 的 vapor 模式都快要发了,但是 React 的 compiler 就要搞得艰难的多。

翻出这篇文章主要是因为最近看到 Meta 把 React 推到了社区维护,我个人觉得把 React 交给社区,后续很难会有大的迭代升级了。