一次useEffect引发浏览器执行机制的思考

2,683 阅读9分钟

抛出"问题"

我们先来阐述阐述问题,今儿在写一个有关于新手指引的公用组件,类似于这样的形式:

image.png

我相信大家首先想到的思路就是在useEffect中通过getBoundingClientRect()获得对应传入元素(id)的位置,然后通过定位增加一个类似的弹窗效果。

当我天真的以为这样就可以实现它的时,我碰到了一个"无从下手"解决的问题。

useEffect中获取getBoundingClientRect()的值是随机的?

随机的???作为一个基本的程序员,随机的代码执行结果,这我怎么能够接受呢!

我们来看看简化后的代码:

"问题"代码

// 代码已经是很简化的版本了 仅仅保留了核心的内容
import React, { useEffect } from 'react'
import './_index.scss'

const GuideBeta = () => {
  useEffect(() => {
    console.log(document.getElementById('step1'))
    console.log(document.getElementById('step1')?.getBoundingClientRect())
  }, [])
  return (
    <div>
      <div className='beta'>
        <div id='step1'>
          <div>第一个指引</div>
        </div>
        <div id='step2'>
          <div>第二个指引</div>
        </div>
      </div>
    </div>
  )
}

export { GuideBeta }

上面代码其实很简单,渲染两个idstep1step2的元素,然后在useEffect()之中去打印获取idstep1的元素。

差不多页面渲染出来就是这个样子:

image.png

输出结果

这个是正常的输出结果:

image.png

当时当我们尝试多刷新几次页面来看看打印结果:

image.png

也许你会奇怪是不是我代码写的有问题,这里先卖个小关子两次不同的打印结果,产生的原因和业务代码没有任何关系

要搞清楚这个问题,我们需要从一些基础的理论知识来层层递进。

血与泪的教训,我checked了我的代码整整一早上...

浏览器加载机制

关于浏览器加载机制其实我相信大家已经老生常谈了,这里我结合上边两次不同打印的原理来稍微聊聊对应的机制:

js执行浏览器会被js引擎"霸占",从而导致渲染进程无法执行阻塞DomTree的渲染的,那么Css呢?css加载是否会阻塞Dom Tree的渲染呢?

让我们带着这个问题来谈谈css是否会阻塞Dom Tree的构建。

css加载是否会阻塞Dom Tree的渲染和解析

验证css加载和Dom Tree的关系

我们尝试先来看看这端代码:

<!DOCTYPE html>
<html lang="en">

<head>
  <meta charset="UTF-8">
  <meta http-equiv="X-UA-Compatible" content="IE=edge">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Document</title>
  <style>
    #h1 {
      color: blue;
    }
  </style>
  <script>
    setTimeout(() => {
      const h1 = document.getElementById('h1')
      console.log(h1)
    }, 0)
  </script>
  <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/css/bootstrap.min.css" rel="stylesheet"
    integrity="sha384-1BmE4kWBq78iYhFldvKuhfTAU6auU8tT94WrHftjDbrCEXSU1oBoqyl2QvZ6jIW3" crossorigin="anonymous">
</head>

<body>
  <h1 id="h1">
    大大的标题
  </h1>
</body>

</html>

代码其实很简单,就是在js脚本中定时器中获取h1标签。之后引入了bootstrap样式库。

注意:我们需要将浏览器中"网络"限制为SLOW 3G进行测试。

Filmage-2021-10-11_193844.gif

通过上边的表现,我们可以看到当页面加载中。js脚本中的setTimeout已经成功的在控制台打印出来了h1标签对应的元素。

也就是说 css还未加载完成,我们就已经可以获取到对应的Dom,

所以 css加载并不会阻塞Dom Tree的构建。

但是同时注意到,当css文件加载完成后页面才会渲染出来蓝色的大大的标题,也就是说css文件加载完成后,页面才会进行渲染。

此时我们可以得知css的加载是会阻塞Render Tree的渲染的,你可以暂时理解成Render TreeDom Tree,之后我们会在后边详细讲解。

css对于Dom Tree结论

我们来谈谈关于css加载的结论:

  1. css加载并不会阻塞Dom Tree的构建,因为css还未加载完时我们已经可以获取到对应的h1标签了。
  2. css加载会阻塞Dom Tree的渲染,只有当css加载完成后页面才会渲染出蓝色的大大的标题

css加载对于js的影响

那么css加载对于js的是否有影响呢?废话不多说我们来看代码:

css加载对于js验证

<!DOCTYPE html>
<html lang="en">

<head>
  <meta charset="UTF-8">
  <meta http-equiv="X-UA-Compatible" content="IE=edge">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Document</title>
  <script>
    const now = window.now = Date.now()
    console.log('css加载之前', now)
  </script>
  <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/css/bootstrap.min.css" rel="stylesheet"
    integrity="sha384-1BmE4kWBq78iYhFldvKuhfTAU6auU8tT94WrHftjDbrCEXSU1oBoqyl2QvZ6jIW3" crossorigin="anonymous">
  <script>
    const scriptExec = Date.now() - window.now + 'ms'
    console.log('css加载完成')
    console.log('间隔:' + scriptExec)
  </script>
</head>

<body>
  <h1 id="h1">
    大大的标题
  </h1>
</body>

</html>

我们先来看看这段代码执行结果,同样是在SLOW 3G情况下:

Filmage-2021-10-11_201815.gif

我们可以看到两次脚本相差2550ms,正好是css代码加载完毕之后才开始执行了后边的script脚本。

css加载对于js的结论

同样我们得知,位于css代码之前的js代码加载执行是毫无疑问的,但位于css加载之后的代码,css代码的加载是会阻塞后续js代码的执行的

css加载结论

我们稍微来总结一下目前关于css加载的结论:

  1. css代码加载并不会阻塞Dom Tree的构建。
  2. css代码加载是会阻塞Dom Tree在浏览器上的渲染。
  3. css代码加载是会阻塞后续js代码的执行。

造成css加载的原理

上边我们已经总结过了css加载对于Dom TreejsRender Tree(Dom Tree在浏览器上的渲染)部分的表现和总结,现在我们来看看造成这一切的原因:

image.png

一次浏览器的渲染流程大概就是如此,关于layout->paint->composite关键渲染帧涉及到一些重塑和回流的知识这部分内容之后我会详细为大家介绍。

我们先来关注下HTMLCss的加载其实他们是并行加载,这也就印证了我们上边提到的css加载并不会影响Dom Tree的构建。

但是我们可以看到,当cssomdom tree家在完成后会合并成为一个Render Tree,浏览器会根据Render Tree的元素和布局进行渲染,这也就是我们上边说到的等到css文件加载成功后浏览器才会渲染出内容

同时浏览器的渲染引擎和js的解释引擎他们是互斥的,也就是说css加载和dom加载都会和js执行加载互斥的。(当然排除scirpt标签上的deferasync)属性。

相关浏览器加载原理部分大概就提到这里,我相信结合实际出发去读原理才会让人印象深刻。结合上两个Demo实例我相信大家已经能很好的拿原理思路来佐证我们的结论。

接下来让我们回归文章开头的问题,来一探究竟:

回到问题本身

针对为什么我们在useEffect中获取到的Dom元素是正常的,但是打印getBoundingClientRect()的值却可能会出现两种结果呢?

看到这里我相信你已经能大概猜出来结果,没错!他和我们的业务代码没有一毛钱关系,完全取决于css文件的加载!!(真的是坑惨我了😭)

偶发正常情况分析

我们先来看看当值打印正常时候的net work控制面板:

image.png

image.png

  • console.js是我们react代码,包含对应业务逻辑。
  • console.css是我们业务的css代码,包含对应的元素位置定义。

我们可以看到,我们的css代码是远远早与js代码加载完成的,也就是说在js代码执行之前页面其实就已经正常渲染了(cssomdomTree合成正确的render Tree),所以此时我们通过useEffect执行完毕拿到的就是正确的位置getBoundingClientRect()

偶发非正常情况分析

我们来看看偶发非正常getBoundingClientRect打印的结果:

image.png

要解释清楚这个问题,我们首先来看看htmljs文件和css文件的顺序:

image.png

这是html中的head标签中加载两个脚本的顺序,js文件引用了defer属性。

所谓defer意思是说js的加载会异步执行并不会阻塞后续加载,按照加载顺序在文档完成解析后,DomContentLoaded事件前依次执行对应加载完成的js脚本。有关defer详细信息你可以在这里看到

所谓的DomContentLoaded事件,当初始的 HTML 文档被完全加载和解析完成之后,DOMContentLoaded 事件被触发,而无需等待样式表、图像和子框架的完全加载。你可以理解成为当Dom Tree构建完成后就会触发DomContentLoaded事件。

此时也就是说我们的script脚本会异步加载等待Dom Tree解析完毕后,DOMContentLoaded事件调用前进行执行。

此时我们来看看对应的网络请求结果:

image.png

是我们的js加载快于css加载13ms完成。当js加载完成后css还在请求download中,此时由于dom Tree已经构建完毕符合我们js的执行时机,所以此时js优先于css执行完成。当我们执行js时页面上并不存在任何样式,此时我们通过getBoundingClientRect获取的值自然是不正确的(其实获取的就是不存在样式时候的位置值)。

由于defer脚本已经完成,所以在css加载过程中其实线程是空虚的,所以此时js引擎会执行加载完成的defer脚本进行执行。造成js提前与css执行完毕。

解决方法

其实解决方式存在很多种,最简单直白的方式就是利用window.onload事件。

The load event is fired when the whole page has loaded, including all dependent resources such as stylesheets and images. This is in contrast to DOMContentLoaded, which is fired as soon as the page DOM has been loaded, without waiting for resources to finish loading.

当然你也可以有自己的方式,清楚了问题的本质后有很多种方法都可以实现。

总结

我们来稍微阶段性总结一下:

  1. css的加载是会阻塞后续js的执行的,后续js会等待css加载完成后才会执行。
  2. css的加载并不会阻塞Dom Tree的构建。
  3. css的加载是会阻塞页面渲染的,因为页面渲染的Render Tree是需要css omdom tree进行合并从而渲染页面的。

Tips:

关于第二点,css的加载并不会阻塞Dom Tree的构建,但是如果在css文件之后存在js脚本,js是会阻塞dom tree的构建的,因为css加载阻塞了js执行,所以间接的阻塞了dom tree的构建。

同时在不同浏览器下可能会有不同的解释机制,这里绝大多数情况下是针对于chrome进行的解释。

文章中由于业务引发的"血案"就到此为止了,我们已经阐述了对应发生的机制以及why to do

当然浏览器执行机制我相信文章的讲述还是比较片面,如果有兴趣我们可以在评论区互相交流。