阅读视图

发现新文章,点击刷新页面。

re-render

渲染

那么首先,我们要聊的就是 React 的渲染机制,我们首先要弄清楚在讲 React 渲染的时候,我们具体在说的是什么。

当我们调用 ReactDOM.render(<App />)(这里就不专门用 createRoot API 了)的时候,或者当我们调用 setState 的时候,React 会从根节点开始重新计算一次整个组件树:

  1. React 生成新的 Virtual DOM 树。
  2. 并与旧的 Virtual DOM 树做 diff。
  3. 得到最终需要应用的更新。
  4. 然后执行最小程度的 DOM API 操作。

这里面分为两个步骤:

  • render phase,也就是到计算得到最终需要执行的 DOM 更新操作为止的步骤
  • commit phase,把这些更新 apply 到 DOM 树上

而我们要聊的渲染就是专门指的第一个步骤,也就是 render phase,这个阶段是纯粹的 JS 执行过程,不涉及任何的 DOM 操作。在 React 中,一旦 Virtual Dom diff 的结果确定, 进入 commit phase 之后,任务就无法再被打断,而且 commit 的内容是固定的,所以基本也没有什么优化空间。

因此围绕 React 性能优化的话题,基本上都是再 render phase 展开, 所以这篇文章自然也就围绕着 render phase —— 也就是渲染 —— 展开。

ReactDOM.render()一般都是初次渲染时进行的,那么整个节点树中的组件都会执行渲染就没有什么可奇怪的,所以本文主要围绕着更新来讨论, 也就是 setState(或者说useState返回的setter)。

我们首先要搞清楚的是当执行setState的时候,React 会做什么。

React 是一个高度遵循 FP(函数编程)的框架,其核心逻辑就是UI = fn(props & state) ,这里的fn就是组件,同时也是组件树。 在 React 的设计初期,就是希望组件(树)是一个纯函数,也就是说,组件的输出完全由输入决定,不会受到任何外部因素的影响,这样的好处就是,组件的输出是可预测的,

注意: 即便是 ClassComponent 时期,React 也不是一个面向对象的框架,React 对待 ClassCompoonent 的核心,仍然是其 render 函数,而 instance 纯粹是用于存储 state 和 props 的。

基础规则

默认 React 并没有太多的渲染优化,当我们通过setState触发了一次更新,React 会从根节点开始重新计算一次整个组件树。 是的,你没有看错,不论你在哪里触发了setState,最终都会导致整个组件树的重新计算,React 会从根节点开始一次遍历,以计算出最新的 VirtualDomTree。

注意: 至少在 React16 版本使用 Fiber 重构其 Reconciliation 算法之后是这样的,每次setState更新都会加入到一个更新队列中并且暂存在 root 节点上, 等到这次 event loop 中所有的 update 都进入队列,React 再从根节点上读取改更新队列并开始重新渲染。

除了后面要讲的memo之外,React 默认有也有一项优化,React 渲染虽然是从根节点开始的,但是在遍历过程中如果发现节点本身以及祖先节点没有更新, 而是其子树发生了更新,那么该节点也不会被重新渲染,我们可以来看一下这个例子:

codesandbox.io/p/sandbox/g…

import React from "react";

let renderTimes = 0;
function Child() {
  return renderTimes++;
}

function Parent() {
  const [count, setCount] = React.useState(0);
  return (
    <div>
    <button onClick={() => setCount(count + 1)}>click {count}</button>
  <Child />
  </div>
);
}

let appRenderTime = 0;
export function App() {
  return (
    <div>
    {appRenderTime++}
    <Parent />
    </div>
  );
}

在这个例子中,state 的更新发生在Parent组件中,而当Parent组件更新导致重新渲染时,虽然Child组件没有任何的 props 和 state 变化, 但其仍然重新渲染了(renderTimes 增加了),相对的App组件却没有重新渲染,这就说明 state 的更新只会导致更新节点的子树重新渲染,并不会影响祖先节点。

注意: 你看到了renderTimes每次都会加 2,这不是 bug,在 React 的开发模式中,每次更新都会渲染两次,以便于检查你写的useEffect有没有正确消除 effect, 官方文档

小结

  • 虽然 React 的更新会从根节点开始遍历,但是只有更新节点的子树会被重新渲染,祖先节点不会被重新渲染
  • 即便更新节点的子节点没有任何变化,也会被重新渲染

规避渲染

现在我们知道 React 更新渲染的基本规则,接下去要讨论的就是如何进行优化。

但在正式开始之前,我们要知道的是,即便你不做任何优化,对于大部分的应用来说,React 的性能也是够用的,你把各种优化加上有时候反而会适得其反, 这也是为什么很多开发者其实并不完全理解 React 的更新机制,甚至一些理解的开发者也并不能第一眼就看出代码是否有优化空间, 但是 React 仍然是世界上使用最多的前端框架,并且大部分用其开发的应用都是正常运行的。

所以很多时候,而是先专注于实现,然后回过头去用 Profiler 这类工具去分析你的应用, 然后再针对有性能问题的地方去做优化,这样的做法在大多数情况下是更有效且高效的。

重新思考你的组件结构

我们来看下面一个例子

codesandbox.io/s/flamboyan…

import React from "react";

let menuRenderTime = 0;
function Menu() {
    return <nav>Menu Render Times: {menuRenderTime++}</nav>;
}

function Nav() {
    const [theme, setTheme] = React.useState("light");

    return (
        <div>
            <Menu />
            <button
                onClick={() => setTheme(theme === "light" ? "dark" : "light")}
            >
                Theme: {theme}
            </button>
        </div>
    );
}

export function App() {
    return (
        <div>
            <Nav />
            <div>Content</div>
        </div>
    );
}

这是一个非常常见的例子,我们的应用包含了一个导航栏,导航栏里面有一个菜单,同时导航栏还包含一个切换主题的按钮, 我相信大部分人在遇到这么一个需求的时候,第一反应应该也就是这么去实现,而在这个例子里就隐藏着一个可以优化的地方。 我们先来看这个例子,现在点击切换主题时,Menu组件每次都会重新渲染,很显然符合我上面说到的子组件会因为祖先组件的渲染而重新渲染。

而我们可以通过简单地调整Nav和Menu之间的关系来规避这个问题,这就是 renderProps,来看我如何改造组件

codesandbox.io/p/sandbox/d…

import React from "react";

let menuRenderTime = 0;
function Menu() {
    return <nav>Menu Render Times: {menuRenderTime++}</nav>;
}

function Nav({ renderMenu }) {
    const [theme, setTheme] = React.useState("light");

    return (
        <div>
            {renderMenu}
            <button
                onClick={() => setTheme(theme === "light" ? "dark" : "light")}
            >
                Theme: {theme}
            </button>
        </div>
    );
}

export function App() {
    return (
        <div>
            <Nav renderMenu={<Menu />} />
            <div>Content</div>
        </div>
    );
}

现在你切换主题时Menu组件就不会再重新渲染了,这里就利用到了上面总结的第一点,子组件的更新不会引起祖先节点的重新渲染, 在这个例子里,Nav是App的子节点,其更新并不会让App节点重新渲染,而Menu是App渲染过程中被创建的, App没有重新渲染,说明Menu节点没有被重新创建,其复用的仍然是上一次渲染时创建的Element。

所以结论就是,相较于:

function C() {
    return <div />;
}

function B() {
    return <C />;
}

function A() {
    return <B />;
}

这样递归嵌套的组件结构,我更推荐这样的结构:

function C() {
    return <div />;
}

function B({ children }) {
    return children;
}

function A() {
    return (
        <B>
            <C />
        </B>
    );
}

在 React 中,children其实也是一个prop,只是一般我们习惯把 children 和 props 分开来对待,所以很多同学可能会下意识地认为 children 和 props 是不同的东西。

那么归结到这个例子里面,因为App节点没有重新渲染,所以我们没有重新创建Menu组件地节点(通过createElement),因此 Nav 组件的 props 是没有任何变化的, 他拿到的 Menu 组件的 Element 和前一次渲染的是完全相同的实例! 而这才是在这种 case 下面 C 节点没有重新渲染的根本原因。我们可以通过代码来进行验证:

codesandbox.io/p/sandbox/4…

import React from "react";

let menuRenderTime = 0;
function Menu() {
    return <nav>Menu Render Times: {menuRenderTime++}</nav>;
}

let lastMenuElement = null;
function Nav({ renderMenu }) {
    const [theme, setTheme] = React.useState("light");

    React.useEffect(() => {
        lastMenuElement = renderMenu;
    }, [renderMenu]);

    return (
        <div>
            {renderMenu}
            Menu Changed: {renderMenu === lastMenuElement ? "No" : "Yes"}
            <button
                onClick={() => setTheme(theme === "light" ? "dark" : "light")}
            >
                Theme: {theme}
            </button>
        </div>
    );
}

export function App() {
    return (
        <div>
            <Nav renderMenu={<Menu />} />
            <div>Content</div>
        </div>
    );
}
owner vs children

在上面的例子里,Menu 节点的 owner 是 App,而它是 Nav 节点的 children,所以这里引出一个结论:

节点是否重新渲染会受到 owner 的影响,但和 parent 并不是直接相关。

理解 owner 和 children 的区别对于理解 React 的一些概念还是非常有帮助的,但是 React 官方其实并没有给出这样的概念,所以这里我只是给出了一个比较形象的图示,

简单来说,owner 就是创建当前节点的节点,比如在这个例子里的Menu,他的创建在App中时,他的 owner 就是App,而如果是在 Nav 里面,则 owner 是 Nav。 对比这个结果我们可以发现,影响Menu节点是否重新渲染的根本原因,是其 owner 是否重新渲染,因为一旦 owner 重新渲染,就会引起Menu节点的重新创建, 就会让Menu节点需要被重新渲染。

那么是不是只要节点的对象没有变化,就可以规避重新渲染呢?没错,这就是接下去我们要聊的第二点。

保持节点不变

使用key优化节点对比

在 React 中,key 属性用于优化 Virtual DOM 中节点的对比过程。具体来说,key 的作用包括:

  1. 唯一标识:
    • key 为每个元素提供唯一标识,帮助 React 在列表更新时识别哪些元素发生了变化。
  1. 高效更新:
    • 当组件更新时,React 使用 key 来快速判断哪些元素是新增、删除或移动的。
    • 通过 key,React 可以复用相同 key 的组件实例,避免不必要的重新渲染。
  1. 避免不必要的操作:
    • 使用 key 能防止由于错误对比而导致的组件状态丢失或不必要的重排。
    • 提高性能,尤其是在列表的元素发生排序或频繁更新时。

总之,合理使用 key 可以显著提升 React 应用的渲染性能和准确性。通常建议使用唯一且稳定的标识(如数据库中的 ID)作为 key

减少节点的无效创建

严格来说,上面的例子也就是保持了节点不变,所以规避了Menu节点的无用渲染,只是因为造成节点不变的原因来自 React 自身的算法优化,所以我单独拿出来说, 而这一节则会围绕更 common 的场景来讲解。我们仍然来看一个例子,这个例子会简单很多:

codesandbox.io/p/sandbox/r…

import React from "react";

let menuRenderTime = 0;
function Menu() {
    return <nav>Menu Render Times: {menuRenderTime++}</nav>;
}

const menuElement = <Menu />;

export function App() {
    const [count, setCount] = React.useState(0);

    return (
        <div>
            {menuElement}
            <button onClick={() => setCount((c) => c + 1)}>
                Count: {count}
            </button>
        </div>
    );
}

我简化了之前的例子,同样保持了 Menu 组件不会随着父组件的重新渲染而渲染,而这个实现就非常简单,我把menuElement的创建挪到了App组件外面, 这样的结果是,menuElement的创建只会发生一次,而不会随着App组件的重新渲染而重新创建,而借此让Menu节点规避了因为祖先节点的重新渲染而引起的无效渲染。

需要注意这种方式并不会导致Menu组件内部的setState失效,我们可以通过代码来验证:

codesandbox.io/p/sandbox/r…

import React from "react";

let menuRenderTime = 0;
function Menu() {
    const [count, setCount] = React.useState(0);

    return (
        <nav>
            Menu Render Times: {menuRenderTime++}
            <button onClick={() => setCount((c) => c + 1)}>
                Menu Count: {count}
            </button>
        </nav>
    );
}

const menuElement = <Menu />;

export function App() {
    const [count, setCount] = React.useState(0);

    return (
        <div>
            {menuElement}
            <button onClick={() => setCount((c) => c + 1)}>
                Count: {count}
            </button>
        </div>
    );
}

所以如果要论如何优化 React 的渲染性能,很大的一个方向其实就是减少节点的无效创建,这一方面减少了createElement的调用次数, 另一方面大大规避了无效渲染,但是这种方式为什么并没有被广泛推广呢?主要是因为其可维护性不高,因为你需要把具体某几个节点单独提出去声明, 这让节点渲染脱离了常规的节点流,而等到你的业务变得复杂,你可能很难避免需要传递一些 props 给该组件,这时候你就需要把这个组件提升到父组件中, 那代码改起来就变得非常的麻烦。

另外一种方式是不把Menu提到App之外,而是放到useMemo中,这也是可行的,但是这会引入useMemo的计算成本,你可能需要去评估这个成本是否值得, 而却虽然方便了一些,但是仍然维护起来比较麻烦。

不过 React 提供了一种更符合使用习惯的优化方式,那就是React.memo,这个 API 的作用就是让组件变成一个纯组件,也就是说,如果组件的props没有变化, 那么就不会重新渲染。

React.memo

React.memo其实就是函数组件版的PureComponent,当你使用memo来定义一个组件的时候,memo会在发现组件需要重新渲染的时候, 先去 check 一遍组件的props是否变化,他的默认 check 算法是shallowEqual,也就是只比较props对象的直接属性,并且直接===来对比, 如果 prop 是对象,他也是直接对比对象的引用是否相同,所以总体来说比较算法的成本是很低的,大概率比组件重新渲染要低很多。

React 的 issue 里也有一个讨论 React 是否应该默认开启memo的帖子,可以看到很多用户其期望可以默认开启memo的, 因为几乎百分之 95%以上的情况(甚至可能更高),你把所有组件都开启memo是没有什么负面影响的,却可以规避大部分的无效渲染, 是属于何乐而不为的事情。有兴趣的同学可以去这个issue看看大佬们的讨论。

总结一下为什么 React 官方不考虑默认开启memo的原因:

  • 兼容老代码,React 的向前兼容是出了名的牛,甚至 5-6 年前的代码现在升级到 18 大概率还能正常运行,只是多了很多 warning, 而因为考虑默认开启memo是对 React 的渲染机制的一种破坏性更新,即便大部分的代码不会受影响,但是出于兼容性的考虑,也不会默认开启
  • 有一些极端的 Case 可能会因为加了memo无法正常工作,比如在一些使用响应式编程来维护组件状态的情况,当然我并没有碰到过类似 case, 一方面我不喜欢在 React 中用响应式,另外一方面即便是响应式编程也需要一些极端的情况才会出现。
  • 不开启memo性能也没有那么差,还是那句话,大部分情况下,即便你不做任何优化,React 的性能也是足够的,如果你发现哪里性能有问题, 你再渐进式地去加memo就可以,这属于 React 地一种设计哲学吧,你可以不认可,但也不能否认他也有正确地地方。

关于 memo 的使用我就不单独举例了,相信大家都用到过,memo 其实就是组件级别的 useMemo,而 props 中的所有属性就是 useMemo 中第二个参数中的数组, memo 只要发现 props 没有变化,就会直接返回之前已经创建过的 Element,也就符合了我上一节中提到的优化方式,却又没有代码难以维护的问题。

注意: memo并没有规避渲染,而是把重复渲染这件事交给了memo返回的HOC,而这个组件只做了一件事,也就是判断props是否变化,如果没有变化就返回他cache的节点, 内部实现有点类似:

function memo(Comp) {
    return MemoHOC(...props) {
        const element = useMemo(() => {
            return <Comp {...props} />
        }, [...Object.values(props)]) // 当然这里需要排序一下

        return element
    }
}

结语

一个词概括就是机制,React设计如此,他的更新就是组件树级别的,如果你时不时打开Profiler看看,你会发现很多时候你的代码大概率就是只有几个叶子节点在更新,只要不犯类似频繁更新Context这样的基本错误。

❌