likes
comments
collection
share

React渲染(Render)全过程解析

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

前言

当我们在使用react开发的时候,我们都知道当状态变量发生改变时会执行render函数将组件重新渲染,那我们知道整个render过程,浏览器究竟做了哪些事情吗?我们知道当react每次重新render都会根据本次改变的依赖(state值)执行对应的副作用函数useEffect和useLayoutEffect,那他们之间的区别是什么呢,执行时机又是在render的哪个阶段呢,我们本文将带着这些疑问解决下面几个问题:

  1. react的每次render过程,到底做了哪些事情?
  2. useEffect和useLayoutEffect到底有何区别,各自作用又是什么?
  3. useEffect和useLayoutEffect触发的阶段是什么时候,为什么要这样设计?
  4. 根据官网描述我们知道,useLayoutEffect要先于useEffect触发,那为什么useEffect中无法获取到最新dom元素,而useLayoutEffect可以?
  5. 我们在useEffect和useLayoutEffect中再去更新非依赖的state值,会发生什么?

准备工作

想要更好的理解上面几个问题,我们首先要了解下面几个概念,可以帮助我们了解上述问题的答案

  1. 浏览器加载一个页面需要经历哪些过程?
  2. 浏览器的两个核心引擎,JavaScript引擎和渲染引擎是什么,有什么作用?
  3. 什么是同步执行,什么是异步执行?

下面我们一次解答这三个概念

浏览器加载一个页面需要经历哪些过程?

浏览器加载页面的主要过程包括:

  1. DNS解析:浏览器将输入的域名转换为IP地址,找到服务器位置
  2. 建立TCP连接:浏览器与服务器建立TCP连接用于传输数据
  3. 发送HTTP请求:浏览器发送HTTP请求到服务器
  4. 服务器响应请求并返回数据:服务器处理请求,并返回请求的HTML、CSS、JS等数据
  5. 浏览器解析接收到的数据:浏览器开始解析接收到的HTML代码,并请求HTML中引用的资源文件
  6. 构建DOM树:解析HTML代码构建DOM树
  7. 构建CSSOM树:解析CSS代码构建CSSOM树
  8. 构建Render树:将DOM和CSSOM合并,生成用于页面显示的Render树
  9. 布局Layout:计算Render树中节点的几何信息
  10. 绘制Paint:使用UI后端层绘制各个节点
  11. 合成Composite:将绘制的各层节点组合显示在页面上

浏览器的两个核心引擎,JavaScript引擎和渲染引擎是什么,有什么作用?

JavaScript引擎

JavaScript引擎是浏览器的核心组件之一,它的主要作用是解析和执行JavaScript代码。常见的JavaScript引擎有Google的V8(用于Chrome)、Mozilla的SpiderMonkey(用于Firefox)等。JavaScript引擎将JavaScript代码编译为机器语言,采用就地解释或预编译的方式来加速JavaScript代码的执行。同时它也负责内存管理、对象模型、事件处理等与JavaScript执行相关的功能。

渲染引擎

渲染引擎是浏览器最核心的组件之一,它负责解析HTML、CSS代码,构建内容树和计算样式规则,并将内容绘制到浏览器界面上。常见的渲染引擎有Google的Blink(Chrome使用)、Mozilla的Gecko(Firefox使用)、WebKit(Safari使用)等。渲染引擎能解析页面内容并针对不同设备优化绘制,使网页在各种设备上展现效果最佳。它也负责页面的重新布局、JavaScript与页面内容的交互等功能。总结来说,JavaScript引擎负责JavaScript的执行,渲染引擎负责内容绘制和页面渲染,两者共同支持了浏览器中JavaScript操作DOM和页面渲染的能力。它们是浏览器的核心,直接影响着页面的性能和效果。

什么是同步执行,什么是异步执行?

  1. 同步执行是顺序执行,每行代码等待前一行执行结束,也就是串行执行方式。例如食堂打饭,所有人排队打饭,每个人打完饭后必须吃完下一个人才能继续打饭吃饭,不管前面的人需要多久后面的人都必须等待
  2. 异步执行可以在不同时间点执行,不会完全阻塞代码,有等待的情况,也就是并行执行方式。也就是说异步执行类似于食堂打完饭之后各自找位置自己吃饭,谁先吃完谁后吃完互不影响

开始探索

react的每次render过程,到底做了哪些事情?

首先我们探索每次render过程,到底做了哪些事情,我们从状态更新开始分析

  1. 调用组件的setState方法,更新状态
  2. React标记组件为需要重新渲染
  3. React将解析 JSX 代码,并根据组件定义实例化相应的组件
  4. React调用Render方法生成新的虚拟DOM
  5. React使用 Diff 算法比较前后两个虚拟 DOM 树的差异,找出需要更新的部分
  6. React通过 Diff 算法的结果,React 知道哪些地方需要更新,然后生成更新的 DOM 操作
  7. React将生成的更新操作应用到实际 DOM 上,进行 DOM 更新,生成等待绘制的真实DOM
  8. React将生成的真实DOM交由浏览器的渲染引擎进行页面的布局和绘制,以显示最新的页面内容

JavaScript引擎和渲染引擎做了哪些事情

上面的八个步骤,实际上前七步都是由JavaScript引擎在处理,最后生成真实DOM之后才交给渲染引擎进行页面布局计算和绘制,由于JavaScript引擎和渲染引擎是两个独立的存在,因此我们可以根据时间线画出以下图解React渲染(Render)全过程解析

useEffect和useLayoutEffect触发时机

我们先看官网对这两个钩子函数触发时机的定义

  1. useEffect:组件首次渲染或者每次依赖变更重新渲染后异步执行
  2. useLayoutEffect:在浏览器重新绘制屏幕前同步执行

我们单独看这两句话可能不好理解,什么是重新渲染后,什么是重新绘制前呢,接下来我们按照我之前总结的八个步骤来解析一下这两句话,看看到底他们在什么时候触发。首先我们知道,useLayoutEffect的触发一定在useEffect之前,那什么时候才算是重新渲染之后,什么时候又是重新绘制之前呢,我们先看优先触发的useLayoutEffect。

useLayoutEffect触发时机

从上面图解我们可以看出,浏览器重新绘制之前应该是生成等待绘制的真实DOM的时间点,那就说明useLayoutEffect触发是在生成了真实并且等待绘制的真实dom之后触发的。官网上有说,我们需要注意的是useLayoutEffect是同步执行,会阻塞浏览器重新绘制,所以在生成真实dom之后,React会开始触发需要执行的useLayoutEffect钩子,等完全执行完所有useLayoutEffect之后才会把真实dom交给渲染引擎进行绘制。由于不管是useLayoutEffect还是useEffect钩子,都是js代码,所以都将在js引擎中执行,我们之前也解释了同步执行的逻辑,因此总结:useLayoutEffect会在生成真实dom之后,根据声明顺序同步执行,图解表示如下:React渲染(Render)全过程解析

useEffect触发时机

根据官网定义,useEffect触发时机是依赖变更重新渲染后异步执行,那什么时候才算是重新渲染后呢,实际是当我们js引擎把生成的真实dom交给渲染引擎之后,就开始执行useEffect钩子了,在js引擎生成真实dom之前,会把所有的useEffect钩子放入队列中等待执行,当把真实dom交给渲染引擎之后,会把所有useEffect钩子函数按顺序依次取出执行,并且每个useEffect钩子执行是异步的,也就是并行的,相互之间并不会影响,每个useEffect钩子函数各自执行。图解表示如下React渲染(Render)全过程解析

为什么useEffect中无法获取到最新dom元素,而useLayoutEffect可以

我们从上述对useEffect和useLayoutEffect触发时机可以很清楚的看出,useLayoutEffect会在useEffect之前执行,那为什么useLayoutEffect能够获取到最新dom元素而useEffect却不能呢,那这个时候就必须要解释一下浏览器对于处理获取元素的方法处理方式了,实际上浏览器对于处理获取元素的方法处理方式有两种

  1. 从刚刚生成还未来得及绘制的真实 DOM 树上获取: 当我们浏览器中存在最新的等待绘制的真实dom时,我们调用获取元素方法时,浏览器会将等待绘制的真实dom上的元素返回给我们
  2. 从已经绘制完成或正在绘制的页面上获取: 当我们浏览器中不存在最新的等待绘制的真实dom时,我们调用获取元素方法时,浏览器会将已经完成绘制或者正在绘制的页面上的元素返回给我们

上面的两种获取dom元素的处理方式真实dom优先级大于已绘制的dom,因此当useLayoutEffect中获取dom元素时,浏览器会将js引擎中生成的等待绘制的真实dom元素返回,此时的dom元素就是最新的元素。而当在useEffect中获取的时候已经到了重新绘制的阶段,浏览器只能从正在绘制的页面上获取元素返回,虽然理论上将useEffect函数的执行和页面的绘制是同时进行的,但是实际上,JavaScript执行速度和页面绘制速度之间的差距可以是相当大的,所以即便两者是同时开始的,但是实际计算机内部执行速度来看,基本上绘制刚刚开始,useEffect函数都已经执行完了,所以在useEffect回调函数中获取元素,几乎不可能获取到最新的元素。useEffect和useLayoutEffect从图上表现如下React渲染(Render)全过程解析

我们在useEffect和useLayoutEffect中再去更新非依赖的state值,会发生什么?

我们从上述的分析可以知道,如果在useEffect中再去修改非依赖的其他状态(指明非依赖是因为在useEffect中修改依赖值会产生死循环,不过如果有if判断还是可以的,这里就不做扩展了,以非依赖为例),并不会影响上次的渲染过程,因此如果在useEffect中再去修改非依赖的其他状态,整个渲染流程会重新走一遍。那如果是在useLayoutEffect中修改呢,实际答案就是并不会再执行后面的绘制,react会把之前的渲染过程销毁,重新生成真实dom,最后再把最新的dom绘制到页面上去。不过虽然在useLayoutEffect中修改非依赖的其他状态不会继续绘制这次的dom,但是本次useEffect还是会被正常执行,只是会重新生成最新的真实dom。例如以下代码的输出结果如下

function Demo01() {
    const [count, setCount] = useState(0)
    const [name, setName] = useState('')

    useLayoutEffect(() => {
        console.log('useLayoutEffect for count')
        if (count > 0) {
            setName('whg')
        }
    }, [count])

    useLayoutEffect(() => {
        console.log('useLayoutEffect for name')
    }, [name])

    useEffect(() => {
        console.log('useEffect for count')
    }, [count])

    useEffect(() => {
        console.log('useEffect for name')
    }, [name])

    return (
        <div>
            <h1>Demo Number is {count}</h1>
            <Button type="primary" onClick={() => setCount(count + 1)}>
                Add
            </Button>
        </div>
    )
}

输出结果React渲染(Render)全过程解析

使用useLayoutEffect和useEffect加延迟函数都能获取到最新元素,该如何选择?

对于这个问题,根据useEffect和useLayoutEffect中再去更新非依赖的state值后续的执行逻辑我们大概能够选择出来,例如下面这个案例:**现在有个需求,在一个列表中点击一个icon,在该icon底部会出现一个更多菜单,但是要求如果该菜单弹窗后超过了页面底部,需要从上面弹出。**根据需求我们可以按照以下步骤实现该功能:1、定义一个是否显示菜单的状态isShowMenu默认为false2、定义一个是否在顶部显示的状态isShowTopClass,默认为false3、点击按钮时将该状态改为true,当isShowMenu为true时渲染该菜单的dom4、获取menu菜单的高度和位置,判断是否触底,若触底,则将isShowTopClass置为true那么此时的问题就是,在合适获取menu菜单的高度和位置,我们首先想到的是在useEffect监听isShowMenu的值,判断该值为true时获取,但是根据上面我们分析的,这个时候在useEffect是无法获取menu的菜单元素的,此时我们就需要做出选择,是在useEffect添加延迟获取还是使用useLayoutEffect获取呢。我们分析两种方式产生的结果:1、在useEffect添加延迟获取如果在useEffect添加延迟获取,那么当isShowMenu置为true之后,menu会正常被渲染到icon的底部,因为此时isShowMenu修改后整个渲染的流程已经结束。在这个时候useEffect中又修改了isShowTopClass的值,让menu渲染到顶部,因此视觉上会有个menu从底部闪现到顶部的过程2、使用useLayoutEffect获取根据我们上面分析的,如果使用useLayoutEffect获取,我们第一次isShowMenu改变后在useLayoutEffect中监听,获取dom后判断需要顶部显示后,再去修改isShowTopClass的值,此时由于isShowMenu更新产生的新dom不会被绘制到页面上,二十销毁后重新根据isShowMenu和isShowTopClass两个的最新状态生成新的dom最终绘制到页面上,这样视觉上就不会有两次页面绘制出现,因此避免了菜单的闪现3、案例代码实现

function Demo01() {
    const [isShowMenu, setIsShowMenu] = useState(false)
    const [isShowTopClass, setIsShowTopClass] = useState(false)

    useLayoutEffect(() => {
        // 获取dom计算是否触底
        // 、、、、
        // 计算结果发现触底了,在顶部显示
        setIsShowTopClass(true)
    }, [isShowMenu])

    return (
        <div>
            <h1>Demo01 Page</h1>
            <Button type="primary" onClick={() => setIsShowMenu(true)}>
                Add
            </Button>
            {isShowMenu && (
                <div className={isShowTopClass ? 'top-class' : 'bottom-class'}>
                    Menu Content
                </div>
            )}
        </div>
    )
}

总结

根据上述分析,我们应该能够解释文章开头的五个问题了。通过了解整个render过程,有助于我们在遇到页面显示与预期不符的问题时,能够快速察觉问题出现在哪个步骤中,很有利于日常开发工作。希望这篇文章能帮助大家理解整个react的渲染过程和副作用函数触发时机