相比于一年前,现在学习东西喜欢从原理和源码角度去了解新的东西,工作中偶尔看到了 alert函数和dom操作的 执行顺序,查查资料,拷贝,
JavaScript Alert 函数执行顺序问题
前几天使用 JavaScript 写 HTML 页面时遇到了一个奇怪的问题:
我想实现的功能是通过 confirm()
弹窗让用户选择不同的需求,每次选择后都将选择结果暂时输出到页面上,最后一次选择结束后再一次性将选项传到后端处理。 代码类似于:
var step1 = confirm("exec step1?");
$('#result').html($('#result').html() + "\n" + step1);
var step2 = confirm("exec step2?");
$('#result').html($('#result').html() + "\n" + step2);
var step3 = confirm("exec step3?");
$('#result').html($('#result').html() + "\n" + step3);
send(step1, step2, step3);
可是代码运行后却发现:每次在执行完 confirm 函数,用户选择选项之后,页面并没有刷新,step1, step2 的结果没有实时刷新到页面上,而是到最后一步跟 step3 一块显示了出来。
后续尝试了 alert()
和 prompt()
这两个跟 confirm 类似的弹对话框函数,情况都与此相同,它们都会跳过页面渲染先被执行。
此时,还有更诡异的情况,我们给某一个 div 里赋值后,立刻 alert 此 div 里的内容,会发现 alert 显示正确的内容,而 div 里的内容却没有更新,并且会一直阻塞到我们点击确定。
如图:
alert、prompt、confirm 三个函数都类似,接下来我们就用最简单的 alert 来说。
文章欢迎转载,请尊重作者劳动成果,带上原文链接:http://www.cnblogs.com/zhenbianshu/p/8686681.html
分析
解决这个问题之前先了解一下它是怎么导致的,而要了解它需要从 JavaScript 的线程模型说起。
JavaScript 引擎是单线程运行的,浏览器无论在什么时候都只且只有一个线程在运行 JavaScript 程序,初衷是为了减少 DOM 等共享资源的冲突。可是单线程永远会面临着一个问题,那就是某一段代码阻塞会导致后续所有的任务都延迟。又由于 JavaScript 经常需要操作页面 DOM 和发送 HTTP 请求,这些 I/O 操作耗时一般都比较长,一旦阻塞,就会给用户非常差的使用体验。
于是便有了事件循环(event loop)的产生,JavaScript 将一些异步操作或 有I/O 阻塞的操作全都放到一个事件队列,先顺序执行同步 CPU代码,等到 JavaScript 引擎没有同步代码,CPU 空闲下来再读取事件队列的异步事件来依次
执行。
这些事件包括:
setTimeout()
设置的异步延迟事件;- DOM 操作相关如布局和绘制事件;
- 网络 I/O 如 AJAX 请求事件;
- 用户操作事件,如鼠标点击、键盘敲击。
解决
明白了原理, 再解决这个问题就有了方向,我们来分析这个问题:
- 由于页面渲染是 DOM 操作,会被 JavaScript 引擎放入事件队列;
alert()
是 window 的内置函数,被认为是同步 CPU代码;- JavaScript 引擎会优先执行同步代码,alert 弹窗先出现;
- alert 有特殊的阻塞性质,JavaScript 引擎的执行被阻塞住;
- 点击 alert 的“确定”,JavaScript 没有了阻塞,执行完同步代码后,又读取事件队列里的 DOM 操作,页面渲染完成。
由上述原因,导致了诡异的 “Alert执行顺序问题”。 我们无法将页面渲染变成同步操作,那么只好把 alert()
变为异步代码,从而才能在页面渲染之后执行。
对于这个解决方向,我们有两种方法可以使用:
替换 Alert() 函数
首先我们考虑替换掉 alert 函数的功能。其实大多数情况下我们替换掉 alert 并不是它不符合我们期待的执行顺序,而是因为它实在是太丑了,而且也不支持各种美化,可以想像在一个某一特定主题的网站上忽然弹出来一个灰色单调的对话框是多么不和谐。
这个我们可以考虑 Bootstrap 的 modal 模块,Bootstrap 在绝大多数网站上都在应用,而多引入一个 modal 模块也不会有多大影响。我们使用 modal 构造一个弹出对话框的样子,使用 modal 的 modal('toggle')/modal('show')/modal('hide')
方法可以很方便地控制 modal 的显隐。
替换掉对话框后,我们还需要解决后续代码执行的问题。使用 alert 函数时,我们点击确定后代码还会继续执行,而使用我们自定义的对话框可没有这种功能了,需要考虑把后续代码绑定在对话框的点击按钮上,这就需要使用 DOM 的 onclick
属性了,我们将后续函数内容抽出一个新的函数,在弹出对话框后将这个函数绑定在按钮的 onclick 事件上即可。
这里还需要注意,新函数内应该包括关闭 modal 对话框的内容。
当然,我们还可以再优化一下,抽象出来一个用来弹出对话框的函数替代 alert 函数,示例如下:
window.alert = function (message, callbackFunc) {
$('#alertContent').html(message);
$('#modal').show();
$('#confirmButton').onclick(function () {
$('#modal').hide();
callbackFunc();
});
};
如此,我们在需要弹出框时调用新的 alert 函数,并传入 callbackFunc ,在里面做后续的事情就可以了。
setTimeOut函数
当然,并不是所有人都愿意使用新的对话框替换 alert 函数的对话框,总感觉上面的方法不是特别的优雅,对此,我们可以采用另外的方法解决这个问题。
前端的同学应该对 setTimeout()
这个函数不陌生,使用它,可以延迟执行某些代码。而对于延迟执行的代码,JavaScript 引擎总是把这些代码放到事件队列里去,再去检查是否已经到了执行时间,再适时执行。代码进入事件队列,就意味着代码变成和页面渲染事件一样异步了。由于事件队列是有序
的,我们如果用 setTimeout 延时执行,就可以实现在页面渲染之后执行 alert 的功能了。
setTimeout 的函数原型为 setTimeout(code, msec)
,code 是要变为异步的代码或函数,msec 是要延时的时间,单位为毫秒。这里我们不需要它延时,只需要它变为异步就行了,所以可以将 msec 设置为 0;
同样,alert 之后的代码我们也需要处理,将它们跟 alert 一块放到 setTimeout 里异步执行。这样,代码就变为 setTimeout("alert('msg');doSomething();", 0);
,如果觉得代码不够美观或字符串不好处理的话,可以将后续代码封装成一个函数放到 doSomething()
里即可。
小结
在上面的两个解决方案中,都利用了 JavaScript 的回调函数,前者将函数所为 alert 的参数并绑定到 DOM 的 onclick 事件,后者使用 setTimeout 将函数转为异步执行。JavaScript 的回调函数确实非常强大,使用起来也很简单,但是却有一个隐含的问题,就是回调嵌套问题,单层的回调很容易理解,但如果要实现像我的需求一样,有多个 alert 和页面渲染轮流执行的情况,需要面临的可能就是“回调地狱”, onclick 事件绑定里的函数又要嵌套绑定 onclick 函数, setTimeout 里还需要另一个 setTimeout 语句,一旦出现问题,排查起来就比较麻烦了。
前端写得不多,可能对 JavaScript 的理解会有些偏差,文章如有错漏,还请在文章下面评论区指出。对于此问题,如果有大神有更好的解决方案,还请不吝赐教。
首先,请牢记2点:
-
JS是单线程语言
-
JS的Event Loop是JS的执行机制。深入了解JS的执行,就等于深入了解JS里的event loop
1.灵魂三问:JS为什么是单线程的?为什么需要异步?单线程又是如何实现异步的呢?
技术的出现,都跟现实世界里的应用场景密切相关的。同样的,我们就结合现实场景,来回答这三个问题。
(1) JS为什么是单线程的?
JS最初被设计用在浏览器中,那么想象一下,如果浏览器中的JS是多线程的。
场景描述:
那么现在有2个进程,process1 process2,由于是多进程的JS,所以他们对同一个dom,同时进行操作。 process1 删除了该dom,而process2 编辑了该dom,同时下达2个矛盾的命令,浏览器究竟该如何执行呢?
这样想,JS为什么被设计成单线程应该就容易理解了吧。
(2) JS为什么需要异步?
场景描述:
如果JS中不存在异步,只能自上而下执行,如果上一行解析时间很长,那么下面的代码就会被阻塞。 对于用户而言,阻塞就意味着"卡死",这样就导致了很差的用户体验
所以,JS中存在异步执行。
(3) JS单线程又是如何实现异步的呢?
既然JS是单线程的,只能在一条线程上执行,又是如何实现的异步呢?
是通过的事件循环(event loop),理解了event loop机制,就理解了JS的执行机制。
2.JS中的event loop(1)
例1,观察它的执行顺序
-
console.log(1)
-
setTimeout(function(){
-
console.log(2)
-
},0)
-
console.log(3)
运行结果是:1 3 2
也就是说,setTimeout里的函数并没有立即执行,而是延迟了一段时间,满足一定条件后才去执行的,这类代码,我们叫异步代码。
所以,这里我们首先知道了JS里的一种分类方式,就是将任务分为:同步任务和异步任务。
按照这种分类方式:JS的执行机制是:
-
首先判断JS是同步还是异步,同步就进入主进程,异步就进入event table
-
异步任务在event table中注册函数,当满足触发条件后,被推入event queue
-
同步任务进入主线程后一直执行,直到主线程空闲时,才会去event queue中查看是否有可执行的异步任务,如果有就推入主进程中
以上三步循环执行,这就是event loop。
所以上面的例子,你是否可以描述它的执行顺序了呢?
-
console.log(1) 是同步任务,放入主线程里
-
setTimeout() 是异步任务,被放入event table, 0秒之后被推入event queue里
-
console.log(3 是同步任务,放到主线程里
当 1、 3在控制条被打印后,主线程去event queue(事件队列)里查看是否有可执行的函数,执行setTimeout里的函数。
3.JS中的event loop(2)
所以,上面关于event loop就是我对JS执行机制的理解,直到我遇到了下面这段代码。
例2:
-
setTimeout(function(){
-
console.log('定时器开始啦')
-
});
-
new Promise(function(resolve){
-
console.log('马上执行for循环啦');
-
for(var i = 0; i < 10000; i++){
-
i == 99 && resolve();
-
}
-
}).then(function(){
-
console.log('执行then函数啦')
-
});
-
console.log('代码执行结束');
尝试按照,上文我们刚学到的JS执行机制去分析:
-
setTimeout 是异步任务,被放到event table
-
new Promise 是同步任务,被放到主进程里,直接执行打印 console.log('马上执行for循环啦')
-
.then 里的函数是异步任务,被放到event table
-
console.log('代码执行结束') 是同步代码,被放到主进程里,直接执行
所以,结果是:马上执行for循环啦---代码执行结束---定时器开始啦---执行then函数啦
吗?
亲自执行后,结果居然不是这样,而是:马上执行for循环啦---代码执行结束---执行then函数啦---定时器开始啦
那么,难道是异步任务的执行顺序,不是前后顺序,而是另有规定?事实上,按照异步和同步的划分方式,并不准确。
而准确的划分方式是:
-
macro-task(宏任务):包括整体代码script,setTimeout,setInterval
-
micro-task(微任务):Promise,process.nextTick
按照这种分类方式,JS的执行机制是:
-
执行一个宏任务,过程中如果遇到微任务,就将其放到微任务的“事件队列”里
-
当前宏任务执行完成后,会查看微任务的“事件队列”,并将里面全部的微任务依次执行完
-
重复以上2步骤,结合event loop(1) event loop(2),就是更为准确的JS执行机制了
尝试按照刚学的执行机制,去分析例2:
-
首先执行script下的宏任务,遇到setTimeout,将其放到宏任务的“队列”里
-
遇到 new Promise直接执行,打印"马上执行for循环啦"
-
遇到then方法,是微任务,将其放到微任务的“队列”里。
-
打印 "代码执行结束"
-
本轮宏任务执行完毕,查看本轮的微任务,发现有一个then方法里的函数,打印"执行then函数啦"
-
到此,本轮的event loop 全部完成。
-
下一轮的循环里,先执行一个宏任务,发现宏任务的“队列”里有一个setTimeout里的函数,执行打印"定时器开始啦"
所以最后的执行顺序是:马上执行for循环啦---代码执行结束---执行then函数啦---定时器开始啦
4.谈谈setTimeout
这段setTimeout代码什么意思? 我们一般说: 3秒后,会执行setTimeout里的那个函数
-
setTimeout(function(){
-
console.log('执行了')
-
},3000)
但是这种说并不严谨,准确的解释是:3秒后,setTimeout里的函数被会推入event queue,而event queue(事件队列)里的任务,只有在主线程空闲时才会执行。
所以只有满足 (1)3秒后 (2)主线程空闲,同时满足时,才会3秒后执行该函数
如果主线程执行内容很多,执行时间超过3秒,比如执行了10秒,那么这个函数只能10秒后执行了。
浏览器的组成
浏览器的核心是两部分:渲染引擎和JavaScript解释器(又称JavaScript引擎)。
(1)渲染引擎
渲染引擎的主要作用是,将网页从代码“渲染”为用户视觉上可以感知的平面文档。不同的浏览器有不同的渲染引擎。
- Firefox:Gecko引擎
- Safari:WebKit引擎
- Chrome:Blink引擎
- IE: Trident引擎
- Edge: EdgeHTML引擎
渲染引擎处理网页,通常分成四个阶段。
- 解析代码:HTML代码解析为DOM,CSS代码解析为CSSOM(CSS Object Model)
- 对象合成:将DOM和CSSOM合成一棵渲染树(render tree)
- 布局:计算出渲染树的布局(layout)
- 绘制:将渲染树绘制到屏幕
以上四步并非严格按顺序执行,往往第一步还没完成,第二步和第三步就已经开始了。所以,会看到这种情况:网页的HTML代码还没下载完,但浏览器已经显示出内容了。
(2)JavaScript引擎
JavaScript引擎的主要作用是,读取网页中的JavaScript代码,对其处理后运行。
本节主要介绍JavaScript引擎的工作方式。
JavaScript代码嵌入网页的方法
JavaScript代码只有嵌入网页,才能运行。网页中嵌入JavaScript代码有多种方法。
直接添加代码块
通过<script>
标签,可以直接将JavaScript代码嵌入网页。
-
<script>console.log('Hello World');
-
</script>
<script>
标签有一个type
属性,用来指定脚本类型。不过,如果嵌入的是JavaScript脚本,type
属性可以省略。
对JavaScript脚本来说,type
属性可以设为两种值。
text/javascript
:这是默认值,也是历史上一贯设定的值。如果你省略type
属性,默认就是这个值。对于老式浏览器,设为这个值比较好。application/javascript
:对于较新的浏览器,建议设为这个值。
加载外部脚本
script
标签也可以指定加载外部的脚本文件。
<script src="example.js"></script>
如果脚本文件使用了非英语字符,还应该注明编码。
<script charset="utf-8"src="example.js"></script>
加载外部脚本和直接添加代码块,这两种方法不能混用。下面代码的console.log
语句直接被忽略。
-
<script charset="utf-8"src="example.js">console.log('Hello World!');
-
</script>
为了防止攻击者篡改外部脚本,script
标签允许设置一个integrity
属性,写入该外部脚本的Hash签名,用来验证脚本的一致性。
<script src="/assets/application.js"integrity="sha256-TvVUHzSfftWg1rcfL6TIJ0XKEGrgLyEq6lEpcmrG9qs="></script>
上面代码中,script
标签有一个integrity
属性,指定了外部脚本/assets/application.js
的SHA265签名。一旦有人改了这个脚本,导致SHA265签名不匹配,浏览器就会拒绝加载。
除了JavaScript脚本,外部的CSS样式表也可以设置这个属性。
行内代码
除了上面两种方法,HTML语言允许在某些元素的事件属性和a
元素的href
属性中,直接写入JavaScript。
<divonclick="alert('Hello')"></div><ahref="javascript:alert('Hello')"></a>
这种写法将HTML代码与JavaScript代码混写在一起,非常不利于代码管理,不建议使用。
<script>
标签的工作原理
正常的网页加载流程是这样的。
- 浏览器一边下载HTML网页,一边开始解析
- 解析过程中,发现
<script>
标签 - 暂停解析,网页渲染的控制权转交给JavaScript引擎
- 如果
<script>
标签引用了外部脚本,就下载该脚本,否则就直接执行 - 执行完毕,控制权交还渲染引擎,恢复往下解析HTML网页
也就是说,加载外部脚本时,浏览器会暂停页面渲染,等待脚本下载并执行完成后,再继续渲染。原因是JavaScript可以修改DOM(比如使用document.write
方法),所以必须把控制权让给它,否则会导致复杂的线程竞赛的问题。
如果外部脚本加载时间很长(比如一直无法完成下载),就会造成网页长时间失去响应,浏览器就会呈现“假死”状态,这被称为“阻塞效应”。
为了避免这种情况,较好的做法是将<script>
标签都放在页面底部,而不是头部。这样即使遇到脚本失去响应,网页主体的渲染也已经完成了,用户至少可以看到内容,而不是面对一张空白的页面。
如果某些脚本代码非常重要,一定要放在页面头部的话,最好直接将代码嵌入页面,而不是连接外部脚本文件,这样能缩短加载时间。
将脚本文件都放在网页尾部加载,还有一个好处。在DOM结构生成之前就调用DOM,JavaScript会报错,如果脚本都在网页尾部加载,就不存在这个问题,因为这时DOM肯定已经生成了。
-
<head><script>console.log(document.body.innerHTML);
-
</script></head><body></body>
上面代码执行时会报错,因为此时document.body
元素还未生成。
一种解决方法是设定DOMContentLoaded
事件的回调函数。
-
<head><script>
-
document.addEventListener(
-
'DOMContentLoaded',
-
function (event) {
-
console.log(document.body.innerHTML);
-
}
-
);
-
</script></head>
另一种解决方法是,使用<script>
标签的onload
属性。当<script>
标签指定的外部脚本文件下载和解析完成,会触发一个load事件,可以把所需执行的代码,放在这个事件的回调函数里面。
<script src="jquery.min.js"onload="console.log(document.body.innerHTML)"></script>
但是,如果将脚本放在页面底部,就可以完全按照正常的方式写,上面两种方式都不需要。
-
<body><!-- 其他代码 --><script>console.log(document.body.innerHTML);
-
</script></body>
如果有多个script标签,比如下面这样。
<script src="a.js"></script><script src="b.js"></script>
浏览器会同时并行下载a.js
和b.js
,但是,执行时会保证先执行a.js
,然后再执行b.js
,即使后者先下载完成,也是如此。也就是说,脚本的执行顺序由它们在页面中的出现顺序决定,这是为了保证脚本之间的依赖关系不受到破坏。
当然,加载这两个脚本都会产生“阻塞效应”,必须等到它们都加载完成,浏览器才会继续页面渲染。
Gecko和Webkit引擎在网页被阻塞后,会生成第二个线程解析文档,下载外部资源,但是不会修改DOM,网页还是处于阻塞状态。
解析和执行CSS,也会产生阻塞。Firefox会等到脚本前面的所有样式表,都下载并解析完,再执行脚本;Webkit则是一旦发现脚本引用了样式,就会暂停执行脚本执行,等到样式表下载并解析完,再恢复执行。
此外,对于来自同一个域名的资源,比如脚本文件、样式表文件、图片文件等,浏览器一般最多同时下载六个(IE11允许同时下载13个)。如果是来自不同域名的资源,就没有这个限制。所以,通常把静态文件放在不同的域名之下,以加快下载速度。
defer属性
为了解决脚本文件下载阻塞网页渲染的问题,一个方法是加入defer属性。
<script src="1.js"defer></script><script src="2.js"defer></script>
defer
属性的作用是,告诉浏览器,等到DOM加载完成后,再执行指定脚本。
- 浏览器开始解析HTML网页
- 解析过程中,发现带有
defer
属性的script标签 - 浏览器继续往下解析HTML网页,同时并行下载script标签中的外部脚本
- 浏览器完成解析HTML网页,此时再执行下载的脚本
有了defer
属性,浏览器下载脚本文件的时候,不会阻塞页面渲染。下载的脚本文件在DOMContentLoaded
事件触发前执行(即刚刚读取完</html>
标签),而且可以保证执行顺序就是它们在页面上出现的顺序。
对于内置而不是连接外部脚本的script标签,以及动态生成的script标签,defer
属性不起作用。
async属性
解决“阻塞效应”的另一个方法是加入async
属性。
<script src="1.js"async></script><script src="2.js"async></script>
async
属性的作用是,使用另一个进程下载脚本,下载时不会阻塞渲染。
- 浏览器开始解析HTML网页
- 解析过程中,发现带有
async
属性的script
标签 - 浏览器继续往下解析HTML网页,同时并行下载
script
标签中的外部脚本 - 脚本下载完成,浏览器暂停解析HTML网页,开始执行下载的脚本
- 脚本执行完毕,浏览器恢复解析HTML网页
async
属性可以保证脚本下载的同时,浏览器继续渲染。需要注意的是,一旦采用这个属性,就无法保证脚本的执行顺序。哪个脚本先下载结束,就先执行那个脚本。另外,使用async
属性的脚本文件中,不应该使用document.write
方法。
defer
属性和async
属性到底应该使用哪一个?
一般来说,如果脚本之间没有依赖关系,就使用async
属性,如果脚本之间有依赖关系,就使用defer
属性。如果同时使用async
和defer
属性,后者不起作用,浏览器行为由async
属性决定。
重流和重绘
渲染树转换为网页布局,称为“布局流”(flow);布局显示到页面的这个过程,称为“绘制”(paint)。它们都具有阻塞效应,并且会耗费很多时间和计算资源。
页面生成以后,脚本操作和样式表操作,都会触发重流(reflow)和重绘(repaint)。用户的互动,也会触发,比如设置了鼠标悬停(a:hover
)效果、页面滚动、在输入框中输入文本、改变窗口大小等等。
重流和重绘并不一定一起发生,重流必然导致重绘,重绘不一定需要重流。比如改变元素颜色,只会导致重绘,而不会导致重流;改变元素的布局,则会导致重绘和重流。
大多数情况下,浏览器会智能判断,将重流和重绘只限制到相关的子树上面,最小化所耗费的代价,而不会全局重新生成网页。
作为开发者,应该尽量设法降低重绘的次数和成本。比如,尽量不要变动高层的DOM元素,而以底层DOM元素的变动代替;再比如,重绘table
布局和flex
布局,开销都会比较大。
-
varfoo= document.getElementById('foobar');
-
foo.style.color='blue';
-
foo.style.marginTop='30px';
上面的代码只会导致一次重绘,因为浏览器会累积DOM变动,然后一次性执行。
下面是一些优化技巧。
- 读取DOM或者写入DOM,尽量写在一起,不要混杂
- 缓存DOM信息
- 不要一项一项地改变样式,而是使用CSS class一次性改变样式
- 使用document fragment操作DOM
- 动画时使用absolute定位或fixed定位,这样可以减少对其他元素的影响
- 只在必要时才显示元素
- 使用
window.requestAnimationFrame()
,因为它可以把代码推迟到下一次重流时执行,而不是立即要求页面重流 - 使用虚拟DOM(virtual DOM)库
下面是一个window.requestAnimationFrame()
对比效果的例子。
-
// 重绘代价高functiondoubleHeight(element) {
-
varcurrentHeight=element.clientHeight;
-
element.style.height= (currentHeight*2) +'px';
-
}
-
all_my_elements.forEach(doubleHeight);
-
// 重绘代价低functiondoubleHeight(element) {
-
varcurrentHeight=element.clientHeight;
-
window.requestAnimationFrame(function () {
-
element.style.height= (currentHeight*2) +'px';
-
});
-
}
-
all_my_elements.forEach(doubleHeight);
脚本的动态嵌入
除了用静态的script
标签,还可以动态嵌入script
标签。
-
['1.js', '2.js'].forEach(function(src) {
-
varscript= document.createElement('script');
-
script.src=src;
-
document.head.appendChild(script);
-
});
这种方法的好处是,动态生成的script
标签不会阻塞页面渲染,也就不会造成浏览器假死。但是问题在于,这种方法无法保证脚本的执行顺序,哪个脚本文件先下载完成,就先执行哪个。
如果想避免这个问题,可以设置async属性为false
。
-
['1.js', '2.js'].forEach(function(src) {
-
varscript= document.createElement('script');
-
script.src=src;
-
script.async=false;
-
document.head.appendChild(script);
-
});
上面的代码依然不会阻塞页面渲染,而且可以保证2.js
在1.js
后面执行。不过需要注意的是,在这段代码后面加载的脚本文件,会因此都等待2.js
执行完成后再执行。
我们可以把上面的写法,封装成一个函数。
-
(function() {
-
varscripts= document.getElementsByTagName('script')[0];
-
functionload(url) {
-
varscript= document.createElement('script');
-
script.async=true;
-
script.src=url;
-
scripts.parentNode.insertBefore(script, scripts);
-
}
-
load('//apis.google.com/js/plusone.js');
-
load('//platform.twitter.com/widgets.js');
-
load('//s.thirdpartywidget.com/widget.js');
-
}());
上面代码中,async
属性设为true
,是因为加载的脚本没有互相依赖关系。而且,这样就不会造成堵塞。
此外,动态嵌入还有一个地方需要注意。动态嵌入必须等待CSS文件加载完成后,才会去下载外部脚本文件。静态加载就不存在这个问题,script
标签指定的外部脚本文件,都是与CSS文件同时并发下载的。
加载使用的协议
如果不指定协议,浏览器默认采用HTTP协议下载。
<script src="example.js"></script>
上面的example.js
默认就是采用HTTP协议下载,如果要采用HTTPS协议下载,必需写明(假定服务器支持)。
<script src="https://example.js"></script>
但是有时我们会希望,根据页面本身的协议来决定加载协议,这时可以采用下面的写法。
<script src="//example.js"></script>
JavaScript虚拟机
JavaScript是一种解释型语言,也就是说,它不需要编译,可以由解释器实时运行。这样的好处是运行和修改都比较方便,刷新页面就可以重新解释;缺点是每次运行都要调用解释器,系统开销较大,运行速度慢于编译型语言。为了提高运行速度,目前的浏览器都将JavaScript进行一定程度的编译,生成类似字节码(bytecode)的中间代码,以提高运行速度。
早期,浏览器内部对JavaScript的处理过程如下:
- 读取代码,进行词法分析(Lexical analysis),将代码分解成词元(token)。
- 对词元进行语法分析(parsing),将代码整理成“语法树”(syntax tree)。
- 使用“翻译器”(translator),将代码转为字节码(bytecode)。
- 使用“字节码解释器”(bytecode interpreter),将字节码转为机器码。
逐行解释将字节码转为机器码,是很低效的。为了提高运行速度,现代浏览器改为采用“即时编译”(Just In Time compiler,缩写JIT),即字节码只在运行时编译,用到哪一行就编译哪一行,并且把编译结果缓存(inline cache)。通常,一个程序被经常用到的,只是其中一小部分代码,有了缓存的编译结果,整个程序的运行速度就会显著提升。
不同的浏览器有不同的编译策略。有的浏览器只编译最经常用到的部分,比如循环的部分;有的浏览器索性省略了字节码的翻译步骤,直接编译成机器码,比如chrome浏览器的V8引擎。
字节码不能直接运行,而是运行在一个虚拟机(Virtual Machine)之上,一般也把虚拟机称为JavaScript引擎。因为JavaScript运行时未必有字节码,所以JavaScript虚拟机并不完全基于字节码,而是部分基于源码,即只要有可能,就通过JIT(just in time)编译器直接把源码编译成机器码运行,省略字节码步骤。这一点与其他采用虚拟机(比如Java)的语言不尽相同。这样做的目的,是为了尽可能地优化代码、提高性能。下面是目前最常见的一些JavaScript虚拟机:
- Chakra(Microsoft Internet Explorer)
- Nitro/JavaScript Core (Safari)
- Carakan (Opera)
- SpiderMonkey (Firefox)
- V8 (Chrome, Chromium)
单线程模型
含义
首先,明确一个观念:JavaScript只在一个线程上运行,不代表JavaScript引擎只有一个线程。事实上,JavaScript引擎有多个线程,其中单个脚本只能在一个线程上运行,其他线程都是在后台配合。JavaScript脚本在一个线程里运行。这意味着,一次只能运行一个任务,其他任务都必须在后面排队等待。
JavaScript之所以采用单线程,而不是多线程,跟历史有关系。JavaScript从诞生起就是单线程,原因是不想让浏览器变得太复杂,因为多线程需要共享资源、且有可能修改彼此的运行结果,对于一种网页脚本语言来说,这就太复杂了。比如,假定JavaScript同时有两个线程,一个线程在某个DOM节点上添加内容,另一个线程删除了这个节点,这时浏览器应该以哪个线程为准?所以,为了避免复杂性,从一诞生,JavaScript就是单线程,这已经成了这门语言的核心特征,将来也不会改变。
为了利用多核CPU的计算能力,HTML5提出Web Worker标准,允许JavaScript脚本创建多个线程,但是子线程完全受主线程控制,且不得操作DOM。所以,这个新标准并没有改变JavaScript单线程的本质。
单线程模型带来了一些问题,主要是新的任务被加在队列的尾部,只有前面的所有任务运行结束,才会轮到它执行。如果有一个任务特别耗时,后面的任务都会停在那里等待,造成浏览器失去响应,又称“假死”。为了避免“假死”,当某个操作在一定时间后仍无法结束,浏览器就会跳出提示框,询问用户是否要强行停止脚本运行。
如果排队是因为计算量大,CPU忙不过来,倒也算了,但是很多时候CPU是闲着的,因为IO设备(输入输出设备)很慢(比如Ajax操作从网络读取数据),不得不等着结果出来,再往下执行。JavaScript语言的设计者意识到,这时CPU完全可以不管IO设备,挂起处于等待中的任务,先运行排在后面的任务。等到IO设备返回了结果,再回过头,把挂起的任务继续执行下去。这种机制就是JavaScript内部采用的Event Loop。
消息队列
JavaScript运行时,除了一根运行线程,系统还提供一个消息队列(message queue),里面是各种需要当前程序处理的消息。新的消息进入队列的时候,会自动排在队列的尾端。
运行线程只要发现消息队列不为空,就会取出排在第一位的那个消息,执行它对应的回调函数。等到执行完,再取出排在第二位的消息,不断循环,直到消息队列变空为止。
每条消息与一个回调函数相联系,也就是说,程序只要收到这条消息,就会执行对应的函数。另一方面,进入消息队列的消息,必须有对应的回调函数。否则这个消息就会遗失,不会进入消息队列。举例来说,鼠标点击就会产生一条消息,报告click
事件发生了。如果没有回调函数,这个消息就遗失了。如果有回调函数,这个消息进入消息队列。等到程序收到这个消息,就会执行click事件的回调函数。
另一种情况是setTimeout
会在指定时间向消息队列添加一条消息。如果消息队列之中,此时没有其他消息,这条消息会立即得到处理;否则,这条消息会不得不等到其他消息处理完,才会得到处理。因此,setTimeout
指定的执行时间,只是一个最早可能发生的时间,并不能保证一定会在那个时间发生。
一旦当前执行栈空了,消息队列就会取出排在第一位的那条消息,传入程序。程序开始执行对应的回调函数,等到执行完,再处理下一条消息。
Event Loop
所谓Event Loop,指的是一种内部循环,用来一轮又一轮地处理消息队列之中的消息,即执行对应的回调函数。Wikipedia的定义是:“Event Loop是一个程序结构,用于等待和发送消息和事件(a programming construct that waits for and dispatches events or messages in a program)”。可以就把Event Loop理解成动态更新的消息队列本身。
下面是一些常见的JavaScript任务。
- 执行JavaScript代码
- 对用户的输入(包含鼠标点击、键盘输入等等)做出反应
- 处理异步的网络请求
所有任务可以分成两种,一种是同步任务(synchronous),另一种是异步任务(asynchronous)。同步任务指的是,在JavaScript执行进程上排队执行的任务,只有前一个任务执行完毕,才能执行后一个任务;异步任务指的是,不进入JavaScript执行进程、而进入“任务队列”(task queue)的任务,只有“任务队列”通知主进程,某个异步任务可以执行了,该任务(采用回调函数的形式)才会进入JavaScript进程执行。
以Ajax操作为例,它可以当作同步任务处理,也可以当作异步任务处理,由开发者决定。如果是同步任务,主线程就等着Ajax操作返回结果,再往下执行;如果是异步任务,该任务直接进入“任务队列”,JavaScript进程跳过Ajax操作,直接往下执行,等到Ajax操作有了结果,JavaScript进程再执行对应的回调函数。
也就是说,虽然JavaScript只有一根进程用来执行,但是并行的还有其他进程(比如,处理定时器的进程、处理用户输入的进程、处理网络通信的进程等等)。这些进程通过向任务队列添加任务,实现与JavaScript进程通信。
想要理解Event Loop,就要从程序的运行模式讲起。运行以后的程序叫做“进程”(process),一般情况下,一个进程一次只能执行一个任务。如果有很多任务需要执行,不外乎三种解决方法。
-
排队。因为一个进程一次只能执行一个任务,只好等前面的任务执行完了,再执行后面的任务。
-
新建进程。使用fork命令,为每个任务新建一个进程。
-
新建线程。因为进程太耗费资源,所以如今的程序往往允许一个进程包含多个线程,由线程去完成任务。
如果某个任务很耗时,比如涉及很多I/O(输入/输出)操作,那么线程的运行大概是下面的样子。
上图的绿色部分是程序的运行时间,红色部分是等待时间。可以看到,由于I/O操作很慢,所以这个线程的大部分运行时间都在空等I/O操作的返回结果。这种运行方式称为”同步模式”(synchronous I/O)。
如果采用多线程,同时运行多个任务,那很可能就是下面这样。
上图表明,多线程不仅占用多倍的系统资源,也闲置多倍的资源,这显然不合理。
上图主线程的绿色部分,还是表示运行时间,而橙色部分表示空闲时间。每当遇到I/O的时候,主线程就让Event Loop线程去通知相应的I/O程序,然后接着往后运行,所以不存在红色的等待时间。等到I/O程序完成操作,Event Loop线程再把结果返回主线程。主线程就调用事先设定的回调函数,完成整个任务。
可以看到,由于多出了橙色的空闲时间,所以主线程得以运行更多的任务,这就提高了效率。这种运行方式称为”异步模式“(asynchronous I/O)。
这正是JavaScript语言的运行方式。单线程模型虽然对JavaScript构成了很大的限制,但也因此使它具备了其他语言不具备的优势。如果部署得好,JavaScript程序是不会出现堵塞的,这就是为什么node.js平台可以用很少的资源,应付大流量访问的原因。
如果有大量的异步任务(实际情况就是这样),它们会在“消息队列”中产生大量的消息。这些消息排成队,等候进入主线程。本质上,“消息队列”就是一个“先进先出”的数据结构。比如,点击鼠标就产生一系列消息(各种事件),mousedown
事件排在mouseup
事件前面,mouseup
事件又排在click
事件的前面。
参考链接
- John Dalziel, The race for speed part 2: How JavaScript compilers work
- Jake Archibald,Deep dive into the murky waters of script loading
- Mozilla Developer Network, window.setTimeout
- Remy Sharp, Throttling function calls
- Ayman Farhat, An alternative to Javascript’s evil setInterval
- Ilya Grigorik, Script-injected “async scripts” considered harmful
- Axel Rauschmayer, ECMAScript 6 promises (1/2): foundations
- Daniel Imms, async vs defer attributes
- Craig Buckler, Load Non-blocking JavaScript with HTML5 Async and Defer
- Domenico De Felice, How browsers work