目录
- JavaScript的异步机制
- JS是异步也是同步
- JavaScript的运行机制
- 事件循环
- 任务队列
- 回调函数
- 顺序执行
- 不可信任的第三方
- Promise
- Promise解决了信任问题
- Promise链式调用
- Promise 的局限性
JavaScript的异步机制
程序中现在运行的部分和将来运行的部分之间的关系就是异步编程的核心。
JavaScript程序总是至少分为两个块:第一个块现在运行;下一个块将来运行,以响应某个事件。所有块共享作用域和状态的访问。任何时候,只要把代码包装成一个函数,并指定它在响应某个事件(定时器、鼠标点击、Ajax响应等)时执行,你就在代码中创建了一个将来执行的块,由此引入了异步机制。
JavaScript是单线程运行的,而并行是能够同时发生的事情。单线程事件循环是并发的一种形式,JavaScript中的并发实质上是指两个或多个事件链随着时间发展交替执行,不停地进行上下文切换,就像是同时在运行(在任意时刻只处理一个事件)。
所以说,JavaScript中的异步也是同步!
JavaScript的运行机制
JavaScript的运行宿主环境提供了一种机制,来处理程序中多个块的执行,执行每个块时调用JavaScript引擎,这种机制称为事件循环
环境总是通过调度“事件”(JS代码执行)来运行。
举个例子:发送一个Ajax请求
通过设置好回调函数,JS引擎会通知宿主环境:“嘿,现在我要暂停执行,你一旦完成了网络请求,拿到数据,就请调用这个函数。”浏览器做的就是拿到数据后,将回调函数插入到事件循环中,实现调度。
事件循环
事件循环的每一轮称为一个tick,在每一轮执行的过程中,如果队列中有等待事件,就会执行队列中的下一个事件,并在恰当的时间将等待事件插入事件循环队列中。
setTimeout(…)函数真正做的事是,设定一个定时器,当定时器时间到后,环境会把你的回调函数放在时间循环中,在未来的某个时刻的tick就会执行这个回调。
用户交互、IO和定时器会向事件队列中加入事件。
协作
利用JS的事件循环机制,可以有效提高某些站点的响应性能,比如批处理大数据文件等。
批处理的原理是,将任务分割成多个步骤或多批任务,将这些任务的运算插入到事件循环队列中交替运行。
var res = []
var CHUNK_COUNT = 1000
function response(data){
// 一次处理CHUNK_COUNT个
var chunk = data.splice(0, CHUNK_COUNT);
res = res.concat(chunk.map(val => val *2))
// 还有剩下的需要处理吗?
if(data.length > 0){
// 异步调度下一次批处理
setTimeout(function(){
response(data)
},0)
}
}
ajax(url1, response)
ajax(url2, response)
这里的setTimeout(…0)就是把这个函数插入到当前事件队列的结尾处。让其他代码的执行和数据处理交替进行,有效的避免了阻塞,提升了性能。
任务队列
任务队列挂在事件循环队列的每个tick的最后。
在每个tick执行中,可能出现的异步动作(Promise)不会导致一个新事件添加到事件循环队列中,而是在当前tick的最后添加一个任务执行。
一个任务可能引起更多的任务添加到同一个队列末尾!
假如用schedule来调度一个任务
console.log("A")
setTimeout(() => console.log("B"))
schedule(() => {
console.log("C")
schedule(() => console.log("D"))
})
正确的输出:A C D B
来个更实际的栗子
console.log('A. 同步任务')
new Promise(resolve => {
//这里的内容会被立即执行
console.log('B. promise')
resolve()
// 调用 resolve 或 reject 并不会终结 Promise 的参数函数的执行
console.log("这里会被打印")
}).then(
// 这里的内容会在每次循环tick的最末尾执行,不在事件队列中,而是在任务队列中
() => {
setTimeout(function () {
console.log("H. setTimeout ...")
}, 0)
// 还是在当前tick末尾追加任务队列中
new Promise(resolve => {
console.log('D. promise')
resolve()
}).then(() => {
console.log('F. promise then..')
})
console.log('E. promise then...')
})
setTimeout(() => {
console.log('G. setTimeout ...')
}, 0)
console.log('C. 同步任务')
最后的输出也正是按照: A B C D E F G H
的顺序。
回调函数
回调(callback)—— 回头调用。
回调是编写和处理JavaScript程序异步逻辑的最常用的方式。回调函数是JavaScript异步的基本单元,但是随着JS的成熟,回调对于异步已经不够用了。
回调嵌套和缩进本身就是一个问题——“回调地狱”。但真正意义上的地狱却是回调中的信任问题。回调机制,通常都是把自己程序一部分的执行控制交给某个第三方,也称为“控制反转”,在你和第三方工具之间有一份并没有明确表达的契约。这才是回调最大的问题!
// A
ajax("..", function(){
// C
})
// B
ajax(…)就是第三方库控制。
能够真正信任的第三方真的有吗?我们需要在程序中写各种逻辑来解决控制反转导致的信任问题。比如:
- 调用过早(在追踪之前)
- 调用过晚(或者没有调用)
- 调用次数太少或者太多
- 没有把所需的参数成功传给你的回调函数
- 吞掉可能出现的错误或异常
- …
写一些特定的逻辑来解决特定的信任问题,难度本身高于应用功能本身了,这是非常低效的,重复的代码导致代码膨胀,显得更笨重,难以维护!
总结,使用回调处理异步程序,存在以下方面的缺陷:
- 不符合大脑对任务步骤的规划方式
- 控制反转导致的不可信任和不可组合
接下来,我们谈谈比回调更好的机制——Promise
Promise
Promise旨在将“控制反转”再反转回来。第三方不再直接调用回调函数,而是希望第三方给我们提供了解其任务何时结束(resolve或者reject)的能力。
new Promise((resolve, reject) => {
resolve() // 完成或者拒绝
//reject() // 拒绝情况
}).then(
// 完成情况调用
res => res,
// 拒绝情况调用
err => console.log(err)
)
Promise的决议结果有两种,完成和拒绝。Promise一旦决议,他就永远保持这个状态不变。Promise的决议结果传递给then(…),它有两个参数来响应完成和拒绝这两种情况。
由此可见,由Promise封装后的第三方工具,可以在任务完成时,通过resolve或者reject来告知程序,异步任务已经处理完成,从而将控制权再次交还给程序,由程序自身调用then里面的回调函数。从而完成了控制反转再反转。
new Promise()中传入的函数会立即执行
new Promise(funciton(){
// 立即执行
})
Promise解决了信任问题
Promise也解决了回调带来的一些信任问题。
调用过早
即使是立即完成的Promise也无法被同步发现。如 new Promise(resolve => resolve(42))
对一个Promise调用then(…)的时候,即使这个Promise已经决议,then中的回调也总是被异步调用。立即 resolved 的 Promise 是在本轮事件循环的末尾执行,总是晚于本轮循环的同步任务。
"A"
new Promise(resolve => resolve(42)).then(res=>{
console.log(res)
})
"B"
A B 42
调用过晚
Promise创建对象调用resolve(…)或reject(…)时,这个Promise的then(…)注册的回调函数就会被自动调用。一旦被决议,回调函数就会被调用,不会延迟一个回调的发生。
var p = new Promise(resolve => {
resolve("完成")
})
p.then(() => {
p.then(()=> {
console.log("C")
})
console.log("A")
})
p.then(() => {
console.log("B")
})
打印结果: A B C
两个独立的Promise上链接的回调相对顺序无法可靠的预测。好的编码实践方案根本不会让多个回调的顺序相互间有影响。
ajax('url1', callback1)
ajax('url2', callback2)
回调未调用
没有任何东西能阻止Promise向你通知它的决议。一旦决议,它总会调用其中一个回调。
调用次数过多
Promise的调用方式决定了它只能被决议一次。
Promise创建代码视图调用resolve或者reject多次,则只会接受第一次决议,忽略后续的调用。
如果同一个回调注册了不止一次(如 p.then(…), p.then(…)),那么调用次数就和注册次数相同。
未能传递参数/环境值
如果未传递,那么这个值默认为undefined。
吞掉错误或者异常
在Promise的任何时间点上出现一个异常错误,都会被捕捉,并使这个Promise被拒绝。
除了then(, errorHandler)第二个参数处理错误函数外,Promise链的一个最佳实战就是最后总以一个catch()结束。
p.then()
.then(..)
...
.catch(erroHandler)
可信任的Promise
Promise并没有完全摆脱回调,只是改变了传递回调的位置。为什么比单纯使用回调更值得信任呢?
ES6的解决方案是 Promise.resolve().通过这个方法可以将非Promise、非thenable的立即值转换成一个promise。
Promise链式调用
Promise链提供以顺序的方式表达异步流的一个更好的方法。
每次对Promise调用then(…),它都会创建并返回一个新的Promise,第一个then(…)回调的返回值就是被链接Promise中回调的参数值。
Promise链不仅是一个表达多步异步序列的流程控制,还是一个从一个步骤到下一个步骤传递消息的消息通道。
来看一个例子:
var p = Promise.resolve(21)
p.then(res => {
console.log("A")
return new Promise(resolve => {
setTimeout(() => {
console.log("B")
resolve(res * 2)
},100)
}).then(() => {
console.log("C")
})
}).then(res => {
console.log(res)
})
A B C undefined
如何判断变量是Promise类型——具有then方法的鸭子类型
if(p !== null &&
(typeof p === "object" || typeof p === 'function') &&
typeof p.then === 'function'
){
// p是一个promise
}