Yinde's Blog

Talk is cheap. Show me the code.

目录
从JS的阻塞角度谈谈浏览器渲染原理
/      

从JS的阻塞角度谈谈浏览器渲染原理

前言

这样,在解析包含的javascript代码之前,页面的内容将完全呈现在浏览器中。而用户也会因为浏览器窗口显示空白页面的时间缩短而感到打开页面的速度加快了。 —— 《JavaSciprt高级程序设计 》

相信你在一些书籍,博客中,经常看到这样的描述,因为JavaScript会堵塞DOM的解析,所以将JS放置到body标签的结束前能够加快页面的首次渲染。

但是,他们似乎对这些行为的细节总是描述的不够清楚,导致我很长时间内对于这个东西都是一知半解。

凭什么在header中的js标签会堵塞DOM解析,放到body处就不会了,就能让页面进行渲染了呢?抱着疑问,我进行了求知之路。

perfermance

为了更直观的查看整个DOM解析渲染,执行的过程,我们需要一个强大的工具,好消息是Chrome已经为我们打造好了。

在这里,我推荐使用Canary版本的Chrome进行观测,原因是它具有更好的Perfermance面板,可以一些东西变得更加直观。

这是一个基本的性能面板,使用它你需要先打开某个页面,接着勾选截图模式,关闭Network面板下的缓存,随后点击性能面板左上方的刷新按钮,等待页面加载完成,点击一下结束按钮,随后你就能看到整个页面加载的详细情况。具体的使用方式可以参考Google的 Devtools文档

基本指标

在开始正式的测试前,首先需要明确一些页面加载的基本术语

FP

First Paint,也就是浏览器的第一次绘制,通常这时候只能显示背景图片等,没有实际上的DOM

FCP

First Contentful Paint,浏览器第一次带有DOM内容的渲染,也就说再发生这个事件的时候,用户应该能够看到一部分页面了

FMP

First Meaningful Paint,真正有意义的绘制,他的核心在于,这个页面是否具有可用性,比如一些基本的事件响应必须得有了,用户能够和页面进行交互了。


这张图可以很好的解释以上概念,来自
User-centric Performance Metrics

这些概念都是为了帮助我们了解一个页面对于用户来说,有哪些极为重要的概念,通常来说FP的结果是白屏,而FCP是结果通常是DOM中已经拥有了一定量的结构,用户能够感知到这个页面已经展现。而FMP这代表用户已经可以正常的操作了。

所以,我们可以大胆猜测,JS的放置位置能够影响FCP的发生时机。

DCL DOMCotentLoad Event

他代表了整个HTML文档被完整的加载并解析,这时候触发该事件。

load Event

他其实就是我们的window.onload事件,他指的是DOM中的所有资源被加载完成。

指的一提的是大家不要混乱DCL和LoadEvent的关系,他们的关系就是没有太大的关系,也没有强行的时间顺序,两者发生的顺序并不是固定的。

现在我们获得了一些概念,能够进一步开始我们的研究,其实Chrome的Perfermance面板可以方便的查看上面的一些概念。

我们能够看到有Onload(红色),DCL(蓝色),FCP(绿色),由于我这个页面东西很少,没有触发FMP,FP等事件,实际上一些复杂的页面是会出现这些状态的。

现在我们正式开始探寻脚本位置与页面加载之间的关系。

浏览器解析原理

这里面讲的好的文章有太多了,我这里就稍微提及一下。

  1. DOM的解析是一个从上到下的过程
  2. 所有外链资源(css,image,script)浏览器都会已接近并发的情况来发起请求
  3. 同一时间内的最大HTTP请求是有上限的
  4. 解析DOM获取DOM树 解析CSS获取CSSDOM树 两者合成渲染树(渲染树内部是拥有布局的,以及盒子模型的)
  5. 获取到渲染树之后将会进行绘制Paint,进行像素级别的渲染,绘制到屏幕上。
  6. js会堵塞DOM树的解析
  7. 当JS面前有一个link css ,无论两者谁先下载完毕,JS都会等待CSS加载并解析完成后再执行。这是因为浏览器不知道JS是否会需要查询CSSDOM,所以需要等待CSS准备完毕
  8. 渲染必须依赖CSSDOM树,可以认为CSS是堵塞渲染,但是不堵塞DOM解析

想要更好更详细的文档可以查看这篇文章 分析关键渲染路径性能

启动测试

开始正式的测试,我们这里需要一个可以被我们任意控制的返回延迟的web服务器,我这里用了node

const express = require('express')
const app = express()
const fs = require('fs')

app.all('*', function(req, res, next) {
  res.header('Access-Control-Allow-Credentials', true)
  res.header('Access-Control-Allow-Origin', req.headers['origin'] || '*')
  res.header('Access-Control-Allow-Headers', 'X-Requested-With')
  res.header('Access-Control-Allow-Methods', 'PUT,POST,GET,DELETE,OPTIONS')
  res.header('Content-Type', 'application/json;charset=utf-8')
  next()
})

const jsDealy = 2000
const cssDealy = 3000

app.get('/test.js', function(req, res) {
  setTimeout(() => {
    res.contentType('application/javascript')
    res.send(fs.readFileSync('./test.js'))
  }, jsDealy)
})

app.get('/test.css', function(req, res) {
  setTimeout(() => {
    res.contentType('text/css')
    res.send(fs.readFileSync('./test.css'))
  }, cssDealy)
})

app.listen(9527, () => console.log('Example app listening on port 9527!'))

写的糙还请见谅,就是能够控制JS与CSS的返回时间

情况1 两者都放头部 

css延迟3s js延迟2s

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <meta http-equiv="X-UA-Compatible" content="ie=edge" />
    <link rel="stylesheet" href="http://localhost:9527/test.css" />
    <script src="http://localhost:9527/test.js"></script>
    <title>Document</title>
  </head>
  <body>
    <h1>sdfidsajfoasdkl</h1>
    <h1>sdfidsajfoasdkl</h1>
    <h1>sdfidsajfoasdkl</h1>
    <h1>sdfidsajfoasdkl</h1>
    <h1>sdfidsajfoasdkl</h1>
    <h1>sdfidsajfoasdkl</h1>
  </body>
</html>

这个性能检测其实frame一开始的画面是上一次的残留,所以无视就好,关键还是在FCP的发生时间

我们可以看到,整个页面的FCP发生时间出现在test.css加载完成的后面,尽管JS脚本很快加载完毕,但依旧要等待CSS加载并解析完毕,才会执行JS脚本(符合上文第7条规则),并继续向下解析DOM。

假设header中如果只有CSS,那么结果其实也是类似的(这里限于篇幅就不放图了),FCP依旧需要到CSS加载解析完毕,才会去绘制画面。原因其实很简单,如果没有加载CSS就绘制,那么用户会感知到一张没有样式的丑陋网页,随后过了可能500ms变化成有样式的网页,这个过程是一种很差的体验,所有浏览器需要等待CSSDOM就绪后,才会进行布局渲染。

整个流程可以这样描述(尽可能的简短)

  • 获取到文档内容,开始解析
  • 浏览器开始获取资源
  • 解析到link css
  • 解析到script发现上面有css 等待上面的css加载并解析完毕后 在进行执行 这时候进入阻塞DOM解析截断
  • js加载完毕 css还没有 继续等待css
  • css加载解析完毕
  • js执行完毕
  • 继续解析DOM
  • DOM解析完毕,两个树进行合并渲染,接着绘制到屏幕上

情况2 css放头部 js放</body>前

<!DOCTYPE html>
<html lang="en">
  <head>
    <link rel="stylesheet" href="http://localhost:9527/test.css" />
    <title>Document</title>
  </head>
  <body>
    <h1>sdfidsajfoasdkl</h1>
    <h1>sdfidsajfoasdkl</h1>
    <h1>sdfidsajfoasdkl</h1>
    <h1>sdfidsajfoasdkl</h1>
    <h1>sdfidsajfoasdkl</h1>
    <h1>sdfidsajfoasdkl</h1>
    <script src="http://localhost:9527/test.js"></script>
  </body>
</html>

WTF? 看上去好像没有任何的变化整个过程依旧需要CSS加载完毕后,才会出现FCP。但是不要慌,我们试试调转资源的延迟。我们把CSS的延迟设置为2s,js的延迟设置为3s

情况3 css放头部 js放</body>前 css会更快的加载

HTML与上面相同,不过我们互换了资源的延迟

const jsDealy = 3000
const cssDealy = 2000

我们可以惊喜的发现,整个页面的FCP时间被缩短了,他在CSS加载完成后,立马进行一次渲染。触发这种提前FP的关键就在于,当浏览器解析到body标签中的第一个脚本的时候,如果header中的资源已经被加载解析完毕并且第一个脚本还未加载完毕时,浏览器将会进行一次提前渲染

不对啊,不是说JS会堵塞DOM的解析吗,怎么明明堵塞了,解析不完怎么还渲染呢?

实际上,这时候的提前渲染,CSSDOM是健全的,但是DOM是残缺的,JS的确阻塞了DOM的解析。

试着在JS标签下面放点内容就能明白这个过程

<!DOCTYPE html>
<html lang="en">
  <head>
    <link rel="stylesheet" href="http://localhost:9527/test.css" />
    <title>Document</title>
  </head>
  <body>
    <h1>sdfidsajfoasdkl</h1>
    <h1>sdfidsajfoasdkl</h1>
    <h1>sdfidsajfoasdkl</h1>
    <h1>sdfidsajfoasdkl</h1>
    <h1>sdfidsajfoasdkl</h1>
    <h1>sdfidsajfoasdkl</h1>
    <script src="http://localhost:9527/test.js"></script>
    <h1>我说一个在JS下面的标签</h1>
  </body>
</html>

大家发现了吗。FCP中的DOM显然只有script标签前面的部分,而后面的在JS加载解析执行完毕后,才会渲染出来。这时候的FP渲染实际上是残缺的DOM树与完整的CSSDOM进行合并渲染后的结果。也可以被认为是部分提前渲染。

现在我们已经能够了解script标签放到body前面确实是有一定道理的,在某些情况下能够让浏览器提前进行一次渲染。那么,css与js标签的顺序又会给加载带来怎样的影响呢?

情况4 加入一个新的CSS

我们来试试在body中放置在script标签前后的css是如何影响FP时间的

<!DOCTYPE html>
<html lang="en">
  <head>
    <link rel="stylesheet" href="http://localhost:9527/test.css" />
    <title>Document</title>
  </head>
  <body>
    <h1>sdfidsajfoasdkl</h1>
    <h1>sdfidsajfoasdkl</h1>
    <h1>sdfidsajfoasdkl</h1>
    <h1>sdfidsajfoasdkl</h1>
    <h1>sdfidsajfoasdkl</h1>
    <h1>sdfidsajfoasdkl</h1>
    <link rel="stylesheet" href="http://localhost:9527/test2.css" />
    <script src="http://localhost:9527/test.js"></script>
    <h1>我说一个在JS下面的标签</h1>
  </body>
</html>

我在底部脚本前面放置了一个CSS标签,他的延迟比css1高了100ms,大家可以试着猜测一下FP的发生在什么时机。

浏览器将会在所有CSS就绪之后,开始进行一次提前渲染。

如果我们反转顺序呢?

情况5 加入新css js在前

<!DOCTYPE html>
<html lang="en">
  <head>
    <link rel="stylesheet" href="http://localhost:9527/test.css" />
    <title>Document</title>
  </head>
  <body>
    <h1>sdfidsajfoasdkl</h1>
    <h1>sdfidsajfoasdkl</h1>
    <h1>sdfidsajfoasdkl</h1>
    <h1>sdfidsajfoasdkl</h1>
    <h1>sdfidsajfoasdkl</h1>
    <h1>sdfidsajfoasdkl</h1>
    <script src="http://localhost:9527/test.js"></script>
    <link rel="stylesheet" href="http://localhost:9527/test2.css" />
    <h1>我说一个在JS下面的标签</h1>
  </body>
</html>

哇哦,看上去JS堵塞了DOM的解析,从而让浏览器不会等待这个脚本之后的CSS就绪。在CSS1就绪后就进行了一次提前渲染。所以我们可以认为,如果我们需要一些不是非常重要的非首屏CSS,那么放到body尾部的JS后面可以降低他的优先级,如果是首屏CSS就千万不要这么做了,等CSS加载完成后,浏览器又会发生一次重绘。浪费了不必要的资源。

其实想要提前加载一些不是特别重要的还有另外一种方式,将CSS放置到头部,并添加如下两种通知浏览器预加载

<link rel="preload">  <link rel="prefetch">

这里可以看这篇文章Preload,Prefetch 和它们在 Chrome 之中的优先级

对于当前页面很有必要的资源使用 preload,对于可能在将来的页面中使用的资源使用 prefetch

情况6 混合

我们这里进行一些混合操作。

<!DOCTYPE html>
<html lang="en">
  <head>
    <link href="https://cdn.bootcss.com/twitter-bootstrap/4.2.1/css/bootstrap.css" rel="stylesheet">
    <script src="https://cdn.bootcss.com/twitter-bootstrap/4.2.1/js/bootstrap.js"></script>
    <title>Document</title>
  </head>
  <body>
    <h1>sdfidsajfoasdkl</h1>
    <h1>sdfidsajfoasdkl</h1>
    <h1>sdfidsajfoasdkl</h1>
    <h1>sdfidsajfoasdkl</h1>
    <h1>sdfidsajfoasdkl</h1>
    <h1>sdfidsajfoasdkl</h1>
    <script src="http://localhost:9527/test.js"></script>
    <link rel="stylesheet" href="http://localhost:9527/test2.css" />
    <h1>我说一个在JS下面的标签</h1>
  </body>
</html>

我们可以看到header中的资源拥有非常高的优先级,无论是JS和CSS,在header中的资源加载就绪后,发现第一脚本还在加载,就直接触发了FP。我们再来试试反过来。让header中的资源变得很慢。

情况7 混合反向 header中的资源变得更慢

这时候由于第一脚本实在太快,在Header中的资源加载完毕前早就加载完毕了,所以这时候没有触发提前FCP,等待Header中的资源加载完毕后再进行FCP。

总结

我们可以看出

当浏览器解析到body标签中的第一个脚本时(当解析到这里都时候,说明header中的资源已经全部就绪),发现这脚本依旧在等待加载,那么浏览器会触发一次提前渲染,并且这次的提前渲染是残缺的DOM与完整的CSSDOM进行合并渲染后的产物

所以,如果你想要触发这种FCP来优化用户体验,让用户更快的能够看到页面,应该尽可能的减小header中资源的数量与体积。这才是优化体验最关键的东西,CSSDOM的优先级是非常非常高的,不是非常必要的css请用preload,prefetch进行延后加载。

如有错误,还请指正!谢谢

参考

Chrome的First Paint触发的时机探究

Preload,Prefetch 和它们在 Chrome 之中的优先级

分析关键渲染路径性能


标题:从JS的阻塞角度谈谈浏览器渲染原理
作者:zhangzhengyi12
地址:https://blog.yinode.tech/articles/2019/01/30/1567739691731.html

评论