JavaScript 中的异步机制可以分为以下几种:
回调函数 的方式,使用回调函数的方式有一个缺点是,多个回调函 数嵌套的时候会造成回调函数地狱,上下两层的回调函数间的代码耦 合度太高,不利于代码的可维护。
Promise 的方式,使用 Promise 的方式可以将嵌套的回调函数作为 链式调用。但是使用这种方法,有时会造成多个 then 的链式调用, 可能会造成代码的语义不够明确。
generator 的方式,它可以在函数的执行过程中,将函数的执行权转 移出去,在函数外部还可以将执行权转移回来。当遇到异步函数执行 的时候,将函数执行权转移出去,当异步函数执行完毕时再将执行权给转移回来。因此在 generator 内部对于异步操作的方式,可以以同步的顺序来书写。使用这种方式需要考虑的问题是何时将函数的控 制权转移回来,因此需要有一个自动执行 generator 的机制,比如说 co 模块等方式来实现 generator 的自动执行。
async 函数 的方式,async 函数是 generator 和 promise 实现的 一个自动执行的语法糖,它内部自带执行器,当函数内部执行到一个 await 语句的时候,如果语句返回一个 promise 对象,那么函数将会等待 promise 对象的状态变为 resolve 后再继续向下执行。因此 可以将异步逻辑,转化为同步的顺序来书写,并且这个函数可以自动执行。
Promise
Promise是解决回调地狱的一种方案。
Promise有三种状态:
待定(pending):初始状态,既没有被完成,也没有被拒绝。
已完成(fulfilled):操作成功完成。
已拒绝(rejected):操作失败。
Promise的状态是不可逆的。当待定状态的 Promise 对象执行的话,最后要么会通过一个值完成,要么会通过一个原因被拒绝。当其中一种情况发生时,我们用 Promise 的 then 方法排列起来的相关处理程序就会被调用。因为最后 Promise.prototype.then 和 Promise.prototype.catch 方法返回的是一个 Promise, 所以它们可以继续被链式调用。
Promise解决回调地狱主要依靠延迟绑定、值穿透和错误冒泡。
延迟绑定:在.then()里面进行回调函数延迟绑定。
let readFilePromise = filename => {
return new Promise((resolve, reject) => {
fs.readFile(filename, (err, data) => {
if (err) {
reject(err)
} else {
resolve(data)
}
})
})
}
readFilePromise('1.json').then(data => {
return readFilePromise('2.json')
});
值穿透指:根据 then 中回调函数的传入值创建不同类型的 Promise,然后把返回的 Promise 穿透到外层,以供后续的调用。
let x = readFilePromise('1.json').then(data => {
return readFilePromise('2.json') //这是返回的Promise
});
x.then(/* 内部逻辑省略 */)
//值穿透
readFilePromise('1.json').then(data => {
return readFilePromise('2.json');
}).then(data => {
return readFilePromise('3.json');
}).then(data => {
return readFilePromise('4.json');
});
错误冒泡:Promise的错误信息会一直向后传递无法用thr捕获只能统一在Promise的.catch中捕获。这样就不用频繁地检查错误了。
readFilePromise('1.json').then(data => {
return readFilePromise('2.json');
}).then(data => {
return readFilePromise('3.json');
}).then(data => {
return readFilePromise('4.json');
}).catch(err => {
// xxx
})
Promise虽然通过.then()让异步流程以同步的方式展示出来了,但是如果操作过多的话一样会让代码变的难以阅读所以出现了async和awati的。Promise 实例被创建时,内部的代码就会立即被执行,而且无法从外部停止。比如无法取消超时或消耗性能的异步调用,容易导致资源的浪费。Promise 处理的问题都是“一次性”的,因为一个 Promise 实例只能 resolve 或 reject 一次,所以面对某些需要持续响应的场景时就会变得力不从心。比如上传文件获取进度时,默认采用的就是通过事件监听的方式来实现。
Promise常用的静态方法(并行)
all
语法: Promise.all(iterable)
参数: 一个可迭代对象,如 Array。
描述: 此方法对于汇总多个 promise 的结果很有用,在 ES6 中可以将多个 Promise.all 异步请求并行操作,返回结果一般有下面两种情况。
-
当所有结果成功返回时按照请求顺序返回成功。
-
当其中有一个失败方法时,则进入失败方法。
应用场景:将多个请求合并到一起,在一个页面中需要加载获取轮播列表、获取店铺列表、获取分类列表这三个操作,页面需要同时发出请求进行页面渲染,这样用 Promise.all 来实现。
//1.获取轮播数据列表
function getBannerList(){
return new Promise((resolve,reject)=>{
setTimeout(function(){
resolve('轮播数据')
},300)
})
}
//2.获取店铺列表
function getStoreList(){
return new Promise((resolve,reject)=>{
setTimeout(function(){
resolve('店铺数据')
},500)
})
}
//3.获取分类列表
function getCategoryList(){
return new Promise((resolve,reject)=>{
setTimeout(function(){
resolve('分类数据')
},700)
})
}
function initLoad(){
Promise.all([getBannerList(),getStoreList(),getCategoryList()])
.then(res=>{
console.log(res)
}).catch(err=>{
console.log(err)
})
}
initLoad()
allSettled:该方法与all类似,接受一个以 Promise为值的数组,最后返回的是一个数组,记录传进来的参数中每个 Promise 的返回值。
const resolved = Promise.resolve(2);
const rejected = Promise.reject(-1);
const allSettledPromise = Promise.allSettled([resolved, rejected]);
allSettledPromise.then(function (results) {
console.log(results);
});
// 返回结果:
// [
// { status: 'fulfilled', value: 2 },
// { status: 'rejected', reason: -1 }
// ]
any
语法: Promise.any(iterable)
参数: iterable 可迭代的对象,例如 Array。
描述: any 方法返回一个 Promise,只要参数 Promise 实例有一个变成 fulfilled 状态,最后 any 返回的实例就会变成 fulfilled 状态;如果所有参数 Promise 实例都变成 rejected 状态,包装实例就会变成 rejected 状态。
const resolved = Promise.resolve(2);
const rejected = Promise.reject(-1);
const anyPromise = Promise.any([resolved, rejected]);
anyPromise.then(function (results) {
console.log(results);
});
// 返回结果:
// 2
race
语法: Promise.race(iterable)
参数: iterable 可迭代的对象,例如 Array。
描述: race 方法返回一个 Promise,只要参数的 Promise 之中有一个实例率先改变状态,则 race 方法的返回状态就跟着改变。那个率先改变的 Promise 实例的返回值,就传递给 race 方法的回调函数。
//请求某个图片资源
function requestImg(){
var p = new Promise(function(resolve, reject){
var img = new Image();
img.onload = function(){ resolve(img); }
img.src = 'http://www.baidu.com/img/flexible/logo/pc/result.png';
});
return p;
}
//延时函数,用于给请求计时
function timeout(){
var p = new Promise(function(resolve, reject){
setTimeout(function(){ reject('图片请求超时'); }, 5000);
});
return p;
}
Promise.race([requestImg(), timeout()])
.then(function(results){
console.log(results);
})
.catch(function(reason){
console.log(reason);
});
应用场景:对于图片的加载,特别适合用 race 方法来解决,将图片请求和超时判断放到一起,用 race 来实现图片的超时判断。
Promise 高级应用
提前预加载应用
有这样一个场景:页面的数据量较大,通过缓存类将数据缓存在了本地,下一次可以直接使用缓存,在一定数据规模时,本地的缓存初始化和读取策略也会比较耗时。这个时候我们可以继续等待缓存类初始完成并读取本地数据,也可以不等待缓存类,而是直接提前去后台请求数据。两种方法最终谁先返回的时间不确定。那么为了让我们的数据第一时间准备好,让用户尽可能早地看到页面,我们可以通过 Promise 来做加载优化。
策略是页面加载后,立马调用 Promise 封装的后台请求,去后台请求数据。同时初始化缓存类并调用 Promise 封装的本地读取数据。最后在显示数据的时候,看谁先返回用谁的。
中断场景应用
实际应用中,还有这样一种场景:我们正在发送多个请求用于请求数据,等待完成后将数据插入到不同的 dom 元素中,而如果在中途 dom 元素被销毁了(比如 react 在 useEffect 中请求的数据时,组件销毁),这时就可能会报错。因此我们需要提前中断正在请求的 Promise,不让其进入到 then 中执行回调。
useEffect(() => {
let dataPromise = new Promise(...);
let data = await dataPromise();
// TODO 接下来处理 data,此时本组件可能已经销毁了,dom 也不存在了,所以需要在下面对 Promise 进行中断
return (() => {
// TODO 组件销毁时,对 dataPromise 进行中断或取消
})
});
我们可以对生成的 Promise 对象进行再一次包装,返回一个新的 Promise 对象,而新的对象上被我们增加了 cancel 方法,用于取消。这里的原理就是在 cancel 方法里面去阻止 Promise 对象执行 then()方法。
下面构造了一个 cancelPromise 用于和原始 Promise 竞速,最终返回合并后的 Promise,外层如果调用了 cancel 方法,cancelPromise 将提前结束,整个 Promise 结束。
function getPromiseWithCancel(originPromise) {
let cancel = (v) => {};
let isCancel = false;
const cancelPromise = new Promise(function (resolve, reject) {
cancel = e => {
isCancel = true;
reject(e);
};
});
const groupPromise = Promise.race([originPromise, cancelPromise])
.catch(e => {
if (isCancel) {
// 主动取消时,不触发外层的 catch
return new Promise(() => {});
} else {
return Promise.reject(e);
}
});
return Object.assign(groupPromise, { cancel });
}
// 使用如下
const originPromise = axios.get(url);
const promiseWithCancel = getPromiseWithCancel(originPromise);
promiseWithCancel.then((data) => {
console.log('渲染数据', data);
});
promiseWithCancel.cancel(); // 取消 Promise,将不会再进入 then() 渲染数据
Promise 深入理解之控制反转
熟悉了 Promise 的基本运用后,我们再来深入点理解。Promise 和 callback 还有个本质区别,就是控制权反转。
callback 模式下,回调函数是由业务层传递给封装层的,封装层在任务结束时执行了回调函数。
而 Promise 模式下,业务层并没有把回调函数直接传递给封装层( Promise 对象内部),封装层在任务结束时也不知道要做什么回调,只是通过 resolve 或 reject 来通知到 业务层,从而由业务层自己在 then() 或 reject() 里面去控制自己的回调执行。
这里可能理解起来有点绕,换种等效的简单理解:我们知道函数一般是分定义+ 调用步骤的,先定义,后调用。谁调用了函数,就表示谁在控制这个函数的执行。
那么我们来看 callback 模式下,业务层将回调函数的定义传给了封装层,封装层在内部完成了回调函数的调用执行**,业务层**并没有调用回调函数,甚至业务层都看不到其调用代码,所以回调函数的执行控制权在封装层。
而 Promise 模式下,回调函数的调用执行是在 then() 里面完成的,是由业务层发起的,业务层不仅能看到回调函数的调用代码,也能修改,因此回调函数的控制权在业务层。
手动实现 Promise 类的思路
现在我们已经熟悉了 Promise 的详细使用方式,假设让你回到 Promise 类出现之前,那时的 ES6 还没出现,你为了淘汰 callback 的回调写法,准备自己写一个 Promise 类,你会怎么做?
其实这就是常见面试手写 Promise 题目。我们只要抓住 Promise 的一些特点和关键点就能比较顺利实现。
首先 Promise 是一个类,构造函数接收参数是一个函数,而这个函数的参数是 resolve 和 reject 两个内部函数,也就是我们需要构建 resolve 和 reject 传给它,同时让它立即执行。另外咱这个类是有三种状态及 then 和 catch 等方法。根据这些就能快速先把类框架创建好。
class MyPromise () {
constructor (fun) {
this.status = 'pending'; // pending、fulfilled、rejected
fun(this.resolve, this.reject); // 立即执行主体函数,参数函数可能需要 bind(this)
}
resolve() {} // 定义 resolve,内容待定
reject() {} // 定义 reject,内容待定
then() {}
catch() {}
}
Promise注意点
1.如果我们在调用 then() 之前,Promise 主体里的异步任务已经执行完了,即 Promise 的状态已经标注为成功了。那么我们调用 then 的时候,并不会错过,还是会执行。但需要记着,即使主体的异步任务早就执行完了,then() 里面的回调永远是放到微任务里面异步执行的,而不是立马执行。
2.通过 then() 的第 2 个参数这种方式能捕获到 promise 主体里面的异常,并执行 errorCallback。但是如果 Promise 主体里面没有异常,然后进入到 successCallback 里面发生了异常,此时将不会进入到 errorCallback。因此我们经常使用下面的方式二来处理异常。我们可以用catch()不管是 Promise 主体,还是 successCallback 里面的出了异常,都会进入到 errorCallback。这里需要注意,按这种链式写法才正确,如果按下面的写法将会和方式一类似,不能按预期捕获。try...catchtry catch 是传统的异常捕获方式,这里只能捕获同步代码的异常,并不能捕获异步异常,因此无法对 Promise 进行完整的异常捕获。
3.每次 then() 或者 catch() 后,返回的是一个新的 Promise,和上一次的 Promise 实例对象已经不是同一个引用了。而这个新的 Promise 实例对象包含了上一次 then 里面的结果,这也是为什么链式调用的 catch 才能捕获到上一次 then 里面的异常的原因。
async/await
async/await 其实是 Generator 的语法糖,它能实现的效果都能用 then 链来实现,它是为优化 then 链而开发出来的。从字面上来看, async 是“异步”的简写,await 则为等待,所以很好理解 async 用
于申明一个 function 是异步的,而 await 用于等待一个异步方法 执行完成。当然语法上强制规定 await 只能出现在 asnyc 函数中,async 函数(包含 函数语句、函数表达式、Lambda 表达式)会返回一个 Promise 对象,如果在函数中 return 一个直接量,async 会把这个直接量通过 Promise.resolve() 封装成 Promise 对象。在最外层不能用 await 获取其返回值的情况下,当然应该用原来的方式:then() 链来处理这个 Promise 对象,如果 async 函数没有返回值,它会返回Promise.resolve(undefined)。联想一下 Promise 的特点——无等待,所以在没有 await 的情况下 执行 async 函数,它会立即执行,返回一个 Promise 对象,并且, 绝不会阻塞后面的语句。这和普通返回 Promise 对象的函数并无二 致。
注意:Promise.resolve(x) 可以看作是 new Promise(resolve => resolve(x)) 的简写,可以用于快速封装字面量对象或其他对象,将 其封装成 Promise 实例。
async/await 的优势:单一的 Promise 链并不能发现 async/await 的优势,但是,如果需要处理由多个 Promise 组成的 then 链的时候,优势就能体现出来 了(很有意思,Promise 通过 then 链来解决多层回调的问题,现在 又用 async/await 来进一步优化它)。
async/await 对比 Promise 的优势:代码读起来更加同步,Promise 虽然摆脱了回调地狱,但是 then 的 链式调⽤也会带来额外的阅读负担 Promise 传递中间值⾮常麻烦,⽽async/await⼏乎是同步的写法, ⾮常优雅 错误处理友好,async/await 可以⽤成熟的 try/catch,Promise 的 错误捕获⾮常冗余 调试友好,Promise 的调试很差,由于没有代码块,你不能在⼀个返 回表达式的箭头函数中设置断点,如果你在⼀个.then 代码块中使⽤ 调试器的步进(step-over)功能,调试器并不会进⼊后续的.then 代 码块,因为调试器只能跟踪同步代码的每⼀步。