js异步编程,详解Promise、Generator、async、await
前言
本文为我原创,如果对同步异步任务的运行机制感兴趣欢迎阅读我上一篇文章 《》;本文如果对你有所帮助,求收藏、点赞、评论,转载请注明出处。
一、同步与异步
1. 同步
首先假设这么一个场景:你们公司要发年终奖了!!!
你们老板和你们公司会计支了个摊,所有员工都去排队领钱,你排第一个,会计开始算你这一年的“考勤”、“绩效”,最后把你应得的金额告诉老板,由老板把钱交到你手上, 你开心的拿着钱走了。
下一个同事来了,会计开始计算,老板给钱;就这么一个一个地发。
这就是典型的 同步,排在后面的同事必须等前一个同事领完钱才能领到钱。
带来的问题就是等所有员工的年终奖都发完,天都黑了,太耗时;如果碰上一个“难缠”的,嫌钱少,跟老板理论上了,那排在后面的同事就得准备在公司过夜了。
具体到 js 中就是 后一个任务必须等待前一个任务执行完毕才会开始执行,碰上“难缠”(耗时长)的任务,就会造成 阻塞。当然,对于 依赖上一个任务执行结果 的任务,同步是程序 正确性 的保障。
2. 异步
继续年终奖的例子。
今年公司有钱了,搞了个 oa 系统,所有员工考勤、福利都放到系统上了。
员工还是那些员工,但是今年的年终奖就不用再排队了,老板说今天开始发年终奖,会计启动系统程序,所有员工的年终奖都由系统自行计算,等到哪个员工的年终奖算出来了,再由会计直接把钱打到员工账户上。
让所有员工的年终奖计算 “同时” 进行,员工不用再排队等,可以去做些自己爱做的事。
回到 js 中,异步允许多个任务同时执行,每个异步任务可以把自己的工作交由其它线程进行,等到执行完毕后,再把执行结果给到主线程继续执行。避免了 阻塞 ,同时也能减少任务等待时间,提高用户体验。
对主线程而言,异步相当于把任务分成了两个阶段,先执行第一段,等到“时机成熟”,再去执行第二段。
二、异步场景
常见的异步场景大致有以下几种
-
响应用户操作:对用户的输入做出响应,比如鼠标点击特定位置 -
指定时间执行:setTimeout、setInterval、requestAnimationFrame -
大量的运算:复杂、数据规模较大的运算工作;不过一般都放服务器,前端很少处理 -
请求服务器数据:使用 XMLHttpRequest 发送异步请求
前两个场景下,浏览器自身实现了异步。
大量的运算工作在以前会是个头疼的问题,不过现在我们可以使用 Web Worker 来进行计算,具体用法可参考这个例子simple-web-worker。
一说起异步,首先想到的便是 ajax 请求、XMLHttpRequest 请求,所以接下来也会将重点放在与后台的通信上。
三、异步请求发展史
异步请求的解决方案经历了大致四个阶段:
-
回调函数 -
Promise -
Generator -
async/await
早期前端攻城狮们被 回调函数 支配了很长一段时间,直到 ES6 带来了一堆新特性,结束了刀耕火种的 “回调函数时代”。
四、回调函数
回调函数 是 JavaScript 对 异步编程 的实现。
如前文所述,异步可以看作是将任务拆成两段,第一段先执行,第二段等到异步任务有结果了再执行;而 回调函数 就是将第二段写入到函数里,需要执行时再直接调用这个函数。
以下是 node 中的一个回调示例:
function callback(err, data) {
console.log(data);
}
fs.readFile("./test.txt", "utf8", callback);
readFile 函数的第三个参数就是一个 回调函数,也就是任务的第二段,等到操作系统成功读取 test.txt 或 出现错误时,callback 函数被执行。
(注:node 中回调函数第一个参数必须是 error 对象,原因是第一段执行完成后,所在的上下文环境已经结束,之后的抛出的错误无法捕捉)
一个回调函数中如果还有异步操作,就会出现回调函数嵌套的情况,如下面的例子:
fs.readFile("./a.txt", "utf8", function (err, data) {
console.log("file a", data);
fs.readFile("./b.txt", "utf8", function (err, data) {
console.log("file b", data);
fs.readFile("./c.txt", "utf8", function (err, data) {
console.log("file c", data);
});
});
});
a.txt 文件被读取或是报错后再去读取 b.txt 文件,b.txt 文件成功读取或是报错后再去读取 c.txt 文件。这里仅展示了三个回调函数的嵌套,实际项目中会嵌套的更多。
回调函数嵌套不仅影响阅读、后期维护;同时,多个异步操作之间形成强耦合;如果其中一个回调函数有变更,它的前一个回调函数以及后一个回调函数都要跟着修改,也就是出现了所谓的 回调地狱。
五、Promise
ES2015(es6)中引入了 Promise,采用 链式调用 的方式,解决 回调地狱 问题。
Promise 是一个 thenable 对象, 可以获取 异步操作 的消息,有三个状态:
-
待定(pending):Promise 实例创建后的初始态,也表示任务正在执行; -
已兑现(fulfilled):任务成功完成。在执行器中调用 resolve 后转变的状态; -
已拒绝(rejected):任务执行失败、被拒绝。在执行器中调用 reject 后转变的状态。
Promise 构造函数接收一个 执行器 函数,执行器接受两个参数(resolve 函数与 reject 函数),根据执行器内异步任务的结果再决定使用 resolve 或 reject 来改变 Promise 实例的状态,pending 状态的 Promise,只能转变为 fulfilled 或 rejected。
-
调用 resolve 将 pending 状态改为 fulfilled 状态 -
调用 reject 将 pending 状态改为 rejected 状态
当状态改变时,Promise then 方法的 处理函数 就会被调用
-
转变为 fulfilled 状态时,then 方法绑定的第一个处理函数(第一个参数)被调用 -
转变为 rejected 状态或报错时,then 方法绑定的第二个处理函数(第二个参数)被调用;若第二个处理函数未绑定,则 catch 方法的处理函数被调用
Promise.prototype.then 和 Promise.prototype.catch 方法都会返回一个新的 promise 对象, 所以它们可以被链式调用。
还有一个不常用的方法 Promise.prototype.finally,同样是返回一个新的 promise 对象;当前 promise 运行完毕后,无论当前 promise 的状态是完成(fulfilled)还是失败(rejected),finally 的处理函数都会被调用。
静态方法
方法 | 参数 | 触发成功 | 触发失败 |
---|---|---|---|
Promise.all | Promise 对象的集合 | 所有 promise 都成功 | 任何一个 promise 失败则立即触发失败 |
Promise.allSettled | Promise 对象的集合 | 所有 promise 执行完毕,无论什么状态 | |
Promise.any | Promise 对象的集合 | 其中一个 promise 成功了就触发,返回成功的 promise | 所有 promise 都被拒绝(rejected),抛出 AggregateError |
Promise.race | Promise 对象的集合 | 第一个被执行完的 promise,且状态为 fulfilled | 第一个被执行完的 promise,且状态为 rejected |
Promise.reject | 拒绝理由 | 被调用 | |
Promise.resolve | 成功的 value | 被调用 |
CustomPromise
Promise 常常是前端面试中必问的知识点,下面通过实现一个简易的 Promise 来深入了解一下。
function CustomPromise(executor) {
// value 记录异步任务成功的执行结果
this.value = null;
// reason 记录异步任务失败的原因
this.reason = null;
// status 记录当前状态,初始化是 pending
this.status = "pending";
// 缓存两个队列,维护 resolved 和 rejected 各自对应的处理函数
this.onResolvedQueue = [];
this.onRejectedQueue = [];
var self = this;
// 定义 resolve 函数
function resolve(value) {
// 如果是 pending 状态,直接返回
if (self.status !== "pending") {
return;
}
// 异步任务成功,把结果赋值给 value
self.value = value;
// 当前状态切换为 resolved
self.status = "resolved";
// 批量执行 resolved 队列里的任务
self.onResolvedQueue.forEach((resolved) => resolved(self.value));
}
// 定义 reject 函数
function reject(reason) {
// 如果是 pending 状态,直接返回
if (self.status !== "pending") {
return;
}
// 异步任务失败,把结果赋值给 value
self.reason = reason;
// 当前状态切换为 rejected
self.status = "rejected";
// 用 setTimeout 延迟队列任务的执行
// 批量执行 rejected 队列里的任务
self.onRejectedQueue.forEach((rejected) => rejected(self.reason));
}
// 把 resolve 和 reject 能力赋予执行器
executor(resolve, reject);
}
CustomPromise 接收一个 执行器,实现了 promise 三个状态与 resolve reject 函数,着重注意两个队列:onResolvedQueue、onRejectedQueue。
// then 方法接收两个函数作为入参(可选)
CustomPromise.prototype.then = function (onResolved, onRejected) {
if (typeof onResolved !== "function") {
onResolved = function (x) {
return x;
};
}
if (typeof onRejected !== "function") {
onRejected = function (e) {
throw e;
};
}
var self = this;
let x;
// 新 Promise 对象
var promise2 = new CustomPromise(function (resolve, reject) {
// 判断状态,分配对应的处理函数
if (self.status === "resolved") {
// resolve 处理函数
resolveByStatus(resolve, reject);
} else if (self.status === "rejected") {
// reject 处理函数
rejectByStatus(resolve, reject);
} else if (self.status === "pending") {
// 若是 pending ,则将任务推入对应队列
self.onResolvedQueue.push(function () {
resolveByStatus(resolve, reject);
});
self.onRejectedQueue.push(function () {
rejectByStatus(resolve, reject);
});
}
});
// resolve态的处理函数
function resolveByStatus(resolve, reject) {
// 包装成异步任务,确保决议程序在 then 后执行
setTimeout(function () {
try {
// 返回值赋值给 x
x = onResolved(self.value);
// 进入决议程序
resolutionProcedure(promise2, x, resolve, reject);
} catch (e) {
// 如果onResolved或者onRejected抛出异常error,则promise2必须被rejected,用error做reason
reject(e);
}
});
}
// reject态的处理函数
function rejectByStatus(resolve, reject) {
// 包装成异步任务,确保决议程序在 then 后执行
setTimeout(function () {
try {
// 返回值赋值给 x
x = onRejected(self.reason);
// 进入决议程序
resolutionProcedure(promise2, x, resolve, reject);
} catch (e) {
reject(e);
}
});
}
// 把包装好的 promise2 return 掉
return promise2;
};
then 函数 接收两个 处理函数,先判断两个参数类型是不是函数,不是则直接返回或抛出。
new 一个新的 CustomPromise 对象,判断调用对象的状态
-
如果状态为 resolved,则调用 resolveByStatus; -
如果状态为 rejected,则调用 rejectByStatus; -
如果状态仍为 pending,则将 resolveByStatus 推入 onResolvedQueue 队列,将 rejectByStatus 推入 onRejectedQueue 队列。resolveByStatus 与 rejectByStatus 主要逻辑是决议程序(promise/A+标准)
function resolutionProcedure(promise2, x, resolve, reject) {
// 这里 hasCalled 这个标识,是为了确保 resolve、reject 不要被重复执行
let hasCalled;
if (x === promise2) {
// 规范:如果 resolve 结果和 promise2相同则reject,这是为了避免死循环
return reject(new TypeError("为避免死循环,此处抛错"));
} else if (x !== null && (typeof x === "object" || typeof x === "function")) {
// 规范:如果x是一个对象或者函数,则需要额外处理下
try {
// 首先是看它有没有 then 方法(是不是 thenable 对象)
let then = x.then;
// 如果是 thenable 对象,则将promise的then方法指向x.then。
if (typeof then === "function") {
then.call(
x,
(y) => {
// 如果已经被 resolve/reject 过了,那么直接 return
if (hasCalled) return;
hasCalled = true;
// 进入决议程序(递归调用自身)
resolutionProcedure(promise2, y, resolve, reject);
},
(err) => {
// 这里 hascalled 用法和上面意思一样
if (hasCalled) return;
hasCalled = true;
reject(err);
}
);
} else {
// 如果then不是function,用x为参数执行promise
resolve(x);
}
} catch (e) {
if (hasCalled) return;
hasCalled = true;
reject(e);
}
} else {
// 如果x不是一个object或者function,用x为参数执行promise
resolve(x);
}
}
决议程序 resolutionProcedure 接收四个参数
-
promise2:新的 promise 对象 -
x:被调用对象绑定的 resolve 函数 或 reject 函数 返回的值 -
resolve: promise2 对象 绑定的 resolve 函数 -
reject: promise2 对象 绑定的 reject 函数
判断 x 的类型
-
等于 promise2:抛出异常 -
thenable 对象:将 this 指向 x,第一个参数为 resolve 处理函数,第二个参数为 reject 处理函数;resolve 处理函数调用自身 -
其它类型:调用 resolve
示例
继续上面的例子,这次改用 Promise 改进一下
function readFilePromise(filePath) {
return new Promise((resolve, reject) => {
fs.readFile(filePath, "utf8", (err, data) => {
if (err) {
return reject(err);
}
resolve(data);
});
});
}
// 使用
readFilePromise("./a.txt")
.then((data) => {
console.log("文件:", data);
return readFilePromise("./b.txt");
})
.then((data) => {
console.log("文件:", data);
return readFilePromise("./c.txt");
})
.then((data) => {
console.log("文件:", data);
})
.catch((err) => {
console.error(err);
});
这里先使用 Promise 包装了一下 readFile,通过 then 方法的链式调用完成对三个文件的读取,并在末尾通过 catch 方法捕获执行过程中抛出的错误。
相比于 回调函数 的方式,代码结构更加清晰,执行顺序也一目了然;但除此之外并无新意,更像是对回调函数的改进,成了 then 方法的堆积,语义依然不清晰。
那有没有更好的办法呢?答案是肯定的,我们继续往下
六、Generator 函数
Generator 函数(生成器函数) 同样是 es6 中新引入的语法,用以解决异步编程。
通过 function* 声明一个 Generator 函数,调用函数并 不会立即执行,而是返回这个生成器的 迭代器 ( iterator )对象。
(注:es6 中没有规定 function 与 函数名 之间的星号在哪个位置)
返回的 迭代器 ( iterator )对象提供以下方法:
方法名 | 参数 | 返回值 | 描述 |
---|---|---|---|
Generator.prototype.next() | value:向生成器传递的值 | {done:已经执行到末尾并返回了则为 true,value:返回的任何 JavaScript 值} | 指针向下执行,直到遇到 yield 或 return 为止,返回一个由 yield 表达式生成的值 |
Generator.prototype.return() | value:需要返回的值 | 返回该函数参数中给定的值 | 返回给定的值并结束生成器 |
Generator.prototype.throw() | exception:抛出的异常 | {done:已经执行到末尾并返回了则为 true,value:返回的任何 JavaScript 值} | 向生成器抛出一个错误 |
Generator 函数 更像是异步任务的容器,因为可以“控制”异步任务的执行,执行中可以被中断也可以再次被唤起。
Generator 函数执行过程中出现以下情况会暂停执行:
-
yield
yield 关键字只能在 Generator 函数 中使用。
Generator 函数 执行中遇到 yield 关键字时执行被中断,直到生成器的 next() 方法被调用,函数才能继续往下执行。
同时 next() 方法返回一个 IteratorResult 对象,它有两个属性,value 和 done。 -
yield*
yield* 关键字后跟一个 generator 或 可迭代对象。函数执行过程中遇到 yield* 也会暂停执行,并将控制权委托给 yield* 后的对象,通过调用 next() 方法执行 yield* 后跟的 generator 或 可迭代对象。yiel* 是表达式,可以直接将值返回给调用者。 -
return
函数执行结束,return 并不会直接将值返回给调用者,仍是需要通过调用 next() 方法,返回的 value 的值由 return 语句指定,并且 done 为 true。 -
throw
抛出异常,函数完全停止执行。 -
生成器函数的末尾
函数执行结束,再次调用 next() 方法时返回的 value 等于 undefined, done 为 true。
借助一个例子来理解一下:
function* g1() {
console.log("g1 start");
yield* [1, 2, 3];
return "foo";
}
var result, yieldResult;
function* g2() {
console.log("g2 start");
result = yield* g1();
yieldResult = yield "g5 yield";
return "g5 inner";
}
var iterator = g2();
console.log(iterator.next()); // {value: 1, done: false}
console.log(iterator.next()); // {value: 2, done: false}
console.log(iterator.next()); // {value: 3, done: false}
console.log(iterator.next()); // {value: "g5 yield", done: false},这时 result 被赋值 ‘foo’
console.log(iterator.next()); // {value: "g5 inner", done: true}
console.log(result); // foo
console.log(yieldResult); // undefined
这里声明了两个生成器函数 g1、g2,调用 g2 函数,并将返回的 迭代器对象 赋值给 iterator。
-
g2 函数不会立即执行,只能通过调用 next() 方法执行,先打印出 g2 start,继续执行遇到 yield* 表达式。
-
yield* 将控制权委托给 g1 函数,g1 函数开始执行,打印出 g1 start,同时也遇到 yield* 表达式,同样将控制权委托给 可迭代对象 也就是这里的数组 [1, 2, 3],并将第一个元素 1 作为 value 值返回,输出 {value: 1, done: false}。
-
控制权仍在 数组中,因此继续调用 next() 方法仍是读取数组中的元素,依次输出 {value: 2, done: false}、{value: 3, done: false}。
-
数组中的元素都读取完了,控制权回到 g2 中,等到下一次 next() 方法被调用,g2 函数继续执行,将 g1 返回的 foo 赋值给 result,并输出 {value: "g5 yield", done: false},yield 与 yield* 不同,没有自己的值,因此 yieldResult 等于 undefined。
-
再往下执行,遇到了 return,执行被中断,直到下一次 next() 方法被调用,输出 {value: "g5 inner", done: true}。
示例
最后再使用 Generator 函数 改进一下上面的例子
function readFilePromise(filePath) {
return new Promise((resolve, reject) => {
fs.readFile(filePath, "utf8", (err, data) => {
if (err) {
return reject(err);
}
resolve(data);
});
});
}
function* readFileGen() {
yield readFilePromise("./a.txt");
yield readFilePromise("./b.txt");
yield readFilePromise("./c.txt");
}
function run() {
const gen = readFileGen();
function next() {
const { done, value } = gen.next();
if (done) {
// 执行到末尾
console.log("done", value);
return value;
}
value.then((data) => {
console.log("文件", data);
next();
});
}
next();
}
run();
这里用 Generator 函数 重新包装了一下 readFilePromise 函数,依次读取三个文件,还需要调用 next 函数才能执行。
-
run 是个自执行函数,gen 是 Generator 函数返回的对象,通过调用 gen.next() 控制指针往下执行,返回的对象包含 done、 value 两个属性。 -
通过 done 是否等于 true,判断 Generator 函数 是不是执行到末尾了。 -
如果 done 为 true,则直接 return value。 -
如果 done 不为 true,则递归调用 next 方法。
本例中 value 是一个 thenable 对象,所以在 then 的处理函数中打印输出数据并执行 next 方法。
对比一下 promise 的 链式调用,虽然没了 then 的堆积,但却多了对 next 方法的调用,虽然能力更强大了,但怎么感觉更复杂了呢。
七、Async/Await
在 ES2017 标准中引入了 async 和 await 处理异步任务。
async 函数
async 函数 顾名思义就是使用 async 关键字声明的函数,async 函数始终返回一个 promise 对象,也可以说 async 函数就是 Generator 函数 的语法糖。
这个语法糖做了哪些事呢?大致可以分为以下几点:
-
内置执行器
Generator 函数的执行必须靠执行器,就是上一节例子中的 run 函数,而 async 函数 自带执行器,async 函数与普通函数一样,直接调用即可。 -
更好的语义
async 和 await 搭配的语法比起 Generator 函数 的星号和 yield,语义更加清楚;async 表明函数中有异步操作,await 表明跟在后面的表达式需要等待结果。 -
返回 Promise
async 函数始终返回的是一个 Promise 对象,相对于 Generator 函数 返回的 IteratorResult 对象,Promise 方便许多。async 可以看作是将函数内多个操作包装成了一个 Promise。
await
await 关键字一般都搭配 async 使用,也只有在 async 函数中才能使用。
await 后通常跟一个 Promise 对象,如果不是一个 Promise 对象则会被转成一个立即 resolve 的 Promise 对象。
老规矩,来看一个简单的小例子
function promiseFunc() {
return new Promise((rs, rj) => {
console.log("promise");
rs("hello world");
});
}
async function asyncFunc() {
console.log("async start");
const result = await promiseFunc();
console.log(result);
}
asyncFunc();
console.log("main thread");
输出
// async start
// promise
// main thread
// hello world
这里疑问较大的应该就是 main thread 为啥会比 hello world 先输出?
-
在 asyncFunc 执行中,遇到 await 关键字,需要等待 promiseFunc 完成,所以 asyncFunc 函数的执行 被中断 了,将控制权交还给调用者,也就是全局上下文中,继续执行 console.log('main thread'),输出 main thread。 -
promiseFunc 有结果了,就继续执行 asyncFunc,输出 hello world
这里涉及到 微任务/宏任务,可以看我上一篇文章。
在这里例子中,async 函数可以看作是将多个异步任务(promise)包装成一个 Promise,await 就像异步操作 then 命令的语法糖。
错误处理
async/await 语法虽好,但是错误处理是个问题, 上面的例子中如果将 promiseFunc 中的 rs('hello world') 换成 rj('hello world'),同样也会输出 “hello world”。
所以处理方法通常是在需要调用 reject 时将值抛出,async 函数 则包在 try...catch 中,以便处理失败、拒绝。
示例
终于,readFile 的最终版本
function readFilePromise(filePath) {
return new Promise((resolve, reject) => {
fs.readFile(filePath, "utf8", (err, data) => {
if (err) {
return reject(err);
}
resolve(data);
});
});
}
async function run() {
const a = await readFilePromise("./a.txt");
console.log("文件", a);
const b = await readFilePromise("./b.txt");
console.log("文件", b);
const c = await readFilePromise("./c.txt");
console.log("文件", c);
}
run();
使用 async/await 写法处理异步任务,看起来更加符合语义,代码也更加清爽。
以上。
后记
由于个人订阅号不具备评论功能,如有其它意见,欢迎前往以下任一站点评论留言:
https://melonfield.club/column/detail/cvWdxxdtFDl(个人独立开发运营网站)
https://juejin.cn/post/6930633868911476743
参考
-
Promise[OL].https://developer.mozilla.org/zh-CN/docs/Web/JavaScript/Reference/Global_Objects/Promise -
Generator[OL].https://developer.mozilla.org/zh-CN/docs/Web/JavaScript/Reference/Global_Objects/Generator -
async函数[OL].https://developer.mozilla.org/zh-CN/docs/Web/JavaScript/Reference/Statements/async_function -
await[OL].https://developer.mozilla.org/zh-CN/docs/Web/JavaScript/Reference/Operators/await -
yield[OL].https://developer.mozilla.org/zh-CN/docs/Web/JavaScript/Reference/Operators/yield -
yield*[OL].https://developer.mozilla.org/zh-CN/docs/Web/JavaScript/Reference/Operators/yield* -
阮一峰.ES6标准入门[M].第3版