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这个名字的由来,它的英语意思就是“承诺”,表示其他手段无法改变。
  1. 一旦状态改变,就不会再变,任何时候都可以得到这个结果。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