JavaScript异步编程终极解决方案

JavaScript异步终极解决方案

同步行为对应内存中顺序执行的处理器指令。每条指令都会严格按照它们出现的顺序来执行,而每条指令执行后也能立即获得存储在系统本地(如寄存器或系统内存)的信息。

相对地,异步行为类似于系统中断,即当前进程外部的实体可以触发代码执行。异步操作经常是必要的,因为强制进程等待一个长时间的操作通常是不可行的(同步操作则必须要等)。如果代码要访问一些高延迟的资源,比如向远程服务器发送请求并等待响应,那么就会出现长时间的等待。

早期的异步编程

在早期JavaScript中,对异步的处理方式并不理想,主要是采用回调函数的方式。

request('https://example.com',function(err){
    if(err){ return ;}
})

回调函数

回调函数字面意思就是"回过头调用的函数",回调函数一般事先定义好,然后在某些时候"回过头"来调用,例如我们使用的addEventListener,第二个参数就是一个回调函数,特定事件触发后就会调用这个函数。

let p = document.querSelector('#p');
p.addEventListener('click',funtion (){
   // 这是一个回调函数,当事件被触发时执行
})

回调函数的概念可以这样理解,执行一个异步操作(这个异步操作可能是一个点击事件,也可能是一个http请求),并且要在异步操作执行完毕后执行一些处理程序,但是由于我们不知道异步操作什么时候执行完,所以我们会先定义好一个函数但是不执行,等异步操作执行完后再调用。

// 先把回调函数定义好
function fn(){
    // 执行某些操作
}
// 异步操作
function asyncFn(fn){
    // 执行某些代码
    // 执行完后执行回调函数
    fn()
}
asyncFn(fn);

回调函数在Node.js里用的非常多,Node.js大多数异步API都是基于回调函数的。

回调地狱

用回调函数处理异步最大的问题就是容易造成回调地狱。

回调地狱是指多个回调函数深度嵌套,造成代码难以阅读和维护。

想象一下,如果我们需要从后端获取a接口、b接口、c接口中的数据,然后进行某些处理,那么我们的代码可能会像这样:

request('https://example.com/a',function(err){
    if(err){ return ;}
    request('https://example.com/b',function(err){
        if(err){ return ;}
        request('https://example.com/c',function(err){
            if(err){ return ;}
            // 进行处理
        })
    })
})

显而易见,代码复杂且不易维护,开发体验非常不好。

解决回调地狱

JavaScript实现异步最大的问题就是容易出现回调地狱。

const fs = require("fs");
fs.stat('./1.txt',(err,data)=>{
    if(err){
        // 错误处理
    }else{
        fs.readFile('./1.txt',(err,data)=>{
            if(err){
                // ...
            }else {
                // ...
            }
        })
    }
})

随着代码越来越复杂,函数嵌套的就越来越多,这是对于代码维护来说是一件非常痛苦的事情,所以我们会想能不能将里面的代码提取到外面来,比如这样

const fs = require("fs");
fs.stat('./1.txt',(err,data)=>{
    if(err){
        // 错误处理
    }else{
       
    }
})
fs.readFile('./1.txt',(err,data)=>{
    if(err){
        // ...
    }else {
        // ...
    }
})

可惜这种行为很显然会有问题,因为fs.stat是一个异步操作,因此fs.readFile不会等到fs.stat执行完毕后才运行,它很可能在fs.stat还没执行完毕前就执行了,这就导致了逻辑错误。

这里要解决的问题就是当fs.stat执行完毕得出结果时,再执行fs.reaFile,这里很自然的可以想到用一个flag变量来标识fs.stat的执行状态,未执行完为false,执行完为true

const fs = require("fs");
let flag = false;
fs.stat("./1.txt", (err, data) => {
	// fs.stat此时已执行完毕
	flag = true;
});
const timer = setInterval(() => {
	if (flag === true) {
		fs.readFile("./1.txt", (err, data) => {
			console.log("成功")
		});
		clearInterval(timer);
	}
}, 1);
// 成功

这种情况固然能解决我们的问题,但是setInterval这种方法太浪费资源了,不可能大规模使用。我们可以换个思路,可以用发布订阅模式来替代setInterval

const fs = require("fs");
// 实现发布订阅模式
function bus(fn) {
	if (!Array.isArray(fn)) {
		bus.fns = [fn];
	} else {
		bus.fns.push(fn);
	}
}
bus.run = function () {
	for (let fn of bus.fns) {
		fn();
	}
};

bus(function () {
	console.log("后执行");
});

fs.stat("./1.txt", (err, data) => {
	console.log("先执行");
	bus.run();
});
// 先执行
// 后执行

这样能达到我们的要求,我们可以改一下代码,让它更方便使用。

const fs = require("fs");

function Bus(fn) {
	const fns = [];
	function resolve() {
		for (fn of fns) {
			fn();
		}
	}

	fn(resolve);

	this.then = function (resolveFn) {
		fns.push(resolveFn);
	};
}

const bus = new Bus(function (resolve) {
	fs.stat("./1.txt", (err, data) => {
		console.log("先执行");
		resolve();
	});
});

bus.then(function () {
	console.log("后执行");
});
// 先执行
// 后执行

改完之后使用起来方便了很多,我们来分析一下。

对于后执行的函数(即打印后执行的那个函数),我们将他们存放到一个数组fns里,方便遍历执行,而为了能够添加后执行函数,我们添加一个实例方法then,它会将参数(即要添加的后执行的函数)添加到数组fns中。

为了判断先执行的那个函数(即包裹fs.stat的那个函数)什么时候执行完成,我们创建了一个函数resolve,它会遍历执行fns中所有的函数,即所有的后执行的函数,当先执行的函数执行完后,我们调用resolve函数就行了,但是如何将resolve函数传递到先执行的函数中呢?很简单,我们将先执行的函数作为构造函数的参数传入,然后在执行这个函数时把resolve作为参数传入,而在先执行的函数里,我们可以灵活的放置resolve函数的位置。

事实上上面的方法只实现了一些简单的功能,还有不少地方没考虑,比如先执行的函数异常怎么办?上面的方法不能实现函数串联执行,即函数a执行完后执行函数b,函数b执行完后执行函数c。而Promise能解决这些的问题。Promise与上面的方法原理类似,但是功能更强大,逻辑更严谨,Promise是实现JavaScript异步编程的重要工具。

Promise

ECMAScript 6新增的引用类型Promise来处理异步,Promise是一个构造函数,可以通过new操作符来实例化。创建Promise实例时需要传入执行器函数作为参数。

Promise有三种状态:pendingfulfilled(也称resolve)reject,三者分别表示三种不同的状态:待定(pending)、兑现(fulfilled)、拒绝(reject)。

待定(pending)是期约的最初始状态。在待定状态下,期约可以落定(settled)为代表成功的兑现(fulfilled)状态,或者代表失败的拒绝(rejected)状态。无论落定为哪种状态都是不可逆的。只要从待定转换为兑现或拒绝,期约的状态就不再改变。

Promise有以下两个特点:

  1. 对象的状态不受外界影响。Promise对象代表一个异步操作,有三种状态:pending(进行中)、fulfilled(已成功)和rejected(已失败)。只有异步操作的结果,可以决定当前是哪一种状态,任何其他操作都无法改变这个状态。这也是Promise这个名字的由来,它的英语意思就是“承诺”,表示其他手段无法改变。

  2. 一旦状态改变,就不会再变,任何时候都可以得到这个结果。Promise对象的状态改变,只有两种可能:从pending变为fulfilled和从pending变为rejected。只要这两种情况发生,状态就凝固了,不会再变了,会一直保持这个结果,这时就称为 resolved(已定型)。

这两个特点很好理解,Promise三个状态分别表示异步操作是正在进行中、已经完成、已经失败,如果是正在进行中那么应该让它继续执行,如果是已完成则调用执行成功的处理程序resolve,如果是已失败则执行失败的处理程序reject。而这种状态的改变是不可逆的,一个异步操作执行成功就是成功,不可能一会成功一会又失败,这样很容易导致混乱。

let pr = new Promise((resolve,reject)=>{
    // 创建promise时需要传入一个函数,函数第一个参数是resolve,第二个参数的reject
    
    // 模拟异步
    setTimeout(()=>{
        if(操作成功){
            // 将状态从pending改成fulfilled
            resolve('成功了')
        }else {
            // 将状态从pending改为reject
            reject('失败了')
        }
    },1000)
})

pr.then((res)=>{
    // fulfilled
    console.log(res) // 成功了
}).catch(err=>{
    // reject
    console.log(err) // 失败了
})

Promise接收一个执行器函数,这个函数有两个参数resolvereject,这两个参数都是函数,可以被调用和传递参数,一旦调用resolve()就表示Promise对象由pending转变为resolve,会触发Promise.then第一个函数,而一旦调用reject()就表示Promise对象由pending转变为reject,会触发Promise.then第二个函数或Promise.catch函数(两者等价)。而转变后是不可再变的。

Promise也使用到了回调函数,例如Promise.then就接收回调函数作为参数,但是Promise却会不产生回调地狱,因为Promise是链式调用,这种方式让代码便于理解和维护,不会出现回调地狱。

// 回调函数产生的回调地狱
request('https://example.com/a',function(err){
    if(err){ return ;}
    request('https://example.com/b',function(err){
        if(err){ return ;}
        request('https://example.com/c',function(err){
            if(err){ return ;}
            // 进行处理
        })
    })
})
// Promise的链式调用,这里假设pro是一个Promise对象
pro.then((res)=>{
    // 某些操作
    return 1;
}).then((res=>{
    // 某些操作
    // 上个Promise对象的返回值是此函数的参数
    console.log(res) // 1
})).then(res=>{
    // 某些操作
}).catch((err)=>{
    // Promise错误的处理程序
}).finally(()=>{
    // 无论Promise转变为resolve还是reject,都会触发此函数
    // finally函数无法判断Promise是resolve状态还是reject状态
})

Promise的链式调用不仅能够使代码更易维护,还有其他的优点。比如说在回调函数里需要对每个 回调函数判断是否出现错误,而在Promise中如果出现错误,会顺着调用链一直传递,直到遇到catch()才会被捕获。另外需要注意try...catch无法捕捉Promise中的错误。

Promise能够实现链式调用的秘诀在于,Promise.then()Promise.catch()的返回值仍然是一个Promise对象。

let pro = new Promise((resolve, reject) => {
	setTimeout(() => {
		resolve();
	}, 100);
});
let result = pro.then(() => {});
console.log(result);  // Promise { <pending> }

Promise更多语法这里不再赘述,详情可以看阮一峰老师的文章: 点我

除此之外,Promise还提供多种静态方法。例如之前将的回调地狱,在Promise可以用更优雅的方式解决。

// 这里为了方便,默认getA是封装了异步请求的promise的实例
Promise.all([getA,getB,getC]).then(res=>{
    // 所有请求都成功时才执行此函数
}).catch(err=>{
    // 有请求失败时
}).finnaly(()=>{
    // fulfilled和reject都会触发此函数
    // finally函数无法判断Promise是resolve状态还是reject状态
})

除了all方法外,Promise还有另外五种方法: raceresolverejectallSettledany

异步函数

ES2017 标准引入了 async函数,使得异步操作变得更加方便。

实现一个异步函数非常简单,只需要在函数声明前加上关键字async就可以了

async function asyncFn(){    
    // 
}

单是这样的话,异步函数与普通函数没什么不同,异步函数里最重要的还是await关键字,它是实现异步函数的关键,而await关键字必须是在async函数中。

await关键字后面跟着的只能是Promise对象或者是原始值,当异步函数执行的时候,一旦遇到await就会先跳出函数,执行后面的语句,等到异步操作完成,再接着执行函数体内后面的语句。

let pro = new Promise((resolve, reject) => {
	setTimeout(() => {
		resolve();
	}, 100);
});

async function asyncFn() {
	await pro;
	console.log(1);
}

asyncFn();
console.log(2);
// 2 1

当执行到await pro;语句时,会跳出异步函数,执行后面的语言打印2,等异步操作执行完成后在执行await后面的语句,打印1.

await关键字会返回Promise对象resolve的第一个参数。

let pro = new Promise((resolve, reject) => {
	setTimeout(() => {
		resolve("promise");
	}, 100);
});

async function asyncFn() {
	let a = await pro;
	console.log(a);
}

asyncFn();
// promise

async函数中使用try...catch捕获异常,这点与Promise不同。需要注意的是,如果await后面的Promise对象进入的是reject状态,则异步函数执行会被中断。解决的方法是将await语句没有被包裹在try..catch中或者在Promise对象后添加catch捕获异常。

let pro = new Promise((resolve, reject) => {
	setTimeout(() => {
		reject();
	}, 100);
});

async function asyncFn() {
	await pro;
}

asyncFn();
// 报错
// 正确的做法应该是将await语句包裹在try中
let pro = new Promise((resolve, reject) => {
	setTimeout(() => {
		reject("promise");
	}, 100);
});

async function asyncFn() {
    try {
         await pro;
    } catch (e) {
    }
}

asyncFn();
// 或者是
let pro = new Promise((resolve, reject) => {
	setTimeout(() => {
		reject("promise");
	}, 100);
});

async function asyncFn() {
	await pro.catch(() => {});
}

asyncFn();

async函数返回一个 Promise 对象,可以使用then方法添加回调函数。

console.log(asyncFn());
// Promise { <pending> }

使用async函数需要注意的是,如果使用await,那么await语句所在的函数必须是async函数。也就是说嵌套函数中,最外层是async函数,内层函数是普通函数,那么就不能在内层函数中使用await关键字。

// 错误写法
async function asyncFn(){
    await 1; // 正确
    setTimeout(()=>{
        await 1; // 错误,这个函数不是async函数
    },10)
}
// 正确写法,在setTimeout的回调函数前加上async
async function asyncFn(){
    await 1; // 正确
    setTimeout(async ()=>{
        await 1; // 正确
    },10)
}

async函数本质上是 Generator函数的语法糖。async函数将Generator函数的星号和yield改成了语义更明确的asyncawait,另外async函数内置执行器,可以自动执行,还有就是async函数的返回值是Promise对象。

异步函数处理异步操作的方式比Promise更优雅,例如在之前的Promise的链式调用,在异步函数中可以这样写。

let getA = new Promise((resolve, reject) => {
	setTimeout(() => {
		console.log("getA");
		resolve();
	}, 1000);
});
let getB = new Promise((resolve, reject) => {
	setTimeout(() => {
		console.log("getB");
		resolve();
	}, 500);
});
let getC = new Promise((resolve, reject) => {
	setTimeout(() => {
		console.log("getC");
		resolve();
	}, 100);
});

async function asyncFn() {
	await getA;
	await getB;
	await getC;
}

asyncFn();
// getA getB getC

最后更新于