JavaScript之异步 - Promise (一)

 JavaScript之异步 - Promise (一)

1. 背景


从前面的文章中得到回调表达程序异步和管理并发的两个主要缺陷:缺乏顺序性和可信任性。
回忆一下,我们用回调函数来封装程序中的 continuation,然后把回调交给第三方(甚至可能是外部代码),接着期待其能够调用回调,实现正确的功能。从之前的文章中也可以看到存在着信任问题。
但是,如果我们能够把控制反转再反转回来,会怎样呢?如果我们不把自己程序的continuation 传给第三方,而是希望第三方给我们提供了解其任务何时结束的能力,然后由我们自己的代码来决定下一步做什么,那将会怎样呢?这种范式就称为 Promise。

2. 什么是Promise


(1) 未来值

a. 举例
设想一下这样一个场景:
        我走到快餐店的柜台,点了一个芝士汉堡。我交给收银1.47 美元。通过下订单并付款,我已经发出了一个对某个值(就是那个汉堡)的请求。我已经启动了一次交易。但是,通常我不能马上就得到这个汉堡。收银员会交给我某个东西来代替汉堡:一张带有订单号的收据。订单号就是一个 IOU( I owe you,我欠你的) 承诺( promise),保证了最终我会得到我的汉堡。所以我得好好保留我的收据和订单号。我知道这代表了我未来的汉堡,所以不需要担心,只是现在我还是很饿!
        在等待的过程中,我可以做点其他的事情,比如给朋友发个短信:“嗨,要来和一起吃午饭吗?我正要吃芝士汉堡。”我已经在想着未来的芝士汉堡了,尽管现在我还没有拿到手。我的大脑之所以可以这么做,是因为它已经把订单号当作芝士汉堡的占位符了。从本质上讲,这个占位符使得这个值不再依赖时间。这是一个未来值。
        终于,我听到服务员在喊“订单 113”,然后愉快地拿着收据走到柜台,把收据交给收银员,换来了我的芝士汉堡。换句话说,一旦我需要的值准备好了,我就用我的承诺值( value-promise)换取这个值本身。
        但是,还可能有另一种结果。他们叫到了我的订单号,但当我过去拿芝士汉堡的时候,收银员满是歉意地告诉我:“不好意思,芝士汉堡卖完了。”除了作为顾客对这种情况感到愤怒之外,我们还可以看到未来值的一个重要特性:它可能成功,也可能失败。每次点芝士汉堡,我都知道最终要么得到一个芝士汉堡,要么得到一个汉堡包售罄的坏消息,那我就得找点别的当午饭了。

b.现在值与将来值
具体了解Promise的工作方式之前,先来通过我们已经了解的方式-回调(处理未来值)进行推导。
var x, y = 2;
console.log( x + y ); // NaN <-- 因为x还没有设定
这里我们在编写代码的时候就可以预知运算的结果,如果x, y 的值是通过回调函数之后我们才可以知道的呢?

设想如果可以通过一种方式表达:“把 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 ); // 是不是很容易?
} );
先来简单解释一下上面的代码,首先fetchX() 和fetchY()是同步或者异步函数,这二者函数会在执行完成之后回调的时候执行add function中的getX,getY,getX,getY中的匿名函数是回调函数。所以两个函数不管是谁先执行,都会去将对应的add函数的局部变量赋值,然后在另外一个回调函数执行的时候进行add操作。

这里对于两个回调函数其实存在着现在和将来的问题,因为两个回调函数在javascript引擎中执行是一个存在先后顺序的,但是由于我们在回调函数中加上了条件,也是前面文章中提到latch,所以对于add 操作符,x,y变量是同步的了,因为真正在执行add操作的时候,两个变量都是已知的了。这里也是将现在和将来归一化了。

c. promise值
现在我们将上面的代码用promise的方式实现。
function add(xPromise,yPromise) {
	// Promise.all([ .. ])接受一个promise数组并返回一个新的promise,
	// 这个新promise等待数组中的所有promise完成
	return Promise.all( [xPromise, yPromise] )
	// 这个promise决议之后,我们取得收到的X和Y值并加在一起
	.then( function(values){
		// values是来自于之前决议的Promise的消息数组
		return values[0] + values[1];
	} );
}
// fetchX()和fetchY()返回相应值的promise,可能已经就绪,
// 也可能以后就绪
add( fetchX(), fetchY() )
// 我们得到一个这两个数组的和的promise
// 现在链式调用 then(..)来等待返回promise的决议
.then( function(sum){
	console.log( sum ); // 这更简单!
} );
fetchX() 和 fetchY() 是直接调用的,它们的返回值( promise !)被传给 add(..)。这些promise 代表的底层值的可用时间可能是现在或将来,但不管怎样, promise 归一保证了行为的一致性。我们可以按照不依赖于时间的方式追踪值 X 和 Y。它们是未来值。

第二层是 add(..)(通过 Promise.all([ .. ]))创建并返回的 promise。我们通过调用then(..) 等待这个 promise。 add(..) 运算完成后,未来值 sum 就准备好了,可以打印出来。我们把等待未来值 X 和 Y 的逻辑隐藏在了 add(..) 内部。

Promise 的决议结果可能是拒绝而不是完成。拒绝值和完成的 Promise 不一样:完成值总是编程给出的,而拒绝值,通常称为拒绝原因( rejection reason),可能是程序逻辑直接设置的,也可能是从运行异常隐式得出的值。

通过 Promise,调用 then(..) 实际上可以接受两个函数,第一个用于完成情况(如前所示),第二个用于拒绝情况:
add( fetchX(), fetchY() )
.then(
	// 完成处理函数
	function(sum) {
	console.log( sum );
	},
	
	// 拒绝处理函数
	function(err) {
	console.error( err ); // 烦!
	}
);

如果在获取 X 或 Y 的过程中出错,或者在加法过程中出错, add(..) 返回的就是一个被拒绝的 promise,传给 then(..) 的第二个错误处理回调就会从这个 promise 中得到拒绝值。

Note 1: 从外部看,由于 Promise 封装了依赖于时间的状态——等待底层值的完成或拒绝,所以Promise 本身是与时间无关的。因此, Promise 可以按照可预测的方式组成(组合),而不用关心时序或底层的结果。

Note 2: 一旦 Promise 决议,它就永远保持在这个状态。此时它就成为了不变值( immutablevalue),可以根据需求多次查看。Promise 决议后就是外部不可变的值,我们可以安全地把这个值传递给第三方,并确信它不会被有意无意地修改。特别是对于多方查看同一个 Promise决议的情况,尤其如此。一方不可能影响另一方对 Promise 决议的观察结果。

Promise 是一种封装和组合未来值的易于复用的机制。

(2) 完成事件

a. event事件
单独的 Promise 展示了未来值的特性。但是,也可以从另外一个角度看待Promise 的决议:一种在异步任务中作为两个或更多步骤的流程控制机制,时序上的 this-then-that。

假定要调用一个函数 foo(..) 执行某个任务。我们不知道也不关心它的任何细节。这个函1数可能立即完成任务,也可能需要一段时间才能完成。我们只需要知道 foo(..) 什么时候结束,这样就可以进行下一个任务。换句话说,我们想要通过某种方式在 foo(..) 完成的时候得到通知,以便可以继续下一步。

使用回调的话,通知就是任务( foo(..))调用的回调。而使用 Promise 的话,我们把这个关系反转了过来,侦听来自 foo(..) 的事件,然后在得到通知的时候,根据情况继续。

首先,考虑以下代码:
function foo(x) {
	// 开始做点可能耗时的工作
	// 构造一个listener事件通知处理对象来返回
	return listener;
}


var evt = foo( 42 );
evt.on( "completion", function(){
// 可以进行下一步了!
} );
evt.on( "failure", function(err){
// 啊,foo(..)中出错了
} );
foo(..) 显式创建并返回了一个事件订阅对象,调用代码得到这个对象,并在其上注册了两个事件处理函数。相对于面向回调的代码,这里的反转是显而易见的,而且这也是有意为之。这里没有把回调传给 foo(..),而是返回一个名为 evt 的事件注册对象,由它来接受回调。如果你还记得回调本身就表达了一种控制反转。所以对回调模式的反转实际上是对反转的反转,或者称为反控制反转——把控制返还给调用代码,这也是我们最开始想要的效果。

因为对于之前的回调函数,是由第三方来直接调用的,但是现在的event只是由第三方触发,然后由event对于回调函数进行执行操作。

Note: 一个很重要的好处是,可以把这个事件侦听对象提供给代码中多个独立的部分;在foo(..) 完成的时候,它们都可以独立地得到通知,以执行下一步.这样就很好的实现了关注点分离。
var evt = foo( 42 );
// 让bar(..)侦听foo(..)的完成
bar( evt );
// 并且让baz(..)侦听foo(..)的完成
baz( evt );
对控制反转的恢复实现了更好的关注点分离,其中 bar(..) 和 baz(..) 不需要牵扯到foo(..) 的调用细节。类似地, foo(..) 不需要知道或关注 bar(..) 和 baz(..) 是否存在,或者是否在等待 foo(..) 的完成通知。
从本质上说, evt 对象就是分离的关注点之间一个中立的第三方协商机制。

b. Promise“事件”
你可能已经猜到,事件侦听对象 evt 就是 Promise 的一个模拟。在基于 Promise 的方法中,前面的代码片段会让 foo(..) 创建并返回一个 Promise 实例,而且这个 Promise 会被传递到 bar(..) 和 baz(..)。
function foo(x) {
	// 可是做一些可能耗时的工作
	// 构造并返回一个promise
	return new Promise( function(resolve,reject){
	// 最终调用resolve(..)或者reject(..)
	// 这是这个promise的决议回调
	} );
}
var p = foo( 42 );
bar( p );
baz( p );
new Promise( function(..){ .. } ) 模式通常称为 revealing constructor。传入的函数会立即执行(不会像 then(..) 中的回调一样异步延迟),它有两个参数,在本例中我们将其分别称为 resolve 和 reject。这些是 promise 的决议函数。resolve(..) 通常标识完成,而 reject(..) 则标识拒绝。

你可能会猜测 bar(..) 和 baz(..) 的内部实现或许如下:
function bar(fooPromise) {
// 侦听foo(..)完成
fooPromise.then(
	function(){
	// foo(..)已经完毕,所以执行bar(..)的任务
	},
	function(){
	// 啊,foo(..)中出错了!
	}
	);
}
// 对于baz(..)也是一样
Promise 决议并不一定要像前面将 Promise作为未来值查看时一样会涉及发送消息。它也可以只作为一种流程控制信号,就像前面这段代码中的用法一样。

另外一种实现方式是:
function bar() {
// foo(..)肯定已经完成,所以执行bar(..)的任务
}
function oopsBar() {
// 啊,foo(..)中出错了,所以bar(..)没有运行
}
// 对于baz()和oopsBaz()也是一样
var p = foo( 42 );
p.then( bar, oopsBar );
p.then( baz, oopsBaz );
这里没有把 promise p 传给 bar(..) 和 baz(..),而是使用 promise 控制 bar(..) 和 baz(..)何时执行,如果执行的话。最主要的区别在于错误处理部分。
在第一段代码的方法里,不论 foo(..) 成功与否, bar(..) 都会被调用。并且如果收到了foo(..) 失败的通知,它会亲自处理自己的回退逻辑。显然, baz(..) 也是如此。
在第二段代码中, bar(..) 只有在 foo(..) 成功时才会被调用,否则就会调用 oppsBar(..)。baz(..) 也是如此。
这两种方法本身并谈不上对错,只是各自适用于不同的情况。

3. Promise信任问题


先回顾一下只用回调编码的信任问题。把一个回调传入工具 foo(..) 时可能出现如下问题:
• 调用回调过早;
• 调用回调过晚(或不被调用);
• 调用回调次数过少或过多;
• 未能传递所需的环境和参数;
• 吞掉可能出现的错误和异常。
Promise 的特性就是专门用来为这些问题提供一个有效的可复用的答案。

(1). 调用过早
Promise是基于任务队列的,我们在看一下任务队列。

任务队列:挂在事件循环队列的每个 tick 之后的一个队列。在事件循环的每个 tick 中,可能出现的异步动作不会导致一个完整的新事件添加到事件循环队列中,而会在当前 tick 的任务队列末尾添加一个项目(一个任务)。这就像是在说:“哦,这里还有一件事将来要做,但要确保在其他任何事情发生之前就完成它。”

这个就是当前代码段执行完之后再去执行的任务,所以不会存在同步的问题,可以看一下回调函数 JavaScript之异步 - 回调函数中对于回调函数的处理, 这里在执行最后的任务时,已经是在同步代码执行完成之后。即使是立即完成的Promise(类似于 newPromise(function(resolve){ resolve(42); }))也无法被同步观察到。也就是说,对一个 Promise 调用 then(..) 的时候,即使这个 Promise 已经决议,提供给then(..) 的回调也总会被异步调用

(2) 调用过晚
这个问题还是要根据任务队列来解释, 在JS中ES6 中新增的任务队列(promise)是在事件循环之上的,事件循环每次 tick 后会查看 ES6 的任务队列中是否有任务要执行,也就是 ES6 的任务队列比事件循环中的任务(事件)队列优先级更高。

Promise 就使用了 ES6 的任务队列特性。也即在执行完任务栈后首先执行的是任务队列中的promise任务。

Promise 创建对象调用 resolve(..) 或 reject(..) 时,这个 Promise 的then(..)注册的观察回调就会被自动调度。可以确信,这些被调度的回调在下一个异步事件点上一定会被触发。也就是说,一个 Promise 决议后(准备好,也可以理解成当前的承诺值可以得到对应的值),这个 Promise 上所有的通过then(..) 注册的回调都会在下一个异步时机点上依次被立即调用,所以也就是一旦promise信号到达,回调函数就会紧跟着执行。

Promise 调度技巧
两个独立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 <-- 而不是像你可能认为的B A
p1 不是用立即值而是用另一个 promise p3 决议,后者本身决议为值 "B"。规定的行为是把 p3 展开到 p1,但是是异步地展开。所以,在异步任务队列中, p1 的回调排在 p2 的回调之后。

(3) 回调未调用
首先,没有任何东西(甚至 JavaScript 错误)能阻止 Promise向你通知它的决议(如果它决议了的话)。如果你对一个 Promise 注册了一个完成回调和一个拒绝回调,那么 Promise在决议时总是会调用其中的一个。

这里有一个问题: promise通过什么来判断它的决议,后面应该会碰到,现在我也不知道,后面的学习相信会有个答案,到时候会在更新这里。猜测应该是一个时间戳。

(4) 调用次数过少或过多
根据定义,回调被调用的正确次数应该是 1。“过少”的情况就是调用 0 次,和前面解释过的“未被”调用是同一种情况。

“过多”的情况很容易解释。 Promise 的定义方式使得它只能被决议一次。如果出于某种原因, Promise 创建代码试图调用 resolve(..) 或 reject(..) 多次,或者试图两者都调用,那么这个 Promise 将只会接受第一次决议,并默默地忽略任何后续调用。

(5) 未能传递参数 / 环境值
Promise 至多只能有一个决议值(完成或拒绝)。
如果你没有用任何值显式决议,那么这个值就是 undefined,这是 JavaScript 常见的处理方式。但不管这个值是什么,无论当前或未来,它都会被传给所有注册的(且适当的完成或拒绝)回调。

如果要传递多个值,你就必须要把它们封装在单个值中传递,比如通过一个数组或对象。

对环境来说, JavaScript 中的函数总是保持其定义所在的作用域的闭包,所以它们当然可以继续访问你提供的环境状态。

(6) 吞掉错误或异常
如果拒绝一个 Promise 并给出一个理由(也就是一个出错消息),这个值就会被传给拒绝回调。
如果在 Promise 的创建过程中或在查看其决议结果过程中的任何时间点上出现了一个 JavaScript 异常错误,比如一个 TypeError 或ReferenceError,那这个异常就会被捕捉,并且会使这个 Promise 被拒绝。

举例来说:
var p = new Promise( function(resolve,reject){
	foo.bar(); // foo未定义,所以会出错!
	resolve( 42 ); // 永远不会到达这里 :(
} );


p.then(
	function fulfilled(){
	// 永远不会到达这里 :(
	},
	function rejected(err){
	// err将会是一个TypeError异常对象来自foo.bar()这一行
	}
);
foo.bar() 中发生的 JavaScript 异常导致了 Promise 拒绝,你可以捕捉并对其作出响应。

这是一个重要的细节,出错可能会引起同步响应,而不出错则会是异步的。 Promise 甚至把 JavaScript 异常也变成了异步行为。相信你的同步代码中如果出现了错误,整个代码会直接挂掉。除非是你自己代码中加上了try catch

但是,如果 Promise 完成后在查看结果时( then(..) 注册的回调中)出现了 JavaScript 异常错误会怎样呢?即使这些异常不会被丢弃,但你会发现,对它们的处理方式还是有点出乎意料。
var p = new Promise( function(resolve,reject){
	resolve( 42 );
} );


p.then(
	function fulfilled(msg){
		foo.bar();
		console.log( msg ); // 永远不会到达这里 :(
	},
	function rejected(err){Promise | 195
	// 永远也不会到达这里 :(
	}
);

这看起来像是 foo.bar() 产生的异常真的被吞掉了。别担心,实际上并不是这样。但是这里有一个深藏的问题,就是我们没有侦听到它。 p.then(..) 调用本身返回了另外一个 promise,正是这个 promise 将会因 TypeError 异常而被拒绝。当时也没有调用错误处理程序啊?

为什么它不是简单地调用我们定义的错误处理函数呢?表面上的逻辑应该是这样啊。如果这样的话就违背了 Promise 的一条基本原则,即 Promise 一旦决议就不可再变。 p 已经完成为值 42,所以之后查看 p 的决议时,并不能因为出错就把 p 再变为一个拒绝。

除了违背原则之外,这样的行为也会造成严重的损害。因为假如这个 promise p 有多个then(..) 注册的回调的话,有些回调会被调用,而有些则不会,情况会非常不透明,难以解释。

(7) 是可信任的 Promise 吗
基于 Promise 模式建立信任还有最后一个细节需要讨论。你肯定已经注意到 Promise 并没有完全摆脱回调。它们只是改变了传递回调的位置。我们并不是把回调传递给 foo(..),而是从 foo(..) 得到某个东西(外观上看是一个真正的Promise),然后把回调传给这个东西。

但是,为什么这就比单纯使用回调更值得信任呢?如何能够确定返回的这个东西实际上就是一个可信任的 Promise 呢?这难道不是一个(脆弱的)纸牌屋,在里面只能信任我们已经信任的?

关于 Promise 的很重要但是常常被忽略的一个细节是, Promise 对这个问题已经有一个解决方案。包含在原生 ES6 Promise 实现中的解决方案就是 Promise.resolve(..)。
如果向 Promise.resolve(..) 传递一个非 Promise、非 thenable 的立即值,就会得到一个用这个值填充的 promise。
var p = {
	then: function(cb,errcb) {
		cb( 42 );
		errcb( "evil laugh" );
	}
};

p.then(
	function fulfilled(val){
		console.log( val ); // 42
	},
	function rejected(err){
		// 啊,不应该运行!
		console.log( err ); // 邪恶的笑
	}
);
这个 p 是一个 thenable,但是其行为和 promise 并不完全一致。这是恶意的吗?还只是因为它不知道 Promise 应该如何运作?说实话,这并不重要。不管是哪种情况,它都是不可信任的。尽管如此,我们还是都可以把这些版本的 p 传给 Promise.resolve(..),然后就会得到期望Promise中的规范化后的安全结果:

Promise.resolve( p )
.then(
	function fulfilled(val){
		console.log( val ); // 42
	},
	function rejected(err){
	// 永远不会到达这里
	}
);
Promise.resolve(..) 可以接受任何 thenable,将其解封为它的非 thenable 值。从 Promise.resolve(..) 得到的是一个真正的 Promise,是一个可以信任的值。
对于用 Promise.resolve(..) 为所有函数的返回值(不管是不是 thenable)都封装一层。另一个好处是,这样做很容易把函数调用规范为定义良好的异步任务。

到此,对于Promise的概念上的知识基本上学习完了,后面的主要就是真正在使用的内容了!
  • 1
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值