w3ctech

前端要给力之:红绿灯大战中的火星生命-Promise

本文由周爱民编写,原文地址:http://blog.csdn.net/aimingoo/article/details/45014325

传说的开始

前些日子看了三集的《浪客剑心》的电影版,它的最后一集是叫“传说的终结”。这几个字让我很感概:我不是剑心迷,我的一些80、90后朋友却是看着剑心漫画长大的,因此他们常讲的一些梗在我看来便如同究极深奥的科学谜题;然而当我有一天终于要看这“传说中的剑心”的时候,它却“终结”了。

我看这个片子纯粹是因为打得好看,看完也就知道“剑心是个在脸上画叉叉的高颜值冷男(好吧我承认为了显示我还算个跟得上时代的人我把最近听到的几乎所有的流行词以及流行的文字写法全用上了)”。然而面对这样的尴尬,究其根源却在于我是个不知道“传说的开始”的家伙。

所以,今次讲Promise,请容我从这场“红绿灯大战”的起源讲起,更早的,再讲讲我与Promise的故事。至于这样做会不会让读者对Promise这个东西有更深入或更新颖的了解,我实在不知,我只是觉得:当一个传说已经过去,而去看这个故事的人既然不知道它的起始,真是悲剧。

看到winter的代码,我的第一反应是:全无promise的精髓

公历年2015年4月10日,这天中午,天气晴好,时间是正正经的12:00的时候,裕波同学在微博上@我跟hax:

谁来当裁判?

这要裁判的便是@寒冬winter 的一段代码(winter的代码#31,在这里)。这段代码是winter向@十年踪迹 同学宣战的,原文是:

我表示不服!来PK呀!

代码在code.w3ctech上的标题也是两个字母:pk。

这就是后来史书家所称的“红绿灯大战”了。我随后回复winter的微博,写到:

老实说,你写得不怎么样。全无promise的精髓。

我一开始就把自己搅进了战局,这样不好。

不过也正是这样,才有了后面的好故事。至于现在,看客们请耐点心,请容我再讲讲更早一点的故事。

其实我了解Promise也是新近的事情

hax总是跟近最新的ECMAScript规范以及JavaScript圈子的新近话题。相反的,我则不同,我不是太追新,在JS/前端的圈子中也总是伪前端自居。所以,请原谅我这么迟才了解到Promise这个东西,甚至于我对nodejs的callback hell有深切的感受也还是新近的事情。

这是在今年元旦前后,我因为一个项目要写RESTApi接口,而选用了nodejs来写一个test case。因此,大家可以想见的,我一定会面临callback,并显而易见地会痛恨之。所以,我就就写了一个名为Continuer的项目,源代码中还写着“Callback Must Die!”。

所以,我最初想用来搞掉callback的方法,其实是表现在Continuer这个项目中的(continuer@github)。

这个项目开源了并在微博上引起了一些前端同学的兴趣。这个时候便有人提到了Promise,质疑为什么在有了Promise的情况下还要做Continuer这样的东西。随后我就在翻读微博的时候,看到了hax与@孢子响马 同学的吵架贴,在这里在这里,快来看呀,打架好好看。hax向来被我称为吵架王的(有没有周星星电影的即视感),所以他在回复时言辞激烈那是再正常不过了——所以你看我就说“hax这10年来风格依旧,奥柏伦亲王真是大爱啊(这个梗要到“冰与火之歌”中去找)”,这完全符合他“认为正确就要誓死悍卫(或称为死性不改)”的风格。

hax跟孢子响马讨论的是fibjs中解决异步的方式问题,hax的主要观点是

“fib的问题并不是说他用的人少所以不好,而是说你选的不兼容道路导致工程上要采纳这个方案有很多障碍。而这障碍来自于一个没有明显优势(如果不是劣势)的编程模型。”

在讨论其中关于“用的人多人少”的问题时,hax批评一种“(你得)用了才知道好不好”的观点时,说了一段我非常非常在意的话:

第一,你不可能所有东西都尝试一遍。第二,有些东西你抓住重点看一下就可以推导出结果了。当然我的具体意见可能是错的,可能是出于误解,可能是某些我的基本前提不对,但是希望看到针对性的反驳而不是简单来一句“爱用不用”。

hax的意思是说:有些正确性是可以推论出来的,并不因为实用经验多寡的而改变。这是我这么些年来对hax的了解中,他讲过的最哲学而又最逻辑的话(其它的大多数时候,他的哲学正确与逻辑正确是分离的,^^.)。

好了,这也就是我了解Promise的源起,它来自于另一场战争。那场战争比今次所讨论的要激烈得多。许多猛士在那场地战争中倒下了。这也包括我。我后来因为这个缘故写了一篇《关于Continuer的What与Why》来解释我为什么写Continuer这个项目(在这里),这篇文字把我面临的问题锁定在“需要一个顺序执行的序列(以用于run testcases)”。在这个问题下面,写一个轻量的Continuer模块,并不算得“一件多么不正确”的事。

hax后来接受了我的观点,一半是给我面子,另一半大概是懒得跟我吵架(我是吵架王的那个时代已经一去不返了,hax同学请继续坚持,我们这代人就靠你了)。因为,我在上面这篇文章里说Promise“在概念上仍然是基于事件触发的”,在我如今看来,这句话是大大的错了。

我相信这样的问题hax是看得到的。

我与Promise后来发生的故事

我原本打算春节期间用点时间来讨论一下javascript中关于异步的几种解决方案的,但当时忙于ngx_cc项目的开源所以耽误了。而春节之后,公司的项目追得又特别紧,所以一直拖着。

到了3月初的时候,公司项目中有一个地方需要设计一种编程模型。这种编程模型是什么样的呢?它将所有的东西都理解为“一个带有服务能力的数据”,这在scala里,就是一个actor。Ok,当任何东西都变成这样一个“独立存在的actor”时,我们该怎么编程呢?

这个时候我想到了Promise。Promise编程的核心思想其实是:

如果数据就绪(promised),那么(then)做点什么。

假定我们设定:

对于“独立存在的actor”来说,这个actor(以及actor中的部分或全部成员)是否ready,是驱动后续逻辑的唯一方式。

那么这种方式实现的框架,就是纯异步模式的框架了。因为它从逻辑上是纯异步的,而在数据上,也是原生的、自然的分布式的。

我立即开始着手这个框架了,一方面Continuer被我抛得远远的(在实用中也发现了不少的问题),另一方面我打算写的文字也被继续搁置了。而我在这个框架上要解决的第一个问题,其实是:Lua不支持Promise。

呵呵,我是要在ngx_cc这个项目上(这个项目是nginx集群通讯的,在这里)继续做些事情,当然得考虑到语言问题。于是就着手写了一个真正实现Promise的Lua库(你能找到的所有所有lua-promise库都达不到真正ECMAScript兼容)。做这件事的时候是在三亚。话说这次三亚的“F100技术领袖峰会(3月20-22)”到底有多么“技术领袖”呢?主要的表征之一,就是所有人中就只剩下我一个还在写代码的了。我一边开着会跟大家讨论软件工程、设计艺术、技术领导的风格与公司组织架构之间的关系以及传统企业的互联网转型过程中技术决策者的价值……等等,另一面写着这样一个Promise for Lua的库。

离开三亚的时候,我跟麦子同学说:Promise库写完了。麦子同学一脸茫然和无限深情地看着我:老公,你潜水的时候像条鱼。

红绿灯大战的亲历实录

红绿灯大战中,我join进去的时候已经是winter的挑战了,这事实上应该是@winter 对@十年踪迹 一次还击。十年踪迹同学最早是写了这样一个例子(十年踪迹的代码#30,在这里):

function promiseDef(async, i, j){
  return function(){
    var args = [].slice.call(arguments);
    var self = this;
    return new Promise(function(resolve, reject) {
      if(i != null){
        args.splice(i, 0, resolve);
      }else{
        args.push(resolve);
      }
      if(j != null){
        args.splice(j, 0, reject);
      }else{
        args.push(reject);
      }
      async.apply(self, args);
    });
  }
}


//红绿灯切换:绿 5s -> 黄 2s -> 红 5s 循环

var greenPromise = promiseDef(setTimeout, 0).bind(null, 5000);
var yelloPromise = promiseDef(setTimeout, 0).bind(null, 2000);
var redPromise = promiseDef(setTimeout, 0).bind(null, 5000);
var traffic = document.getElementById('traffic');

(function restart(){'use strict'
  greenPromise()
    .then(function(){
      traffic.className = 'yellow';
      return yelloPromise();
    })
    .then(function(){
      traffic.className = 'red';
      return redPromise();
    })
    .then(function(){
      traffic.className = 'green';
      restart();
    });
})();

来说明(在这里):

过程抽象之promise化——用过程抽象的思路将一个普通的异步函数“变换”成promise形式~

而winter就写了下面这段代码来约战(winter的代码#31,在这里):

function turnGreen(){
    return new Promise(function(resolve, reject) {
        traffic.className = 'green';
        resolve();
    })
}
function turnRed(){
    return new Promise(function(resolve, reject) {
        traffic.className = 'red';
        resolve();
    })
}
function turnYellow(){
    return new Promise(function(resolve, reject) {
        traffic.className = 'yellow';
        resolve();
    })
}
function wait5000(){
    return new Promise(function(resolve, reject) {
        setTimeout(resolve,5000);
    })
}
function wait2000(){
    return new Promise(function(resolve, reject) {
        setTimeout(resolve,2000);
    })
}

//执行!
void function (){
    turnGreen()
    .then(wait5000)
    .then(turnYellow)
    .then(wait2000)
    .then(turnRed)
    .then(wait5000)
    .then(arguments.callee)
}();

我呢,我的第一反应是:winter这个也不是Promise的调调呀。我当时还并不知道@十年踪迹 的原始代码的样子,也不知道这场大战的原委。我只是觉得:

Promise写出来怎么会是这个样子?

好吧!这真的是一个问题,“Promise写出来的样子”是什么样子?我没有细思考这个问题,我就手写了一个,表示“可以是这样”(aimingoo的代码#41, 在这里):

function turn(color) {
  traffic.className = color;
  return new Promise(function(resolve) {
    setTimeout(function() { resolve(this.next) }.bind(this), this.wait)
  }.bind(this))
}

turn.and = turn.bind; // 仅为了可读性
void function (){
  Promise.resolve('green')
    .then(turn.and({wait: 5000, next: 'yellow'}))
    .then(turn.and({wait: 2000, next: 'red'}))
    .then(turn.and({wait: 5000}))
    .then(arguments.callee)
}();

有人要跳出来了吧!有人要大喊NB了吧?!浅薄啊。我可不是为了显摆这个才在这里话痨的。如果仅是为了一些代码行数或者可读性上的差异,就要在这里吧啦吧啦一堆,那得是hax十年前的调性。

这三段代码的好坏,我们晚点在说。反正当时裕波同学是笑出了眼泪的,然后这个case就在前端圈子里面传开了,然后就出现了很多很多种“不同的样子”:


@winter寒冬 强调turn/wait要分离提出的#46:在这里,与#39有相似之处。

@米粽粽 在#41上的改进型#51:在这里,这是我后来认为“完美”的版本。

@winter寒冬 根本停不下来,张牙舞爪ES6版!#52:在这里,这是使用yield的版本。

@青空残红 的#45和#48版本,在这里这里,前者尝试对Promise的“.then只能传递一个值”作修补;后者则采用深度递归来处理数组中的每个颜色和时间值。

@随机 的全栈解决方案 #69,在这里,这个方案无关promise,而是引入服务端来做计时器,用ajax/jsonp来处理回调的

@十年踪迹 引入Task概念,将红绿灯模拟(或声明、描述)成任务的方案 #57,在这里

@-Lucifier- 的玩坏promise版 #49,在这里,这个方案也无关promise,是直接定义红绿灯状态/转换过程,然后用setTimeout回调来处理循环/链的。Lucifier强调:将过程碎片化的promise不是好promise,数据被打散从而无法组织。

@-Lucifier- 过程与数据分离的promise版 #59,在这里,这个版本是#49的改进,加入了promise。

@教主 逗比版(作者自己说的)#42,在这里,这个也是否定promise的版本,作者可能只是想表达一下情绪。^^.

@ECMA #40,在这里,分别处理了color/timer定义。

(注#40、#42、#49、#59等在讨论另一个 session:如果定义的红绿灯规则频繁变化,例如要加个紫灯怎么办。在这里

还有一些我找不到出处的版本,比如#183 在这里,事实上这个写得我也看不懂了。


欢迎回到火星

是的,我们刚才的地球探险结束了,火星文明在地球毁灭之后成了银河系中唯一有生命居住的地方。尽管单细胞生命过于原始,但总之好过于地球上的一片荒芜。

不要再问我“地球文明毁灭于什么”这样的问题了。

感谢“米粽粽”同学提到我的一篇旧文

事实上,让我换了一个角度思考这个问题的,正是我自己的那篇旧文,感谢“米粽粽”同学提到这篇文章:

《前端要给力之:代码可以有多烂?》 在这里

那是另外的一段历史,那段历史揭示了这样一个真理:

写一段牛B的代码,不如写一段有用的代码。

然而此时,我所关注的并不是这个。我在读那篇文章时看到自己曾经的一个思考:

第一步的抽象通常是最关键的。

让我们回到火星吧,那里的单细胞生物也比复杂的地球人类好得很多。我们也许不解决任何问题,但我们知道什么是正确的。

我相信hax的那个判断:如果它能证明正确,那就是正确的。并不因为人多的就是文明,而一个细胞就不能为自己发出声音。

第一步的抽象

我们所有上面的例子,都做错了第一步。

我们所有的例子,从@十年踪迹 的第一个例子开始,都定义错了问题。大家不约而同地把问题想像成了:

  • 先画一个红灯
  • 再等5000ms
  • 再画一个黄灯
  • 循环到第一步

有一部分人将这个问题理解成另一种行为模式:

  • 画一个红灯,等5000ms;然后
  • 画一个黄灯,等…;然后
  • 循环到第一步

注意这个“循环到第一步”。在所有人的抽象里面,“循环到第一步”都是一个大问题。一方面,它总一个独立而又不同的步骤,所以它会打乱了所有的节奏,使得在画灯(turn)和等待(timeout)之外出现了一个“特殊的动作”;另一方面,它无论采用函数递归还是数组遍历来实现,都完全是基于一种旧的、非Promise化的思维模式。而正是这种思绪模式害了我们,使我们一步步地远离了真相,也远离了整个问题的本质。

这种思维模式是:命令式编程。

一点说明/前提

在所有后续的讨论之间,我需要先说明一点。整个问题的最初提出,是@十年踪迹 的一段关于Promise如何使用的代码,十年踪迹先定义(def)了一些Promise,然后用.then()把它们连续起来,在最后一个.then()中它递归调用restart()来实现循环。整个过程如@十年踪迹 所说的,是Promise应用的一个示例。而Winter与它PK的,以及裕波m给我和hax需要去评判的,也是“如何写好Promise”的一个问题。

所以我们不讨论非Promise实现,也不讨论Promise在特定问题上是否“更好/更不好”的问题,而是讨论“怎样才算是正确的Promise实现”。

重新定义问题

我们说“循环到第一步”是一个大问题,是指它难于处理,而不是说它是“问题的本身”。在前面的例子中,没有人想到这样的一件事,既然是:

  • 画一个红灯,等5000ms;然后

那么,在红灯之前在做什么?

而答案可以是这样:在红灯之前,请先等待0ms。如此一来,整个的操作就变成了这样:

  • 等0ms,画一个红灯
  • 等5000ms,画一个黄灯
  • 等2000ms,画一个绿灯
  • 等5000ms,回到第一步

然而这样定义问题的好处是什么呢?这样一来,我们可以把每一组操作理解这样两个数据:

time, color

以及加在这对数据上的一个行为(WaitAndDo)或一个行为序列(Wait time, and Do turn color)。至于最后一个“操作”,它的模式跟上面没有区别,只是第二个行为是Do call,而不是Do turn color。

然而,为什么要这么来定义问题呢?

原因就是我们前面就一再提到过的,Promise编程的核心思想其实是:

如果数据就绪(promised),那么(then)做点什么。

这决定了在Promise架构下的“正确的思绪方式”。

看到了吗?我们正确的,要处理的东西是这样:

var promisedData = [
        [0,     trun.bind('green')],
        [5000,  trun.bind('yellow')],
        [2000,  trun.bind('red')],
        [5000,  arguments.callee]
];

注意,在每对数据(step)中,元素step[0]是timeout的ms值,而step[1]是一个行为——行为也是数据,它是另一个被称为“调用”的行为的处理对象。

Promise的编程基础之逻辑过程

有了上面的数据抽象之后,我们该怎么处理逻辑过程呢?这总结起来只有如下的三步。

如何确认一个数据“就绪”

无论就绪是指“成功”还是“失败”,一旦这个数据可供处理我们就称为就绪。就我们现在的理解中,一个数据要么在声明出来就已经就绪,要么是过一段时间,由一个异步过程来“使之”就绪。

对于前者,Promise提供三种方法简单地得到这个数据的Promise实例:

Promise.reslove() 
Promise.reject() 
Promise.all()

很多人在处理Promise时会绕圈子,比如以为Promise总是异步的。在winter最早写的#31中就会这样:

function turnGreen(){
    return new Promise(function(resolve, reject) {
        traffic.className = 'green';
        resolve();
    })
}

这样的Promise是不需要用new来实现的。它表明的意思,在Promise架构下应该是这样来实现:

var turn = function(color) {
    traffic.className = color;
}

Promise.resolve('green')  // 数据就绪
    .then(turn)           // 做点什么

所以上面的代码在实现逻辑上写成:

// turn()略
var print = function(msg) {
    console.log(msg)
}

var promised = Promise.resolve('green');
promised.then(turn);
promised.then(print);

也是合理的。这里的代码看起来过程式、面向对象,但实际上也是Promise化的。这与Promise的基本思想一点儿也不矛盾。

而使用new关键字的:

new Promise(func)

这种方法,通常是用于func是一个异步过程的情况。——如果func是一个普通的(同步的)过程,那么它调用时就会直接返回,那简单地:

promised = Promise.resolve(func());

就好了 。而new Promise(func)中,要求这个func接收两个参数的目的也就在这里:

promised = new Promise(function func(resolve, reject) {
   ...
});

对于func来说,它的函数返回值对new Promise()过程,以及其结果promised来说都是无关紧要的。它只需要在合适的时候调用resolve/reject即可。

但我们必须强调:Promise的编程理念中,“是不是异步系统”一点儿也不关键。甚至可以说,“在异步系统中使用”只是这种理念中的一个处理技巧。

然后(then)只能处理一个数据

在.then(fulfilled)中的函数fulfilled不但只能处理一个数据,而且只能处理“刚才”就绪的那个数据。这个所谓刚才,只是指在语法上的顺序。例如:

// ...
promisedA.then(func1);
promisedB.then(func2);
func3();
});

这表明func1处理A在刚才promised的数据,而func2处理B在刚才promised的数据。由于promised可以是来自用

new Promise()

异步得到的一个数据,因此所谓“刚才”仅是指在执行func1之前,而与语法上的.then()——的出现顺序——无关。所以,正确的说法是:

  • promisedA.then()
  • promisedB.then()
  • func3()

总是立即地、顺序地执行,而func1()和func2()的调用时间取决于promisedA/B就绪的时间。

而如上面所说,.then()中的函数只能处理一个数据,这个数据就是就绪的那个数据(有些文档称“将数据从promised中解包”)。这个“只处理一个”是Promise思想本质上决定的,任何试图去改变这一点的企图都将导致灾难。我后来称@米粽粽 的版本最好,是因为它在这一点上是绕过去了,而不是去“fix掉它”:

function turn(color, duration) {
  return new Promise(function(resolve, reject) {
    traffic.className = color
    setTimeout(resolve, duration)
  })
}

//执行!
void function run() {
    turn('green', 1000)
    .then(turn.bind(null, 'yellow', 400))
    .then(turn.bind(null, 'red', 1000))
    .then(run)
}()

请注意这个turn.bind利用了bind的特性在函数闭包中传参,而在turn()处理的代码中,resolve实际上不返回也不“就绪“任何数据。所以.then()链中并没有数据流,而只有逻辑顺序。

与之对照的,@青空残红 的#45代码就试图使then()链中传递多个数据(在这里):

typeof time == 'number' ? setTimeout(function(){resolve.apply(null, Array.prototype.slice(arg))},time) : resolve.apply(null, Array.prototype.slice(arg));

最后,任何情况下.then()总是立即返回一个promise

.then()总是“!!立即!!”返回一个promise,而这是很少有人理解的部分。大多数人会提出“这个返回什么时候发生”这样的问题。而事实上,这总是立即发生的。——如前所说,Promise的这一切都与“异步/同步”无关。你必须非常明确:

Promise.prototype.then()在执行过程中是立即返回,并总是返回一个新的promise2。

不明确的只是:这个promise(内的数据)是否就绪,或什么时候就绪。

只有在就绪的时候,.then()中的函数才会被调用。这也是.then方法被声明为:

Promise.prototype.then(onFulfilled, onRejected);

的原因:一是所有的promise原型中就有的方法,二是它的两个参数都是事件句柄(直到“数据就绪”事件触发时/触发后才执行)。

在一定程度上,类似这样的说法:

promised.then()的返回值总是下一个.then()的入口值

只是一种假象。它更准确的说法是:

  • promised.then()返回的是一个新的promise2;而
  • promise2的就绪,是由promise.then(onFulfilled, onRejected)中onFulfilled/onRejected的返回值所决定的。

下面的示例说明这一点:

// case 1: 返回任意值(包括undefind/Error实例)
//   - 将被以Promise.resolve(value)的方式返回到promise2
promise2 = promise.then(function() {
    return 'true'
})

// case 2: 返回一个新的promise,这个promise可以是就绪(resolved/rejected)的数据
//  - 也可以是未就绪的(异步调用处理)的数据
promise2 = promise.then(function() {
    return Promise.reject('ERROR')
})


// case 3: 任何时候,代码执行错或throw,都将使promise2得到一个rejected的数据
promise2 = promise.then(function() {
    throw new Error('ERROR')
})

// case 4: 在onRejected()的处理中,也可以返回resolved的数据
var _ = undefined;
promise2 = promise.then(_, function() {
    return Promise.resolve('OK')
})

解决问题的方法

我们已经得到了那个原始问题的、基于Promise思想的数据定义:

var promisedData = [
        [0,     trun.bind('green')],
        [5000,  trun.bind('yellow')],
        [2000,  trun.bind('red')],
        [5000,  arguments.callee]
];

那么,接下来呢?

得到一些基础件

我们先想像一下,我们能“就绪”的,和接下来要处理的是什么。在这个问题中,我们留意到,我们事实上要处理上述4个项中的“每一个”;具体到每一个,都是一个简单的“等待,然后调用(WaitAndDo)”。那么,对“每一个”来说,我们需要一个迭代器来列举之。这个好办,在chrome中array.entries()就可以得到它了;而对每一次的“等待+调用(WaitAndDo)”,我们用一个runner来处理每step的数据。

这样一来,我们可以先得到一些与基本的数据和处理。这些与具体的逻辑是无关的:

function turn() {
    traffic.className = this;
}

function runner(step){
    return new Promise(function(resolve) {
        setTimeout(resolve, step[0]);
    }).then(step[1])
}

var promisedData = [
    [0,     turn.bind('green')],  // step1
    [5000,  turn.bind('yellow')], // step2
    [2000,  turn.bind('red')],    // ...
    [5000,  main]
];

function main() {
    var iteratorPromise = Promise.resolve(promisedData.entries());
    ...
}

注意这里的iteratorPromise,它是entries()的返回结果,是一个iterator。如果在别的js引擎里,你可能需要其它方式得到它。当然,要手写一个也不难,不会用到.yield。此外,promisedData 可以放在main外面,只是因为它利用了main函数名在当前闭包中可用(而与声明的顺序无关),如果不这样,你可能真得把它放在main函数内部并用argument.callee来得到它。

如何在Promise中处理迭代

在Promise中处理迭代是一件比较麻烦的事,原因是Promise本身并不考虑“流程/逻辑”的问题——再再一次强调Promise关注的是“数据就绪”,是面向数据思考的。

处理这样的迭代的通常思路是使用一个循环,比如用递归函数来实现。考虑到Promise的特性,所以递归函数应该返回一个新的Promise实例,并且总是用Promise链的最后一个.then()来进入下一次递归。这样的一来,main()函数的逻辑就很明显了:

// ...

// 获得迭代器中的iterator.next()的成员,并使之resolved
//  - 通过picker.bind()将iterator绑定到this
function picker(item) {
    return (item = this.next()).done ? Promise.reject(item) : Promise.resolve(item.value[1]);
}

function main() {
    var iteratorPromise = Promise.resolve(promisedData.entries());
    iteratorPromise.then(function(iterator) {
        var getPromisedItem = picker.bind(iterator);
        return function loop() {
            return getPromisedItem().then(runner).then(loop);
        }()
    })
}

完整的代码#73(在这里)。我通常会用一些技巧来把代码写得更像函数式一点,但那些仅仅是技巧而已,例如另一份类似的代码是#60(在这里),只是在loop的处理上有些不同,但整个框架是一致的。

我更喜欢在#73中对picker的使用,这个picker通用性很好,没有负担,而且也不影响在后面的代码组织,看起来很清洁的样子。但picker和#60中的loop的关系,就跟Array.prototype.forEach与for (var i=0, …)两种循环类似,不影响使用Promise来解题的思路了。

迭代中的一些其它问题

正如一些语言禁止访问for循环的中间变量(或其“返回”)一样,将“迭代”用于“完成一批处理/得到一个数据”是两种不同的抽象,因此也有不同的迭代设计。但总的来说,这是循环结构的一种。

“结构程序设计”中不是讨论了三种吗?顺序结构是显而易见的,而分支结构在Promise中通过.then(yes, no)这样的语义来表达。当然,在程序内部,你也可以用这样的语言来控制(后续的)流程。这是上面picker()设计中采用的一处技巧:

function picker(item) {
    return (item = this.next()).done ? Promise.reject(item) : Promise.resolve(item.value[1]);
}

当iterator.next()到done时,picker将返回Promise.reject(item)。这里是否使用item并不重要,关键在于它reject了一个值。因此,这时在main()的loop()函数中:

function loop() {
    return getPromisedItem().then(runner).then(loop);
}

then()链只响应了onFulfilled而没有处理onRejected,于是得以退出循环。并且最终整个loop()向外面的main()返回的,也将是一个reject()的item,它是iterator的迭代结果值:{ done: true, value: undefined }。

这可能不是你想要的。一方面,在整个过程中要考虑是否响应onRejected,另一方面,整个迭代如果真的需要返回值(例如汇总)呢?

如果“迭代”是一个过程,那么如何做到这个过程对函数外无副作用?这的确需要一些设计,但不是没有现成的解决方法,要不你认为array.forEach()中传入thisObject是来做什么的?至于onRejected,反倒是最容易理解的:该迭代没有设计有效的返回。

一点点好处

这样解决问题究竟有什么价值呢?

我们把原始问题抽象成了“数据就绪,则处理之”的简单模型,所以你会留意到对于更复杂的情况,只要是可以用:

[wait, andRun]

数据模型表示的序列,都可以由交由上面的框架去run,整个的main()逻辑上并不需要修改。如果是不需要循环,也只是需要将

[5000, main]

这个item从promisedData[]中抽掉即可。可见,main()带来了一个清晰、稳定和可靠的执行器环境 。

接下来,让我们再一次回到原始的问题。看起来我们的“第一步的抽象”还算不错,但实际上仍有那么些粗糙的。比如我们其实可以将2000ms理解为“一个2000的值,和一个称为timeout的行为”这样的一对数据。在这个基础上,我们可以得到更精确/精美的promisedData 和相关的runner。如下(#184):

function timeout(ms) {
    return new Promise(function(next) { setTimeout(next, ms) })
}
function turn(color) {
    return Promise.resolve(traffic.className = color);
}
function runner(data){
    // 请试想这里为什么不直接用data[1](data[0]) ?
    return Promise.resolve(data[0]).then(data[1]);
}
var promisedData = [
    [0,        timeout],  // step1
    ['green',  turn],     // step2
    [5000,     timeout],  // ...
    ['yellow', turn],
    [2000,     timeout],
    ['red',    turn],
    [5000,     timeout],
    [undefined, main]
];

// (其它同于代码#73)
// ...

考虑到更通用的情况,为什么我们不能将“所有的东西”都理解为一个数据呢?又或者将andRun元素理解为数据的模型该如何做呢?

当我们将上述step1也理解为“一个行为的一组数据对”的时候,我们就可以“自然而然地”想到,如果“0”不是一个时间,而是一个用表明“远端服务的状态是否就绪”呢?那么,上面这个程序可以非常非常简单地扩展到全栈:

var waiting = new Promise(function(resolve, reject) {
    // ajaxLoad and exec resolve() in callback, or reject anything
});
var promisedData = [
    [waiting,     timeout],  // step1
    ...
];

// (没什么要改的了)
// ...

其它

  1. 我写的Promise in Lua还没有发布。
  2. 所有代码在code.w3ctech上可以找到,我的在这里:http://code.w3ctech.com/4204
  3. “前端要给力”这个系列是很久以前就写的了:
    1. 前端要给力之:代码可以有多烂?
    2. 前端要给力之:原子,与原子联结的友类、友函数
    3. 前端要给力之:分解对象构造过程new()
    4. 前端要给力之:URL应该有多长?
  4. 本文最后一个示例是说明在“全栈”的背景下,正确使用Promise可以得到很好的系统弹性。
  5. 在.then()界面上处理多个参数的方法,是使用Promise.all()来就绪一组数据并作为参数。我建议使用工具函数unpack来处理调用界面部分,例如(#185):
function unpack(promised) {
    return this.apply(null, promised)
}

Promise.all([1,2,3])
    .then(unpack.bind(function(a, b, c) {
        console.log('result:', a +b +c);
});
w3ctech微信

扫码关注w3ctech微信公众号

共收到3条回复

  • 深受本文、链接中的文章、continuer启发

    回复此楼
  • 很值得学习,现在还在惊讶中没回过神,太棒

    回复此楼
  • 波波 你可以写小说了。

    回复此楼