学习笔记—JavaScript异步编程

723 阅读13分钟

前言

在我们学习JS的时候一般都知道JavaScript是单线程的,那单线程是怎么处理网络复杂的请求和文件读写等耗时的操作呢,会不会效率很低,在不断的深入学习和了解,也逐渐明白了其中的奥秘,这也是接下来文章要写的我对于异步编程的理解。

正文

一、同步和异步

同步

在学习异步之前我们先来看一下同步,比如在调用函数取得返回值的时候,能够直接得到预期结果(得到了预期的返回值),是按照你的代码顺序执行的,是连续的,那么就说这个函数是同步执行的。

下边看一个例子:

//在函数返回的时候,获得了预期的效果,即在控制台上打印了‘123’
var A = function(){};
A.prototype.n = 123;
var b = new A();
console.log(b,n);  // 123

如果函数是同步的,即是调用函数执行的任务比较耗时,也会一直等待直到得到预期结果。 因为它是按代码执行顺序执行的。

异步

如果在调用函数返回值的时候,不直接得到预期结果(预期的返回值),而是需要通过一定的方式获得,是不连续的不按代码顺序执行的,那么就可以说这个函数是异步的。

如下所示:

//读取文件
fc.readFile('hello','utf8',function(err,data){
    console.log(data)
});
//网络请求
var pzh = new XMLHttpRequest();
pzh.onreadystatechange = yyy;  // 这里添加回调函数
pzh.open('GET',url);
pzh.send();//发起函数

上述示例中读取文件函数 readFile和网络请求的发起函数 ,send都将执行耗时操作,虽然函数会立即返回,但是不能立刻获取预期的结果,因为耗时操作交给其他线程执行,暂时获取不到预期结果。而在JavaScript中通过回调函数 function(err, data) { console.log(data); }和 onreadystatechange ,在耗时操作执行完成后把相应的结果信息传递给回调函数,通知执行JavaScript代码的线程执行回调。

简单来说:同步按你的代码顺序执行,异步不按照代码顺序执行,异步的执行效率更高。

二、首先要知道的异步机制

浏览器内核的多线程

image.png

我们都知道JavaScript是单线程的,但是浏览器的内核是多线程的;他们在内核的控制下互相配合以保持同步,一个浏览器至少实现三个常驻:Javascrpt引擎线程、GUI渲染线程、浏览器事件触发线程。

  • JS引擎:基于事件驱动单线程执行的,JS引擎一直等待这任务队列中任务的到来,然后加以处理;浏览器无论什么时候都只有一个JS线程在运行JS程序。

  • GUI渲染线程:当界面需要重绘或由于某种操作引发回流时,线程就会执行。这里需要注意的是,渲染线程和JS引擎线程是不能同时进行的。

  • 事件触发线程:当一个事件被触发时,该线程会把世间添加到等待队列的队尾,等待JS引擎的处理,这些时间可来自JavaScript引擎执行当前的代码块,如:setTimeOut,也可以来自浏览器内核和其他线程如鼠标点击;AJAX异步请求等,但是由于JS 的单线程关系,所有这些事情都得排队等待JS引擎处理。

事件循环机制

image.png

如上图所示,左边的栈存储的是同步任务,就是那些能立即执行、不耗时的任务,如变量和函数的初始化、事件的绑定等等那些不需要回调函数的操作都可归为这一类。

右边的堆用来存储声明的变量、对象。下面的队列就是消息队列,一旦某个异步任务有了响应就会被推入队列中。如用户的点击事件、浏览器收到服务的响应和setTimeout中待执行的事件,每个异步任务都和回调函数相关联。

JS引擎线程用来执行栈中的同步任务,当所有同步任务执行完毕后,栈被清空,然后读取消息队列中的一个待处理任务,并把相关回调函数压入栈中,单线程开始执行新的同步任务。

JS引擎线程从消息队列中读取任务是不断循环的,每次栈被清空后,都会在消息队列中读取新的任务,如果没有新的任务,就会等待,直到有新的任务,这就叫事件循环(Eventloop)。

什么是宏任务与微任务?

我们都知道 Js 是单线程的,但是一些高耗时操作就带来了进程阻塞问题。为了解决这个问题,Js 有两种任务的执行模式:同步模式(Synchronous)和异步模式(Asynchronous)。

在异步模式下,创建异步任务主要分为宏任务与微任务两种。ES6 规范中,宏任务(Macrotask) 称为 Task, 微任务(Microtask) 称为 Jobs。宏任务是由宿主(浏览器、Node)发起的,而微任务由 JS 自身发起。

  • 1)宏任务 (macrotask):优先级低,先定义的先执行。包括:ajax,setTimeout,setInterval,事件绑定,postMessage,MessageChannel(用于消息通讯)。
  • 2)微任务 (microtask):优先级高,并且可以插队,不是先定义先执行。包括:promise.then,async/await [generator],requestAnimationFrame,observer,MutationObserver,setImmediate。

image.png 由上图可以看到:

从JS主线程(整体代码)开始第一次循环,发起异步任务后,由(橙色)线程执行异步操作,而JS引擎主线程继续执行堆中的其他同步任务,直到堆中的所有异步任务执行完毕。之后全局上下文进入函数调用栈。直到调用栈清空(只剩全局)。然后执行所有的micro-task,当所有可执行的micro-task执行完毕之后。循环再次从macro-task开始,找到其中一个任务队列执行完毕,然后再执行所有的macro-task,这样一直循环下去。

根据事件循环机制,我们重新梳理一下流程:

1)首先执行栈里的任务

2)先找微任务队列,如果微任务队列中有,先从微任务队列中,一般按照存放顺序获取并且去执行。

3)如果微任务队列中没有,则再去宏任务队列中查找,在宏任务队列中,一般是按照谁先到达执行的条件,就先把谁拿出来执行。

4)以此循环

明白事件循环之后我们要知道Javascript异步编程先后经历了四个阶段,分别是Callback阶段,Promise阶段,Generator阶段和Async/Await阶段。

三、回调函数(Callback)阶段

回调函数是异步操作最基本的方法。

demo1:假定有一个异步操作(asyncFn),和一个同步操作(normalFn)。

function asyncFn(){
    setTimeout(() => {
        console.log('asyncFn');
     ),0)
}

function normalFn(){
    console.log('normalFn');
}
asyncFn();   //asyncFn
normalFn();   //normalFn
    

如果按照正常的JS处理机制来说,同步操作一定发生在异步之前。如果我想要将顺序改变,最简单的方式就是使用回调(callback)的方式处理。

function asyncFn(callback){
    setTimeout(() => {
        console.log('asyncFn');
        callback();
    },0);
}
function normalFn(){
    console.log('normalFn');
}

asyncFn(normalFn);
//asyncFn
//normalFn

回调函数的优点是简单、容易理解和实现,缺点是不利于代码的阅读和维护,各个部分之间高度耦合,使得程序结构混乱、流程难以追踪(尤其是多个回调函数嵌套的情况,容易出现回调地狱,可读性差),而且每个任务只能指定一个回调函数。此外不能使用 try catch 捕获错误,不能直接return。

回调函数易混淆点——传参:

一,将回调函数的参数作为与回调函数同等级的参数进行传递。

image.png

二,回调函数的参数在调用回调函数内部创建。

image.png

事件监听、发布订阅

事件监听

事件监听也是一种非常常见的异步编程模式,它是一种典型的逻辑分离方式,对代码解耦很有用处。

下边看例子:还是以函数f1和f2为例

f1.on('done', f2);  //f2必须等到f1执行完成,才可执行
function f1() { 
    setTimeout(function () { // ... 
        f1.trigger('done'); 
    }, 1000); 
    }

以上,f1.trigger('done')表示,执行完成后,立即触发done事件,从而开始执行f2。

这种方法的优点是比较容易理解,可以绑定多个事件,每个事件可以指定多个回调函数,而且可以“去耦合”,有利于实现模块化。

缺点是整个程序都要变成事件驱动型,运行流程会变得很不清晰。阅读代码的时候,很难看出主流程。

发布订阅模式

发布订阅式的应用非常 广泛,既可以用在异步编程中,也可以帮助我们完成更松耦合的代码编写。

假定,一家三口,妈妈作为“发布者”(publisher)实施和发布信号,爸爸作为中介“订阅”(subscribe)和处理这个信号,最后小明"订阅者"(subscriber)知道什么时候自己可以开始执行。这就叫做“发布/订阅模式”(publish-subscribe pattern)。

下边来看代码:

//订阅者接收到消息
function eat() {
    console.log('妈妈做好饭啦,去吃饭啦');
}

function cooking() {
    console.log('妈妈认真做饭中');
    //发布者向订阅中介发布消息
    setTimeout(() => {
        console.log('孩儿他爸饭做好了,叫小明来吃饭')
        Dad.publish("done");//中介接收消息
    },3000)
}

function read(){
    console.log('小明假装学习') //订阅者等消息
    Dad.subscribe('done',eat);
}

//执行代码
cooking();
read()

/*执行顺序
妈妈认真做饭中
小明假装学习
孩儿他爸饭做好了,叫小明来吃饭
妈妈做好饭啦,去吃饭啦

*/

这种模式下实现的异步编程,本质上还是通过回调函数实现的 ,但是依然存在回调嵌套和无法捕捉异常问题的情况,接下来进入Promise阶段,看看是否能解决这两个问题。

四、Promise阶段

Promise 并不是指某种特定的某个实现,它是一种规范(PromiseA+规范),是一套处理JavaScript异步的机制。

1.Promise的三种状态

  • Promise有三种状态pending,fulfilled和rejected
  • 状态转换只能是 pending到 resolved
  • 或者pending到 rejected 状态一旦转换完成,不能再次转换

可以由下图表示:

1628436673(1).png 附上代码栗子:

let p = new Promise((resolve,reject) => {
    reject('reject');
    resolve('success')  //无效代码不会执行
})
p.then(
    value => {
        console.log(value)
    },
    reason => {
        console.log(reason)  //reject
    }
   )

当我们构造Promise 的时候,构造函数内部的代码是立即执行的

2.链式Promise

先看两个例子:

demo1;

//例1:
Promise.resolve(1)
    .then(res => {
        console.log(res);        //打印 1
        return 2   //包装成Promsie.resolve(2)
    })
    .catch(err => 3);   //这里catch会捕获没有捕获的异常
    .then(res => console.log(res))   //打印 2

当Promise创建对象调用resolve(...)或reject(...)时,这个Promise通过then(...)注册的回调函数就会在新的异步时间点上被触发。(then的链式调用); 在then中使用return,那么return的值会被Promise.resolve()包装。

demo2:以家务分配为例:

function read() {
  console.log('小明认真读书');
}

function eat() {
  return new Promise((resolve, reject) => {
    console.log('好嘞,吃饭咯');
    setTimeout(() => {
      resolve('饭吃饱啦');
    }, 1000)
  })
}

function wash() {
  return new Promise((resolve, reject) => {
    console.log('唉,又要洗碗');
    setTimeout(() => {
      resolve('碗洗完啦');
    }, 1000)
  })
}
const cooking = new Promise((resolve, reject)=>{ 
    console.log('妈妈认真做饭'); 
    setTimeout(() => { 
        resolve('小明快过来,开饭啦'); 
    }, 2000); 
})

cooking.then(msg => { 
    console.log(msg); 
    return eat(); 
}).then(msg => { 
    console.log(msg); 
    return wash();
}).then(msg => {
    console.log(msg);
    console.log('做完家务了,可以玩了')
})

read();
/* 执行顺序: 
妈妈认真做饭 
小明认真读书 
小明快过来,开饭啦 
好嘞,吃饭咯 
饭吃饱啦 
唉,又要洗碗
碗洗完啦 
做完家务了,可以玩了 
*/

其实可以看出Promise.then()可以解决的回调地狱(callback hell),但是无法捕获异常,还需要调用回调函数来解决。

换句话说就是Promise 并没有真正脱离 callback ,Promise 只不过是用 then 方法来延迟了 callback 的绑定。

五、生成器Generators/yield

Generator 函数是 ES6 提供的一种异步编程解决方案,语法行为与传统函数完全不同,Generator 最大的特点就是可以控制函数的执行。

  • function *会定义一个生成器函数,并返回一个Generator(生成器)对象,其内部可以通过 yield 暂停代码,通过调用 next 恢复执行。

简单看一下例子:

 function * gen() {
     yield console.log('hello');
     yield console.log('world');
     return console.log('ending');
}

var hw = gen();

在控制台输入hw.next():

hw.next(); 
index.html:156 hello 
hw.next(); 
index.html:157 world 
hw.next(); 
index.html:158 ending

上面代码定义了一个 Generator 函数helloWorldGenerator,它内部有两个yield表达式(hello和world),即该函数有三个状态:hello,world 和 return 语句(结束执行)。

必须调用遍历器对象的next()方法,使得指针移向下一个状态。每次调用next方法,内部指针就从上一次停下来的地方开始执行,直到遇到下一个yield表达式(或return语句)为止。

generator很方便处理异步(一般要配合tj/co库来用),这里举例说一下coco是一个为Node.js和浏览器打造的基于生成器的流程控制工具,借助于Promise,你可以使用更加优雅的方式编写非阻塞代码

安装co库只需:npm install co

也可以自己去github找一下源码,了解一下

index.js

var co = require('co')
var fs = require('fs')
// wrap the function to thunk
function readFile(filename) {
    return new Promise(function(resolve, reject) {
        fs.readFile(filename, function(err, date) {
            if (err) reject(err)
            resolve(data)
        })
    })
}
// generator 函数
function *gen() {
    var file1 = yield readFile('./file/1.txt') // 1.txt内容为:content in 1.txt
    var file2 = yield readFile('./file/2.txt') // 2.txt内容为:content in 2.txt
    console.log(file1)
    console.log(file2)
    return 'done'
}
// co
co(gen).then(function(err, result) {
    console.log(result)
})
// content in 1.txt
// content in 2.txt
// done

co 函数库可以让你不用编写 generator 函数的执行器,generator 函数只要放在 co 函数里,就会自动执行。 再来看一个例子

co(function *(){ 
    try { 
      var res = yield get('http://baidu.com');
      console.log(res); 
    } catch(e) { 
      console.log(e.code) 
   } 
})

co 最大的好处在于通过它可以把异步的流程以同步的方式书写出来,并且可以使用 try/catch。

六、async/await

使用async/await,可以轻松地达成之前使用生成器和co函数所做到的工作; 一句话,async 函数就是 Generator 函数的语法糖。

然后用async/await实现上边(两个文件)的例子就可以这么写:

var asyncReadFile = async function (){
  var f1 = await readFile('./file/1.txt');
  var f2 = await readFile('./file/2.txt');
  console.log(f1.toString());
  console.log(f2.toString());
};

一比较就会发现,async 函数就是将 Generator 函数的星号(*)替换成 async,将 yield 替换成 await。

1.async函数的特点:

1.执行 async 函数,返回的都是Promise 对象

async function test1(){
     return 123;
}
async function test2(){
     return Promise.resolve(2);
}
const result1 = test1();
const result2 = test2();
console.log('result1',result1);   //promise
console.log('result2',result2)    //promise

自己可以 打印验证一下。

2.Promise.then 成功的情况对应 await

async function test3(){
    const p3 = Promise.resolve(3);
    p3.then(data => {
        console.log('data',data);    
    })
    //await 后边跟一个promise对象
    const data =await p3;  
    console.log('data',data);   //data3
}
test3()

async function test4(){       //await跟一个普通的数
    const data4 = await 4;   //await Promise.resolve(4)
    console.log('data4',data4);     //data4.4
}
test4();

async function test5(){
     const data5 = await test1();   //  await跟一个异步的函数
     console.log('data5',data5);     //data5.123
}
test5()
  1. Promise.catch 异常的情况 对应 try...catch
async function test6(){
    const p6 = Promise.reject(6);
    // const data6 = await p6;
    // console.log('data6',data6)    //报错:Uncaught (in promise)     6
    try{
        const data6 = await p6;
        console.log('data6',data6);
    }catch(k){
        console.error('k',k);     //捕获异常  k 6
    }
}
test6()

总结了这么多,如果还是不太理解,我推荐看一下这些 实战题(ES6Promise实战练习题)加速帮助消化。

此文章为个人学习笔记分享,技术有限,欢迎大家一起讨论学习。

参考文章:

1.JavaScript异步机制详解

2.JS 异步编程六种方案

3.Javascript异步编程的4种方法

4.JS 基础之异步(五):Generator