# 关于 JS 与 CSS 是否阻塞 DOM 的渲染和解析

# 前言

  最近系统梳理HTML5所有涉及到的标签时,梳理至<link><script>标签时,碰巧想到一个困扰很久的问题,即一般把<script>放在<body>尾部,<link>标签放在<head>内部,而页面通过CDN引入第三方框架或库时,基本都是将其<script>标签放在<link>标签前面。

  可能此方式已经成为了约定俗成,但是究竟其好处在哪里,或者说其它的方式为什么不可取,想必你也和我有同样的疑问,那就接着来往下看吧。

# 准备工作

  首先需要做的准备工作是,搭建一个服务器,目的是为了返回css样式和js脚本,并且让服务器根据传递的参数,固定延时返回数据。

  其目录结构如下,其中index.jsstyle.css就是用于返回的数据,app.js为服务器启动文件,index.html是用来测试案例的文件,剩余文件或文件夹可以忽略。

├── static
│   ├── index.js
│   ├── style.css
├── app.js
├── index.html
├── package.json
├── node_modules/

  涉及的相关代码也贴一下吧,方便复制调试。有必要说明一下,本地运行node app.js启动后,浏览器输入http://127.0.0.1:3000/就能访问到index.html,而访问style.css可以输入http://127.0.0.1:3000/static/style.css?sleep=3000,其中sleep参数则可自由控制css文件延时返回,例如想要文件5s后返回就设置sleep=5000

// app.js
const express = require('express')
const fs = require('fs')
const app = new express()
const port = 3000

const sleepFun = time => {
  return new Promise(res => {
    setTimeout(() => {
      res()
    }, time)
  })
}

const filter = (req, res, next) => {
  const { sleep } = req.query || 0

  if (sleep) {
    sleepFun(sleep).then(() => next())
  } else {
    next()
  }
}

app.use(filter)

app.use('/static/', express.static('./static/'))

app.get('/', function (req, res, next) {
  fs.readFile('./index.html', 'UTF-8', (err, data) => {
    if (err) return

    res.send(data)
  })
})

app.listen(port, () => {
  console.log(`app is running at http://127.0.0.1:${port}/`)
})

// static/index.js
var p = document.querySelector('p')

console.log(p)

// static/style.css
p {
  color: lightblue;
}

  接着就是index.html的准备工作,其中HTML部分的架子就长下面那样,然后你只需要记住DOMContentLoaded事件将在页面DOM解析完成后触发。

<!DOCTYPE html>
<html lang="zh-CN">

<head>
  <script>
    document.addEventListener('DOMContentLoaded', () => {
      var p = document.querySelector('p')

      console.log(p)
    })
  </script>
</head>

<body>
  <p>hello world</p>
</body>

</html>

# CSS 不会阻塞 DOM 解析,但是会阻塞 DOM 渲染

  首先在index.html插入如下<link>标签,然后在浏览器输入http://127.0.0.1:3000/访问此页面。

<head>
  <script>
    document.addEventListener('DOMContentLoaded', () => {
      var p = document.querySelector('p')

      console.log(p)
    })
  </script>
  <link rel="stylesheet" href="./static/style.css?sleep=3000">
</head>

<body>
  <p>hello world</p>
</body>

  页面初始显示为空白,控制台打印出了p元素,同时浏览器标签页上加载loading3s后页面显示出浅蓝色的hello world

  以上情况也就说明,CSS不会阻塞DOM的解析,如果说CSS阻塞DOM解析的话,那么p标签不会被解析,进而DOM不会被解析完成,CSS请求过程中也就不可能会触发DOMContentLoaded事件。而且在css请求过程中,控制台立即打印出了p元素,因此CSS不会阻塞DOM的解析。

  另一个情况就是,虽然DOM很早就被解析完成,但是p标签却迟迟没有渲染,原因在于CSS样式还未请求完成,在样式获取后hello world才被渲染出来,所以说CSS会阻塞页面渲染。

  简单阐述一下浏览器的解析渲染过程,解析DOM生成DOM Tree,解析CSS生成CSSOM Tree,两者结合生成render tree渲染树,最后浏览器根据渲染树渲染至页面。由此可以看出DOM Tree的解析和CSSOM Tree的解析是互不影响的,两者是并行的。因此CSS不会阻塞页面DOM的解析,但是由于render tree的生成是依赖DOM TreeCSSOM Tree的,因此CSS必然会阻塞DOM的渲染。

  更为严谨一点的说,CSS会阻塞render tree的生成,进而会阻塞DOM的渲染。

# JS 会阻塞 DOM 解析

  为了避免加载CSS造成的干扰,如下仅关注JS的执行情况,其中for循环的循环体中逻辑暂不考虑,仅仅是让JS执行更多时间。

<head>
  <script>
    document.addEventListener('DOMContentLoaded', () => {
      var p = document.querySelector('p')

      console.log(p)
    })
  </script>
</head>

<body>
  <script>
    const p = document.querySelector('p')

    console.log(p)

    for (var i = 0, arr = []; i < 100000000; i++) {
      arr.push(i)
    }
  </script>
  <p>hello world</p>
</body>

  浏览器访问页面,初始时为空白且控制台打印null,浏览器loading短暂延时后,控制台打印出p标签同时页面渲染出hello world

  以上情况很容易说明JS会阻塞DOM解析了,JS执行初控制台打印null,因为此时p标签还未被解析,for循环执行时,可以明显感觉到执行耗时,执行完成p标签被解析,此时触发DOMContentLoaded事件,控制台打印出p标签,同时页面渲染出hello world

  比较合理的解释就是,首先浏览器无法知晓JS的具体内容,倘若先解析DOM,万一JS内部全部删除掉DOM,那么浏览器就白忙活了,所以就干脆暂停解析DOM,等到JS执行完成再继续解析。

# CSS 会阻塞 JS 的执行

  如下在页内JS脚本前插入<link>标签,并且延时3s获取CSS样式。

<head>
  <script>
    document.addEventListener('DOMContentLoaded', () => {
      var p = document.querySelector('p')

      console.log(p)
    })
  </script>
  <link rel="stylesheet" href="./static/style.css?sleep=3000">
  <script src="./static/index.js"></script>
</head>

<body>
  <p>hello world</p>
</body>

  初始页面空白,浏览器loading加载3s后,控制台打印出null,紧接着打印出p标签,同时页面渲染出浅蓝色p标签。

  此情况好像是CSS不仅阻塞了DOM的解析,并且也阻塞了DOM渲染。

  但是首先要思考下是什么阻塞了DOM的解析,刚刚已经证明了CSS不会阻塞DOM的解析,所以只可能是JS阻塞了DOM解析。但是JS只有两行代码,不会阻塞长达3s左右的时间。所以只有一个可能就是CSS会阻塞JS的执行。

  因此输出结果也能大致分析出来了,首先解析到第一个<script>标签,document绑定上DOMContentLoaded事件,紧接着解析到link标签,浏览器请求CSS样式,由于CSS不会阻塞DOM解析,因此浏览器继续向下解析,发现第二个<script>标签,浏览器请求JS脚本,此时JS获取完成,但是由于CSS还在获取,JS需要一直等待其获取完成,所以不能立即执行。

  而第二个<script>不能立即执行,导致它后面的p标签也没办法解析,原因则是JS会阻塞DOM解析。只有等待到CSS样式获取成功后,此时JS立即执行,控制台输出null,然后浏览器继续解析到p标签,解析完成,DOMContentLoaded事件触发,控制台输出p标签,最后浅蓝色hello world渲染至页面。

  其实这样做也是有道理的,设想JS脚本中的内容是获取DOM元素的CSS样式属性,如果JS想要获取到DOM最新的正确的样式,势必需要所有的CSS加载完成,否则获取的样式可能是错误或者不是最新的。因此要等到JS脚本前面的CSS加载完成,JS才能再执行,并且不管JS脚本中是否获取DOM元素的样式,浏览器都要这样做。

  回溯文章开头的那个疑问,所以一般将<script>放在<link>标签前面是有道理的。

# JS 会触发页面渲染

  如下CSS采用页内方式,其中颜色名及其rgb值分别为浅绿色lightgreenrgb(144, 238, 144))、粉色pinkrgb(255, 192, 203))。

// index.html
<head>
  <style>
    p {
      color: lightgreen;
    }
  </style>
</head>

<body>
  <p>hello</p>
  <script src="./static/index.js?sleep=2000"></script>
  <p>beautiful</p>
  <style>
    p {
      color: pink;
    }
  </style>
  <script src="./static/index.js?sleep=4000"></script>
  <p>world</p>
  <style>
    p {
      color: lightblue;
    }
  </style>
</body>

// static/index.js
var p = document.querySelector('p')
var style = window.getComputedStyle(p, null)

console.log(style.color)

  页面初始渲染出浅绿色hello,紧接着2s后渲染出粉色hello beautiful且控制台打印rgb(144, 238, 144),然后又2s后渲染出浅蓝色hello beautiful world且控制台打印rgb(255, 192, 203)

  上述结果大致分析为浏览器首先解析第一个<style>标签和hello文本的p标签,此时继续向下解析发现了第一个<script>标签,紧接着触发一次渲染,由于此过程非常快所以页面初始就能看到浅绿色hello

  然后浏览器发出JS请求,2sJS获取完成立即运行控制台输出rgb(144, 238, 144)JS运行完成后浏览器继续向下解析到beautiful文本的p标签和第二个<style>标签,再继续向下解析发现了第二个<script>标签,触发一次渲染,这个过程也是非常快,所以可以看到控制台输出结果和渲染粉色hello beautiful几乎是同时的。

  解析到第二个<script>标签时,浏览器不会发出请求(稍作解释),2s后获取到JS脚本并执行,控制台输出rgb(255, 192, 203),紧接着浏览器继续向下解析到world文本的p标签和第三个<style>标签,此时DOM解析完成,再进行正常的渲染,这个过程也是非常快,所以也能看到控制台输出结果和渲染浅蓝色hello beautiful world几乎是同时的。

  现在来解答刚才那个问题,浏览器解析DOM时,虽然会一行一行向下解析,但是它会预先加载具有引用标记的外部资源(例如带有src标记的<script>标签),而在解析到此标签时,则无需再去加载,直接运行,以此提高运行效率。所以就会有上述两个输出结果间隔2s的情况,而不是4s,因为浏览器预先就一起加载了两个<script>脚本,第一个<script>脚本加载完成时,第二个<script>脚本还剩大概2s加载完成。

  而这个结论才是解释为何CSS会阻塞JS的执行的真正原因,浏览器无法预先知道脚本的具体内容,因此在碰到<script>标签时,只好先渲染一次页面,确保<script>脚本内能获取到DOM的最新的样式。倘若在决定渲染页面时,还有尚未加载完成的CSS样式,只能等待其加载完成再去渲染页面。

# Body 内的 CSS

  来看一个较为特殊的情况。

<head>
  <script>
    document.addEventListener('DOMContentLoaded', () => {
      var p = document.querySelector('p')

      console.log(p)
    })
  </script>
</head>

<body>
  <p>hello</p>
  <link rel="stylesheet" href="./static/style.css?sleep=3000">
  <p>world</p>
</body>

  按照上述的所有结论,预先分析一下运行结果,首先浏览器解析<script>脚本,document上绑定了DOMContentLoaded事件,紧接着浏览器继续向下解析,发现了文本为hellop标签和<link>标签,浏览器发起CSS请求,由于CSS不会阻塞DOM解析,浏览器继续向下解析至文本为worldp标签,此时页面解析完成,DOMContentLoaded事件触发控制台输出p标签,3s后页面渲染出浅蓝色hello world

  因此初始时页面空白,浏览器loading加载3s后,控制台打印出p标签,同时页面渲染出浅蓝色hello world

  但是实际结果并不是这样,先来看看Chrome浏览器下的表现。

  再来看看Firefox浏览器下的表现。

  接着再来看看Opera浏览器下的表现。

  ;IE11及以下浏览器的表现。

  ;Edge浏览器下的表现。

  怎么样,蒙了吧,5种浏览器3种表现形式,并且没有一种表现与预先分析的一致。

  其实造成这种差异的原因,是一种被称为浏览器样式闪烁(FOUC (opens new window)Flash of Unstyled Content)的现象。

  由于浏览器引擎的架构方式不一致,在浏览器解析到Body内的CSS的时候,浏览器引擎是可以做出选择的。

  一种选择是它可以在请求CSS的时候暂停后续DOM的解析,在CSS加载完成后再继续往后解析,此情况就会造成CSS阻塞DOM的解析,导致页面DOM解析及其样式渲染推迟,而此表现形式就与JS的情况相似。

  另一种选择是它也可以在请求CSS的时候继续往后解析,此时CSS的加载与DOM的解析就是并行的,等到CSS加载完成后再更新样式,此情况下部分DOM样式就会从默认样式立即跳转到有样式状态,形成样式闪烁。

  此小结不必太过于纠结,只需记住Body内的CSS会由于浏览器的差异,呈现不同的表现形式,而这种现象一般称为FOUC,代码中应当极力避免此种情况发生即可。

# 综上所述

  综合上述所有情况,可以得出如下结论。

  • CSS不会阻塞DOM解析,但是会阻塞DOM渲染,严谨一点则是CSS会阻塞render tree的生成,进而会阻塞DOM的渲染
  • JS会阻塞DOM解析
  • CSS会阻塞JS的执行
  • 浏览器遇到<script>标签且没有deferasync属性时会触发页面渲染
  • Body内部的外链CSS较为特殊,会形成FOUC现象,请慎用

# 🎉 写在最后

🍻伙伴们,如果你已经看到了这里,觉得这篇文章有帮助到你的话不妨点赞👍或 Star (opens new window) ✨支持一下哦!

手动码字,如有错误,欢迎在评论区指正💬~

你的支持就是我更新的最大动力💪~

GitHub (opens new window) / Gitee (opens new window)GitHub Pages (opens new window)掘金 (opens new window)CSDN (opens new window) 同步更新,欢迎关注😉~

最后更新时间: 3/6/2022, 9:06:37 PM