浅析前端页面渲染机制

35 篇文章 1 订阅
9 篇文章 0 订阅

目录

一、浏览器渲染网页的具体流程

1. 构建 DOM 树( DOM tree )

2. 构建 CSS 规则树( CSSOM )

3. 执行 JavaScript 脚本时的相关问题

4. 构建渲染树( Render tree )

5. 渲染树与 DOM 树

6. 渲染树布局( layout of the render tree )

6.1 “dirty” 位系统( dirty bit system )

6.2 全局布局与局部布局

6.3 增量布局

6.4 异步布局和同步布局

6.5 强制重排

7. 绘制( painting )

8. 重排和重绘( reflow 和 repaint )

二、前端优化


一、浏览器渲染网页的具体流程


我们先来梳理一下浏览器渲染的大致流程:

  1. 构建 DOM 树( DOM tree ):从上到下解析 HTML 文档生成 DOM 节点树( DOM tree ),也叫内容树( Content tree )。
  2. 构建 CSS 规则树( CSSOM ):加载解析样式生成 CSS 规则树。
  3. 执行 JavaScript :加载并执行 JavaScript 代码(包括内联代码或外联 JavaScript 文件)。
  4. 构建渲染树( Render tree ):根据 DOM 树和 CSS 规则树,生成渲染树( Render tree )【渲染树:按顺序展示在屏幕上的一系列矩形,这些矩形带有字体,颜色和尺寸等视觉属性】。
  5. 布局( layout ):根据渲染树将节点树的每一个节点布局在屏幕上的正确位置。
  6. 绘制(Painting):遍历渲染树绘制所有节点,为每一个节点适用对应的样式,这一过程是通过 UI 后端模块完成。

为了更友好的用户体验,浏览器会尽可能快的展现内容,而不会等到文档所有内容都解析完成才开始渲染,而是每次处理一部分,并展现在屏幕上,这也是为什么我们经常可以看到页面加载的时候内容是从上到下一点一点展现的。

这个 “每次处理的一部分”,其实就是 “Event Loop” 中的一次循环,也就是执行完一个宏任务,然后执行这个宏任务管理的微任务队列中的任务,微任务队列中的任务全部执行完后,便会进行一次渲染。然后执行下一个宏任务...

想了解Event Loop具体流程的同学,可以看一下我的这篇文章:「硬核JS」一次搞懂JS运行机制 - 读后感

1. 构建 DOM 树( DOM tree )

DOM,即文档对象模型( Document Object Model ) ,DOM 树,即文档内所有节点构成的一个树形结构。当浏览器客户端从服务器那接受到 HTML 文档后,就会遍历文档节点然后生成 DOM 树,DOM 树结构和 HTML 标签一一对应。

这个过程需要注意一下几点:

  1. DOM 树在构建的过程中可能会被 JS 的加载执行阻塞。(这在后面会详细介绍。)
  2. display:none 的元素也会在 DOM 树中。
  3. 注释也会在 DOM 树中
  4. script 标签会在 DOM 树中

假设浏览器获取返回的如下 HTML 文档:

<!doctype html>
<html>
<head>
    <link rel="stylesheet" href="./theme.css"></link>
    <script src="./config.js"></script>
    <title>关键渲染路径</title>
</head>
<body>
    <h1 class="title">关键渲染路径</h1>
    <p>关键渲染路径介绍</p>
    <footer>@copyright2017</footer>
</body>
</html>

首先浏览器从上到下依次解析文档构建 DOM 树,如下:

2. 构建 CSS 规则树( CSSOM )

浏览器会解析 CSS 文件并生成 CSS 规则树,在过程中,每个 CSS 文件都会被分析成 StyleSheet 对象,每个对象都包括 CSS 规则,CSS 规则对象包括对应的选择器和声明对象以及其他对象。

这个过程需要注意一下几点:

  • CSS 解析可以与 DOM 解析同步进行。
  • CSS 解析与 script 的执行互斥 。
  • 在 Webkit 内核中进行了 script 执行优化,只有在 JS 访问 CSS 时才会发生互斥。
  • 解析 CSS 不会阻塞 HTML 的解析,但是会阻塞页面的渲染。

解析 CSS 和解析 HTML 会生成两颗不同的树,这两个过程互不影响,也就可以同时进行,那么也就说解析 CSS 不会影响解析 HTML。但是,我们知道重新渲染页面的代价是非常高的,如果 CSS 的解析和渲染过程同步进行,一个 DOM 就会被频繁修改样式,整个过程就在不停地重绘重排。为了最大节省开销,浏览器就将页面的渲染放在 CSS 解析完成之后进行,对一个 DOM 的渲染操作一次性执行完毕,就可以最大限度地节省开销。所以解析 CSS 会阻塞页面的渲染。因此浏览器在 CSSOM 构建完毕前不会渲染任何已处理的内容。

theme.css样式内容如下:

html, body {
    width: 100%;
    height: 100%;
    background-color: #fcfcfc;
}
.title {
    font-size: 20px;
}
.footer {
    font-size: 12px;
    color: #aaa;
}

构建 CSS 规则树如图:

3. 执行 JavaScript 脚本时的相关问题

  • 当 HTML 解析器被脚本阻塞时,解析器虽然会停止构建 DOM,但仍然会辨识该脚本后面的资源,并进行预加载
  • JavaScript 会读取和修改 DOM、CSS 结构,因此 DOM、CSS 解析与 script 的执行互斥。
  • 当 JS 文件较多时,页面白屏的时间也会变长。

由于以上这些原因,script 标签的位置很重要,我们在实际开发中应该尽量坚持以下两个原则:

  • 在引入顺序上,CSS 资源先于 JavaScript 资源。
  • JavaScript 应尽量少的去影响 DOM 的构建。

同时,提供三种常用的解决方案:

1)设置 defer 属性

给 script 标签设置 defer 属性,浏览器会重新开启一个线程下载脚本文件,同时继续解析 HTML,等 HTML 全部解析完、DOM 加载完成之后,再去执行下载好的 JS 脚本。只适用于引用外部 JS 文件,并且可以确保所有加了 defer 属性的脚本会按顺序执行。

2)设置 async 属性

async 属性和 defer 属性类似,也是会开启一个线程去下载 JS 文件,但和 aysnc 会在 JS 加载完成后立刻执行,而不是会等到 DOM 加载完成之后再执行,所以还是有可能会造成阻塞。同样的也是只适用于外部 JS 文件,如果有多个设置了 aysnc 的 JS 文件,不能像 defer 那样保证按照顺序执行。

3)动态创建脚本

这样去动态创建文件也不会影响到页面的加载,举个例子:

<script>
 var _hmt = _hmt || [];
(function () {
  var hm = document.createElement("script");
  hm.src = "https://hm.baidu.com/hm.js";
  var s = document.getElementsByTagName("script")[0];
  s.parentNode.insertBefore(hm, s);
})();
</script>

4. 构建渲染树( Render tree )

在 CSS 规则树构建完毕后,浏览器接着构建渲染树。浏览器可以通过 DOM 树和 CSS 规则树构建渲染树。浏览器会先从 DOM 树的根节点开始遍历每个可见节点,然后对每个可见节点找到适配的 CSS 样式规则并应用。

这一过程需要借助一个模型:CSSOM( CSS Object Module,CSS 对象模型 ),通过 CSSOM 把这些 CSS 规则节点放到对应的DOM节点上,形成 Render 树。

那么问题来了,CSSOM 到底是做什么用的?

引用掘金_【译】CSSOM 介绍 的一段解释:

CSSOM 将样式表中的规则映射到页面对应的元素上。

虽然 CSSOM 采取了复杂的措施来做这件事,但是 CSSOM 最终的功能还是将样式映射到它们应该对应的元素上去。

更确切地说,CSSOM 识别 tokens 并把这些 tokens 转换成一个树结构上的对应的结点。所有结点以及它们所关联的页面中的样式就是所谓的 CSS Object Model

具体的规则有以下几点需要注意:

  • Render tree和DOM tree不完全对应。
  • display: none的元素不在Render tree中。
  • visibility: hidden的元素在Render tree中。

 

5. 渲染树与 DOM 树

每一个渲染对象都对应着 DOM 节点,但是非视觉(隐藏,不占位)DOM 元素不会插入渲染树,即渲染树不会包含显式或隐式的标签元素。如 <head> 元素或声明 display: none; 的元素。

        在 DOM 树构建的同时,浏览器会构建渲染树( render tree )。渲染树的节点(渲染器),在 Gecko 中称为 frame ,而在 Webkit 中称为 renderer。渲染器是在文档解析和创建 DOM 节点后创建的,会计算 DOM 节点的样式信息。

       在 Webkit 中,renderer(渲染节点)是由 DOM 节点调用 attach() 方法创建的。attach() 方法计算了 DOM 节点的样式信息。attach() 是自上而下的递归操作。也就是说,父节点总是比子节点先创建自己的 renderer(渲染节点)。销毁的时候,则是自下而上的递归操作,也就是说,子节点总是比父节点先销毁。

       如果元素的 display 属性值为 none,renderer(渲染节点)不会被创建。而 visibility:hidden 的元素会被创建。

总结:每个 DOM 节点不一定对应的有渲染节点,当 DOM 节点设置了某些不可见属性,例如 display:none ,就不会为其创建渲染节点。

渲染树及其对应 DOM 树如图:

6. 渲染树布局( layout of the render tree )

        创建渲染树后,下一步就是布局( Layout )。布局是一个从上到下,从外到内进行的递归过程,从根渲染对象,即对应着 HTML 文档根元素 <html>,然后下一级渲染对象,对应着 <body> 元素,如此层层递归,依次计算每一个渲染对象的几何信息(位置和尺寸)。

        渲染树的每个节点都是一个 Render Object,包含宽高,位置,背景色等样式信息。所以浏览器就可以通过这些样式信息来确定每个节点对象在页面上的确切大小和位置,布局阶段的输出就是我们常说的盒子模型,它会精确地捕获每个元素在屏幕内的确切位置与大小。而有些时候我们会在文档布局完成后再对 DOM 进行修改,这时候可能需要再次进行布局,此时,我们常常称之为重排( relayout、reflow   也称回流 ),本质上还是一个布局的过程。

        每一个渲染对象都有一个布局或者重排方法,实现其布局或重排。(注意:布局(重排)研究的是渲染对象的几何信息,即位置状态和大小尺寸)

需要注意的是:我们常说的 float、absoulte、fixed属性值会导致元素脱离文档流,这里说的文档流,其实就是脱离 Render tree。

6.1 “dirty” 位系统( dirty bit system )

为避免对所有细小更改都进行整体布局,浏览器采用了一种 “dirty ” 位系统( Dirty bit system )。如果某个呈现器发生了更改,或者将自身及其子代标注为“ dirty ”,则需要进行布局。

有两种标记:“dirty” 和 “children are dirty” 。“children are dirty” 表示尽管呈现器自身没有变化,但它至少有一个子代需要布局( Layout )。

6.2 全局布局与局部布局

对渲染树的重排可以分为全局和局部的,全局即对整个渲染树进行重排,如当我们改变了窗口尺寸或方向或者是修改了根元素的尺寸或者字体大小等。而局部布局可以是对渲染树的某部分或某一个渲染对象进行重排。

6.3 增量布局

重排(布局)可以采用增量方式,也就是只对 dirty 呈现器进行重排(这样可能存在需要进行额外重排的弊端)。

当呈现器为 dirty 时,会异步触发增量布局。例如,当来自网络的额外内容添加到 DOM 树之后,新的呈现器附加到了呈现树中。

6.4 异步布局和同步布局

增量布局是异步执行的。Firefox 将增量布局的 “reflow 命令”加入队列,而调度程序会触发这些命令的批量执行。Webkit 也有用于执行增量布局的计时器:对呈现树进行遍历,并对 dirty 呈现器进行重排。

  • 请求样式信息(例如 “offsetHeight” )的脚本可同步触发增量布局。
  • 全局布局往往是同步触发的。
  • 有时,当初始布局完成之后,如果一些属性(如滚动位置)发生变化,布局就会作为回调而触发。

6.5 强制重排

有过 CSS3 动画开发经验的同学可能会有这样的经历,我们修改了 CSS,但是却不会生效,如下入场动画:

.slide-left {
    -webkit-transition: margin-left 1s ease-out;
    -moz-transition: margin-left 1s ease-out;
    -o-transition: margin-left 1s ease-out;
    transition: margin-left 1s ease-out;
}

然后执行如下脚本:

var $slide = $('.slide-left');

$slide.css({
    "margin-left": "100px"
}).addClass('slide-left');

$slide.css({
    "margin-left": "10px"
});

我们会发现并没有效果,为什么呢?因为对 margin-left 的修改并不会触发重排,元素 margin-left 值的改变被缓存,如果我们在中间进行强制触发重排

var $slide = $('.slide-left');

$slide.css({
    "margin-left": "100px"
});

console.log($slide.css('padding');  //访问CSS属性会触发重排操作
$slide.addClass('slide-left');

$slide.css({
    "margin-left": "10px"
});

这样就达到了预期效果。

一些常见的可以强制触发重排的操作:

  • DOM 操作,如增加,删除,修改或移动;
  • 变更内容;
  • 激活伪类;
  • 访问或改变某些 CSS 属性(包括修改样式表或元素类名或使用 JavaScript 操作等方式);
  • 浏览器窗口变化(滚动或尺寸变化)。
// 举个例子:
$('body').css('padding'); // reflow
$('body')[0].offsetHeight; // reflow

7. 绘制( painting )

最后是绘制( paint )阶段,浏览器 UI 组件将遍历渲染树并调用渲染对象的 paint 方法,将内容展现在屏幕上,也有可能在绘制之后再次对 DOM 进行修改,需要重新绘制渲染对象,也就是重绘( repaint )。

8. 重排和重绘( reflow 和 repaint )

我们都知道 HTML 默认是流式布局的,但 CSS 和 JS 会打破这种布局,改变 DOM 的外观样式以及大小和位置。因此我们就需要知道两个概念:

  • reflow(重排、回流):当浏览器发现某个部分发生了变化从而影响了布局,这个时候就需要倒回去重新渲染,大家称这个回退的过程叫 reflow。 常见的 reflow 是一些会影响页面布局的操作,诸如Tab,隐藏等。reflow 会从 html 这个 root frame 开始递归往下,依次计算所有的结点几何尺寸和位置,以确认是渲染树的一部分发生变化还是整个渲染树。reflow 几乎是无法避免的,因为只要用户进行交互操作,就势必会发生页面的一部分的重新渲染,且通常我们也无法预估浏览器到底会 reflow 哪一部分的代码,因为他们会相互影响。
  • repaint(重绘): repaint 则是当我们改变某个元素的背景色、文字颜色、边框颜色等等不影响它周围或内部布局的属性时,屏幕的一部分要重画,但是元素的几何尺寸和位置没有发生改变。

需要注意的是,display:none 会触发重排,而visibility: hidden属性则并不算是不可见属性,它的语义是隐藏元素,但元素仍然占据着布局空间,它会被渲染成一个空框,这在我们上面有提到过。所以 visibility:hidden 只会触发重绘,因为没有发生位置变化。

另外有些情况下,比如修改了元素的样式,浏览器并不会立刻 reflow 或 repaint 一次,而是会把这样的操作积攒一批,然后做一次 reflow,也就是异步布局或增量异步布局。但是在有些情况下,比如 resize 窗口,改变了页面默认的字体等。对于这些操作,浏览器会马上进行 reflow。

触发重排必然会触发重绘,但是触发重绘不一定会触发重排。举个例子:我们改变一个元素的大小,必然会触发重排,但是外观也改变了,那必然也要触发重绘;改变一个元素的颜色,这个时候,必然要触发重绘,但是因为结构没有变,所以就不会触发重排。

二、前端优化


浏览器对上文介绍的关键渲染路径进行了很多优化,针对每一次变化产生尽量少的操作,还有优化“判断需要重绘或重排的方式”等等。

在改变文档根元素的字体颜色等视觉性信息时,会触发整个文档的重绘。而改变某元素的字体颜色则只触发特定元素的重绘。改变元素的位置信息会同时触发此元素(可能还包括其兄弟元素或子级元素)的重绘和重排。某些重大改变,如更改文档根元素<html>的字体尺寸,则会触发整个文档的重新重绘和重排。

这些属于“重绘重排”方面的优化相关。具体优化操作可以查看:segmentfault_前端性能优化之重排和重绘

据此及上文所述,推荐以下优化和实践:

  1. HTML 文档结构层次尽量少,最好不深于六层;
  2. 脚本尽量后放,放在 </body> 前即可;
  3. 少量首屏样式内联放在 <head> 标签内;
  4. 样式结构层次尽量简单;
  5. 在脚本中尽量减少 DOM 操作,尽量缓存访问 DOM 的样式信息,避免过度触发重排;
  6. 减少通过 JavaScript 代码修改元素样式,尽量使用修改 class 名方式操作样式或动画;
  7. 动画尽量使用在绝对定位或固定定位的元素上;
  8. 隐藏在屏幕外,或在页面滚动时,尽量停止动画;
  9. 尽量缓存 DOM 查找,查找器尽量简洁;
  10. 涉及多域名的网站,可以开启域名预解析。

有关前端的优化可以单列出来作为一篇专题来讲,篇幅缘故,就不一一展开,具体的优化细节,可以参考这个文章:CSDN_前端性能优化方法总结


参考文章(按文章参考顺序排序):

  • 1
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

麦田里的POLO桔

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值