浏览器线程阻塞和无阻塞加载脚本的理解

samchowgo

一个页面,从被请求访问,到用户可以看到页面、操作页面,到最后页面完全加载完毕,中间需要经历一个相当奇幻的过程,这个过程的速度被“web性能师”孜孜不倦、前赴后继的优化。本文讨论的是其中一个优化。

浏览器线程和阻塞

虽然大家耳熟能详的一句话是:

JavaScript是单线程的。

但是:

浏览器当然不是单线程的。

浏览器的多线程中,有的线程负责加载资源,有的线程负责执行脚本,有的线程负责渲染界面,有的线程负责轮询、监听用户事件。

这些线程,根据浏览器自身特点以及web标准等等,有的会被浏览器特意的阻塞。两个很明显的阻塞就是:脚本执行时对其他线程的阻塞脚本加载时对其他线程的阻塞

这两个阻塞发生在HTML页面初次解析时,它们对性能的影响较大,原因是:

document对象绑定了一个事件:DOMContentLoaded。这个事件会在DOM解析完成之后触发。这个事件触发之后(而不是window.load事件1),会进入异步事件驱动阶段(另一个线程控制)。也就是说,DOM解析工作不完成,用户与页面的很多(并不是所有)事件交互就无法进行。这时候浏览器的忙指示(那个页面上方的烦人的旋转的圆圈)不会消失。

我们先从执行脚本时的阻塞说起。

<!--more-->

执行脚本带来的阻塞

众所周知,浏览器中有两个引擎——JavaScript引擎和渲染引擎,它们对应了浏览器的两个线程。这两个引擎各司其职:

  1. JavaScript引擎解析并执行JavaScript代码。

  2. 渲染引擎对界面进行绘制或者重绘(对DOM的渲染)。

在浏览器取得HTML文档并解析HTML的时候,浏览器会:

  1. 对DOM树进行解析。解析<body>中标签内容时,能形成渲染树,并渲染其中的元素。这个是在渲染引擎中做的。

  2. 遇到<script>标签时(注意解析HTML时遇到的script标签),基于JavaScript可能会修改DOM的考虑,其中的内容将会在此时被执行(如果是外部JS文件的代码,会先加载这个文件资源,这部分后面再表)。执行JavaScript代码是在JavaScript引擎中做的。

由于:

脚本执行和渲染DOM的并发可能会引发严重的冲突,

所以:

JavaScript引擎和渲染引擎所在的两个线程被设计为互斥的!

这就意味着:

在执行<script>中内容时,浏览器会切换到JavaScript引擎所在的线程,此时渲染引擎所在的线程会阻塞,故其后元素的解析和渲染会暂停。这时候如果脚本执行时间太长的话,不仅后面的元素会一直看不到,对DOM的解析工作也会一直完不成。用户会陷入焦急的等待中。

解决这个问题的一个经典思路,就是:

<script>放到紧跟</body>之前的位置。这样就不会影响需要放到页面上的UI元素的解析了。这样的好处就是,用户能即使看到页面上的UI元素,而防止出现了浏览器白屏等现象。

加载资源带来的阻塞

再来说说加载。

加载是浏览器从网络中请求资源(比如图片、样式文件、静态脚本等),将资源进行相应的处理。

与上述说的两个线程不同,对资源进行加载的线程一般不会和上述两个线程互斥。例如图片资源就可以并行下载,下载的最大并行数量与浏览器的配置有关系。(这里还有一个知识点,下载的最大并行数指的是从一个主机上下载的最大并行数,如果从多个主机下载资源,这个数量会翻倍,但是由于对DNS的解析也是一个性能优化的点,故而一般策略是:不应设置超过4个主机,最好只设置2个主机)。

不会互斥意味着:资源的加载可以和UI渲染、重排,事件响应,或者JavaScript代码的执行的并发进行。

但是操蛋的就是,如果浏览器解析DOM时需要下载脚本资源,那么下载这个资源的线程就是阻塞其他下载线程以及渲染线程,导致渲染速度变慢。

为了解决这个问题,我们依然是将<script>标签放到最后。

但是假设该脚本下载的速度较慢,而且多个脚本非并发下载,并且假如多个<script>内脚本执行时间较长的话,DOM解析工作还是会一直完不成。

故而我们需要无阻塞加载脚本的技术。

无阻塞加载脚本之一——defer

将问题暴露出来之后,我们可以根据其阻塞的原因反向想出解决思路。

我们能否将脚本资源文件像图片一样并发的加载而不是让渲染线程挂起等待?道理上是可以的。之所以要让它阻塞等待,是因为担心JavaScript脚本会修改DOM。所以如果我们对其比较放心的话,是不必让渲染线程等待的。

这就是原生的defer思路:为<script>标签配置一个defer属性(这是个bool属性,配置之后表示为true),这表明我们知道脚本内没有类似document.write的方法,此时标签内的脚本就会并行的加载,并且在DOM解析完毕之后(即document.readyState变为inactive之后和上述的DOMContentLoaded事件触发之前),将脚本执行。注意:不同脚本的执行顺序,是按照不同脚本相应的<script>在HTML中出现的顺序决定的

<script src='..' defer></script>

但是defer在不同浏览器中的支持程度不同。我们目前还不能特别依赖它。

defer的另一个缺点是,下载的脚本,其执行顺序和<script>标签在HTML中出现的顺序是一致的(同步的),并且执行脚本时也会阻塞其他线程(这个无法优化)。

另一个更加没有得到支持的属性是async,它跟defer类似,但是它是异步的。比同步的defer更快一步。我们在这里不讨论async为什么不能被支持的问题,但是我们接下来的技术跟async的步骤是相似的。

无阻塞加载脚本之二——动态脚本元素

所谓的动态脚本元素,就是说<script>标签不是写死在HTML中的,而是由现有的脚本生成的。

为什么可以做到这样呢?因为<script>标签也是DOM元素的一种,而JavaScript是可以通过DOM API操作DOM的。

不同于静态脚本元素的解析,动态脚本元素在下载的时候是不会阻塞渲染线程的,也就是实现了并行下载。

<script>
    
        var node = document.createElement('script');
        
        node.src = '...';
        
        document.head.appendChild(node);
        
 </script>

这个标签可以放到</body>之前。

document.head.appendChild代码之后,由于没有触发渲染树的重绘,切换回的渲染线程会将剩下的DOM解析并渲染完毕。同时新插入的<script>中的资源也会并发的下载。

那么脚本在什么时候执行呢?

答案是<script>中的资源下载完之后会马上执行。但是由于此时DOM已经解析完毕,并且进入异步事件阶段,所以即使切换到JavaScript引擎所在的线程上执行脚本,用户也不会感觉明显的UI阻塞。

由于资源的大小不同,所以这些脚本的执行将会是异步的

由于脚本的异步执行,那么如何解决脚本之间前后依赖的问题呢?我们自然就会想到回调函数。在脚本加载执行完毕后,会触发该<script>元素的onload事件,我们可以将回调放到这个事件中处理。

无阻塞加载脚本之三——XMLHttpRequest

这种方法的局限很明显:无法跨域

但是如果是非跨域的脚本,我们可以使用XMLHttpRequest请求,将脚本放到responseText中,并且将其放到生成的<script>中.

xhr.onreadystatechange = function(){

    if (xhr.readyState == 4){
    
        var node = document.createElement('script');
        
        node.text = xhr.responseText;
        
        document.head.appendChild(node);
    }
}

这种方法同样可以异步的加载并执行脚本。

结束

上述是我自己的微小的见解。如有错误,欢迎指正。


  1. 自信源于犀牛书:《JavaScript权威指南(中文第六版)》326页,13章。
阅读 9.3k

Sam君
Sam君的技术笔记

有一咻咻故事的男同学。

327 声望
22 粉丝
0 条评论

有一咻咻故事的男同学。

327 声望
22 粉丝
文章目录
宣传栏