JS异步编程
JS采用单线程模式的原因
最早的js就是运行在浏览器端的脚本语言,目的就是为了实现页面的动态交互,实现页面交互的核心就是DOM操作,这就决定了它必须使用单线程模式,否则就会出现很复杂的线程同步问题。
为什么呢?假如说js是多线程的,也就是操作DOM编程了多线程并发执行,当一个线程对DOM元素做了修改,而同事的另一个并发线程对同一个DOM元素也做出了修改,最终浏览器是听谁的呢?显然很不合理。
单线程决定了在js执行环境中负责执行代码的线程只有一个,如果有多个任务,就只能排队一个任务一个任务的执行。那么问题又来了,如果有一个任务是非常耗费时间的,那后面的任务就只能在队列中等着,出现假死的情况。为了解决这个问题,js将任务的执行模式分成了同步模式和异步模式
内容概要
- 同步模式和异步模式
- 事件循环与消息队列
- 异步编程的几种方式
Promise
异步方案、宏任务/微任务队列Generator
异步方案、Async/Await
语法糖
同步模式
程序阻塞执行,只有上一个任务执行完成才回去执行后面的任务。
异步模式
非堵塞执行程序,后面的任务不会等待上一个任务执行结束才执行,而是当上一个任务开启之后就会立即去执行后续的任务。而单线程的js是如何处理上一个任务的呢?一般是通过回调函数的方式定义后续的程序执行方式,具体如何请往下看。
异步模式给单线程的js提供了一种解决大量耗时任务占用线程时间过长的思路。
大家都知道用回调函数的方式,会造成回调的层层嵌套,即回调地狱,导致代码非常的难以维护,js也提供了很多解决回调地狱的方法。
下面是一个异步程序在浏览器中执行的过程案例:
首先大体了解一下这块代码,代码中首尾两个console.log,中间显示一个延时器函数,然后接着又是一个延时器函数,在第二个延时器函数里面又嵌套了一个延时器。下面来解释这块代码是如何执行的。
- 首先,将一个匿名的函数调用压入调用栈(Call stack)中;
- 对于
console.log()
这种同步执行的api,直接将其压入调用栈,紧接着执行完之后弹出栈,并在执行的过程中在控制台输出‘global begin’; - 然后就会执行到第一个延时器函数,首先还是先将
setTimeout
压入调用栈,这个函数内部的api是以异步的方式执行的,调用函数里面的异步api就会在Web APIs中添加一个倒计时器,并把这个倒计时器放到一边,这个倒计时器是在一个单独的专门执行异步线程中,在这个倒计时器放进去之后就开始倒计时了,而且他会被扔在一旁并不影响js线程的正常执行。 - 当第一个延时器被放到另一个计时器线程中之后,程序会立即继续向下执行,开始第二个延时器的执行,和第一个延时器一样,在Web APIs中添加一个倒计时器,程序继续向下执行。
- 将
console.log
压入调用栈,在控制台打印‘global end’。 - 当执行完最后一步
console.log
,调用栈中就会空闲,当Web APIs中的倒计时器执行倒计时完成的时候,就会将回调函数添加到消息队列中(Queue),事件环(Event loop)会同时的监听调用栈和消息队列,当调用栈空闲并且消息队列中又待处理的消息的时候,事件环就会将消息队列中的第一个消息交给调用栈去处理。这里的第一个延时器显然要晚于第二个延时器,所以第二个延时器先倒计时完之后先将第二个定时器的回调函数添加到消息队列中等待执行,等到事件环监听到调用栈空闲的时候就会将消息队列中的第一条消息交给调用栈,也就是第二个定时器的回调开始执行,打印‘time2 invoke’。 - 和第二个延时器一样,第一个延时器的回调也会添加一个延时器到专门倒计时的线程中倒计时。
- 当第一个延时器倒计时完成,进行和第二个延时器相同的操作之后打印‘teimer1 invoke’.
- 然后第二个延时器中的延时器也会进行相同的操作之后打印‘inner invoke’。
这里有一个疑惑就是,为什么会存在除了js线程之外的另一个线程。
其实js确实是单线程的,但是浏览器是多线程的,而且通过js调用的某些浏览器的api也不是单线程的,例如延时器api,浏览器里面会有一个单独用于倒计时的线程,专门用来倒计时,等到倒计时完成的时候将倒计时器的回调函数添加到消息队列。
再次强调一下,js就是单线程,它的程序只能在js那一个线程中执行,只不过对于浏览器的一些用js调用的api会在浏览器中单独的一个线程单独执行。
我们说的同步模式和异步模式也是相对于运行环境提供的API而言的。
回调函数
由调用者定义,由被调用者执行。
Promise
直接使用传统的回调方式去玩长城复杂的异步流程,就会出现很多回调函数嵌套的问题,造成回调地狱。
为了避免回调地狱,CommonJS社区提出了Promise的规范,为异步编程提供一种更加合理的编码规范。并在后来在ES2015中被标准化,成为语言规范。
Promise
其实就是一个对象,用于表示一个任务执行完之后是成功还是失败。有三种状态,Pending
、Fullfilled
、Rejected
。当状态有pending
变更之后,不管是成功或者是失败都会有对应的任务去根据最终的状态进行相应的处理程序。
特点:
对象的状态不受外界影响。Promise
对象代表一个异步操作,有三种状态:pending
(进行中)、fulfilled
(已成功)和rejected
(已失败)。只要异步操作的结果可以决定当前是哪一种状态,任何其他操作都无法改变这个状态。这也是Promise这个名字的由来。
一旦状态改变,就不会再变,任何时候都是可以得到这个结果的。Promise
对象的状态改变只有两种可能:*从pending
变为fulfilled
和从pending
变为rejected
。只要这两种情况发生,状态就会凝固,不会再变了。再对Promise
对象添加回调函数也会立即得到这个结果。有了Promise
对象,就可以将异步操作以同步操作的流程表达出来。
缺点:
首先无法取消Promise
,一旦新建他就会立即执行,无法中途取消。其次,如果不设置回调函数,Promise
内部跑出的错误无法反应到外部。当pending
的时候,无法知道进展到了哪一步。
基本用法
ES6规定,Promise
对象是一个构造函数,用来生成Promise
实例。
下面代码创造了一个Promise
实例。
const promise = new Promise(function(resolve, reject) {
if(success) {
resolve(value)
}else{
reject(error)
}
})
Promise
构造函数接受一个函数作为参数,该函数的两个参数分别是resolve
和reject
。它们是两个函数,由JavaScript
引擎提供,不用自己部署。
resolve
函数的作用是,将Promise
对象的状态从"未完成"变成"成功"。(即从pending
变为resolved
)。在异步操作成功的时候调用,并将异步操作结果作为参数传递出去;
reject
函数的作用是,将promise
对象的状态从"未完成"变成"失败"(即从pending
变为rejected
)。在异步操作失败时调用,并将异步操作报出的错误,作为参数传递出去。
Promise
实例生成后,可以用then
方法分别指定resolve
状态和rejected
状态的回调函数。
promise.then(function(value) {},function(error){})
then
方法可以接受两个回调函数作为参数,
第一个回调函数是promise
对象的状态变为resolved
的时候调用,
第二个回调函数是promise
对象的状态变为rejected
时调用。
其中第二个函数是可选的,不一定需要提供。
这两个函数都接受Promise
对象传出的值作为参数。
function timeout(ms) {
return new Promise((resolve, reject) => {
setTimeout(resolve, ms, 'done')//setTimeout 传参
})
}
timeout(100).then((value) => {
console.log(value)//done
})
上面代码中,timeout
方法返回一个Promise
实例,表示一段时间后才会发生的结果。
过了指定的时间以后,Promise
实例的状态变为resolved
,就会触发then
方法绑定的回调函数。
Promise
新建后就会立即执行。
Promise使用案例
使用Promise封装ajax请求
function ajax(url){
return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest()
xhr.open('GET', url)
xhr.responseType = 'json'
xhr.onload = function(){
if(this.status === 200){
resolve(this.response)
}else{
reject(new Error(this.statusText))
}
}
xhr.send()
})
}
ajax('data/dists.json').then(function(res){
console.log(res)
},function(error){
console.error(error)
})
Promise的常见误区
嵌套使用的方式是使用Promise最常见的误区
ajax('data/user.json').then(function(res){
ajax(res.data.distUrl).then(function(res){
console.log(res)
}, function(error){
console.log(error)
})
}, function(error){
console.log(error)
})
上面的代码可以看见,照样用到了回调嵌套的方式,只能说使用Promise多此一举,增加了难度又没解决问题。
正确方式应该是借助于Promise then方法链式调用的特点,保证异步任务的扁平化。
Promise链式调用
ajax('data/user.json').then(function(res){
return ajax(res.data.distUrl)
}, function(error){
console.log(error)
}).then(function(res){
console.log(res)
}, function(error){
console.log(error)
})
- Promise对象的
then
方法会默认返回一个全新的Promise对象。 - 后面的
then
方法就是为上一个then
返回的Promise注册回调。 - 前面
then
方法回调函数的返回值会作为后面then
方法回调的参数,如果没有返回参数值为undefined
。 - 如果回调中返回的是一个新的Promise,那后面的
then
方法的回调会等待它的结束。
Promise异常回调
我们知道当Promise的结果一旦失败就会调用在then
方法传入的onRejected
回调。这个失败可能是Promise里面的执行程序报错也有可能是手动抛出异常。
除了使用then的第二个参数作为onRejected
回调之外还可以使用.cath()
链式回调。
ajax('data/user.json').then(function(res){
// return ajax(res.data.distUrl)
throw error('hahaha')
}).then(function(res){
console.log(res)
}).then(undefined,function(error){
console.log(error)
})
/*上面的代码就等同于下面的代码,即.catch()的原理就有了呗*/
ajax('data/user.json').then(function(res){
// return ajax(res.data.distUrl)
throw error('hahaha')
}).then(function(res){
console.log(res)
}).catch(function(error){
console.log(error)
})
由上面的代码可以知道,其实.catch()
虽然可以作为Promise失败的回调,但是实际上,它是他前面的.then()
返回的Promise的失败的回调。因为这是在同一个Promise的链条上面,第一个Promise的异常状态会一直往下传递。所以在.catch()
中才能捕获到第一个Promise中的错误。
Promise上面的异常会一直向下传递直到被捕获
再补充一个我的小想法
ajax('data/user.json').then(function(res){
// return ajax(res.data.distUrl)
throw error('hahaha')
}).then(function(res){
console.log(res)
}).catch(function(error){
console.log(error) // error
return 1
}).then(function(res){
console.log(res) // 1
})
当.catch()
捕获到前面的异常并成功执行完处理程序并且没有再发生异常的时候,就会触发下一个.then()
。
unhandledrejection
我们可以在全局对象上注册一个unhandledrejection
事件,去处理那些代码中没有被手动捕获的Promise异常。
//在浏览器中
window.addEventListener('unhandledrejection', event => {
const{ reason, promise } = event
console.log(reason, promise)
// reason => Promise失败原因,一般是一个错误对象
// promise => 出现异常的Promise对象
event.preventDefault()
})
//在node环境中
process.on('unhandledRejection', (reason, promise) => {
console.log(reason, promise)
// reason => Promise失败原因,一般是一个错误对象
// promise => 出现异常的Promise对象
})
有于这种方式要在全局注册事件,会给后期的更新迭代增加维护成本,所以不推荐使用。
最好的做法就是在代码中明确捕获每一个可能的异常。
Promise静态方法
Promise.resolve()
直接返回一个状态为fullfilled
的Promise对象。
Promise.reject()
直接返回一个状态为rejected
的Promise对象。
Promise并行执行Promise.all()
接收一个数组作为参数,这个数组的每一个元素都是一个Promise对象,当所以的Promise对象的状态确定了之后,返回一个全新的Promise对象,如果传入的Promise对象至少有一个为假,返回的Promise对象则为假,如果传入的Promise全都为真,返回的新的Promise对象则为真,其resolve
传入的参数为一个数组,数组中是每一个Promise的resolve
参数。
Promise并行执行Promise.race()
和Promise.all()
不同的是,Promise.race()
只要有一个Promise对象的状态凝固就会立即返回一个新的Promise对象;Promise.all()
必须所有的Promise参数对象的状态都凝固才会返回。
利用Promise.race()
可以对异步任务进行超时处理。
Promise执行实行顺序及宏任务和微任务
一篇非常好的关于宏任务和微任务的文章
console.log('begin')
setTimeout(function(){
console.log('timeout end')
}, 0)
Promise.resolve("Promise end").then(res => {
console.log(res)
})
console.log('end')
// begin
// end
// Promise end
// timeout end
上述这段代码,timeout
异步和Promise异步都会直接执行其成功的回调,但是最终打印的结果确实Promise end先于timeout
打印。为什么呢?先了解两个概念,宏任务和微任务。
(敲黑板)对于延时为0的timeout
会立即将回调函数添加到消息队列中
(敲黑板)对于内部全是同步程序的Promise,也会作为异步程序将其成功的回调添加到消息队列中等待执行
- 宏任务
(macro)task,可以理解是每次执行栈执行的代码就是一个宏任务(包括每次从事件队列中获取一个事件回调并放到执行栈中执行)
浏览器为了能够使得JS内部(macro)task与DOM任务能够有序的执行,会在一个(macro)task执行结束后,在下一个(macro)task 执行开始前,对页面进行重新渲染,流程如下:
(macro)task->渲染->(macro)task->...
宏任务包含:
script(整体代码)
setTimeout
setInterval
I/O
UI交互事件
postMessage
MessageChannel
setImmediate(Node.js 环境)
- 微任务
microtask,可以理解是在当前 task 执行结束后立即执行的任务。也就是说,在当前task任务后,下一个task之前,在渲染之前。
所以它的响应速度相比setTimeout(setTimeout是task)会更快,因为无需等渲染。也就是说,在某一个macrotask执行完后,就会将在它执行期间产生的所有microtask都执行完毕(在渲染前)。
微任务包含:
Promise.then
Object.observe
MutaionObserver
process.nextTick(Node.js 环境)
宏任务和微任务会大大的提高程序的执行效率。
Genetor
其实Promise虽然使用链式调用回调的方法解决了回调嵌套的问题,但是其还是需要then一级一级的往后链式回调,还有那么一丢丢的不符合常规同步代码的习惯。所以Genetor就来了。
我们期望的异步代码其实是这样的:
虽然期望这样,但是应用ajax同步请求显然不可能,得不偿失。
为了让我们的异步编程更简洁,更加易读,就有了Genetor(生成器函数)。
简单回顾一下Genetor
function * G(){
console.log('begin')
const a = yield 100
console.log('a,'+a)
}
const generator = G() // 调用函数返回生成器对象
const result = generator.next() //启动genetor
console.log(result) //{ value: 100, done: false }
const result2 = generator.next(2) // a,2
console.log(result2) //{ value: undefined, done:true }
function * G(){
console.log('begin')
//可以通过try catch来捕获genetor抛出的错误
try {
const a = yield 100
console.log('a'+a)
} catch(e) {
console.log(e)
}
}
const generator = G() // 调用函数返回生成器对象
const result = generator.next() // 启动
console.log(result) // { value: 100, done: false }
generator.throw(new Error('error'))
Genetor异步方案
一种Genetor和Promise配合使用的方案
function * G(){
const res = yield ajax('data/dist.json')
console.log(res)
const res1 = yield ajax('data/user.json')
console.log(res1)
}
const generator = G()
const result = generator.next()
result.value.then(res => {
const result1 = generator.next(res)
if(result1.done) return
result1.value.then(res => {
const result2 = generator.next(res)
if(result2.done) return
result2.value.then(res => {
const result2 = generator.next(res)
})
})
})
配合递归
function * G(){
try {
const res = yield ajax('data/dist.json')
console.log(res)
const res1 = yield ajax('data/user.json')
console.log(res1)
const res2 = yield ajax('data/url.json')
console.log(res2)
} catch(e) {
console.log(e)
}
}
co(G)
//生成器函数的执行器,可以作为一个公共函数
function co(G){
const generator = G()
const result = generator.next()
function handleResult(generator,result){
if(result.done) return
result.value.then(res => {
const result = generator.next(res)
handleResult(generator,result)
}).catch(error => {
//异常处理
generator.throw(error)
})
}
handleResult(generator,result)
}
社区中有一个更加完善的生成器函数执行器库
有了async/await之后这种方案其实已经不再流行了
Async/Await语法糖(ES2017)
这其实就是在语言层面给开发者提供了一种Genetor使用的语法糖。
所以上面的genetor稍微修改就有了
async function G(){
try {
const res = await ajax('data/dist.json')
console.log(res)
const res1 = await ajax('data/user.json')
console.log(res1)
const res2 = await ajax('data/url.json')
console.log(res2)
} catch(e) {
console.log(e)
}
}
G()
语言层面的标准异步编程语法
这……省去了Genentor函数执行器,就问你香不香?
除此之外,async
函数还会返回一个Promise对象,更加利于我们对整体的代码进行控制。
var promise = G()
promise.then(res => {
// console.log(res) // undefined
console.log("all completed")
})
await关键字 必须在async中使用