「万字总结」🍒动画 + 大白话讲清楚React渲染原理

·  阅读 15634
「万字总结」🍒动画 + 大白话讲清楚React渲染原理

前言

相信很多人跟我之前一样,看到源码两个字觉得触不可及,觉得离自己还很遥远,是需要非常多年的工作经验的大佬才能触及到的领域。就在去年我改变了这个想法,当时被react的几个生命周期执行顺序弄的睡不着觉,为什么有些时候生命周期的执行事与愿违?又为什么数组中必须要加上key属性?为啥在render中不能写setState等等问题......在一系列的问题中,我终于还是打开了那份久违的源码,并且Ctrl + F慢慢探索了起来。

直到今天,趁着二季度业务结束忙里偷闲总结出这份不看源码也能让你看懂的渲染原理。因为有些地方需要承上启下,所以文本分为两大部分讲解,一部分是首次挂载渲染原理,另一部分是更新和卸载原理,很多地方非常抽象,希望大家仔细阅读,不然容易脱节。废话不多话,开车!!

正文

在开始之前,需要一些前置知识才能帮助我们更好的理解整个渲染过程。首先就是生命周期(16版本之后),为什么要讲一下生命周期?跟渲染原理有关系吗?当然有,如果你不理解渲染原理的话,更新一个嵌套很深的组件你甚至连父与子生命周期执行的先后顺序都不知道。本文直接对照16版本之后的新生命周期进行讲解,就不讲解老版本了。

初探-生命周期

顾名思义,跟人生一样,生命周期就是一个组件从诞生销毁的过程。React在组件的生命周期中注册了一系列的钩子函数,支持开发者在其中注入代码,并在适当的时机运行。这里指的生命周期仅针对于类组件中的钩子函数。因为生命周期不是本文的重点,所以Hooks中的新增的钩子函数在本文中均不涉及,可以以后出个Hooks原理篇。

从图中可以看到,我把生命周期分为了挂载阶段更新阶段卸载阶段三个阶段。同时,在挂载阶段更新阶段都会运行getDerivedStateFromPropsrender,卸载阶段很好理解,只有一个componentWillUnMount,在卸载组件之前做一些事情,通常用来清除定时器等副作用操作。那么挂载阶段更新阶段中的生命周期我们来逐一看下每个运行点及作用。

1. constructor

在同一个类组件对象只会运行一次。所以经常来做一些初始化的操作。同一个组件对象被多次创建,它们的construcotr互不干扰。

注意:在construcotr中要尽量避免(最好禁止)使用setState 我们都知道使用setState会造成页面的重新渲染,但是在初始化阶段,页面都还没有将真实DOM挂载到页面上,那么重新渲染的又有什么意义呢。除异步的情况,比如setInterval中使用setState是没问题的,因为在执行的时候页面早已渲染完成。但也最好不要,容易一些引起奇怪的问题。

    constructor(props) {
        super(props);

        this.state = {
            num: 1
        };

        //不可以,直接Warning
        this.setState({
            num: this.state.num + 1
        });

        //可以使用,但不建议
        setInterval(()=>{
            this.setState({
                num: this.state.num + 1
            });
        }, 1000);
    }
复制代码

截屏2022-07-13 下午2.09.07.png

2. 静态属性 static getDerivedStateFromProps

该方法是一个静态属性,在16版本之前不存在,在新版生命周期中主要用来取代componentWillMountcomponentWillReceiveProps,因为这两个老生命周期方法在一些开发者不规范的使用下极容易产生一些反模式的bug。因为是静态方法,所以你在其中根本拿不到this,更不可能调用setState

该方法在挂载阶段更新阶段都会运行。它有两个参数propsstate当前的属性值状态。它的返回值会合并掉当前的状态(state)。 如果返回了非Object的值,那么它啥都不会做,如果返回的是Object,那么它将会跟当前的状态合并,可以理解为Object.assign。通常情况下,几乎不怎么使用该方法。

    /**
     * 静态方法,首次挂载和更新渲染都会运行该方法
     * @param {*} props 当前属性
     * @param {*} state 当前状态
     */
    static getDerivedStateFromProps(props, state){
        // return 1; //没用
        return {
            num: 999,   //合并到当前state对象
        };
    }
复制代码

3. render

最重要的生命周期,没有之一。用来生成虚拟节点(vDom)树。该方法只要遇到需要重新渲染都会运行。同样的,在render中也严禁使用setState,因为会导致无限递归重新渲染导致爆栈

    render() {
        //严禁使用!!!
        this.setState({
            num: 1
        })
        return (
            <>{this.state.num}</>
        )
    }
复制代码

截屏2022-07-13 下午4.03.13.png

4. componentDidMount

该方法只会运行一次,在首次渲染时页面将真实DOM挂载完毕之后运行。通常在这里做一些异步操作,比如开启定时器、发起网络请求、获取真实DOM等。在该方法中,可以大胆使用setState,因为页面已经渲染完成。执行完该钩子函数后,组件正式进入到活跃状态。

    componentDidMount(){
        // 初始化或异步代码...
        this.setState({});

        setInterval(()=>{});

        document.querySelectorAll("div");
    }
复制代码

5. 性能优化 shouldComponentUpdate

在原理图更新阶段中可以看到,执行完static getDerivedStateFromProps后,会执行该钩子函数。该方法通常用来做性能优化。它的返回值(boolean)决定了是否要进行渲染更新。该方法有两个参数nextPropsnextState表示此次更新(下一次)的属性状态。通常我们会将当前值与此次要更新的值做比较来决定是否要进行重新渲染。

React中,官方给我们实现好了一个基础版的优化组件PureComponent,就是一个HOC高阶组件,内部实现就是帮我们用shouldComponentUpdate做了浅比较优化。如果安装了React代码提示的插件,我们可以直接使用rpc + tab键来生成模版。注意:继承了PureComponent后不需要再使用shouldComponentUpdate进行优化。

    /**
     * 决定是否要进行重新渲染
     * @param {*} nextProps 此次更新的属性
     * @param {*} nextState 此次更新的状态
     * @returns {boolean}
     */
    shouldComponentUpdate(nextProps, nextState){
        // 伪代码,如果当前的值和下一次的值相等,那么就没有更新渲染的必要了
        if(this.props === nextProps && this.state === nextState){
            return false;
        }
        return true;
    }
复制代码

6. getSnapshotBeforeUpdate

如果shouldComponentUpdate返回是true,那么就会运行render重新生成虚拟DOM树来进行对比更新,该方法运行在render后,表示真实DOM已经构建完成,但还没有渲染到页面中。可以理解为更新前的快照,通常用来做一些附加的DOM操作。

比如我突然想针对具有某个class的真实元素做一些事情。那么就可以在此方法中获取元素并修改。该函数有两个参数prevPropsprevState表示此次更新前的属性状态,该函数的返回值(snapshot)会作为componentDidUpdate的第三个参数。

    /**
     * 获取更新前的快照,通常用来做一些附加的DOM操作
     * @param {*} prevProps 更新前的属性
     * @param {*} prevState 更新前的状态
     */
    getSnapshotBeforeUpdate(prevProps, prevState){
        // 获取真实DOM在渲染到页面前做一些附加操作...
        document.querySelectorAll("div").forEach(it=>it.innerHTML = "123");
        
        return "componentDidUpdate的第三个参数";
    }
复制代码

7. componentDidUpdate

该方法是更新阶段最后运行的钩子函数,跟getSnapshotBeforeUpdate不同的是,它的运行时间点是在真实DOM挂载到页面后。通常也会使用该方法来操作一些真实DOM。它有三个参数分别是prevPropsprevStatesnapshot,跟Snapshot钩子函数一样,表示更新前的属性状态Snapshot钩子函数的返回值。

    /**
     * 通常用来获取真实DOM做一些操作
     * @param {*} prevProps 更新前的属性
     * @param {*} prevState 更新前的状态
     * @param {*} snapshot  getSnapshotBeforeUpdate的返回值
     */
    componentDidUpdate(prevProps, prevState, snapshot){
        document.querySelectorAll("div").forEach(it=>it.innerHTML = snapshot);
    }
复制代码

8. componentWillUnmount

如开头提到的,该钩子函数属于卸载阶段中唯一的方法。如果组件在渲染的过程中被卸载了,React会报出Warning:Can't perform a React state update on an unmounted component的警告,所以通常在组件被卸载时做清除副作用的操作

    componentWillUnmount(){
        // 组件被卸载前清理副作用...
        clearInterval(timer1);
        clearTimeout(timer2);
        this.setState = () => {};
    }
复制代码

到这里,React生命周期中每一个钩子函数的作用以及运行时间点就已经全部了解了,斯国一!等在下文中提到的时候也有一个大致的印象。大家可以先喝口水休息一下~

React element(初始元素)

先来认识下第一个概念,就是React element,what?当我伞兵?我还不知道什是element?别激动,这里的元素不是指真实DOM中的元素,而是通过React.createElement创建的类似真实DOM的元素。比如我们在开发中通过语法糖jsx写出来的html结构都是React element,为了跟真实DOM区分开来,本文就统称为React初始元素

为什么要有一个初始元素的概念?我们都知道通过jsx编写的html不可能直接渲染到页面上,肯定是经历了一系列的复杂的处理最后生成真实DOM挂载到页面上。那么到底是怎么样的一个过程?在我们认识一些概念之后才能更深入的理解整个过程。先看看平时写的代码哪些是初始元素

import React, { PureComponent } from 'react'

//创建的是React初始元素
const A = React.createElement("div");
//创建的是React初始元素
const B = <div>123</div>

export default class App extends PureComponent {
    render() {
        return (
            //创建的是React初始元素
            <div>
                {A}
                {B}
            </div>
        )
    }
}
复制代码

React vDom(虚拟节点)

前面提到React在渲染过程中要做很多事情,所以不可能直接通过初始元素直接渲染。还需要一个东西就是虚拟节点。在本文中不涉及React Fiber的概念,将vDom树和Fiber树统称为虚拟节点。有了初始元素后,React就会根据初始元素其他可以生成虚拟节点的东西生成虚拟节点请记住:React一定是通过虚拟节点来进行渲染的。 接下来就是重点,除了初始元素能生成虚拟节点以外,还有哪些可能生成虚拟节点?总共有多少种节点类型?

1. DOM节点(ReactDomComponent)

此DOM非彼DOM,这里的DOM指的是虚拟DOM节点。当初始元素的type属性为字符串的时候React就会创建虚拟DOM节点。例如我们前面使用jsx直接书写的const B = <div></div>。它的属性就是"div",可以打印出来看一下。

微信图片_20220714212514.png

2. 组件节点(ReactComposite)

初始元素type属性为函数或是的时候,React就会创建虚拟组件节点

65463463.png

789789.png

3. 文本节点(ReactTextNode)

顾名思义,直接书写字符串或者数字React会创建为文本节点。比如我们可以直接用ReactDOM.render方法直接渲染字符串数字

import ReactDOM from 'react-dom/client';

const root = ReactDOM.createRoot(document.getElementById('root'));
//root.render('一头猪');   //创建文本节点
root.render(123465);      //创建文本节点
复制代码

4. 空节点(ReactEmpty)

我们平时写React代码的时候经常会写三目表达式{this.state.xxx ? <App /> : false}用来进行条件渲染,只知道为false就不会渲染,那么到底是怎么一回事?其实遇到字面量nullfalsetrueundefinedReact中均会被创建为一个空节点。在渲染过程中,如果遇到空节点,那么它将什么都不会做。

import ReactDOM from 'react-dom/client';

const root = ReactDOM.createRoot(document.getElementById('root'));
//root.render(false);      //创建空节点
//root.render(true);       //创建空节点
//root.render(null);       //创建空节点
root.render(undefined);    //创建空节点
复制代码

5. 数组节点(ReactArrayNode)

什么?数组还能渲染?当然不是直接渲染数组本身啦。当React遇到数组时,会创建数组节点。但是不会直接进行渲染,而是将数组里的每一项拿出来,根据不同的节点类型去做相应的事情。所以数组里的每一项只能是这里提到的五个节点类型。不信?那放个对象试试。

import React from 'react';
import ReactDOM from 'react-dom/client';

const root = ReactDOM.createRoot(document.getElementById('root'));

function FuncComp(){
    return (
        <div>组件节点-Function</div>
    )
}

class ClassComp extends React.Component{
    render(){
        return (
            <div>组件节点-Class</div>
        ) 
    }
}

root.render([
    <div>DOM节点</div>,  //创建虚拟DOM节点
    <ClassComp />,       //创建组件节点
    <FuncComp />,        //创建组件节点
    false,               //创建空节点
    "文本节点",           //创建文本节点
    123456,              //创建文本节点
    [1,2,3],             //创建数组节点
    // {name: 1}         //对象不能生成节点,所以会报错
]);
复制代码

微信图片_20220714225738.png

真实DOM(UI)

通过document.createElement创建的元素就是真实DOM。了解完初始元素虚拟节点以及真实DOM这几个重要的概念后,就可以进入到原理的学习了。 再次强调:React的工作是通过初始元素或可以生成虚拟节点的东西生成虚拟节点然后针对不同的节点类型去做不同的事情最终生成真实DOM挂载到页面上!所以为什么对象不能直接被渲染,因为它生成不了虚拟节点(实际上是ReactDOM库进行渲染,为了减少混淆本文中就直接说React

渲染原理.gif

首次渲染阶段

如上图所示,React首先根据初始元素先生成虚拟节点,然后做了一系列操作后最终渲染成真实的UI。生成虚拟节点的过程上面已经讲过了,所以这里说的是根据不同的虚拟节点它到底做了些什么处理。

1. 初始元素-DOM节点

对于初始元素type属性为字符串时,React会通过document.createElement创建真实DOM。因为初始元素type为字符串,所以直接会根据type属性创建不同的真实DOM。创建完真实DOM后会立即设置该真实DOM的所有属性,比如我们直接在jsx中可以直接书写的classNamestyle等等都会作用到真实DOM上。

//jsx语法:React初始元素
const B = <div className="wrapper" style={{ color: "red" }}>
    <p className="text">123</p>
</div>
复制代码

1657855131681.jpg

222222222222.jpg

当然我们的html结构肯定不止一层,所以在设置完属性后React会根据children属性进行递归遍历。根据不同的节点类型去做不同的事情,同样的,如果children初始元素,创建真实DOM、设置属性、然后检查是否有子元素。重复此步骤,一直到最后一个元素为止。遇到其他节点类型会做以下事情。⬇️

2. 初始元素-组件节点

前面提到的,如果初始元素type属性是一个class类或者function函数时,那么会创建一个组件节点。所以针对函数组件,它的处理是不同的。

  • 函数组件

对于函数组件会直接调用函数,将函数的返回值进行递归处理(看看是什么节点类型,然后去做对应的事情,所以一定要返回能生成虚拟节点的东西),最终生成一颗vDOM树。

  • 类组件

对于类组件而言会相对麻烦一些。但前面有了生命周期的铺垫,结合图中挂载阶段来看这里理解起来就很方便了。

  1. 首先创建类的实例(调用constructor)。
  2. 调用生命周期方法static getDerivedStateFromProps
  3. 调用生命周期方法render,根据返回值递归处理。跟函数组件处理返回值一样,最终生成一颗vDom树。
  4. 将该组件的生命周期方法componentDidMount加入到执行队列中等待真实DOM挂载到页面后执行(注意:前面说了render是一个递归处理,所以如果一个组件存在父子关系的时候,那么肯定要等子组件渲染完父组件才能走出render,所以子组件componentDidMount一定是比父组件先入队列的,肯定先运行!)。

3. 文本节点

针对文本节点,会直接通过document.createTextNode创建真实的文本节点。

4. 空节点

如果生成的是空节点,那么它将什么都不会做!对,就是那么简单,啥都不做。

5. 数组节点

就像前面提到的一样,React不会直接渲染数组,而是将里面的每一项拿出来遍历,根据不同的节点类型去做不同的事,直到递归处理完数组里的每一项。(这里留个问题,为什么在数组里我们要写key?)

一图胜千言

当处理完了所有的节点后,我们的vDom树和真实DOM也创建好了,React会将vDom树保存起来,方便后续使用。然后将创建好的真实DOM都挂载到页面上。至此,首次渲染的阶段就全部结束了。有点懵?没事,正常,我们举个例子。

import React from 'react';
import ReactDOM from 'react-dom/client';
const root = ReactDOM.createRoot(document.getElementById('root'));

/**
 * 组件节点-类组件
 */
class ClassSon extends React.Component {

    constructor(props){
        super(props);
        console.log("444 ClassSon constructor");
    }

    static getDerivedStateFromProps(props, state){
        console.log("555 ClassSon getDerivedStateFromProps");
        return {};
    }

    componentDidMount(){
        console.log("666 ClassSon componentDidMount");
    }

    render() {
        return (
            <div className="func-wrapper">
                <span>
                    textNode22
                    {undefined}
                </span>
                {[false, "textNode33", <div>textNode44</div>]}
            </div>
        )
    }
}

/**
 * 组件节点-类组件
 */
class ClassComp extends React.Component {

    constructor(props){
        super(props);
        console.log("111 ClassComp constructor");
    }

    static getDerivedStateFromProps(props, state){
        console.log("222 ClassComp getDerivedStateFromProps");
        return {};
    }

    componentDidMount(){
        console.log("333 ClassComp componentDidMount");
    }
    
    render() {
        return (
            <div className="class-wrapper">
                <ClassSon />
                <p>textNode11</p>
                {123456789}
            </div>
        )
    }
}

root.render(<ClassComp />);

复制代码

从代码结构来看,渲染的是ClassComp类组件,类组件内包含了一个函数组件以及一些其他可以生成虚拟节点的东西,同样的,函数组件内也是一些可以生成虚拟节点的结构。因为用图表示比较复杂,时间可能会有点久(gif很大已压缩...,显示有点小的话麻烦右键新标签打开看好了)

123123.gif

从图中可以看到,在ClassComp首次挂载运行render的过程中,发现了ClassSon组件,然后又开始了一个新的类组件节点的渲染过程。要等到ClassSon和其他兄弟节点渲染完后ClassComprender才算完成。所以ClassSoncomponentDidMount一定是先进队列的。所以控制台执行顺序一定是111222444555666333。到这里,首次挂载的所有过程就结束了。再喝口水休息一下~

更新和卸载

挂载完成后组件进入活跃状态,等待数据的更新进行重新渲染。那么到底有几种场景会触发更新?整个过程又是怎么样的,有哪些需要注意的地方?

更新的场景

  • 组件更新(setState

最常见的,我们经常用setState来重新设置组件的状态进行重新渲染(本文不涉及Hooks概念,不讲useState)。使用setState只会更新调用此方法的类。不会涉及到兄弟节点以及父级节点。影响范围仅仅是自己的子节点。结合文章最前面的生命周期图看,步骤如下:

  1. 运行当前类组件的生命周期静态方法static getDerivedStateFromProps。根据返回值合并当前组件的状态。
  2. 运行当前类组件的生命周期方法shouldComponentUpdate。如果该方法返回的false。直接终止更新流程!
  3. 运行当前类组件的生命周期方法render,得到一个新的vDom树,进入新旧两棵树的对比更新
  4. 将当前类组件的生命周期方法getSnapshotBeforeUpdate加入执行队列,等待将来执行。
  5. 将当前类组件的生命周期方法componentDidUpdate加入执行队列,等待将来执行。
  6. 重新生成vDom树。
  7. 根据vDom树更新真实DOM.
  8. 执行队列,此队列存放的是更新过程中所有新建类组件的生命周期方法componentDidMount
  9. 执行队列,此队列存放的是更新过程涉及到原本存在的类组件的生命周期方法getSnapshotBeforeUpdate
  10. 执行队列,此队列存放的是更新过程涉及到原本存在的类组件的生命周期方法componentDidUpdate
  11. 执行队列,此队列存放的是更新过程中所有卸载的类组件的生命周期方法componentWillUnMount
  • 根节点更新(ReactDOM.createRoot().render

ReactDOM的新版本中,已经不是直接使用ReactDOM.render进行更新了,而是通过createRoot(要控制的DOM区域)的返回值来调用render,无论我们在嵌套多少的组件里去调用控制区域.render,都会直接触发根节点对比更新。一般不会这么操作。如果触发了根节点的更新,那么后续步骤是上面组件更新6-11步。

对比更新过程(diff)

知道了两个更新的场景以及会运行哪些生命周期方法后,我们来看一下具体的过程到底是怎么样的。所谓对比更新就是将新vDom树跟之前首次渲染过程中保存的老vDom树对比发现差异然后去做一系列操作的过程。那么问题来了,如果我们在一个类组件中重新渲染了,React怎么知道在产生的新树中它的层级呢?难道是给vDom树全部挂上一个不同的标识来遍历寻找更新的哪个组件吗?当然不是,我们都知道Reactdiff算法将之前的复杂度O(n^3)降为了O(n)。它做了以下几个假设:

  1. 假设此次更新的节点层级不会发生移动(直接找到旧树中的位置进行对比)。
  2. 兄弟节点之间通过key进行唯一标识。
  3. 如果新旧的节点类型不相同,那么它认为就是一个新的结构,比如之前是初始元素div现在变成了初始元素span那么它会认为整个结构全部变了,无论嵌套了多深也会全部丢弃重新创建。

key的作用

如果前面copy了文中的代码例子就会发现在使用数组节点的时候,如果里面有初始元素,并且没有给初始元素添加key那么它会警告Warning: Each child in a list should have a unique "key" prop.。那么key值到底是干嘛用的呢?其实key的作用非常简单,仅仅是为了通过旧节点,寻找对应的新节点进行对比提高节点的复用率。我们来举个例子,假如现在有五个兄弟节点更新后变成了四个节点

未添加key

渲染原理.gif

添加了key

渲染原理.gif

看完两张图会发现如果有key的话在其他节点未变动的情况下复用了之前的所有节点。所以请尽量保持同一层级内key唯一性稳定性。这就是为什么不要用Math.random作为key的原因,跟没写一样。

找到对比目标-节点类型一致

经过假设和一系列的操作找到了需要对比的目标,如果发现节点类型一致,那么它会根据不同的节点类型做不同的事情。

1. 初始元素-DOM节点

如果是DOM节点React会直接重用之前的真实DOM。将这次变化的属性记录下来,等待将来完成更新。然后遍历其子节点进行递归对比更新

import React, { PureComponent } from 'react'

export default class App extends PureComponent {
    state = {
        flag: true
    }
    
    render() {
        console.log("render了");
        return (
            <div className={this.state.flag ? "wrapper" : "flagFlase"}>
                <button onClick={()=>{
                    this.setState({
                        flag: !this.state.flag
                    });
                    console.log("属性名变了吗现在?", document.querySelector(".wrapper").className);
                }}>更新</button>
            </div>
        )
    }
}
复制代码

截屏2022-07-17 上午1.00.26.png

2. 初始元素-组件节点

  • 函数组件

如果是函数组件React仅仅是重新调用函数拿到新的vDom树,然后递归进行对比更新

  • 类组件

针对类组件React也会重用之前的实例对象。后续步骤如下:

  1. 运行生命周期静态方法static getDerivedStateFromProps。将返回值合并当前状态。
  2. 运行生命周期方法shouldComponentUpdate,如果该方法返回false,终止当前流程。
  3. 运行生命周期方法render,得到新的vDom树,进行新旧两棵树的递归对比更新
  4. 生命周期方法getSnapshotBeforeUpdate加入到队列等待执行。
  5. 生命周期方法componentDidUpdate加入到队列等待执行。
import React, {Component} from 'react'

export default class App extends Component {

    static getDerivedStateFromProps(props, state){
        console.log("111 getDerivedStateFromProps");
        return {};
    }

    shouldComponentUpdate(){
        console.log("222 shouldComponentUpdate");
        return true;
    }

    getSnapshotBeforeUpdate(){
        console.log("444 getSnapshotBeforeUpdate");
        return null;
    }

    componentDidUpdate(){
        console.log("555 getSnapshotBeforeUpdate")
    }

    render() {
        console.log("333 render");
        return (
            <div className={"wrapper"}>
                <button onClick={()=>{
                    this.setState({});
                }}>更新</button>
            </div>
        )
    }
}
复制代码

截屏2022-07-17 上午1.27.37.png

3. 文本节点

对于文本节点,同样的React也会重用之前的真实文本节点。将新的文本记录下来,等待将来统一更新(设置nodeValue)。

import React, { PureComponent } from 'react'

export default class App extends PureComponent {

    state = {
        text: "文本节点"
    }

    render() {
        return (
            <div className="wrapper">
                {this.state.text}
                <button onClick={()=>{
                    this.setState({
                        text: "新文本节点"
                    })
                }}>更新</button>
            </div>
        )
    }
}

复制代码

截屏2022-07-17 上午1.40.31.png

4. 空节点

如果节点的类型都是空节点,那么React啥都不会做。

5. 数组节点

首次挂载提到的,数组节点不会直接渲染。在更新阶段也一样,遍历每一项,进行对比更新,然后去做不同的事。

找到对比目标-节点类型不一致

如果找到了对比目标,但是发现节点类型不一致了,就如前面所说,React会认为你连类型都变了,那么你的子节点肯定也都不一样了,就算一万个子节点,并且他们都是没有变化的,只有最外层的父节点节点类型变了,照样会全部进行卸载重新创建,与其去一个个递归查看子节点,不如直接全部卸载重新新建。

import React, { PureComponent } from 'react'

export default class App extends PureComponent {

    state = {
        flag: true,
    }

    render() {
        console.log("重新渲染render");
        
        if (this.state.flag) {
            return <span className="wrapper">
                <button onClick={() => {
                    this.setState({
                        flag: !this.state.flag
                    })
                }}>更新</button>
            </span>
        }

        return (
            <div className="wrapper">
                <button onClick={() => {
                    this.setState({
                        flag: !this.state.flag
                    })
                }}>更新</button>
            </div>
        )
    }
}
复制代码

截屏2022-07-17 下午7.42.18.png

渲染原理.gif

未找到对比目标

如果未找到对比的目标,跟节点类型不一致的做法类似,那么对于多出的节点进行挂载流程,对于旧节点进行卸载直接弃用。如果其包含子节点进行递归卸载。对于初始类组件节点会多一个步骤,那就是运行生命周期方法componentWillUnmount注意:尽量保持结构的稳定性,如果未添加key的情况下,兄弟节点更新位置前后错位一个那么后续全部的比较都会错位导致找不到对比目标从而进行卸载新建流程,对性能大打折扣。

import React, { PureComponent } from 'react'

export default class App extends PureComponent {

    state = {
        flag: true,
    }

    render() {
        console.log("重新渲染render");
        if (this.state.flag) {
            return <div className="wrapper">
                <span>123</span>
                <button onClick={() => {
                    this.setState({
                        flag: !this.state.flag
                    })
                }}>更新</button>
            </div>
        }

        return (
            <div className="wrapper">
                <button onClick={() => {
                    this.setState({
                        flag: !this.state.flag
                    })
                }}>更新</button>
            </div>
        )
    }
}
复制代码

截屏2022-07-17 下午8.03.52.png

渲染原理.gif

从图中可以看到,哪怕经过条件渲染前后button理论上没有任何变化的情况下,照样没有重用之前的真实DOM,如果在button之后还有一万个兄弟节点,那么也全部都找不到对比目标从而进行卸载重新创建流程。所以在进行条件渲染显示隐藏时,官方推荐以下做法:

  1. 控制style:visibility来控制显示隐藏。
  2. 在隐藏时给一个空节点来保证对比前后能找到同一位置。不影响后续兄弟节点的比较。
this.state.flag ? <div></div> : false
复制代码

来点栗子加深印象

1. 是否重用了真实DOM

import React, { PureComponent } from 'react'

export default class App extends PureComponent {

    state = {
        flag: true,
    }

    render() {
        console.log("重新render!");

        if(this.state.flag){
            return <div className="flag-true">
                <button onClick={()=>{
                    this.setState({
                        flag: !this.state.flag
                    })
                }}>更新</button>
            </div>

        }
        return (
            <div className="flag-false">
                 <button onClick={()=>{
                    this.setState({
                        flag: !this.state.flag
                    })
                }}>更新</button>
            </div>
        )
    }
}
复制代码

截屏2022-07-17 下午5.30.08.png

尽管从代码结构看起来像是返回了两个不同的DOM,但其实在更新的过程中,React发现他们的节点类型一致,所以会重用之前的真实DOM所以请注意:尽量保持节点的类型一致,如果更新前后节点类型不一致的话无论有多少子组件将全部卸载重新创建。

渲染原理.gif

2. 一个神奇的效果

import React, { PureComponent } from 'react'

export default class App extends PureComponent {
    state = { flag: false }

    render() {
        return (
            <>
                {
                    this.state.flag ?
                        <div>
                            <input type="password" />
                            <button onClick={() => {
                                this.setState({
                                    flag: !this.state.flag
                                })
                            }}>显示/隐藏</button>
                        </div>
                        :
                        <div>
                            <input type="password" />
                            <input type="text" />
                            <button onClick={() => {
                                this.setState({
                                    flag: !this.state.flag
                                })
                            }}>显示/隐藏</button>

                        </div>
                }
            </>
        )
    }
}
复制代码

渲染原理.gif

从图中可以看到,我们输入了密码后,重新渲染生成了新的DOM,但是里面的密码还存在。这就很好的证明了React是如何重用真实DOM的。

一道面试题

import React, { PureComponent } from 'react'

class ClassCompA extends PureComponent {
    componentDidMount() {
        console.log("111 ClassCompA componentDidMount");
    }

    componentWillUnmount() {
        console.log("222 ClassCompA componentWillUnmount");
    }

    render() {
        return (<div className="ClassCompA"></div>)
    }
}

class ClassCompB extends PureComponent {
    componentDidMount() {
        console.log("333 ClassCompB componentDidMount");
    }

    render() {
        return (<div className="ClassCompB">
            <ClassCompC />
        </div>)
    }
}


class ClassCompC extends PureComponent {
    componentDidMount() {
        console.log("444 ClassCompC componentDidMount");
    }

    render() {
        return (<div className="ClassCompC"></div>)
    }
}


export default class App extends PureComponent {
    state = {
        flag: true,
    }

    componentDidMount(){
        console.log("666 App componentDidMount");
    }

    componentDidUpdate() {
        console.log("555 App componentDidUpdate");
    }

    render() {
        return (
            <div className="wrapper">
                {this.state.flag ? <ClassCompA/> : <ClassCompB/>}
                <button onClick={() => {
                    this.setState({
                        flag: !this.state.flag
                    })
                }}>更新</button>
            </div>
        )
    }
}
复制代码

问:首次渲染和按下button控制台输出的顺序是什么?

看的仔细的同学,相信根本就难不倒你,我们一起来捋一捋。

  1. 首先,最外层的组件是App,所以开始App的挂载流程,运行render的过程中发现条件渲染先渲染ClassCompA
  2. 进入ClassCompA的挂载流程,没啥好渲染的就一个div,执行完render后将componentDidMount加入到队列中等待执行。此时队列里是[111]
  3. App再针对初始元素button做处理后,render执行结束,将自己的componentDidMount加入到队列中等待执行,此时队列里是[111、666]
  4. React根据虚拟节点生成真实DOM后,保存vDom树,开始运行队列。此时控制台打印111666
  5. 按下button后,调用setState进行重新渲染,此时App还会运行两个生命周期方法 getDerivedStateFromPropsshouldComponentUpdate,然后运行render,生成新的vDom树。
  6. 进入新旧两棵树的对比更新,虽然都是组件节点,但生成出的实例不同,认为是不相同的节点类型。开始卸载旧节点ClassCompA,并将ComponentWillUnMount加入到执行队列,等待执行。此时队列[222]
  7. 进入新节点挂载流程,创建ClassCompB实例,调用render生成虚拟节点。发现存在组件节点ClassCompC。再次进入到新节点挂载流程,创建实例。
  8. ClassComC运行完render生成vDom树,将自己的componentDidMount加入到队列,等待将来执行。此时队列[222、444]
  9. 挂载完ClassComC后,ClassComBrender才算结束,此时将自己的componentDidMount加入到队列,等待执行,此时队列[222、444、333]
  10. 此时Apprender才算结束,将自己的componentDidUpdate加入到队列,等待执行。此时队列[222、444、333、555]
  11. 将根据虚拟节点生成的真实DOM挂载到页面上后,开始执行队列。控制台输出222444333555

总结

对于生命周期我们只需关注比较重要的几个生命周期的运行点即可,比如render的作用、使用componentDidMount在挂载完真实DOM后做一些副作用操作、以及性能优化点shouldComponentUpdate、还有卸载时利用componentWillUnmount清除副作用。

对于首次挂载阶段,我们需要了解React的渲染流程是:通过我们书写的初始元素和一些其他可以生成虚拟节点的东西来生成虚拟节点。然后针对不同的节点类型去做不同的事情,最终将真实DOM挂载到页面上。然后执行渲染期间加入到队列的一些生命周期。然后组件进入到活跃状态。

对于更新卸载阶段,需要注意的是有几个更新的场景。以及key的作用到底是什么。有或没有会产生多大的影响。还有一些小细节,比如条件渲染时,不要去破坏结构。尽量使用空节点来保持前后结构顺序的统一。重点是新旧两棵树的对比更新流程。找到目标,节点类型一致时针对不同的节点类型会做哪些事,类型不一致时会去卸载整个旧节点。无论有多少子节点,都会全部递归进行卸载。

到这里,文章所有的部分就全部结束了,本文没有涉及到一行源码,全部都是总结出能在不看源码的情况下能大致了解整个渲染流程。为了减少混淆,也没有涉及到Hooks以及Fiber的概念,有兴趣的同学可以留言,可以考虑下次出一篇。最后,再喝一口水休息一下。对本文内容有异议或交流欢迎评论~

我正在参与掘金技术社区创作者签约计划招募活动,点击链接报名投稿

分类:
前端
标签:
收藏成功!
已添加到「」, 点击更改