你真的懂异步编程吗?

为什么要学习异步编程?

在JS 代码中,异步无处不在,Ajax通信,Node中的文件读写等等等,只有搞清楚异步编程的原理和概念,才能在JS的世界中任意驰骋,随便撒欢;

单线程 JavaScript 异步方案

首先我们需要了解,JavaScript 代码的运行是单线程,采用单线程模式工作的原因也很简单,最早就是在页面中实现 Dom 操作,如果采用多线程,就会造成复杂的线程同步问题,如果一个线程修改了某个元素,另一个线程又删除了这个元素,浏览器渲染就会出现问题;
单线程的含义就是: JS执行环境中负责执行代码的线程只有一个;就类似于只有一个人干活;一次只能做一个任务,有多个任务自然是要排队的;
优点:安全,简单
缺点:遇到任务量大的操作,会阻塞,后面的任务会长时间等待,出现假死的情况;


image-20201224170055928.gif

为了解决阻塞的问题,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')

动画形式展现 同步代码 的执行过程:

image-20201224190320238.gif

代码会按照既定的语法规则,依次执行,如果中间遇到大量复杂任务,后面的代码则会阻塞等待;

再来看一段异步代码:

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')

异步代码的执行,要相对复杂一些:


image-20201224190320240.gif

代码首先按照同步模式执行,当遇到异步代码时,会开启异步执行线程,在上面的代码中,setTimeout 会开启环境运行时的执行线程运行相关代码,代码运行结束后,会将结果放入到消息队列,等待 JS 线程结束后,消息队列的任务再依次执行;

流程图如下:


clipboard.png
回调函数

通过上图,我们会看到,在整个代码的执行中,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
Snipaste_2020-11-20_14-00-99.gif

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);

代码运行结果:


image-20201230193712942.png

带着 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')

运行结果:


image-20201230193446596.png

是不是恍然大明白呢……

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 158,560评论 4 361
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 67,104评论 1 291
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 108,297评论 0 243
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 43,869评论 0 204
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 52,275评论 3 287
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 40,563评论 1 216
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 31,833评论 2 312
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 30,543评论 0 197
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 34,245评论 1 241
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 30,512评论 2 244
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 32,011评论 1 258
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 28,359评论 2 253
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 33,006评论 3 235
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 26,062评论 0 8
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 26,825评论 0 194
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 35,590评论 2 273
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 35,501评论 2 268

推荐阅读更多精彩内容