异步编程
在 JavaScript 这种单线程事件循环模型
中,同步操作与异步操作是代码所要依赖的核心机制。
异步行为是为了优化因计算量大而时间长的操作。在等待其他操作完成时,即使运行其他指令,系统也能保持稳定。只要你不想为等待某个异步操作而阻塞线程执行,那么任何时候都可以使用。
以往的异步编程(setTimeout)
function double(value) {
setTimeout(() => setTimeout(console.log, 0, value * 2), 1000);
}
double(3);
// 6(大约 1000 毫秒之后)
对这个例子而言,1000 毫秒之后,JavaScript 运行时会把回调函数推到自己的消息队列上去等待执行。推到队列之后,回调什么时候出列被执行对 JavaScript 代码就完全不可见了。double()函数在 setTimeout 成功调度异步操作之后会立即退出。
1.处理异步返回值
假设 setTimeout 操作会返回一个有用的值。有什么好办法把这个值传给需要它的地方?
=》给异步操作提供一个回调,这个回调中包含要使用异步返回值的代码(作为回调的参数)。
function double(value, callback) {
setTimeout(() => callback(value * 2), 1000);
}
double(3, (x) => console.log(`I was given: ${x}`));
// I was given: 6(大约 1000 毫秒之后)
这里的 setTimeout 调用告诉 JavaScript 运行时在 1000 毫秒之后把一个函数推到消息队列上。这个函数会由运行时负责异步调度执行。而位于函数闭包中的回调及其参数在异步执行时仍然是可用的。
2.失败处理
成功回调和失败回调:
function double(value, success, failure) {
setTimeout(() => {
try {
if (typeof value !== 'number') {
throw 'Must provide number as first argument';
}
success(2 * value);
} catch (e) {
failure(e);
}
}, 1000);
}
const successCallback = (x) => console.log(`Success: ${x}`);
const failureCallback = (e) => console.log(`Failure: ${e}`);
double(3, successCallback, failureCallback);
double('b', successCallback, failureCallback);
// Success: 6(大约 1000 毫秒之后)
// Failure: Must provide number as first argument(大约 1000 毫秒之后)
这种模式已经不可取了,因为必须在初始化异步操作时定义回调。异步函数的返回值只在短时间内存在,只有预备好将这个短时间内存在的值作为参数的回调才能接收到它。
3.嵌套异步回调
如果异步返值又依赖另一个异步返回值,那么回调的情况还会进一步变复杂。在实际的代码中,这
就要求嵌套回调:
function double(value, success, failure) {
setTimeout(() => {
try {
if (typeof value !== 'number') {
throw 'Must provide number as first argument';
}
success(2 * value);
} catch (e) {
failure(e);
}
}, 1000);
}
const successCallback = (x) => {
double(x, (y) => console.log(`Success: ${y}`));
};
const failureCallback = (e) => console.log(`Failure: ${e}`);
double(3, successCallback, failureCallback);
// Success: 12(大约 1000 毫秒之后)
显然,随着代码越来越复杂,回调策略是不具有扩展性的。“回调地狱
”这个称呼可谓名至实归。嵌套回调的代码维护起来就是噩梦。
Promise
成为了主导性的异步编程机制。
Promise 基础
ECMAScript 6 新增的引用类型 Promise,可以通过 new 操作符来实例化。
当通过new
创建Promise
实例时,需要传入一个回调函数,我们称之为executor
。
- 这个回调函数会被立刻执行,并传入两个回调参数
resolve
、reject
- 当调用
resolve
回调函数时,会执行 Promise 对象的then
方法传入的回调 - 当调用
reject
回调函数时,会执行 Promise 对象的catch
方法传入的回调
promise 是一个有状态的对象,有以下3种:
待定(
pending
):最初始状态代表尚未开始或者正在执行中
兑现(fulfilled
,有时候也称为“解决”,resolved
):代表成功
拒绝(rejected
):代表失败
无论落定为哪种状态都是不可逆的。
只要从待定转换为兑现或拒绝,promise的状态就不再改变。
重要的是,promise的状态是私有的,不能直接通过 JavaScript 检测到或修改。promise故意将异步行为封装起来,从而隔离外部的同步代码。
2.解决值、拒绝理由及用例
promise主要有两大用途。
(1)抽象地表示一个异步操作。
(2)promise封装的异步操作会实际生成某个值,而程序期待promise状态改变时可以访问这个值。
为了支持这两种用例,每个promise只要状态切换为resolved,就会有一个私有的内部值(value)。
每个promise只要状态切换为rejected,就会有一个私有的内部理由(reason)。
3.通过执行函数控制promise状态
由于promise的状态是私有的,所以只能在内部进行操作。内部操作在promise的执行器函数
中完成。
执行器函数主要有两项职责:初始化promise的异步行为和控制状态的最终转换。
其中,
控制promise状态的转换
是通过调用它的两个函数参数实现的。调用
resolve()
会把状态切换为兑现,调用
reject()
会把状态切换为拒绝。另外,调用 reject()也会抛出错误(后面会讨论这个错误)。
let p1 = new Promise((resolve, reject) => resolve());
setTimeout(console.log, 0, p1); // Promise <resolved>
let p2 = new Promise((resolve, reject) => reject());
setTimeout(console.log, 0, p2); // Promise <rejected>
// Uncaught error (in promise)
在前面的例子中,并没有什么异步操作,因为在初始化promise时,执行器函数已经改变了每个promise的状态。这里的关键在于,执行器函数是同步执行的。这是因为执行器函数是promise的初始化程序
。
通过下面的例子可以看出上面代码的执行顺序:
new Promise(() => setTimeout(console.log, 0, 'executor'));
setTimeout(console.log, 0, 'promise initialized');
// executor
// promise initialized
添加 setTimeout 可以推迟切换状态
let p = new Promise((resolve, reject) => setTimeout(resolve, 1000));
// 在 console.log 打印promise实例的时候,还不会执行超时回调(即 resolve())
setTimeout(console.log, 0, p); // Promise <pending>
无论 resolve()和 reject()中的哪个被调用,状态转换都不可撤销了。于是继续修改状态会静默失败,如下所示:
let p = new Promise((resolve, reject) => {
resolve();
reject(); // 没有效果
});
setTimeout(console.log, 0, p); // Promise <resolved>
为避免promise卡在待定状态,可以添加一个定时退出功能。比如,可以通过 setTimeout 设置一个10 秒钟后无论如何都会拒绝promise的回调:
let p = new Promise((resolve, reject) => {
setTimeout(reject, 10000); // 10 秒后调用 reject()
// 执行函数的逻辑
});
setTimeout(console.log, 0, p); // Promise <pending>
setTimeout(console.log, 11000, p); // 11 秒后再检查状态
// (After 10 seconds) Uncaught error
// (After 11 seconds) Promise <rejected>
因为promise的状态只能改变一次,所以这里的超时拒绝逻辑中可以放心地设置让promise处于待定状态的最长时间。如果执行器中的代码在超时之前已经解决或拒绝,那么超时回调再尝试拒绝也会静默失败。
4.Promise.resolve()
Promise并非一开始就必须处于待定状态,然后通过执行器函数才能转换为落定状态。通过调用Promise.resolve()静态方法,可以实例化一个解决的Promise。下面两个Promise实例实际上是一样的:
let p1 = new Promise((resolve, reject) => resolve());
let p2 = Promise.resolve();
a. 使用这个静态方法,实际上可以把任何值都转换为一个promise:
setTimeout(console.log, 0, Promise.resolve());
// Promise <resolved>: undefined
setTimeout(console.log, 0, Promise.resolve(3));
// Promise <resolved>: 3
// 多余的参数会忽略
setTimeout(console.log, 0, Promise.resolve(4, 5, 6));
// Promise <resolved>: 4
b. 如果传入的参数本身是一个promise,那它的行为就类似于一个空包装。因此,Promise.resolve()可以说是一个幂等方法,如下所示:这个幂等性会保留传入promise的状态
:
let p = new Promise(() => {});
setTimeout(console.log, 0, p); // Promise <pending>
setTimeout(console.log, 0, Promise.resolve(p)); // Promise <pending>
c. 这个静态方法能够包装任何非promise值,包括错误对象,并将其转换为解决的promise。因此,也可能导致不符合预期的行为:
let p = Promise.resolve(new Error('foo'));
setTimeout(console.log, 0, p);
// Promise <resolved>: Error: foo
5.Promise.reject()
Promise.reject()会实例化一个拒绝的promise并抛出一个异步错误(这个错误不能通过 try/catch 捕获,而只能通过拒绝处理程序捕获)。下面的两个promise实例实际上是一样的:
let p1 = new Promise((resolve, reject) => reject());
let p2 = Promise.reject();
这个拒绝的promise的理由就是传给 Promise.reject()的第一个参数。这个参数也会传给后续的拒绝处理程序
:
let p = Promise.reject(3);
setTimeout(console.log, 0, p); // Promise <rejected>: 3
p.then(null, (e) => setTimeout(console.log, 0, e)); // 3
关键在于,Promise.reject()并没有照搬 Promise.resolve()的幂等逻辑。如果给它传一个promise对象,则这个promise会成为它返回的拒绝promise的理由:
setTimeout(console.log, 0, Promise.reject(Promise.resolve()));
// Promise <rejected>: Promise <resolved>
6.同步/异步执行的二元性
Promise 的设计很大程度上会导致一种完全不同于 JavaScript 的计算模式。包含了两种模式下抛出错误的情形:
try {
throw new Error('foo');
} catch(e) {
console.log(e); // Error: foo
}
try {
Promise.reject(new Error('bar'));
} catch(e) {
console.log(e);
}
// Uncaught (in promise) Error: bar
第一个 try/catch 抛出并捕获了错误,第二个 try/catch 抛出错误却没有捕获到。
这里的同步代码之所以没有捕获promise抛出的错误,是因为它没有通过异步模式捕获错误。从这里就可以看出promise真正的异步特性:它们是同步对象(在同步执行模式中使用),但也是异步执行模式的媒介。
Promise的实例方法
promise实例的方法是连接外部同步代码与内部异步代码之间的桥梁。这些方法可以访问异步操作返回的数据,处理promise成功和失败的结果,连续对promise求值,或者添加只有promise进入终止状态时才会执行的代码。
1.实现 Thenable 接口
在 ECMAScript 暴露的异步结构中,任何对象都有一个 then()方法。这个方法被认为实现了Thenable 接口。
ECMAScript 的 Promise 类型实现了 Thenable 接口。
2.Promise.prototype.then()
Promise.prototype.then()
是为promise实例添加处理程序的主要方法
。这个 then()方法接收最多两个参数:onResolved 处理程序和 onRejected 处理程序。这两个参数都是可选的,如果提供的话,则会在promise分别进入“兑现”和“拒绝”状态时执行。
通过then
方法可以对 Promise 中的resolve
进行处理。then
方法的返回值是一个 Promise 实例。
function onResolved(id) {
setTimeout(console.log, 0, id, 'resolved');
}
function onRejected(id) {
setTimeout(console.log, 0, id, 'rejected');
}
let p1 = new Promise((resolve, reject) => setTimeout(resolve, 3000));
let p2 = new Promise((resolve, reject) => setTimeout(reject, 3000));
p1.then(() => onResolved('p1'),
() => onRejected('p1'));
p2.then(() => onResolved('p2'),
() => onRejected('p2'));
//(3 秒后)
// p1 resolved
// p2 rejected
3.Promise.prototype.catch()
Promise.prototype.catch()
方法用于给promise添加拒绝处理程序
。这个方法只接收一个参数:onRejected 处理程序。事实上,这个方法就是一个语法糖,调用它就相当于调用 Promise.prototype. then(null, onRejected)。
Promise.prototype.catch()返回一个新的promise实例:
let p1 = new Promise(() => {});
let p2 = p1.catch();
setTimeout(console.log, 0, p1); // Promise <pending>
setTimeout(console.log, 0, p2); // Promise <pending>
setTimeout(console.log, 0, p1 === p2); // false
4.Promise.prototype.finally()
Promise.prototype.finally()
方法用于给promise添加 onFinally 处理程序,这个处理程序在promise转换为解决或拒绝状态时都会执行。这个方法可以避免 onResolved 和 onRejected 处理程序中出现冗余代码。但 onFinally 处理程序没有办法知道promise的状态是解决还是拒绝,所以这个方法主要用于添加清理代码。
let p1 = Promise.resolve();
let p2 = Promise.reject();
let onFinally = function() {
setTimeout(console.log, 0, 'Finally!')
}
p1.finally(onFinally); // Finally
p2.finally(onFinally); // Finally
Promise.prototype.finally()方法返回一个新的promise实例
let p1 = new Promise(() => {});
let p2 = p1.finally();
setTimeout(console.log, 0, p1); // Promise <pending>
setTimeout(console.log, 0, p2); // Promise <pending>
setTimeout(console.log, 0, p1 === p2); // false
这个新promise实例不同于 then()或 catch()方式返回的实例。因为 onFinally 被设计为一个状态 无关的方法,所以在大多数情况下它将表现为父promise的传递。对于已解决状态和被拒绝状态都是如此。
promise连锁与promise合成
promise连锁
每个promise实例的方 法(then()、catch()和 finally())都会返回一个新的promise对象,而这个新promise又有自己的实例方法。这样连缀方法调用就可以构成所谓的“promise连锁”。
可以让每 个后续promise都等待之前的promise,也就是串行化异步任务。
let p1 = new Promise((resolve, reject) => {
console.log('p1 executor');
setTimeout(resolve, 1000);
});
p1.then(() => new Promise((resolve, reject) => {
console.log('p2 executor');
setTimeout(resolve, 1000);
}))
.then(() => new Promise((resolve, reject) => {
console.log('p3 executor');
setTimeout(resolve, 1000);
}))
.then(() => new Promise((resolve, reject) => {
console.log('p4 executor');
setTimeout(resolve, 1000);
}));
// p1 executor(1 秒后)
// p2 executor(2 秒后)
// p3 executor(3 秒后)
// p4 executor(4 秒后)
把生成promise的代码提取到一个工厂函数中,就可以写成这样:
function delayedResolve(str) {
return new Promise((resolve, reject) => {
console.log(str);
setTimeout(resolve, 1000);
});
}
delayedResolve('p1 executor')
.then(() => delayedResolve('p2 executor'))
.then(() => delayedResolve('p3 executor'))
.then(() => delayedResolve('p4 executor'))
// p1 executor(1 秒后)
// p2 executor(2 秒后)
// p3 executor(3 秒后)
// p4 executor(4 秒后)
这种结构可以简 洁地将异步任务串行化,解决之前依赖回调的难题。
假如这种情况下不使用promise,那么前面的代码可能 就要这样写了:
function delayedExecute(str, callback = null) {
setTimeout(() => {
console.log(str);
callback && callback();
}, 1000)
}
delayedExecute('p1 callback', () => {
delayedExecute('p2 callback', () => {
delayedExecute('p3 callback', () => {
delayedExecute('p4 callback');
});
});
});
// p1 callback(1 秒后)
// p2 callback(2 秒后)
// p3 callback(3 秒后)
// p4 callback(4 秒后
这不正是promise所要解决
的回调地狱问题
吗!!!
promise图
因为一个promise可以有任意多个处理程序,所以promise连锁可以构建有向非循环图
的结构。这样,每个 promise都是图中的一个节点,而使用实例方法添加的处理程序则是有向顶点。因为图中的每个节点都会等 待前一个节点落定,所以图的方向就是promise的解决或拒绝顺序。
// A
// / \
// B C
// /\ /\
// D E F G
let A = new Promise((resolve, reject) => {
console.log('A');
resolve();
});
let B = A.then(() => console.log('B'));
let C = A.then(() => console.log('C'));
B.then(() => console.log('D'));
B.then(() => console.log('E'));
C.then(() => console.log('F'));
C.then(() => console.log('G'));
// A
// B
// C
// D
// E
// F
// G
注意,日志的输出语句是对二叉树的层序遍历。如前所述,promise的处理程序是按照它们添加的顺序 执行的。由于promise的处理程序是先添加到消息队列,然后才逐个执行,因此构成了层序遍历。
Promise.all()
Promise 类提供两个将多个promise实例组合成一个promise的静态方法:Promise.all()和 Promise.race()。 而合成后promise的行为取决于内部promise的行为。
Promise.all()静态方法创建的promise会在一组promise全部解决之后再解决
。这个静态方法接收一个 可迭代对象,返回一个新promise。
如果所有promise都成功解决,则合成promise的解决值就是所有包含promise解决值的数组,按照迭代器顺序:
let p = Promise.all([
Promise.resolve(3),
Promise.resolve(),
Promise.resolve(4)
]);
p.then((values) => setTimeout(console.log, 0, values)); // [3, undefined, 4]
如果至少有一个包含的promise待定,则合成的promise也会待定。如果有一个包含的promise拒绝,则合成的 promise也会拒绝,则第一个拒绝的promise会将自己的理由作为合成promise的拒绝理由。
Promise.race()
Promise.race()静态方法返回一个包装promise,是一组集合中最先解决或拒绝的promise的镜像。这个方法接收一个可迭代对象,返回一个新promise。
Promise.race()不会对解决或拒绝的promise区别对待。无论是解决还是拒绝,只要是第一个落定的 promise
,Promise.race()就会包装其解决值或拒绝理由并返回新promise:
// 解决先发生,超时后的拒绝被忽略
let p1 = Promise.race([
Promise.resolve(3),
new Promise((resolve, reject) => setTimeout(reject, 1000))
]);
setTimeout(console.log, 0, p1); // Promise <resolved>: 3
// 拒绝先发生,超时后的解决被忽略
let p2 = Promise.race([
Promise.reject(4),
new Promise((resolve, reject) => setTimeout(resolve, 1000))
]);
setTimeout(console.log, 0, p2); // Promise <rejected>: 4
异步函数 async/await
异步函数,也称为“async/await
”(语法关键字),是 ES6 promise模式在 ECMAScript 函数中的应用。 async/await 是 ES8 规范新增的。这个特性从行为和语法上都增强了 JavaScript,让以同步方式写的代码能够异步执行
。
异步函数可以暂停执行,而不阻塞主线程。
1.async
async
关键字用于声明异步函数
。这个关键字可以用在函数声明、函数表达式、箭头函数和方法上:
async function foo() {}
let bar = async function() {};
let baz = async () => {};
class Qux {
async qux() {}
}
使用 async 关键字可以让函数具有异步特征,但总体上其代码仍然是同步求值的。
async function foo() {
console.log(1);
}
foo();
console.log(2);
// 1
// 2
不过,异步函数如果使用 return 关键字返回了值
(如果没有 return 则会返回 undefined),这个值会被 Promise.resolve()包装成一个promise对象
。异步函数始终返回promise对象,在函数外部调用这个函数可以得到它返回的promise:
async function foo() {
console.log(1);
return 3;
}
// 给返回的promise添加一个解决处理程序
foo().then(console.log);
console.log(2);
// 1
// 2
// 3
当然,直接返回一个promise对象也是一样的:
async function foo() {
console.log(1);
return Promise.resolve(3);
}
// 给返回的promise添加一个解决处理程序
foo().then(console.log);
console.log(2);
// 1
// 2
// 3
在异步函数中抛出错误会返回拒绝的promise:
async function foo() {
console.log(1);
throw 3;
}
// 给返回的promise添加一个拒绝处理程序
foo().catch(console.log);
console.log(2);
// 1
// 2
// 3
不过,拒绝promise的错误不会被异步函数捕获:
async function foo() {
console.log(1);
Promise.reject(3);
}
// Attach a rejected handler to the returned promise
foo().catch(console.log);
console.log(2);
// 1
// 2
// Uncaught (in promise): 3
2.await
因为异步函数主要针对不会马上完成的任务,所以自然需要一种暂停和恢复执行的能力。使用 await
关键字可以暂停异步函数代码的执行,等待promise解决
。
await 关键字必须在异步函数中使用。
注意,await
关键字会暂停执行异步函数后面的代
码,让出 JavaScript 运行时的执行线程。
// 1000 毫秒后异步打印"baz"
async function baz() {
await new Promise((resolve, reject) => setTimeout(resolve, 1000));
console.log('baz');
}
baz();
// baz(1000 毫秒后)
如前面的例子所示,单独的 Promise.reject()不会被异步函数捕获,而会抛出未捕获错误。不 过,对拒绝的promise使用 await 则会释放(unwrap)错误值(将拒绝promise返回):
async function foo() {
console.log(1);
await Promise.reject(3);
console.log(4); // 这行代码不会执行
}
// 给返回的promise添加一个拒绝处理程序
foo().catch(console.log);
console.log(2);
// 1
// 2
// 3
停止和恢复执行
JavaScript 运行时在碰 到 await 关键字时,会记录在哪里暂停执行。等到 await 右边的值可用了,JavaScript 运行时会向消息 队列中推送一个任务,这个任务会恢复异步函数的执行。
async function foo() {
console.log(2);
await null;
console.log(4);
}
console.log(1);
foo();
console.log(3);
// 1
// 2
// 3
// 4
控制台中输出结果的顺序很好地解释了运行时的工作过程:
(1) 打印 1;
(2) 调用异步函数 foo();
(3)(在 foo()中)打印 2;
(4)(在 foo()中)await 关键字暂停执行,为立即可用的值 null 向消息队列中添加一个任务;
(5) foo()退出;
(6) 打印 3;
(7) 同步线程的代码执行完毕;
(8) JavaScript 运行时从消息队列中取出任务,恢复异步函数执行;
(9)(在 foo()中)恢复执行,await 取得 null 值(这里并没有使用);
(10)(在 foo()中)打印 4; (11) foo()返回。
事件循环
JS的运行机制就是事件循环!
核心思想是将所有的异步任务放入一个队列中,然后按照队列中的顺序依次执行,直到队列为空为止。主线程从"任务队列"中读取事件,这个过程是循环不断的,所以整个的这种运行机制又称为Event Loop(事件循环)。
任务队列
通常分为两种类型:宏任务队列和微任务队列。先进先出
。
调用栈
也叫执行栈,是 JavaScript 运行时用于存储函数调用的数据结构(栈先进后出
),它记录了当前执行的上下文和函数调用链。
JS的执行顺序是什么?
- JS是从上到下一行一行执行。
- 如果某一行执行报错,则停止执行下面的代码。
- 先执行同步代码,再执行异步代码
事件循环的工作机制
1.先执行同步代码
,所有同步代码都在主线程上执行,形成一个执行栈。
2.当遇到异步任务
时,会将其挂起并添加到任务队列
中,宏任务放入宏任务队列,微任务放进微任务队列。
3.当执行栈为空时,事件循环从任务队列中取出一个任务,加入到执行栈中执行。
4.重复上述步骤,直到任务队列为空。
微任务执行时机比宏任务要早
,微任务在DOM渲染前触发,宏任务在DOM渲染后触发。
举例:
console.log(1) //同步代码先执行,调用栈执行后直接出栈
setTimeout(() => {
console.log(4) //异步代码,宏任务放入宏任务队列中
})
Promise.resolve().then(() => {
console.log(3) //异步代码,微任务放入微任务队列中
})
console.log(2) //同步代码先执行,调用栈执行后直接出栈
//1
//2
//3
//4
setTimeout、Promise、Async/Await的区别
🔶 延时器:setTimeout(回调函数,间隔时间):以毫秒为单位间隔指定时间后调用回调函数(仅1次)。
setTimeout的回调函数放到
宏任务队列
里,等到执行栈清空以后执行。🔶 promise.then里的回调函数会放到
微任务队列
里,等宏任务里面的同步代码执行完再执行。🔶 async和await必须基于返回了pormise的函数,对于其它的函数没有任何作用。
async方法执行时,遇到await会立即await后代码,把代码放到
微任务队列
里,让出执行栈让同步代码先执行。