为什么要学习异步编程?
在JS 代码中,异步无处不在,Ajax通信,Node中的文件读写等等等,只有搞清楚异步编程的原理和概念,才能在JS的世界中任意驰骋,随便撒欢;
单线程 JavaScript 异步方案
首先我们需要了解,JavaScript 代码的运行是单线程,采用单线程模式工作的原因也很简单,最早就是在页面中实现 Dom 操作,如果采用多线程,就会造成复杂的线程同步问题,如果一个线程修改了某个元素,另一个线程又删除了这个元素,浏览器渲染就会出现问题;
单线程的含义就是: JS执行环境中负责执行代码的线程只有一个;就类似于只有一个人干活;一次只能做一个任务,有多个任务自然是要排队的;
优点:安全,简单
缺点:遇到任务量大的操作,会阻塞,后面的任务会长时间等待,出现假死的情况;
为了解决阻塞的问题,Javascript 将任务的执行模式分成了两种,同步模式( Synchronous)和 异步模式( Asynchronous)
后面我们将分以下几个内容,来详细讲解 JavaScript 的同步与异步:
1、同步模式与异步模式
2、事件循环与消息队列
3、异步编程的几种方式
4、Promise 异步方案、宏任务/微任务队列
5、Generator 异步方案、 Async / Await语法糖
同步与异步
代码依次执行,后面的任务需要等待前面任务执行结束后,才会执行,同步并不是同时执行,而是排队执行;
先来看一段代码:
console.log('global begin')
function bar () {
console.log('bar task')
}
function foo () {
console.log('foo task')
bar()
}
foo()
console.log('global end')
动画形式展现 同步代码 的执行过程:
代码会按照既定的语法规则,依次执行,如果中间遇到大量复杂任务,后面的代码则会阻塞等待;
再来看一段异步代码:
console.log('global begin')
setTimeout(function timer1 () {
console.log('timer1 invoke')
}, 1800)
setTimeout(function timer2 () {
console.log('timer2 invoke')
setTimeout(function inner () {
console.log('inner invoke')
}, 1000)
}, 1000)
console.log('global end')
异步代码的执行,要相对复杂一些:
代码首先按照同步模式执行程,在上面的代码中,setTimeout 会开启环境运行时的执行线程运行相关代码,代码运行结束后,会将结果放入到消息队列,等待 JS 线程结束后,消息队列的任务再依次执行;
流程图如下:
回调函数
通过上图,我们会看到,在整个代码的执行中,JS 本身的执行依然是单线程的,异步执行的最终结果,依然需要回到 JS 线程上进行处理,在JS中,异步的结果 回到 JS 主线程 的方式采用的是 “ 回调函数 ” 的形式 , 所谓的 回调函数 就是在 JS 主线程上声明一个函数,然后将函数作为参数传入异步调用线程,当异步执行结束后,调用这个函数,将结果以实参的形式传入函数的调用(也有可能不传参,但是函数调用一定会有),前面代码中 setTimeout 就是一个异步方法,传入的第一个参数就是 回调函数,这个函数的执行就是消息队列中的 “回调”;
下面我们自己封装一个 ajax 请求,来进一步说明回调函数与异步的关系
Ajax 的异步请求封装
function myAjax(url,callback) {
var xhr = new XMLHttpRequest();
xhr.onreadystatechange = function () {
if (this.readyState == 4) {
if (this.status == 200) {
// 成功的回调
callback(null,this.responseText)
} else {
// 失败的回调
callback(new Error(),null);
}
}
}
xhr.open('get', url)
xhr.send();
}
上面的代码,封装了一个 myAjax 的函数,用于发送异步的 ajax 请求,函数调用时,代码实际是按照同步模式执行的,当执行到 xhr.send() 时,就会开启异步的网络请求,向指定的 url 地址发送网络请求,从建立网络链接到断开网络连接的整个过程是异步线程在执行的;换个说法就是 myAjax 函数执行到 xhr.send() 后,函数的调用执行就已经结束了,如果 myAjax 函数调用的后面有代码,则会继续执行,不会等待 ajax 的请求结果;
但是,myAjax 函数调用结束后,ajax 的网络请求却依然在进行着,如果想要获取到 ajax 网络请求的结果,我们就需要在结果返回后,调用一个 JS 线程的函数,将结果以实参的形式传入:
myAjax('./d1.json',function(err,data){
console.log(data);
})
回调函数让我们轻松处理异步的结果,但是,如果代码是异步执行的,而逻辑是同步的; 就会出现 “回调地狱”,举个栗子:
代码B需要等待代码A执行结束才能执行,而代码C又需要等待代码B,代码D又需要等待代码C,而代码 A、B、C都是异步执行的;
// 回调函数 回调地狱
myAjax('./d1.json',function(err,data){
console.log(data);
if(!err){
myAjax('./d2.json',function(err,data){
console.log(data);
if(!err){
myAjax('./d3.json',function(){
console.log(data);
})
}
})
}
})
没错,代码执行是异步的,但是异步的结果,是需要有强前后顺序的,著名的"回调地狱"就是这么诞生的;
相对来说,代码逻辑是固定的,但是,这个编码体验,要差很多,尤其在后期维护的时候,层级嵌套太深,让人头皮发麻;
如何让我们的代码不在地狱中受苦呢?
有请 Promise 出山,拯救程序员的头发;
Promise
Promise 译为 承诺、许诺、希望,意思就是异步任务交给我来做,一定(承诺、许诺)给你个结果;在执行的过程中,Promise 的状态会修改为 pending ,一旦有了结果,就会再次更改状态,异步执行成功的状态是 Fulfilled , 这就是承诺给你的结果,状态修改后,会调用成功的回调函数 onFulfilled 来将异步结果返回;异步执行成功的状态是 Rejected, 这就是承诺给你的结果,然后调用 onRejected 说明失败的原因(异常接管);
将前面对 ajax 函数的封装,改为 Promise 的方式;
Promise 重构 Ajax 的异步请求封装
function myAjax(url) {
return new Promise(function (resolve, reject) {
var xhr = new XMLHttpRequest();
xhr.onreadystatechange = function () {
if (this.readyState == 4) {
if (this.status == 200) {
// 成功的回调
resolve(this.responseText)
} else {
// 失败的回调
reject(new Error());
}
}
}
xhr.open('get', url)
xhr.send();
})
}
还是前面提到的逻辑,如果返回的结果中,又有 ajax 请求需要发送,可一定记得使用链式调用,不要在then中直接发起下一次请求,否则,又是地狱见了:
// ==== Promise 误区====
myAjax('./d1.json').then(data=>{
console.log(data);
myAjax('./d2.json').then(data=>{
console.log(data)
// ……回调地狱……
})
})
链式的意思就是在上一次 then 中,返回下一次调用的 Promise 对象,我们的代码,就不会进地狱了;
myAjax('./d1.json')
.then(data=>{
console.log(data);
return myAjax('./d2.json')
})
.then(data=>{
console.log(data)
return myAjax('./d3.json')
})
.then(data=>{
console.log(data);
})
.catch(err=>{
console.log(err);
})
虽然我们脱离了回调地狱,但是 .then 的链式调用依然不太友好,频繁的 .then 并不符合自然的运行逻辑,Promise 的写法只是回调函数的改进,使用then方法以后,异步任务的两段执行看得更清楚了,除此以外,并无新意。Promise 的最大问题是代码冗余,原来的任务被 Promise 包装了一下,不管什么操作,一眼看去都是一堆 then,原来的语义变得很不清楚。于是,在 Promise 的基础上,Async 函数来了;
终极异步解决方案,千呼万唤的在 ES2017中发布了;
Async/Await 语法糖
Async 函数使用起来,也是很简单,将调用异步的逻辑全部写进一个函数中,函数前面使用 async 关键字,在函数中异步调用逻辑的前面使用 await ,异步调用会在 await 的地方等待结果,然后进入下一行代码的执行,这就保证了,代码的后续逻辑,可以等待异步的 ajax 调用结果了,而代码看起来的执行逻辑,和同步代码几乎一样;
async function callAjax(){
var a = await myAjax('./d1.json')
console.log(a);
var b = await myAjax('./d2.json');
console.log(b)
var c = await myAjax('./d3.json');
console.log(c)
}
callAjax();
注意:await 关键词 只能在 async 函数内部使用
因为使用简单,很多人也不会探究其使用的原理,无非就是两个 单词,加到前面,用就好了,虽然会用,日常开发看起来也没什么问题,但是一遇到 Bug 调试,就凉凉,面试的时候也总是知其然不知其所以然,咱们先来一个面试题试试,你看你能运行出正确的结果吗?
async 面试题
请写出以下代码的运行结果:
setTimeout(function () {
console.log('setTimeout')
}, 0)
async function async1() {
console.log('async1 start')
await async2();
console.log('async1 end')
}
async function async2() {
console.log('async2')
}
console.log('script start')
async1();
console.log('script end')
答案我放在最后面,你也可以自己写出来运行一下;
想要把结果搞清楚,我们需要引入另一个内容:Generator 生成器函数;
Generator 生成器函数,返回 遍历器对象,先看一段代码:
Generator 基础用法
function * foo(){
console.log('test');
// 暂停执行并向外返回值
yield 'yyy'; // 调用 next 后,返回对象值
console.log(33);
}
// 调用函数 不会立即执行,返回 生成器对象
const generator = foo();
// 调用 next 方法,才会 *开始* 执行
// 返回 包含 yield 内容的对象
const yieldData = generator.next();
console.log(yieldData) //=> {value: "yyy", done: false}
// 对象中 done ,表示生成器是否已经执行完毕
// 函数中的代码并没有执行结束
// 下一次的 next 方法调用,会从前面函数的 yeild 后的代码开始执行
console.log(generator.next()); //=> {value: undefined, done: true}
你会发现,在函数声明的地方,函数名前面多了 * 星号,函数体中的代码有个 yield ,用于函数执行的暂停;简单点说就是,这个函数不是个普通函数,调用后不会立即执行全部代码,而是在执行到 yield 的地方暂停函数的执行,并给调用者返回一个遍历器对象,yield 后面的数据,就是遍历器对象的 value 属性值,如果要继续执行后面的代码,需要使用 遍历器对象中的 next() 方法,代码会从上一次暂停的地方继续往下执行;
是不是so easy 啊;
同时,在调用next 的时候,还可以传递参数,函数中上一次停止的 yeild 就会接受到当前传入的参数;
function * foo(){
console.log('test');
// 下次 next 调用传参接受
const res = yield 'yyy';
console.log(res);
}
const generator = foo();
// next 传值
const yieldData = generator.next();
console.log(yieldData)
// 下次 next 调用传参,可以在 yield 接受返回值
generator.next('test123');
Generator 的最大特点就是让函数的运行,可以暂停,不要小看他,有了这个暂停,我们能做的事情就太多,在调用异步代码时,就可以先 yield 停一下,停下来我们就可以等待异步的结果了;那么如何把 Generator 写到异步中呢?
Generator 异步方案
将调用ajax的代码写到 生成器函数的 yield 后面,每次的异步执行,都要在 yield 中暂停,调用的返回结果是一个 Promise 对象,我们可以从 迭代器对象的 value 属性获取到Promise 对象,然后使用 .then 进行链式调用处理异步结果,结果处理的代码叫做 执行器,就是具体负责运行逻辑的代码;
function ajax(url) {
……
}
// 声明一个生成器函数
function * fun(){
yield myAjax('./d1.json')
yield myAjax('./d2.json')
yield myAjax('./d3.json')
}
// 返回 遍历器对象
var f = fun();
// 生成器函数的执行器
// 调用 next 方法,执行异步代码
var g = f.next();
g.value.then(data=>{
console.log(data);
// console.log(f.next());
g = f.next();
g.value.then(data=>{
console.log(data)
// g.......
})
})
而执行器的逻辑中,是相同嵌套的,因此可以写成递归的方式对执行器进行改造:
// 声明一个生成器函数
function * fun(){
yield myAjax('./d1.json')
yield myAjax('./d2.json')
yield myAjax('./d3.json')
}
// 返回 遍历器对象
var f = fun();
// 递归方式 封装
// 生成器函数的执行器
function handle(res){
if(res.done) return;
res.value.then(data=>{
console.log(data)
handle(f.next())
})
}
handle(f.next());
然后,再将执行的逻辑,进行封装复用,形成独立的函数模块;
function co(fun) {
// 返回 遍历器对象
var f = fun();
// 递归方式 封装
// 生成器函数的执行器
function handle(res) {
if (res.done) return;
res.value.then(data => {
console.log(data)
handle(f.next())
})
}
handle(f.next());
}
co(fun);
封装完成后,我们再使用时,只需要关注 Generator 中的 yield 部分就行了
function co(fun) {
……
}
function * fun(){
yield myAjax('./d1.json')
yield myAjax('./d2.json')
yield myAjax('./d3.json')
}
此时你会发现,使用 Generator 封装后,异步的调用就变的非常简单了,但是,这个封装还是有点麻烦,有大神帮我们做了这个封装,相当强大:https://github.com/tj/co ,感兴趣看一研究一下,而随着 JS 语言的发展,更多的人希望类似 co 模块的封装,能够写进语言标准中,我们直接使用这个语法规则就行了;
其实你也可以对比一下,使用 co 模块后的 Generator 和 async 这两段代码:
// async / await
async function callAjax(){
var a = await myAjax('./d1.json')
console.log(a);
var b = await myAjax('./d2.json');
console.log(b)
var c = await myAjax('./d3.json');
console.log(c)
}
// 使用 co 模块后的 Generator
function * fun(){
yield myAjax('./d1.json')
yield myAjax('./d2.json')
yield myAjax('./d3.json')
}
你应该也发现了,async 函数就是 Generator 语法糖,不需要自己再去实现 co 执行器函数或者安装 co 模块,写法上将 * 星号 去掉换成放在函数前面的 async ,把函数体的 yield 去掉,换成 await; 完美……
async function callAjax(){
var a = await myAjax('./d1.json')
console.log(a);
var b = await myAjax('./d2.json');
console.log(b)
var c = await myAjax('./d3.json');
console.log(c)
}
callAjax();
我们再来看一下 Generator ,相信下面的代码,你能很轻松的阅读;
function * f1(){
console.log(11)
yield 2;
console.log('333')
yield 4;
console.log('555')
}
var g = f1();
g.next();
console.log(666);
g.next();
console.log(777);
带着 Generator 的思路,我们再回头看看那个 async 的面试题;
请写出以下代码的运行结果:
setTimeout(function () {
console.log('setTimeout')
}, 0)
async function async1() {
console.log('async1 start')
await async2();
console.log('async1 end')
}
async function async2() {
console.log('async2')
}
console.log('script start')
async1();
console.log('script end')
是不是恍然大明白呢……