likes
comments
collection
share

React 重复渲染的逻辑和优化,看这一篇就够了

作者站长头像
站长
· 阅读数 22

说实话国内网上搜 React 渲染机制的文章你一搜一大把,但是能讲清楚的没几个,作为React的忠实用户,就决定先把最基础也是最容易被弄错的 React 渲染机制和优化讲讲清楚。

如果你觉得我说的有问题,非常欢迎讨论,但希望评论带着观点和论证来,比如如果你觉得我说的不对,最好直接给出代码Demo来反驳,可以分享codesandbox的链接,对于能够做到这种程度的打脸行为,我表示非常欢迎O(∩_∩)O!

渲染

那么首先,我们要聊的就是 React 的渲染机制,我们首先要弄清楚在讲 React 渲染的时候,我们具体在说的是什么, 当我们调用ReactDOM.render(<App />)(这里就不专门用createRootAPI 了)的时候,或者当我们调用setState的时候,React 会从根节点开始重新计算一次整个组件树, 然后得到新的 Virtual Dom Tree,并且和老的 Virtual Dom Tree 做 diff,得到最终需要 apply 的更新,然后执行最小程度的 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(<App />)一般都是初次渲染时进行的,那么整个节点树中的组件都会执行渲染就没有什么可奇怪的,所以我主要围绕着更新来讨论, 也就是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 再从根节点上读取改更新队列并开始重新渲染。可以看看我在16 版本更新之后阅读源码之后的解析

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

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 这类工具去分析你的应用, 然后再针对有性能问题的地方去做优化,这样的作法在大多数情况下是更有效且高效的。

重新思考你的组件结构

我们来看下面一个例子

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组件每次都会重新渲染,很显然符合我上面说到的子组件会因为祖先组件的渲染而重新渲染。

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

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组件就不会再重新渲染了,这里就利用到了上面总结的第一点,子组件的更新不会引起祖先节点的重新渲染, 在这个例子里,NavApp的子节点,其更新并不会让App节点重新渲染,而MenuApp渲染过程中被创建的, 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,只是一般我们习惯把childrenprops分开来对待,所以很多同学可能会下意识地认为childrenprops是不同的东西。 那么归结到这个例子里面,因为App节点没有重新渲染,所以我们没有重新创建Menu组件地节点(通过createElement),所以Nav组件地props是没有任何变化的, **他拿到的Menu组件的Element和前一次渲染的是完全相同的实例!**而这才是在这种 case 下面C节点没有重新渲染的根本原因。我们可以通过代码来进行验证:

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 官方其实并没有给出这样的概念,所以这里我只是给出了一个比较形象的图示,

React 重复渲染的逻辑和优化,看这一篇就够了

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

那么是不是只要节点的对象没有变化,就可以规避重新渲染呢?没错,如果你想到了这一点,说明你思考非常自己,这就是接下去我们要聊的第二点。

保持节点不变

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

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失效,我们可以通过代码来验证:

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的计算成本,你可能需要去评估这个成本是否值得, 而却虽然方便了一些,但是仍然维护起来比较麻烦。21(还是 22)年 React Conf 那个华人小子展示的编译时优化方案里面就包含类似的优化, 如果要做这方面的优化,放在编译时确实是一个更好的选择。

不过 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 中用响应式,另外一方面即便是响应式编程也需要一些极端的情况才会出现,这不是我说的,是一个 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重复渲染地原因就是这么简单,一个词概括就是机制,React设计如此,他的更新就是组件树级别的,其实你时不时打开Profiler看看,你会发现这其实并没有那么可怕,很多时候你的代码大概率就是只有几个叶子节点在更新,只要你不犯了类似频繁更新Context这样的基本错误。而规避重复渲染也的话题的答案也很简单,如果你觉得有必要就用memo就完了。

个人语文表达能力有限,已经在最大程度地尽力把这个 React 比较难以理解却又非常基础地知识点讲清楚了,如果你一遍没看懂,建议你多配合例子跑起来看看, 自己尝试修改修改去验证自己地一些想法,然后再结合文章内容去理解,我相信内容就是这些内容,在笨的人多看几遍总能理解。

我很自信这篇文章绝对是国内技术分析质量最高的那一档,但即便是这样,我还是要说一句,这些内容 React 官网文档都有!你有时间逛社区学 React, 干嘛不先把官网都认真看一遍呢!

当然你要学 React 跟着我学也是没错的,(逃 ε=ε=ε=┏(゜ロ゜;)┛

以上文章的Demo在我自己的博客上都可以直接运行,这应该会更好地帮助你理解文章内容。既然你都看到这里了,就点个赞再去我博客看看呗(*´∀`)~♥

参考文章: