这篇文章是我看完《JavaScript异步编程》之后结合书中内容和现在掌握的知识记录下来的。
首先要认识JavaScript是单线程语言,可以利用事件模型处理异步触发任务。如果只有两三个可能的事件,单线程语言编写的面向事件的代码要比多线程代码简单得多。但如果有很多事件,同时要求数据的状态能够从一个事件传递到下一个事件,那么就会像下面这样:
step1(function(result1){
step2(function(result2){
step3(function(result3){
//...
})
})
})
这被称为『回调地狱』,很明显是不能让人忍受的。
JavaScript 运行机制 和 Event Loop
想要让 JavaScript 中的某段代码将来再运行,可以将它放在回调中。回调就是就是一个普通函数,运行回调时,我们称已触发某事件。
setTimeout
对 setTimeout 的描述通常是:
给定一个回调及n毫秒的延迟,setTimeout 就会在 n 毫秒后运行该回调。
那么看一个例子:
for(var i=1;i<=3;i++){
setTimeout(function(){
console.log(console.log(i))
},0)
}
返回的是3个4,因为:
-
i
变量的作用域在 setTimeout 的回调函数内 - 循环结束时,
i
等于4 - JavaScript事件处理器在线程空闲之前不会运行
第3条很重要也很难理解,但只要我们了解 线程的阻塞 就明白了。
思考下面的代码:
var start = new Date()
setTimeout(function(){
var end = new Date()
console.log(end - start)
},500)
while(new Date - start<1000){
}
因为 while 循环会持续一秒,线程繁忙,结果是大于等于 1000 的数字,可能会稍有不同,这是因为 setTimeout
很不精准。不过,这个数字至少是1000,因为setTimeout回调在while循环结束之前不可能被触发。
之所以会这样,全是因为 任务队列 的存在。
任务队列 task queue
因为JavaScript是单线程的,所有任务需要排队,前一个任务结束,才会执行后一个任务。如果前一个任务耗时长,后一个任务就不得不一直等着。
很多时候我们可以先挂起等待中的任务,先运行排在后面的任务,等之前的任务有了结果,再把挂起的任务继续执行。
所以,任务分成了两种:
- 同步任务:在主线程中排队依次执行的任务
- 异步任务:不进入主线程,进入任务队列的任务。只有任务队列通知主线程某异步任务可以执行,该任务才会进入主线程执行。
异步任务通常可以分为两大类:I/O 函数(AJAX、readFile等)和计时函数(setTimeout、setInterval)
其实所谓的『回调函数』,就是那些被主线程挂起来的代码。异步任务必须指定回调函数,当主线程开始执行异步任务,就是执行对应的回调函数。
- 所有同步任务都在主线程上执行,形成一个执行栈(execution context stack)。
- 主线程之外,还存在一个"任务队列"(task queue)。只要异步任务有了运行结果,就在"任务队列"之中放置一个事件。
- 一旦"执行栈"中的所有同步任务执行完毕,系统就会读取"任务队列",看看里面有哪些事件。那些对应的异步任务,于是结束等待状态,进入执行栈,开始执行。
- 主线程不断重复上面的第三步。
主线程和任务队列示意图:
主线程和任务队列示意图只要主线程空了,就会去读取"任务队列",这就是JavaScript的运行机制。这个过程会不断重复。
// 点击按钮 5s 后才会生效
var start = new Date()
btn.onclick = function(){
console.log('click')
}
while(new Date - start<5000){
}
以上面代码为例,用户点击 btn 时,会有一个 click 事件排入队列。但是,该单击事件处理器要等到当前所有正在运行的代码都结束后(可能还要等其他此前已排队的事件也以次结束)才会执行。
事件循环 Event Loop
主线程从『任务队列』中读取事件,这个过程是循环不断的,所以这个运行机制又称为 事件循环 (Event Loop)
Event Loop上图中,主线程运行的时候,产生堆(heap)和栈(stack),栈中的代码调用各种外部API,它们在"任务队列"中加入各种事件(click,load,done)。只要栈中的代码执行完毕,主线程就会去读取"任务队列",依次执行那些事件所对应的回调函数。
参考文章
事件模型 - 发布 / 订阅模式
上面介绍了 JavaScript 的运行机制,现在说说 JavaScript 的事件在实际中的处理。JavaScript 中的事件模型是典型的 发布/订阅模式。
jQuery 的 on / trigger 就是一个具体实现的例子。
如果一个事件的回调里有很多任务要完成,比如登录网站成功后要更新header模块的头像、导航模块的头像、刷新消息列表:
login.succ(function(data){
header.setAvatar(data.avatar)
nav.setAvatar(data.avatar)
message.refresh()
})
这样模块之间耦合严重,header模块不能再更改 setAvatar 方法。header模块要重构的话,势必也会影响这里。最好是这样:与用户登录相关的业务模块订阅登录成功的事件,登录模块只要发布订阅成功的信息。这样,登录成功后,它们进行各自的业务处理,两不相干。
$.ajax('/login',function(data){ //登录成功
login.trigger('loginSucc',data) //发布登录成功消息
})
var header = (function(){
login.listen('loginSucc',function(data){
header.setAvatar(data.avatar)
})
return {
setAvatar: function(data){
//...
}
}
})()
这样,就算要在登录成功后增加新功能,登录模块也不用改,只要新加模块就行:
var xxx = (function(){
login.listen('loginSucc',function(data){
xxx.abc(data)
})
return {
abc: function(data){
//...
}
}
})()
参考文章
Promise
Promise 是一个管理事务的对象。
Promise有三个状态,Pending(进行中)、Resolved(已完成)
、Rejected(已失败)。只有异步操作的结果才决定当前是哪一种状态。
状态一旦改变,就不会再变,任何时候可以得到这个结果。只有两个结果:Pending -> Resolved
和 Pending -> Rejected
。
具体可以看 Promise
异步 API
这几个是JavaScript异步编程的API
异步脚本加载
defer - 脚本延迟运行
<script>
的 defer
属性,相当于告诉浏览器:马上加载这个脚本,但是,等到文档就绪且此前具有 defer
属性的脚本都结束运行之后再运行它。
简单来说,加了 defer
之后 <script>
放在 <head>
或 <body>
是没有区别的。
async - 脚本的并行化
<script async src="a.js"></script>
<script async src="b.js"></script>
这两个脚本会以任意次序运行,而且会立即运行,不论文档是否就绪。
如果同时使用 defer
和 async
,async
会覆盖掉 defer
。
<html>
<head>
<script src="a.js"></script>
<script defer src="b.js"></script>
</head>
<body>
<!-- content -->
<script async defer src="c.js"></script>
<script async defer src="d.js"></script>
</body>
</html>
在大多数浏览器中,a.js
结束运行时,DOM开始渲染。在渲染的同时,加载 b.js
。渲染结束时,运行 b.js
、c.js
和 d.js
。其中,c.js
和 d.js
会无序运行。
网友评论