前往小程序,Get更优阅读体验!
立即前往
首页
学习
活动
专区
工具
TVP
发布
社区首页 >专栏 >你真的懂异步编程吗?

你真的懂异步编程吗?

原创
作者头像
西岭老湿
修改2021-01-12 17:59:27
8090
修改2021-01-12 17:59:27
举报
文章被收录于专栏:西岭老湿西岭老湿

为什么要学习异步编程?

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

单线程 JavaScript 异步方案

首先我们需要了解,JavaScript 代码的运行是单线程,采用单线程模式工作的原因也很简单,最早就是在页面中实现 Dom 操作,如果采用多线程,就会造成复杂的线程同步问题,如果一个线程修改了某个元素,另一个线程又删除了这个元素,浏览器渲染就会出现问题;

单线程的含义就是: JS执行环境中负责执行代码的线程只有一个;就类似于只有一个人干活;一次只能做一个任务,有多个任务自然是要排队的;

优点:安全,简单

缺点:遇到任务量大的操作,会阻塞,后面的任务会长时间等待,出现假死的情况;

image-20201224170055928.gif
image-20201224170055928.gif

为了解决阻塞的问题,Javascript 将任务的执行模式分成了两种,同步模式( Synchronous)和 异步模式( Asynchronous)

后面我们将分以下几个内容,来详细讲解 JavaScript 的同步与异步:

1、同步模式与异步模式

2、事件循环与消息队列

3、异步编程的几种方式

4、Promise 异步方案、宏任务/微任务队列

5、Generator 异步方案、 Async / Await语法糖

同步与异步

代码依次执行,后面的任务需要等待前面任务执行结束后,才会执行,同步并不是同时执行,而是排队执行;

先来看一段代码:

代码语言:txt
复制
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
image-20201224190320238.gif

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

再来看一段异步代码:

代码语言:txt
复制
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
image-20201224190320240.gif

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

流程图如下:

clipboard.png
clipboard.png

回调函数

通过上图,我们会看到,在整个代码的执行中,JS 本身的执行依然是单线程的,异步执行的最终结果,依然需要回到 JS 线程上进行处理,在JS中,异步的结果 回到 JS 主线程 的方式采用的是 “ 回调函数 ” 的形式 , 所谓的 回调函数 就是在 JS 主线程上声明一个函数,然后将函数作为参数传入异步调用线程,当异步执行结束后,调用这个函数,将结果以实参的形式传入函数的调用(也有可能不传参,但是函数调用一定会有),前面代码中 setTimeout 就是一个异步方法,传入的第一个参数就是 回调函数,这个函数的执行就是消息队列中的 “回调”;

下面我们自己封装一个 ajax 请求,来进一步说明回调函数与异步的关系

Ajax 的异步请求封装

代码语言:txt
复制
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 线程的函数,将结果以实参的形式传入:

代码语言:txt
复制
myAjax('./d1.json',function(err,data){
    console.log(data);
})

回调函数让我们轻松处理异步的结果,但是,如果代码是异步执行的,而逻辑是同步的; 就会出现 “回调地狱”,举个栗子:

代码B需要等待代码A执行结束才能执行,而代码C又需要等待代码B,代码D又需要等待代码C,而代码 A、B、C都是异步执行的;

代码语言:txt
复制
// 回调函数 回调地狱 
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
Snipaste_2020-11-20_14-00-99.gif

Promise 译为 承诺、许诺、希望,意思就是异步任务交给我来做,一定(承诺、许诺)给你个结果;在执行的过程中,Promise 的状态会修改为 pending ,一旦有了结果,就会再次更改状态,异步执行成功的状态是 Fulfilled , 这就是承诺给你的结果,状态修改后,会调用成功的回调函数 onFulfilled 来将异步结果返回;异步执行成功的状态是 Rejected, 这就是承诺给你的结果,然后调用 onRejected 说明失败的原因(异常接管);

将前面对 ajax 函数的封装,改为 Promise 的方式;

Promise 重构 Ajax 的异步请求封装

代码语言:txt
复制
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中直接发起下一次请求,否则,又是地狱见了:

代码语言:txt
复制
//  ==== Promise 误区====
myAjax('./d1.json').then(data=>{
    console.log(data);
    myAjax('./d2.json').then(data=>{
        console.log(data)
        // ……回调地狱……
    })
})

链式的意思就是在上一次 then 中,返回下一次调用的 Promise 对象,我们的代码,就不会进地狱了;

代码语言:txt
复制
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 调用结果了,而代码看起来的执行逻辑,和同步代码几乎一样;

代码语言:txt
复制
 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 面试题

请写出以下代码的运行结果:

代码语言:txt
复制
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 基础用法

代码语言:txt
复制
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 就会接受到当前传入的参数;

代码语言:txt
复制
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 进行链式调用处理异步结果,结果处理的代码叫做 执行器,就是具体负责运行逻辑的代码;

代码语言:txt
复制
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.......
    })
})

而执行器的逻辑中,是相同嵌套的,因此可以写成递归的方式对执行器进行改造:

代码语言:txt
复制
// 声明一个生成器函数
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());

然后,再将执行的逻辑,进行封装复用,形成独立的函数模块;

代码语言:txt
复制
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 部分就行了

代码语言:txt
复制
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 这两段代码:

代码语言:txt
复制
//  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; 完美……

代码语言:txt
复制
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 ,相信下面的代码,你能很轻松的阅读;

代码语言:txt
复制
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
image-20201230193712942.png

带着 Generator 的思路,我们再回头看看那个 async 的面试题;

请写出以下代码的运行结果:

代码语言:txt
复制
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
image-20201230193446596.png

是不是恍然大明白呢……

原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。

如有侵权,请联系 cloudcommunity@tencent.com 删除。

原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。

如有侵权,请联系 cloudcommunity@tencent.com 删除。

评论
登录后参与评论
0 条评论
热度
最新
推荐阅读
相关产品与服务
领券
问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档