渲染与加载


载入与解析

在我们访问一个 HTML 文件的时候,浏览器对 HTML 文件的解析分为以下几步:

  1. 获取 HTML 代码。通过一个 URL 来获取 HTML 代码,这个代码可能是本地的,也可能是网络的。HTML 代码会被浏览器从上到下解析。

  2. 解析的开始,浏览器会为 HTML 代码生成一个 DOM,最初这个 DOM 并不是完整的,通过对 HTML 一步一步的解析,DOM 的内容逐步被填充。比如在 head 元素内打印 document.body,会得到 null,就是因为 body 元素当时还没有被解析。

  3. 如果解析过程中,遇到外部资源,比如外部 CSS、外部 JavaScript、图片、视频等,那么会立即拉取这些资源:

    • 如果遇到的是 CSS 代码,会对 CSS 进行解析。根据 CSS 的选择器对各个 CSS 的声明块分类,DOM 会匹配这些 CSS 的样式生成一个可以用来渲染的渲染树。页面会根据渲染树的规则,进行绘制。一般情况下,渲染树是等页面解析结束后,一次性渲染,不会解析到一个元素,就渲染一个元素。特殊情况下,渲染树会提前渲染。

    • 如果遇到的是 JavaScript 代码,内部代码会直接执行,外部代码就立刻拉取代码,不同的浏览器引擎,JavaScript 可能会不同程度的干扰到渲染树的绘制。

      编写以下代码嵌入到 HTML 中间:

      <script>
        for (var i = 0; i < 100000000; i++);
      </script>
      
      • 在火狐中,JavaScript 代码并不会阻塞浏览器对 HTML 代码的解析,JavaScript 会执行,但不会触发渲染树对页面进行绘制。
      • 在 IE 中,渲染树的绘制会因 JavaScript 运行完全阻塞,直到 JavaScript 运行结束。
      • 在 Chrome 中, 渲染树会先绘制当前渲染树中的内容,再去执行 JavaScript 代码,JavaScript 代码的运行会阻塞页面的解析。
    • 如果遇到的是其他外部资源,比如图片、视频等,它们会立即被拉取,并且在拉取完成后触发对应的加载结束事件。它们的拉取过程并不会阻塞页面向下解析。

  4. 当 HTML 解析结束,DOM 构建完毕,浏览器会触发 DOM 的 DOMContentLoaded 事件,此时并不代表页面已经正常渲染,因为渲染树的绘制可能还没有结束,图片视频等资源也不一定都加载完成,但是 document 对象已经可以正常访问了。

  5. 当页面上所有资源的拉取都结束后,会触发 BOM 的 load 事件,代表页面加载完毕。

非常遗憾的是,并没有提供浏览器绘制页面结束的事件。

浏览器内核

各个浏览器的底层并不一样,浏览器的底层引擎常常被称为 内核。因为内核的不同,所以在加载资源与绘制页面时,所发生的情况并不一致,比如前一节中,不同浏览器对待 JavaScript 运行和页面绘制的处理方式并不完全相同。目前主流的内核如下:

内核浏览器描述
TridentIE微软自研内核,不是开源的,专为 windows 设计,但是优化非常差,比如上一段代码,一亿次空循环,在其他内核很快,但是在 Trident 内核中就会严重卡顿。它的兼容性也非常的差,在 CSS 渲染、DOM&BOM 上构建混乱,前端开发大部分兼容性问题都来自 Trident 内核。
GeckoFireFox 火狐火狐自研开源内核,功能强大,跨拼台能力强,横跨 windows、linux、安卓或者 mac 系统。不论是对用户还是前端开发者,都非常友好。
webkitSafari & 旧版 Chrome苹果公司自研开源内核,同样具有优秀的功能,很多国产浏览器也是使用这个内核。
BlinkChrome & 新版 Edge谷歌公司基于 webkit 修改的内核,历经更改,它已经成为了一个独立的内核。

JavaScript 载入

为了更好的演示本节的知识点,可以使用以下代码:

<!DOCTYPE html>
<html lang="zh-CN">
  <head>
    <meta charset="UTF-8" />
    <style>
      body {
        column-count: 5;
      }
      p {
        background-color: black;
        color: white;
      }
      h1 {
        column-span: all;
      }
    </style>

    <title>Document</title>
  </head>
  <body>
    <h1>荷塘月色</h1>

    <p>作者: 朱自清</p>

    <p>
      这几天心里颇不宁静。今晚在院子里坐着乘凉,忽然想起日日走过的荷塘,在这满月的光里,总该另有一番样子吧。月亮渐渐地升高了,墙外马路上孩子们的欢笑,已经听不见了;妻在屋里拍着闰儿,迷迷糊糊地哼着眠歌。我悄悄地披了大衫,带上门出去。
    </p>
    <p>
      沿着荷塘,是一条曲折的小煤屑路。这是一条幽僻的路;白天也少人走,夜晚更加寂寞。荷塘四面,长着许多树,蓊蓊郁郁的。路的一旁,是些杨柳,和一些不知道名字的树。没有月光的晚上,这路上阴森森的,有些怕人。今晚却很好,虽然月光也还是淡淡的。
    </p>
    <p>
      路上只我一个人,背着手踱着。这一片天地好像是我的;我也像超出了平常的自己,到了另一世界里。我爱热闹,也爱冷静;爱群居,也爱独处。像今晚上,一个人在这苍茫的月下,什么都可以想,什么都可以不想,便觉是个自由的人。白天里一定要做的事,一定要说的话,现在都可不理。这是独处的妙处,我且受用这无边的荷香月色好了。
    </p>
    <p>
      曲曲折折的荷塘上面,弥望的是田田的叶子。叶子出水很高,像亭亭的舞女的裙。层层的叶子中间,零星地点缀着些白花,有袅娜地开着的,有羞涩地打着朵儿的;正如一粒粒的明珠,又如碧天里的星星,又如刚出浴的美人。微风过处,送来缕缕清香,仿佛远处高楼上渺茫的歌声似的。这时候叶子与花也有一丝的颤动,像闪电般,霎时传过荷塘的那边去了。叶子本是肩并肩密密地挨着,这便宛然有了一道凝碧的波痕。叶子底下是脉脉的流水,遮住了,不能见一些颜色;而叶子却更见风致了。
    </p>
    <p>
        月光如流水一般,静静地泻在这一片叶子和花上。薄薄的青雾浮起在荷塘里。叶子和花仿佛在牛乳中洗过一样;又像笼着轻纱的梦。虽然是满月,天上却有一层淡淡的云,所以不能朗照;但我以为这恰是到了好处——酣眠固不可少,小睡也别有风味的。月光是隔了树照过来的,高处丛生的灌木,落下参差的斑驳的黑影,峭楞楞如鬼一般;弯弯的杨柳的稀疏的倩影,却又像是画在荷叶上。塘中的月色并不均匀;但光与影有着和谐的旋律,如梵婀玲上奏着的名曲。
    </p>
    <p>
        荷塘的四面,远远近近,高高低低都是树,而杨柳最多。这些树将一片荷塘重重围住;只在小路一旁,漏着几段空隙,像是特为月光留下的。树色一例是阴阴的,乍看像一团烟雾;但杨柳的丰姿,便在烟雾里也辨得出。树梢上隐隐约约的是一带远山,只有些大意罢了。树缝里也漏着一两点路灯光,没精打采的,是渴睡人的眼。这时候最热闹的,要数树上的蝉声与水里的蛙声;但热闹是它们的,我什么也没有。
    </p>
    <p>
        忽然想起采莲的事情来了。采莲是江南的旧俗,似乎很早就有,而六朝时为盛;从诗歌里可以约略知道。采莲的是少年的女子,她们是荡着小船,唱着艳歌去的。采莲人不用说很多,还有看采莲的人。那是一个热闹的季节,也是一个风流的季节。梁元帝《采莲赋》里说得好:
    </p>
    <p>
        于是妖童媛女,荡舟心许;鷁首徐回,兼传羽杯;欋将移而藻挂,船欲动而萍开。尔其纤腰束素,迁延顾步;夏始春余,叶嫩花初,恐沾裳而浅笑,畏倾船而敛裾。
    </p>
    <p>  可见当时嬉游的光景了。这真是有趣的事,可惜我们现在早已无福消受了。</p>
    <p>  于是又记起《西洲曲》里的句子:</p>
    <p>
        采莲南塘秋,莲花过人头;低头弄莲子,莲子清如水。今晚若有采莲人,这儿的莲花也算得“过人头”了;只不见一些流水的影子,是不行的。这令我到底惦着江南了。——这样想着,猛一抬头,不觉已是自己的门前;轻轻地推门进去,什么声息也没有,妻已睡熟好久了。
    </p>
  </body>
</html>

多列布局可以更好的演示各个浏览器的差异。

目前为止,我们编写的 JavaScript 都是同步加载的,这些同步加载的 JS 代码被称作 同步脚本,同步脚本按从上到下的顺序执行。因此,如果不在 HTML 末尾执行 JavaScript,并不能获取到完整的 DOM 树,所以一直到现在,我们演示的大部分代码,都是在 HTML 文件的末尾。

如果太早执行 JS 代码,将无法获取完整的 DOM,比如把脚本放到 head 元素中,在脚本汇总获取 document.body,就会得到 null,这是因为当时 DOM 还没有解析完成。

DOMContentLoaded 事件可以监听 DOM 加载完毕,在 DOM 加载完成后事件会被触发。通过 DOMContentLoaded 事件可以让代码在 DOM 构建结束后再执行代码。

document.addEventListener("DOMContentLoaded", function () {
  // 这里可以正常选取到所有 p 元素,不论这段 JavaScript 代码放到页面中的任何地方。
  var p = document.getElementsByTagName("p");
});

如果使用外部引入 JavaScript 代码,那么可以通过下面两个属性设置 JavaScript 的运行和解析方式。

属性描述
async布尔属性,设置一个异步脚本。JavaScript 代码被异步拉取,不干扰 HTML 的解析,当 JS 代码全部拉取完毕后,会尽可能早的执行。它可能是会在任何一个时间段执行,不一定等到 HTML 解析完毕。
defer布尔属性,设置一个延迟脚本。让代码在 DOM 构建完毕,DOM 事件 DOMContentLoaded 之前运行。

新建三个 JS 文件,default.jsasync.jsdefer.js 内容如下:

// default.js
console.log("default.js", document.getElementsByTagName("p").length);
document.addEventListener("DOMContentLoaded", function () {
  console.log("DOMContendLoaded", document.getElementsByTagName("p").length);
});
// async.js
console.log("async.js", document.getElementsByTagName("p").length);
// defer.js
console.log("defer.js", document.getElementsByTagName("p").length);

通过外部引入,把它们放到 head 元素中,如下:

<script src="./async.js" async></script>
<script src="./defer.js" defer></script>
<script src="./default.js"></script>

在浏览器中连续刷新,它们不是每次执行的顺序都完全一致,如果异步脚本拉取执行得早,可能会和默认脚本一样,在 DOM 构建完毕之前执行。同步脚本会在运行的时候直接执行,延时脚本会紧跟其后,DOMContentLoaded 事件最后执行。

如果异步脚本执行的晚,可能获得一个解析到一半的 DOM,如果更晚,那么可以获取到一个完整的 DOM,不同的内核对此的处理并不一致。而延迟的脚本 defer.js 总是会等到 DOM 构建完毕后,才执行,所以总是能获取到完整的 DOM。然而在其他内核中可能又会出现不同的情况,比如火狐的 Gecko,它会不会在 HTML 解析过程结束前执行异步脚本,快得话就立马执行,慢的话就不执行。

可以给 HTML 添加几千上万行代码,来验证上述观点(vscode 中,你可以使用 p{填充文本}*1000 + 【tab】键创建 1000 个 p 元素)。如果添加几千个 p 元素并填充一定量的内容,那么 HTML 文件会非常大,异步脚本使用 document.getElementsByTagName('p') 可能无法获取到全部 p 元素,或者获取到一部分 p 元素。而延迟脚本就总是获取到全部 p 元素,并且在 DOMContentLoaded 事件前执行。

异步脚本和延迟脚本有什么用?

首先,它们都不会阻塞页面渲染,在本页【载入与解析】一节中,同步脚本使用 for 执行上亿次循环时,因为 JavaScript 的执行,某些浏览器会阻塞页面渲染,但是异步脚本和延迟脚本不会阻塞页面。因为它们都不会干扰当前页面的解析。合理的运用的话,页面初次绘制会非常迅速并不产生阻塞。

需要注意的是,在异步脚本或者延迟脚本中不要注册 DOMContentLoaded 事件。如果异步脚本在 DOMContentLoaded 事件后执行,那么代码就会作废,因为此事件只会执行一次。如果在延迟脚本中使用 DOMContentLoaded 事件也是多此一举,因为延迟脚本在 DOM 构建完毕后,DOMContentLoaded 事件前执行,注册这个事件并没有多大意义。

样式计算、绘制与排列

引擎会根据渲染树的规则,对元素进行 绘制排列。每次渲染树的改变,都会重新进行 样式计算,从而得到一个新的渲染树,浏览器会按照新的渲染树进行 重绘重排

样式计算、绘制与排列都会对消耗性能,如果代码在这方面的优化太差,网页在使用时就很容易出现卡顿的现象,比较明显的就是页面出来了,滚动鼠标页面一卡一卡的,出现这种情况的原因就可能是因为渲染树的改变,页面一直在进行样式计算、重绘或者重排。

改变渲染树中布局之外的样式修改发生重绘,比如,字体颜色,边框颜色,透明度等等,浏览器会被重新绘制这部分样式。

渲染树中的布局改变会发生重排,比如一个普通元素修改成了浮动,或者修改了 margin 让其位置改变,或者一行文本被添加了几个字,内容产生了换行等等,这些情况浏览器会重新排列并绘制页面,所以重排是包含重绘的。

得力于现代浏览器内核的优化,通常来说,浏览器并不会因为一个小操作,立即对页面进行重绘或者重排,比如:

var ul = document.createElement("ul");

// 这里把 ul 添加到 DOM 中,渲染树被修改,但是并不会立即重排页面
document.body.appendChild(ul);

for (var i = 0; i < 100; i++) {
  var li = document.createElement("li");
  li.innerText = "第 " + i + " 项";

  // 这里同理,每个 li 元素被插入到 ul 中,渲染树被修改,但是不会立即重排
  ul.appendChild(li);

  // 这里在改变了样式,渲染树被修改,但不会立刻进行样式计算
  li.style.color = "#" + Math.random().toString(16).slice(2, 8);

  // 获取元素在渲染树中的真实样式,getComputedStyle 属性会产生样式计算
  console.log(getComputedStyle(li).color);
}

// 程序结束 页面开始根据新的渲染树重排重绘页面

关于页面的性能,可以在浏览器控制台的 Performance 模块看到,如果页面打开很卡顿,那么就可以到此模块下分析原因。

【Performance 使用演示】

如果一个元素没有被加入到 DOM 中,是无法通过渲染树计算其样式的。所以上面代码中,如果把 documen.body.appendChild(ul) 放到末尾,代码可以照样运行,但 getComputedStyle 无法获取到 li 元素的样式。虽然这样做无法实时获取 li 的样式,但也有好处,因为改变 li.style.color 的值时,渲染树并不会立即重新计算样式,它会等到最后 ul 被加入到 DOM 后,一次性计算所有样式,可以减少计算产生的开销。

重排和重绘发生在任何页面改变的情况下,比如页面滚动,窗口大小变化等等。

主动重排

我们可以主动触发浏览器重排,在此之前先介绍一个额外的 CSS3 属性 transition

transition 属性可以让元素的样式改变,有一个平滑的过渡,而不是直接改变,比如:

<style>
  #foo {
    width: 100px;
    height: 100px;
    background-color: #fff;
    border: 1px solid black;
  }
  #foo:hover {
    background-color: #000;
    width: 800px;
  }
</style>

<div id="foo"></div>

上面样式 HTML 代码中,当我们把鼠标放到 #foo 元素上后,其背景色和宽度发送了变化,这种变化并没有过渡,是直接触发的,尝试给 #foo 选择器添加以下属性:

#foo {
  transition: background-color 2s, width 1s;
}

transition: background-color 2s, width 1s; 这行声明会让 #foo 元素的背景色和宽度改变时,分别在 2 秒的时间内从开始状态过渡到最终状态。现在把鼠标放到 #foo 元素上,你会发现元素的背景和宽度改变时会平滑的过渡。如果使用 JS 改变 #foo 元素的背景色和宽度,过渡依然奏效,过渡不是一次性的,添加以下代码:

var foo = document.getElementById("foo");
setInterval(function () {
  foo.style.backgroundColor = "#" + Math.random().toString(16).slice(2, 8);
  foo.style.width = Math.random() * 1000 + "px";
}, 2000);

#foo 元素会不断改变背景色和宽度,每次改变都会产生过渡效果。

这和重排重绘有什么关系?

思考下面代码,页面会发生什么:

<style>
  #foo {
    width: 100px;
    height: 100px;
    background-color: #fff;
    border: 1px solid black;
    transition: background-color 2s, width 1s;
  }
</style>

<script>
  var foo = document.createElement("div");
  foo.id = "foo";
  document.body.appendChild(foo);
  foo.style.backgroundColor = "#000";
  foo.style.width = "800px";
</script>

上面的代码我们创建了一个 div 元素 idfoo,记作 #foo。我们设置了 #foo 元素的样式,让它的背景色和宽度发生变化时平滑过渡。

我们希望的想法是 #foo 元素在插入 DOM 后有一个平滑过渡的状态,因为我们改变了它的背景色和宽度,但是过渡并没有发生。

这是因为当我们把元素插入到 DOM 中时,会产生样式计算,但是页面不会立刻重绘重排,#foo 元素还没有渲染。接着我们改变了它的背景色和宽度,又产生了样式计算。当程序运行结束后,浏览器会根据计算后的新渲染树进行绘制,此时背景色和宽度已经被设置为最终状态了,所以过渡无法完成。

为了解决这个问题,我们需要在元素插入到 DOM 后,让浏览器立即重绘或者重排,保留一个过渡的初始状态。浏览器并没有内置触发重排和重绘的方法,但是访问一些属性的时候,浏览器会立即重排,会触发重排的属性如下:

窗口偏移:

  • window.pageXOffset:获取 X 轴的滚动偏移量
  • window.pageYOffset:获取 X 轴的滚动偏移量
  • document.body.scrollLeft: IE7 以下获取 X 轴的滚动偏移量
  • document.body.scrollTop:IE7 以下获取 Y 轴的滚动偏移量
  • document.documentElement.scrollLeft:IE9 以下获取 X 轴的滚动偏移量
  • document.documentElement.scrollTop:IE9 以下获取 Y 轴的滚动偏移量

显示大小:

  • Element.clientHeight:元素的 padding + content - 滚动条 的高度。
  • Element.clientWidth:元素的 padding + content - 滚动条 的宽度。
  • Element.offsetHeight:元素的 border + padding + content 的高度。
  • Element.offsetWidth:元素的 border + padding + content 的宽度。

相对位置:

  • Element.offsetParent:获取元素的参考父级
  • Element.offsetTop:相对 offsetParent 的顶部内边距距离
  • Element.offsetLeft:相对 offsetParent 的左侧内边距距离

绝对位置:

  • Element.getBoundingClientRect

滚动位置:

  • Element.scrollHeight:滚动区高度。
  • Element.scrollWidth:滚动区宽度。
  • Element.scrollTop:已滚动高度,可设置。
  • Element.scrollLeft:已滚动宽度,可设置。

这是之前在 DOM 大小与位置中介绍的属性,你肯定觉得非常难记忆,那么你可以反向记忆,获取页面或元素大小与位置的所有属性中,只有以下四个不会发生重排:

  • window.innerHeight:窗口高度。
  • window.innerWidth:窗口宽度。
  • getComputedStyle():获取计算样式,此方法会产生样式计算。

eval 加载 JavaScript

极少数情况下,JavaScript 代码会以字符串的形式出现,可以使用内置函数 eval 把一段字符串当做 JavaScript 代码执行,比如:

var jscode = "var foo = 1; console.log(foo)";
eval(jsCode);

使用 eval 这种加载 JS 代码的方式是明令禁止的,因为它非常不安全。

如果代码本身就由前端开发人员编写,eval 完全不需要,所以一般这使用 eval 运行的代码都是开发者之外人员编写的代码,这部分代码可能存在一定的安全问题,比如盗取数据,或者干扰当前代码。

eval 中的代码并不是独立运行的,它会使用当前作用域,比如:

var foo = 1;
var jsCode = "var bar = 2; console.log(foo)";
eval(jsCode);
console.log(bar);

上述代码中,eval 执行的 JS 代码可以使用外部的变量 foo,其内部定义的变量 bar 也可以在外部定义,所以非常不安全。