React 渲染

React 在渲染方面的基础知识和优化方式

虚拟DOM

虚拟 DOM 就是用 JS 表示的抽象化的节点树,主要是通过局部更新和合并 DOM 操作来减少浏览器 reflow 和 repaint 的次数,以此来提高 DOM 操作的效率,且可以实现跨平台的开发,下面是他工作的流程:

  1. 每次数据模型变化的时候,重新生成抽象化的节点树;
  2. 通过 Dffi 算法,计算出差异部分;
  3. 将差异部分的渲染进行批处理,渲染出真实的 DOM;

Diff算法策略

Diff 算法有三个步骤:

tree diff

对 dom 树进行分层比较,对同一层级的节点进行比较,如果节点不存在时,就会卸载该节点及其所有子节点

component diff

判断组件的类型是否发生变化,如果发生变化(例如组件A变成了组件B),则重新渲染该组件及其子组件;

如果类型没有发生变化,则会先判断 shouldComponentUpdate 的返回值,如果为 true 则跳过虚拟 DOM 树的对比直接判断为需要被修改,如果为 false,则通过对比前后虚拟 DOM 树,判断是否需要重新渲染;

element diff

对于全新的元素节点会执行插入操作;

对于已有的元素节点,根据其是否可复用,执行移动或删除操作(基于Key值)

Fiber

React 渲染页面的两个阶段

  • 调度阶段(reconciliation):在这个阶段 React 会更新数据生成新的虚拟 DOM,然后通过 Diff 算法,快速找出需要更新的元素,放到更新队列中去,得到新的更新队列。
  • 渲染阶段(commit):这个阶段 React 会遍历更新队列,将其所有的变更一次性更新到DOM上。

在 React16 以前,采用递归的方式创建虚拟 DOM 并进行 Diff,递归过程是不能中断的,且整个过程的 JS 计算会一直占用浏览器的主进程。如果组件树的层级很深,递归会占用线程很多时间,递归更新时间超过了16ms,用户交互就会卡顿。为了解决这个问题,React16 将递归的无法中断的更新重构为异步的可中断更新,即 Fiber。

Fiber 改变了之前 React 的组件更新机制,将一次更新过程分成多个分片,并赋予不同的优先级。

有可能一个更新任务还没有完成,就被另一个更高优先级的更新过程打断,这时候,优先级高的更新任务会优先处理完,而低优先级更新任务所做的工作则会完全作废,然后等待重新调用。

新的架构调整的是调度阶段(commit阶段无法暂停),核心思想是任务拆分和协同,主动把执行权交给主线程,使主线程有时间空挡处理其他高优先级任务。

何时重渲染

setState

当调用 setState 进行状态修改的时候,不论新状态和旧状态是否是否相等,都会重新渲染。

但是在 hooks 组件内使用 useState 进行的状态修改时会在状态相等时会阻止其重新渲染。

props变动

当父组件传给子组件的 props 发生变动时,就会引起子组件的重新渲染。

父组件更新

当父组件发生更新时,子组件都会重新渲染

渲染优化方案

React.lazy

组件的动态加载方案,将组件打包成对应的 chunk,当组件被需要时,通过 script 标签来动态加载 chunk。

使用 lazy 方法,需要修改原有的 import 方式:

const OtherComponent = React.lazy(() => import('./OtherComponent'));

这里需要注意的是 import 方法要求接受一个 default export 的 React 组件。

然后使用 Suspense 组件进行包裹,用于等待加载时的降级:

import React, { Suspense } from 'react';

const OtherComponent = React.lazy(() => import('./OtherComponent'));

function MyComponent() {
  return (
    <div>
      <Suspense fallback={<div>Loading...</div>}>
        <OtherComponent />
      </Suspense>
    </div>
  );
}

shouldComponentUpdate

当父组件发生重新渲染时,子组件的 render 方法也会被调用,但此时父组件传给子组件的 props 并没有发生变化,那么子组件其实不需要被重新渲染,子组件可以通过 shouldComponentUpdate方法来判断当前组件是否需要被更新。

例如,如果父组件传入的 props 中的 x 属性没有变化,子组件就不需要被更新:

class MyComponent extends Component{
    shouldComponentUpdate(nextProps,nextState){
        if(nextProps.x === this.props.x){
            return false
        };
        return true;
    }
}

PureComponent

使用PureComponent替代Component,其内部已经封装了shouldComponentUpdate的浅比较逻辑。

React.memo

通过校验 Props 中的数据的内存地址是否改变来决定组件是否重新渲染组件的一种技术。

对于函数组件(FunctionComponent)可以通过React.memo()进行优化,React.memo(Component)返回一个新的组件,该组件和 PureComponent 类似,能够在 state 和 props 变化时检查新旧值是否相等再决定是否渲染。

useMemo

useMemo 用来存储变量,防止计算变量的函数在页面刷新时被频繁调用。

举个例子:如果有一个函数,返回一个计算后的值,那么每次组件重新渲染的时候,都会重新触发该函数,重新计算值。如果函数内部的计算逻辑较多,且希望在想关联的变量变更时才重新计算,那就需要使用 useMemo 将值进行一个缓存。

小Tip:当同时存在 useMemo 和 useEffect ,且两者的依赖相同时,会先执行 useMemo。

useCallback

useCallback 缓存的是函数,常用于缓存父组件通过 props 传递给子组件的函数,防止子组件进行不必要的渲染。

实际上,当组件重新渲染时,被useCallBack包裹了的函数也会被重新构建,useCallBack 的本质工作不是在依赖不变的情况下阻止函数创建,而是在依赖不变的情况下不返回新的函数地址而返回旧的函数地址。不论是否使用 useCallBack 都无法阻止组件 render 时函数的重新创建。

那么是否需要给每一个函数都加上 useCallback 呢?

每一个被 useCallBack 的函数都将被加入 useCallBack 内部的管理队列。而当我们大量使用 useCallBack 的时候,管理队列中的函数会非常之多,任何一个使用了 useCallBack 的组件重新渲染的时候都需要去遍历 useCallBack 内部所有被管理的函数,找到需要校验依赖是否改变的函数,并进行校验。
在以上这个过程中,寻找指定函数需要性能,校验也需要性能。所以,滥用 useCallBack 不但不能阻止函数重新构建还会增加不必要的负担。

同时需要注意的是:子元素需要使用React.memo进行包裹,不然不会生效。

来看一个实例:查看例子

这里有一个子组件,包含一个 input 元素,在输入时会调用父函数传递过来的 inputChange 函数,父组件会将传递过来的值保存在父组件的 value 状态中。

如果父元素不进行 value 状态的更新操作,那么子组件的 input 元素在输入的时候,不会触发父组件和子组件的重新渲染;

如果父组件设置了 value 状态的更新操作,首先父组件会触发重新渲染,直接导致子组件也进行重新渲染。

对于子组件的重新渲染,首先想到的是使用 React.memo 进行 props 的新旧判断,但结果是子组件还是会重新渲染,也就是说:props 其实发生了改变。

这也不难理解,当父组件重新渲染时,function 内的方法都会重新被调用,handleInputChange 变量会被重新分配内存指针,所以传给子组件的 props 自然就变了。

那么这时候就需要进行函数的缓存,让 props 变得稳定,防止子组件无故刷新。

List key

对于列表或其他结构相同的节点,为其中的每一项增加唯一key属性,以方便React的diff算法中对该节点的复用,减少节点的创建和删除操作

渲染HTML

使用dangerouslySetInnerHTML可以渲染 html 元素,但会增加被跨站脚本(XSS)攻击的风险,同时需要注意,并不是直接传入 html,而是需要用一个__html为键的对象包裹起来:

function Component(props){
    return <div dangerouslySetInnerHTML={{_html:'<span>你好</span>'}}></div>
}
作者

BiteByte

发布于

2021-08-19

更新于

2024-01-11

许可协议