繁星闪烁着,深蓝的空中,何曾听得见他们的对话
沉默中,微光里,他们深深的互相颂赞了
说明
学习总结 + 个人理解,巩固 + 方便查阅,大家愿意看的简单看看就好
tips: Promise 调用 then函数加入微任务队列的时机是,当前 Promise 状态改变且构造器中同步代码执行完毕(这才叫决议)。并不是 resolve() 或 reject() 显示将 Promise 状态改变后就加入微任务队列,一定要等决议完成才会加入。
事件循环运行机制(Event Loop):
- 执行一个宏任务(这里指
script全部代码),栈中没有就从事件队列中获取一个压入栈中
tips:script全部代码就是一个宏任务,setTimeout、setInterval也分别是单独的宏任务 - 执行过程中如果遇到微任务,就将它添加到微任务队列末尾
- 宏任务执行完毕后,立即执行当前微任务队列中的所有微任务(依次执行)
- 当前宏任务执行完毕,开始检查渲染,如果有重绘回流(
Layout/reflow)就由GUI线程接管渲染 - 渲染完毕后,
JS线程继续接管,开始下一个宏任务(从事件队列中获取)
参考:《从浏览器多进程到JS单线程,JS运行机制最全面的一次梳理》
其实 Event Loop 并不复杂,只是当遇到了 Promise 一切就变复杂了,所以我们需要深入理解 Promise。
为什么需要 Promise ?
在上一篇《你不知道的JS系列——异步和回调》中,我们确定了通过回调表达程序异步和管理并发的两个主要缺陷:缺乏顺序性和可信任性。
而缺陷产生的最主要原因就在控制反转上,我们将回调的控制权交给了第三方,期待其能够调用回调,实现正确的功能,但我们与第三方之间的信任太脆弱了,这导致了一系列问题的发生。试想如果我们能够把控制反转再反转回来,即我们不把自己程序的 continuation (延续)传给第三方,而是希望第三方给我们提供了解其任务何时结束的能力,然后由我们自己的代码来决定下一步做什么,那么问题就都够得到解决。这种范式就称为 Promise。
什么是 Promise ?
未来值
通过如下场景来阐述:
我到快餐店的柜台,点了一个芝士汉堡。通过下订单并付款,我已经发出了一个对某个值(芝士汉堡)的请求。我已经启动了一次交易。但是,通常我不能马上就得到这个汉堡。收银员会交给我某个东西来代替汉堡:一张带有订单号的收据。收据就是一个
IOU(I owe you,我欠你的)承诺(promise),保证了最终我会得到我的汉堡。
所以我得好好保留我的收据,我知道这代表了我未来的汉堡,所以不需要担心, 只是现在我还是很饿!
在等待的过程中,我还可以做点其他的事情,我脑海中已经在想着未来的芝士汉堡了,尽管现在我还没有拿到手。我的大脑之所以可以这么做,是因为它已经把订单号当作芝士汉堡的占位符了。 从本质上讲,这个占位符使得这个值不再依赖时间。这是一个未来值。
终于,我听到服务员在喊“订单 113”,然后愉快地拿着收据走到柜台,把收据交给收银 员,换来了我的芝士汉堡。换句话说,一旦我需要的值准备好了,我就用我的承诺值(value-promise)换取这个值本身。
但是,还可能有另一种结果。他们叫到了我的订单号,但当我过去拿芝士汉堡的时候,收银员满是歉意地告诉我:“不好意思,芝士汉堡卖完了。”除了作为顾客对这种情况感到愤怒之外,我们还可以看到未来值的一个重要特性:它可能成功,也可能失败。
现在值与将来值
不要小瞧 x + y。
var x, y = 2;
console.log( x + y ); // NaN
运算 x + y假定了 x和 y 都已经设定。也就是说在这里我们假定了 x 和 y 的值都是已决议的,那么试想如果其中一个值未决议(是未来值)就进行计算,那会怎么样?
如果有的语句现在完成,而有的语句将来完成,那就会在程序里引起混乱。例如:如果语句 2 依赖于语句 1 的完成,那么就会有两个输出:要么语句 1 马上完成,一切
顺利执行;要么语句 1 还未完成,语句 2 因此也将会失败。
以x + y为例,如果它们中的任何一个还没有准备好,那我们就等待两者都准备好。一旦可以就马上执行加运算。下面是通过回调的实现:
function add(getX,getY,cb) {
var x, y;
getX( function(xVal){
x = xVal;
// 两个都准备好了?
if (y != undefined) {
cb( x + y ); // 发送和
}
} );
getY( function(yVal){
y = yVal;
// 两个都准备好了?
if (x != undefined) {
cb( x + y ); // 发送和
}
} );
}
// fetchX() 和fetchY()是同步或者异步函数
add( fetchX, fetchY, function(sum){
console.log( sum ); // 是不是很容易?
} );
上面我们把 x 和 y 都当作未来值来处理。在我们不确定某个值是现在值还是将来值的时候,我们就把它当做将来值来处理,这样可以防止很多意外的发生。说得直白些就是,为了统一处理现在和将来,我们把它们都变成了将来,即所有的操作都成了异步的。
Promise 值
先来看看,通过 Promise 函数表达 x + y 的例子:
function add(xPromise,yPromise) {
return Promise.all( [xPromise, yPromise] )
.then( function(values){
return values[0] + values[1];
} );
}
// fetchX()和fetchY()返回相应值的promise
add( fetchX(), fetchY() )
.then( function(sum){
console.log( sum );
} );
这样就简单多了。
Promise 的决议结果可能是拒绝而不是完成。拒绝值和完成的 Promise 不一样:
完成值总是编程给出的,而拒绝值,通常称为拒绝原因,可能是程序逻辑直接设置的,也可能是从运行异常隐式得出的值
通过 Promise,调用 then(..)实际上可以接受两个函数,第一个用于完成情况,第二个用于拒绝情况:
add( fetchX(), fetchY() )
.then(
// 完成处理函数
function(sum) {
console.log( sum );
},
// 拒绝处理函数
function(err) {
console.error( err );
}
);
我们清晰的看见,Promise采用了分离式回调。Promise 是一种封装和组合未来值的易于复用的机制。
注意: 关于 Promise 需要理解的最强大也最重要的一个概念:一旦 Promise 决议,它就永远保持在这个状态,成为了不变值。
完成事件
从另外一个角度看待
Promise 的决议:一种在异步任务中作为两个或更多步骤的流程控制机制,时序上的 this-then-that。
假定要调用一个函数执行某个任务,这个函数可能立即完成任务,也可能需要一段时间才能完成。我们只需要知道它什么时候结束,这样就可以进行下一个任务。即我们想要通过某种方式在程序完成的时候得到通知。
侦听某个通知,我们就会想到事件,我们的脑海中可能会出现如下的伪代码:
foo(x) {
// 开始做点可能耗时的工作
}
foo( 42 )
on (foo "completion") {
// 可以进行下一步了!
}
on (foo "error") {
// 啊,foo(..)中出错了
}
当然这样的代码,Javascript并不提供,更自然的表达方法是:
function foo(x) {
// 开始做点可能耗时的工作
return listener; // 构造一个listener事件通知处理对象来返回
}
var evt = foo( 42 );
evt.on( "completion", function(){
// 可以进行下一步了!
} );
evt.on( "failure", function(err){
// 啊,foo(..)中出错了
} );
bar( evt ); // 让bar(..)侦听foo(..)的完成
这里没有把回调传给 foo(..),而是返回一个名为 evt的事件注册对象,由它来接受回调。此处的反转显而易见,我们通过反转再反转,拿回了对代码的控制权,即调用代码将控制权反转给第三方,再从第三方那里反转回来。对控制反转的恢复实现了更好的关注点分离,即bar不需要关注foo(...)的调用细节,foo(..)也不需要关注bar是否存在。从本质上说,evt 对象就是分离的关注点之间一个中立的第三方协商机制。
Promise “事件”
其实上面的事件侦听对象 evt 就是 Promise 的一个模拟。
foo(..) 与 bar(..)的内部实现或许如下:
function foo(x) {
// 可是做一些可能耗时的工作
// 构造并返回一个promise
return new Promise( function(resolve,reject){
// 最终调用resolve(..)或者reject(..)
// 这是这个promise的决议回调
} );
}
function bar(fooPromise) {
// 侦听foo(..)完成
fooPromise.then(
function(){
// foo(..)已经完毕,所以执行bar(..)的任务
},
function(){
// 啊,foo(..)中出错了!
}
);
}
注意: 传入Promise的函数会立即执行,不会像 then(..) 中的回调一样异步延迟
另一种实现方式:
function bar() {
// foo(..)肯定已经完成,所以执行bar(..)的任务
}
function oopsBar() {
// 啊,foo(..)中出错了,所以bar(..)没有运行
}
// 对于baz()和oopsBaz()也是一样
var p = foo( 42 );
p.then( bar, oopsBar );
p.then( baz, oopsBaz );
注意: p.then( .. ).then( .. ) 与
p.then(..); p.then(..); 是两个意义,前者p决议后调用then,因为.then返回的总是Promise,所以才能支持链式调用,此时第二个.then用的是第一个.then返回的Promise的决议值,而后者用的都是p的决议值。
具有 then 方法的鸭子类型
判断类似于 Promise 的值是否是真正的 Promise 很重要,多见于 Promise.resolve() 对于 Promise 和 thenable 的展开。 thenable 类似于 Promise,指任何具有 then(..) 方法的对象和函数。
注意: 对象的原型链上若具有 then(..) 方法,那么这个对象也会被识别为 thenable。
这里主要讲对thenable的类型检查。
根据一个值的形态(具有哪些属性)对这个值的类型做出一些假定。这种类型检查一般用术语鸭子类型来表示——“如果它看起来像只鸭子,叫起来像只鸭子,那它一定就是只鸭子”。
对 thenable值的鸭子类型检测就大致如下:
if (
p !== null &&
(
typeof p === "object" ||
typeof p === "function"
) &&
typeof p.then === "function"
) {
// 假定这是一个thenable!
}
else {
// 不是thenable
}
Promise 信任问题
Promise 的特性就是专门用来为回调编码的信任问题提供一个有效的可复用的答案。
调用过早
即使是立即完成的 Promise也无法被同步观察到。即对一个 Promise 调用 then(..) 的时候,即使这个 Promise 已经决议,提供给then(..) 的回调也总会被异步调用(微队列)。所以 Promise 不存在调用过早这个问题。
调用过晚
Promise 创建对象调用 resolve(..) 或 reject(..) 时,这个 Promise 的
then(..) 注册的观察回调就会被自动调度。可以确信,这些被调度的回调在下一个异步事
件点上一定会被触发。所以也不存在调用过晚的问题。
注意: 当 Promise 决议后,其上所有的通过 then(..) 注册的回调都会在下一个异步时机点上依次被立即调用。这些回调中的任意一个都无法影响或延误对其他回调的调用。
举个🌰:
p.then( function(){
p.then( function(){
console.log( "C" );
} );
console.log( "A" );
} );
p.then( function(){
console.log( "B" );
} );
// 结果:A B C
这里的 "C" 无法打断或抢占"B"。
下面这样写又是另一种结果:
p.then( function(){
p.then( function(){
console.log( "C" );
} );
console.log( "A" );
} )
.then( function(){
console.log( "B" );
} );
// 结果:A C B
这里就要注意到 p.then( .. ).then( .. ) 与
p.then(..); p.then(..); 是两个意义。加深理解,你可以试着去看下这道题,点我看题(第一道题)。
Promise 调度技巧
如果两个 promise p1 和 p2 都已经决议,那么 p1.then(..); p2.then(..) 应该最终会先调用
p1 的回调,然后是 p2 的那些。但还有一些微妙的场景可能不是这样的:
var p3 = new Promise( function(resolve,reject){
resolve( "B" );
} );
var p1 = new Promise( function(resolve,reject){
resolve( p3 );
} );
p2 = new Promise( function(resolve,reject){
resolve( "A" );
} );
p1.then( function(v){
console.log( v );
} );
p2.then( function(v){
console.log( v );
} );
// 结果:A B
不易理解的点就在resolve( p3 ),规定的行为是把 p3 展开到 p1,但是注意是异步地展开。
注意: new Promise((resolve)=>{resolve(val)}) 与 Promise.resolve(val) 并不一定等价
- 当
val是一个Promise实例时,resolve(val)是异步展开,而Promise.resolve将不做任何修改、原封不动地返回这个实例 - 当
val是一个thenable对象时,都采取异步展开 - 当
val是其他值时,两种方式等价
这里针对前两种情况给出测试代码(chrome下):
Promise.resolve传入Promise实例
new Promise(resolve => {
resolve(1);
Promise.resolve(
new Promise(function(resolve, reject){
console.log(2);
resolve(3)
})
).then(t => console.log(t))
console.log(4);
}).then(t => console.log(t));
console.log(5);
// 结果: 2 4 5 3 1
Promise.resolve传入thenable对象
new Promise(resolve => {
resolve(1);
Promise.resolve({
then: function(resolve, reject){
console.log(2);
resolve(3)
}
}).then(t => console.log(t))
console.log(4);
}).then(t => console.log(t));
console.log(5);
// 结果: 4 5 2 1 3
resolve()传入Promise实例,chrome下回调会被推迟两个时序
原因:new Promise(r => r(v))里浏览器会创建一个PromiseResolveThenableJob去处理这个Promise实例,也就是所谓的展开
new Promise((resolve, reject) => {
console.log(1);
resolve(Promise.resolve()); // 直接用Promise.resolve()生成实例
}).then(() => {
console.log(2);
});
new Promise(function(resolve) {
console.log(3);
resolve();
}).then(function() {
console.log(4);
}).then(function() {
console.log(5);
}).then(function() {
console.log(6);
});
// 结果:1 3 4 5 2 6
resolve()传入thenable对象,chrome下回调被推迟一个时序
new Promise((resolve, reject) => {
console.log(1);
resolve({
then: function(resolve, reject){
resolve()
}
});
}).then(() => {
console.log(2);
});
new Promise(function(resolve) {
console.log(3);
resolve();
}).then(function() {
console.log(4);
}).then(function() {
console.log(5);
}).then(function() {
console.log(6);
});
// 结果:1 3 4 2 5 6
这些细微的差别,确实很让人头疼。要避免这样的细微区别带来的噩梦,你永远都不应该依赖于不同 Promise 间回调的顺序和调度。
回调未调用
首先,没有任何东西(甚至 JavaScript 错误)能阻止 Promise 向你通知它的决议,Promise
在决议时总是会调用完成回调和拒绝回调中的一个。
如果 Promise 本身永远不被决议,即使这样,Promise 也提供了解决方案:
const p = Promise.race([
fetch('/resource-that-may-take-a-while'),
new Promise(function (resolve, reject) {
setTimeout(() => reject(new Error('request timeout')), 5000)
})
]);
p
.then(val => console.log(val)) // 及时完成
.catch(err => console.error(err)); // 超时
调用次数过少或过多
根据定义,回调被调用的正确次数应该是 1。Promise 的定义方式使得它只能被决议一次。
注意:
如果出于某种原因,
Promise创建代码试图调用resolve(..)或reject(..)多次,或者试图两者都调用,那么这个Promise将只会接受第一次决议,并默默地忽略任何后续调用。
由于Promise只能被决议一次,所以任何通过then(..)注册的(每个)回调就只会被调用一次。如果你把同一个回调注册了不止一次(比如p.then(f); p.then(f);),那它被调用的次数就会和注册次数相同。
未能传递参数 / 环境值
- 如果你没有用任何值显式决议,那么这个值就是
undefined,这是JavaScript常见的处理方 式。 - 如果要传递多个值,你就必须要把它们封装在单个值中传递,比如通过一个数组或对象。
吞掉错误或异常
如果在 Promise 的创建过程中或在查看其决议结果过程中的任何时间点上出现了一个 JavaScript 异常错误,那这个异常就会被捕捉,并且会使这个 Promise 被拒绝。
如果在处理异常的过程中再次发生异常,它看起来好像被吞掉了,其实你并没有捕获。
是可信任的 Promise 吗?
Promise 并没有完全摆脱回调,它只是改变了传递回调的位置。
我们并不是把回调传递给
foo(..),而是从foo(..)得到某个东西(外观上看是一个真正的Promise),然后把回调传给这个东西
Promise.resolve(..) 提供了可信任的 Promise 封装工具,可以链接使用。
链式流
Promise 的两个固有行为特性:
- 调用
Promise的then(..)会自动创建一个新的Promise从调用返回- 在完成或拒绝处理函数内部,如果返回一个值或抛出一个异常,新返回的(可链接的)
Promise就相应地决议- 如果完成或拒绝处理函数返回一个
Promise,它将会被展开,这样一来,不管它的决议 值是什么,都会成为当前then(..)返回的链接Promise的决议值
注意: 从完成(resolve())处理函数返回 thenable 或者 Promise 的时候会发生展开,但从(拒绝reject())处理函数返回 thenable 或者 Promise时不会发生展开(经测试得出)。
举个🌰:
var p = Promise.resolve( 21 );
p.then( function(v){
console.log( v ); // 21
// 创建一个promise并将其返回
return new Promise( function(resolve,reject){
resolve( v * 2 );
} );
} )
.then( function(v){
console.log( v ); // 42
} );
你可以认为返回的Promise实例覆盖了原来默认返回的Promise实例。
再来看看reject():
new Promise(function(resolve,reject){
reject(
Promise.resolve(1)
)
})
.then(val=>console.log(2,val))
.catch(err=>console.log(3,err ))
// 结果:3 Promise {<resolved>: 1}
可以看到 err 是一个 Promise,足以证明 reject() 中并不会发生展开,当然你也可以去试试传入 thenable,也是同样的结果。
- 如果你调用
promise的then(..),并且只传入一个完成处理函数,一个默认拒绝处理函数 就会顶替上来:
var p = new Promise( function(resolve,reject){
reject( "Oops" );
} );
var p2 = p.then(
function fulfilled(){
// 永远不会达到这里
}
// 假定的拒绝处理函数,如果省略或者传入任何非函数值
// function(err) {
// throw err;
// }
);
- 如果没有给
then(..)传递一个适当有效的函数作为完成处理函数参数,还是会有作为替代 的一个默认处理函数:
var p = Promise.resolve( 42 );
p.then(
// 假设的完成处理函数,如果省略或者传入任何非函数值
// function(v) {
// return v;
// }
null,
function rejected(err){
// 永远不会到达这里
}
)
术语:决议、完成以及拒绝
决议(resolve)、完成(fulfill)、拒绝(reject)
为什么 Promise构造器的两个参数回调,第一个参数回调为什么总是命名为resolve,按照常理应该是 fulfill啊?
原因: 第一个参数回调通常用于标识 Promise 已经完成,这里的完成指的是可能完成也可能拒绝。
比如 Promise.resolve(..),对传入的 thenable 会展开。如果这个 thenable 展开得到一个拒绝状态,那么从 Promise.resolve(..) 返回的 Promise 实际上就是一个拒绝状态。
Promise(..) 构造器的第一个参数回调会展开 thenable(和 Promise.resolve(..) 一样)或
真正的 Promise,所以这里使用 resolve 很精确。
错误处理
try...catch...只能捕获到同步异常,对于异步异常无能为力。
为了避免丢失被忽略和抛弃的 Promise 错误,Promise 链的一个最佳实践就是最后总以一个 catch(..) 结束
Promise API 概述
没什么好讲的,更详细的还是看《ECMAScript 6 入门——阮一峰》。
注意: Promise.allSettled() 为 ES2020 新引入的。同时在 <对象的扩展> 一章中,链判断运算符(?.)与 Null 判断运算符(??)也为 ES2020 新引入内容,其他的暂时还没有注意到。
Promise 局限性
顺序错误处理
Promise 链中的错误很容易被无意中默默忽略掉,当然最佳实践就是Promise 链的最后总以一个 catch(..) 结束
单一值
Promise 只能有一个完成值或一个拒绝理由。一般的建议是构造一个值封装(比如一个对象或数组)来保持多个信息。
单决议
还有很多异步的情况适合另一种模式——一种类似于事件和 / 或数据流的模式。
设想场景:你可能要启动一系列异步步骤以响应某种可能多次发生的激励(就像是事件),比如按钮点击。这样可能不会按照你的期望工作:
// click(..)把"click"事件绑定到一个DOM元素
// request(..)是前面定义的支持Promise的Ajax
var p = new Promise( function(resolve,reject){
click( "#mybtn", resolve );
} );
p.then( function(evt){
var btnID = evt.currentTarget.id;
return request( "http://some.url.1/?id=" + btnID );
} )
.then( function(text){
console.log( text );
} );
只有在按钮点击一次的情况下,这种方式才能工作。如果这个按钮被点击了第二次的话,promise p 已经决议,因此第二个 resolve(..) 调用就会被忽略。
解决办法:为每个事件的发生创建一整个新的 Promise 链。如下:
click( "#mybtn", function(evt){
var btnID = evt.currentTarget.id;
request( "http://some.url.1/?id=" + btnID )
.then( function(text){
console.log( text );
} );
} );
这个设计在某种程度上破坏了关注点与功能分离(SoC)的思想。
惯性
这里给到一个基于回调的代码转化为基于 Promise 的代码的范例:
if (!Promise.wrap) {
Promise.wrap = function(fn) {
return function() {
var args = [].slice.call( arguments );
return new Promise( function(resolve,reject){
fn.apply(
null,
args.concat( function(err,v){
if (err) {
reject( err );
}
else {
resolve( v );
}
} )
);
} );
};
};
}
// 使用
var request = Promise.wrap( ajax );
request( "http://some.url.1/" )
.then( .. )
..
我们见识到了闭包的强大之处。闭包是 JS 最强大的特性,没有之一。
无法取消的 Promise
一旦创建了一个 Promise 并为其注册了完成和 / 或拒绝处理函数,如果出现某种情况使得
这个任务悬而未决的话,你也没有办法从外部停止它的进程,一种选择是侵入式地定义你自己的决议回调,当然这很丑陋。如下:
var OK = true;
var p = foo( 42 );
Promise.race( [
p,
timeoutPromise( 3000 )
.catch( function(err){
OK = false;
throw err;
} )
] )
.then(
doSomething,
handleError
);
p.then( function(){
if (OK) {
// 只在没有超时情况下才会发生 :)
}
} );
Promise 性能
更多的工作,更多的保护,这些意味着 Promise 与不可信任的裸回调相比会更慢一些。
但我们不要耿耿于 Promise 微小的性能损失而无视它提供的所有优点,
虽然 Promise 稍慢一些,但是作为交换,得到的是大量内建的可信任性、对 Zalgo 的避免以及
可组合性。
😏😏😏 Promise 非常好,请使用。它们解决了我们因只用回调的代码而备受困扰的控制反转问题。
