React 应用性能优化实战

我将从 React 的渲染流程说起,带你了解渲染的本质,再通过常见的性能优化工具和方案介绍,向你介绍基本的性能优化知识,并附上几个防止性能劣化的建议,这些知识和建议能够帮助你编写出性能更高的代码。

下面我们就先来看一看 React 渲染原理。

React 渲染原理

我们都知道,一个 React 组件中,包含了两种状态:

  • 一种是从父组件中传入的 props
  • 一种是自身内部的状态 state

React 组件的 render 函数,会返回一个 JSX,这个 JSX 会随着每次的组件状态的改变,而被重新计算。那最终我们的组件为了渲染到真实的 DOM 上,框架需要将 JSX 变成真实的 DOM 渲染到页面中。在每次组件更新后,首先 React 会将 JSX 转换成能够和 DOM 一一对应的虚拟 DOM(Virtual DOM),再通过比较最新的 Virtual DOM 和上一次的 Virtual DOM 之间的区别(diff),确认出最优的 DOM 渲染方案。

比如,React 发现这一次的 Virtual DOM 比上一次的多了一个 <a> 标签,那它只会将这个 <a> 标签插入到 DOM 中,而不是把整个 DOM 树删除再重新渲染。这么做也是考虑性能的问题。因为 DOM 操作本身比较耗时,尽可能减少 DOM 操作的成本能够提升性能,这也是 React 的核心设计思想之一。

和普通的 JS 函数执行比起来,DOM 操作是很耗时的。而大多数 React 应用性能问题,也是因为 DOM 操作过于频繁导致的。所以 React 的性能优化,核心就在于如何减少不必要的 DOM 操作。刚刚提到,React 只会重新渲染那些 diff 的部分,所以我们要先搞明白 React 的 diff 算法是如何运作的。

React 的 diff 算法为了优化速度采用了一些取舍,让时间复杂度达到了 O(n), 而这也让它的 diff 算法看起来不是那么地聪明。比如说,对于这两个 Virtual DOM,React 会识别成两个完全不一样的 DOM 树。

plain 复制代码
<div>
  <Counter />
</div>
<span>
  <Counter />
</span>

它用 DOM 树表示为:

尽管两个树的内部都有 <C``o``unter /> , 但是 React 在识别到最外层的元素类型不同时,就会认为这是两个完全不同的树,会触发全量的重新渲染。

从上面的例子中,其实我们也已经能看出 React 的 diff 是如何运作的了,React 会逐层比较两棵树,当发现某一层的节点类型不同时,会直接认为以该节点为根节点的树是完全不同的,会对该树触发重新渲染,那对于相同节点类型,则分为两种情况:

  1. DOM 原生元素
  2. 自定义组件

如果是原生的 DOM 元素的话,当属性改变时,比如:

plain 复制代码
<div className="dog" />
<div className="cat" />

前后只是 divclassName 发生了改变,这时 react 只会调用 DOM 接口更新该元素的 c``lass 值,而不会把整个标签卸载又重新挂载。

对于自定义组件来讲,改变的属性就是父组件向自组件传递的 props 。那么很显然,当 props 发生改变时,该组件具体要发生什么样的变化,必须通过重新执行组件内的逻辑,生成新的虚拟 DOM,然后按照和父组件一样的方式,进行 diff 与渲染更新。本质上是一个递归的过程。

对于子节点的比较,React 也是一个一个地按照先后顺序进行比对,比如:

plain 复制代码
// 变化前
<div>
  <A/>
  <C/>
<div/>
// 变化后
<div>
  <A/>
  <B/>
  <C/>
<div/>

转换成 DOM 图:

比较过程会发现第二个子节点不同,一个是 <B /> 一个是 <C /> ,React 会先把旧的 <C /> 删除,然后将新的 <B /> 挂载到 DOM 上,然后继续向下扫描,再把多出来的 <C /> 进行挂载:

聪明的你是不是已经发现了,这里面出现了冗余操作。其实最优的操作是我们向 <``A``/><``C``/> 之间插入一个 <B />,但是 React 没有办法预知到这一点,那怎么办呢?

其实不难发现我们缺乏一个对每个组件的唯一标识。它类似于 ID 号,只要有了它,React 就可以知道谁是谁,我们现在给它们加上 Key:

xml 复制代码
// 变化前
<div>
  <A key="1"/>
  <C key="3"/>
<div/>
// 变化后
<div>
  <A key="1"/>
  <B key="2"/>
  <C key="3"/>
<div/>

这样,当比较到第二个子节点时,React 就会发现新的虚拟 DOM 仍然保有该节点。因此不会将 <C /> 删除,只会将 <B /> 插入到 <A/><C /> 之间。

在 React 的术语中,「当状态发生改变,重新计算组件的虚拟 DOM,并计算 diff 找到最优的 DOM 更新方案」这个过程,被称为调和阶段(Reconciliation),而将更新方案应用到真实的 DOM 树上的过程被称为提交阶段(Commit)。

实际的项目中,React 会为我们最小化 DOM 的操作,所以卡顿往往是不必要的 render 函数执行次数过多导致的。从上述内容中,我们了解到每次 render 函数的执行,React 都会重新生成虚拟 DOM 并触发 diff 过程。如果不必要的 render 过多,就会出现卡顿现象。那搞明白了 React 是如何计算 diff 渲染 DOM 的,我们再来了解一下 React 在什么时机下会执行 render 函数。

刚才提到的执行 render 函数,在 React Hook 的写法中,其实就是用于定义组件的函数被执行:

plain 复制代码
// 每次 render 的时候,函数就会被执行,得到返回的 jsx
const App = (props) => {
    const { color, width, height } = props;
    const area = width * height;
    return (
      <>
        <Tile area={area} color={color} />
      </>
    )
}

那么到底什么时候 render 函数会执行呢?

初学者可能会认为,如果一个组件内部的状态没发生改变,那么只有它的 props 发生改变时,才会执行 render 函数。但实际上并不是如此, 当父组件的状态发生改变时,它的所有子组件都会重新 render,哪怕子组件的 props 并未发生改变。这也是导致 React 应用性能变差的最主要原因之一。

性能问题方案

讲完了 React 渲染原理,接下来就到我们具体实践的部分了------性能问题的优化方案

我把它分为了两部分:

  1. 如何避免常见的可能会导致性能问题的写法;
  2. 如何通过工具分析出性能的瓶颈点,进行专项优化。

常见优化方式

刚才已经提到,只要父组件更新,React 会默认渲染所有子组件,哪怕子组件的 props 没有发生改变,那有没有什么办法可以让组件在 props 不发生改变的时候就不进行渲染呢?

答案是 使用 React.memo。

memo 函数包裹组件后,如果 props 不改变,该组件就不会重新渲染:

plain 复制代码
import React from 'react';
export default React.memo(({ contacts }) => {
    return <List data={contacts} />;
  }
);

但是在实际使用中,你可能会发现哪怕加上了 memo,组件还是会反复地重新渲染。这是因为在 React 组件中,状态往往都是 Immutable(不可变数据)。每次当一个变量被赋值为一个对象时,哪怕对象的内部值没有区别,但重新赋值仍然会得到一个新的引用变量。让我们看一下例子。

plain 复制代码
const App = () => {
  const data = { test: 'data' };
  return (
    <div className="app">
      <div className="blue-wrapper">
        <TileMemo data={data} />
      </div>
    </div>
  );
};

如果你学过指针就知道,其实一个对象类型的数据本质上是一个指针:

我们看到当重新赋值后, data 包含的属性内容虽然是一样的,但是由于数据在内存中开辟了新空间,所以 data 作为一个指针指向的数据已经发生改变了,因此 React 会认为 data 发生了变化。

当 App 组件执行了 render 函数之后,虽然 TileMemo 使用了 memo,同时 data 也是一样的,但是 TileMemo 组件仍然重新渲染了。原因就在于每次执行 data= {test:'data'}` 这个语句的时候,data 就会被赋值为一个新的引用值,所以这时 memo 会认为组件的 props 发生了改变,从而触发重新渲染。

为了解决这个问题,我们可以使用 React.useMemo() 来记忆引用变量。当依赖不发生改变的时候,仍然使用旧的引用值。

javascript 复制代码
const App = () => {
  const data = React.useMemo(() => ({
    test: 'data',
  }), []);
  return (
    <div className="app">
      <div className="blue-wrapper">
        <TileMemo data={data} />
      </div>
    </div>
  );
};

这时,因为使用了 useMemo ,依赖项指定了空数组,所以函数只在组件被 mount 后执行一次,后续的重新 render, data 仍然会得到旧的引用值,于是 <TileMemo> 就不会被重新渲染了。

我们在 Hook 中,除了值,还要声明很多函数,典型的如很多事件的 Callback。这些 Callback 常常会作为 props 传给子组件:

javascript 复制代码
const App = () => {
    const onClick = () => {
      console.log('click');
    };
    return (
      <div className="app">
          <TileMemo onClick={onClick} />
      </div>
    );
  };

而这些函数的重新声明,也同样会导致刚才的问题。这种情况应该怎么处理呢?

其实我们仍然可以用 useMemo 解决这个问题。

javascript 复制代码
const App = () => {
    const onClick = React.useMemo(() => (() => {
      console.log('click');
    }));
    return (
      <div className="app">
          <TileMemo onClick={onClick} />
      </div>
    );
  };

但是有一个更简单的方式,就是使用 useCallback,它的本质和 useMemo 是一样的,只不过我们可以直接传入函数,而不用手写一个函数去返回一个函数。

javascript 复制代码
const App = () => {
    const onClick = React.useCallback(() => {
      console.log('click');
    });
    return (
      <div className="app">
          <TileMemo onClick={onClick} />
      </div>
    );
  };

性能瓶颈定位

接下来介绍一个工具来帮助开发者快速定位 React 的性能瓶颈,那就是 React 官方提供的 React Profiler。

那我们如何使用它呢?

首先在浏览器商店中安装 R e ac t 拓展,安装后,你可以在 Devtools 中看到 Profiler 一栏。

  • 打开设置可以记录组件 rendered 的原因。

  • 还可以高亮发生 render 的组件。

它可以帮助你分析组件的渲染次数和耗时,用法和浏览器自带的 Performance 类似。点击 Record,开始记录,然后进行页面操作,点击停止记录后,我们会得到一个火焰图。

图中的每一行都代表了一个组件,点击组件就可以看到它过去的 render 时机以及耗时。

就像火焰一样,颜色越黄的耗时越长。开发者可以根据该图定位到 render 耗时最长的组件,再按照前面的内容进行组件的性能优化。

总结

今天我们先讲解了 React 的渲染流程,包括调和阶段和提交阶段。然后给出了 React 性能优化的常见手段,比如 memo。另外我们学习了写代码时可能导致性能问题的常见误区,比如重新赋值生成新的引用对象等,随后我们又介绍了 React 的性能优化工具 React Profiler,它能够帮助开发者快速定位性能的瓶颈所在,对症下药。

相关推荐
袁煦丞2 分钟前
每天省2小时!这个网盘神器让我告别云存储混乱(附内网穿透神操作)
前端·程序员·远程工作
一个专注写代码的程序媛1 小时前
vue组件间通信
前端·javascript·vue.js
一笑code1 小时前
美团社招一面
前端·javascript·vue.js
懒懒是个程序员2 小时前
layui时间范围
前端·javascript·layui
NoneCoder2 小时前
HTML响应式网页设计与跨平台适配
前端·html
凯哥19702 小时前
在 Uni-app 做的后台中使用 Howler.js 实现强大的音频播放功能
前端
烛阴2 小时前
面试必考!一招教你区分JavaScript静态函数和普通函数,快收藏!
前端·javascript
GetcharZp2 小时前
xterm.js 终端神器到底有多强?用了才知道!
前端·后端·go
JiangJiang2 小时前
🚀 React 弹窗还能这样写?手撸一个高质量 Modal 玩起来!
前端·javascript·react.js
吃炸鸡的前端2 小时前
el-transfer穿梭框数据量过大的解决方案
前端·javascript