React 渲染优化

最近时不时又会上一下掘金逛逛,不得不说,掘金上的文章,一如既往的拉,首页点刷一遍就没几篇正经的技术文章,难得看到标题技术一点的,点进去也是一些浅浅的理解, 看上去像是在别的地方或者外网看到了一些解析,然后又似懂非懂的,就上来写个文章感慨自己又学到了。这样的文章往往都是一知半解的,讲到了一些技术点, 却又没讲明白,很可能自己本身也不是很理解,真正要归纳深层原理时,就开始夹杂自己的个人理解了,再加上掘金没有线上 demo 的功能,很多完全没有参考意义的 demo 代码, 就被堂而皇之地挂在上面,让掘金整的一个变成了信任的坟场。我可以打包票如果你的大部分技术知识来源是掘金,那么你大概率也就只能是个Junior了。 所以,掘金看个乐呵就行了,真想学技术你至少多逛逛官网对吧。

OK,吐槽完毕,写这篇文章的初衷就是因为上面说到的,看到一些以偏概面的文章来讲解 React 的渲染机制的,说实话国内网上搜 React 渲染机制的文章你一搜一大把, 但是能讲清楚的没几个,所以突然脑抽的我,就准备开个坑写这篇文章,顺便我的这个网站也即将开发完毕,正好拿这篇文章打个头阵, 希望这次,我的内容创作之路可以走得更远吧。

渲染

那么首先,我们要聊的就是 React 的渲染机制,我们首先要弄清楚在讲 React 渲染的时候,我们具体在说的是什么, 当我们调用ReactDOM.render(<App />)(这里就不特地用新的createRootAPI 了)的时候,或者当我们调用setState的时候,React 会从根节点开始重新计算一次整个组件树, 然后得到新的 Virtual Dom Tree,并且和老的 Virtual Dom Tree 做 diff,得到最终需要 apply 的更新,然后执行最小程度的 DOM API 操作。

这里面分为两个步骤:

而我们要聊的渲染就是专门指的第一个步骤,也就是 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 的设计初期,就是希望组件(树)是一个纯函数,也就是说,组件的输出完全由输入决定,不会受到任何外部因素的影响,这样的好处就是,组件的输出是可预测的,

Note

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

基础规则

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

Note

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

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

jsx
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 的更新只会导致更新节点的子树重新渲染,并不会影响祖先节点。

Note

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

这里先记住这一点,对于我们后面聊渲染优化非常有用。

规避渲染

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

所以大部分时候,你不应该以性能作为第一要素去考虑你的代码如何书写,而是先专注于实现,然后回过头去用 Profiler 这类工具去分析你的应用, 然后再针对有性能问题的地方去做优化,这样的作法在大多数情况下是更有效且高效的。

重新思考你的组件结构

我们来看下面一个例子

jsx
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,来看我如何改造组件

jsx
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

所以结论就是,相较于:

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

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

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

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

jsx
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节点没有重新渲染的根本原因。我们可以通过代码来进行验证:

jsx
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节点需要被重新渲染。

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

保持节点不变

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

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

jsx
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的原因:

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

Note

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

jsx
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 跟着我学也是没错的,(逃 ε=ε=ε=┏(゜ロ゜;)┛

参考文章:

Comments

评论还空缺着,赶紧来讨论