受禁锢的异步编程思维
2011-12-19 21:49 by 老赵, 8087 visits最近一直在努力推广Jscex,补充了很多中文文档和示例,因此博客上都已经有两篇文章有了“上”而没有“下”,即使最复杂的图示也已经绘制完毕。在推广Jscex的过程中,我发现有个比较明显的问题是,许多使用JavaScript的程序员已经习惯旧有的编程方式,甚至推崇一些据他们说很“漂亮”的模式。但在我看来,这其实跟许多GoF模式是在修补OO语言的不足有些类似,很多异步模式都只是因为JavaScript语言特性不足而设计出来的“权宜之计”。我们在传统JavaScript编程环境下并没有其他选择,单纯地认为这是“美”,说实话只不过是一种安慰罢了。
Jscex的重头戏便是处理异步操作,但异步操作并不只是如Node.js中通过回调函数传回结果的那些方法,或者是网页上的AJAX请求等等。异步操作的定义其实可以概括成“会在未来某个时刻完成的操作”,就只是这么简单。什么事情会在未来发生,那么它对你来说就是个异步操作。因此其实在日常开发过程中可谓到处是异步操作,例如:
- 播放动画(播放会在未来结束)
- 模态对话框关闭(模态对话框会在未来关闭)
- 用户操作(用户会在未来点击按钮)
- 各类事件(数据流会在未来关闭,WebWorker会在未来获得消息,图片会在未来加载成功)
这些示例实在数不胜数。但是,在许多JavaScript程序员眼中,似乎只有AJAX或是Node.js中的那些异步方法才算是异步操作。其他的东西,比如用户点击一个按钮,这难道不是个天然的“事件”吗?其实这就要视这个异步任务的性质如何了。如果它是一系列操作的“发起者”,那么的确,使用事件触发的方式来对待这次点击操作可能是最合理的。但如果,这个操作只是一系列过程中的一个步骤,那么如果依然把它视为一个事件型的操作,就只会破坏我们的逻辑了。
举个例子,和Jscex的快速入门比较类似,即菲薄纳契(Fibonacci)数列:
其边界情况为:
以上是其标准定义,直接写成算法即是:
var fib = function () { console.log(0); console.log(1); var a = 0, current = 1; while (true) { var b = a; a = current; current = a + b; console.log(current); } };
上述代码将会无限地循环下去,不断输出数列的每一项。快速入门里的要求,是将其修改为“每隔一秒输出一个数字”,于是有同学就说:这不天生是计时器的场景吗?但事实并非如此。“计时器”或是setTimeout函数,都只是环境提供给我们的唯一可用的功能,我们要意识到这不是我们主动的“选择”。如果一看到“每隔一秒”这样的需求,JavaScript程序员就认为“计时器”是“最好”的办法,这就说明思维被禁锢了。我相信这样的功能交给其他任何平台的程序员,他们的第一感觉几乎都会是“使用Sleep函数暂停一秒”。这其实才是最简单的做法,直接,清晰,完整保留现有代码逻辑。
这也是基于Jscex之后的实现方式。这里我再将要求修改一下,改为用户“每点击一次按钮”输出一个数字,又该怎么做?基于Jscex的做法如下:
var Async = Jscex.Async; var fibAsync = eval(Jscex.compile("async", function () { var button = document.getElementById("button"); $await(Async.onEvent(button, "click")); // 等待用户点击 console.log(0); $await(Async.onEvent(button, "click")); // 等待用户点击 console.log(1); var a = 0, current = 1; while (true) { var b = a; a = current; current = a + b; $await(Async.onEvent(button, "click")); // 等待用户点击 console.log(current); } })); fibAsync().start();
有朋友可能会问:用户点击按钮不是需要响应事件的嘛,这个事件到哪里去了?其实正像我所说的那样,把这里的“用户点击按钮”当作事件对待并非最合理的方式,因为它只是“整个过程”中的一个环节而已。在这里,我们其实只是要在输出数字之前“等待用户点击”即可,这个“输出”以及相关的“计算”操作,并非是由“按钮点击”所触发的逻辑,而是一个连续的统一过程中的一部分而已。
您可以试试纯粹使用事件机制来实现这个功能,保证您需要重新实现这段斐波那契数列的逻辑。当然,菲薄纳契数列的逻辑很简单,重写下估计也不会花太大的功夫,但如果您需要改造汉诺塔的动画效果呢?
var hanoiAsync = eval(Jscex.compile("async", function (n, from, to, mid) { if (n > 0) { $await(hanoiAsync(n - 1, from, mid, to)); } // 等待按钮点击 // var btnNext = document.getElementById("btnNext"); // $await(Jscex.Async.onEvent(btnNext, "click")); $await(moveDishAsync(n, from, to)); if (n > 0) { $await(hanoiAsync(n - 1, mid, to, from)); } }));
以上代码是以动画形式表现汉诺塔的解题过程,但如果用户提出想要“每点一次按钮”才移动一个盘子,那其实我们只要将上面两行代码取消注释即可。如果忽然有一天,老板要求通过一个选项来决定是否“自动移动”,在Jscex里只要加一个if判断即可。您可以简单设想一下直接裸写这些代码会遇到什么样的景象,改造时会遇到哪些困难。
我还为Jscex准备了一个示例,是关于“模态对话框”配合相关异步操作的。由于是“模态对话框”,我们是要在对话框关闭之后才继续做某些事情。可惜在JavaScript中,如果您直接把一个界面元素展现为一个模态对话框,它是无法阻止后面的代码继续执行的,要阻止则只能使用confirm或alert方法。于是,我们只能把后续操作放到一个回调函数中去,并在模态对话框关闭之后才执行。但是您要知道,模态对话框只不过是整个过程中的一个步骤,理想状况下我们的完整功能不该被拆成多个部分,再使用所谓“美妙”的回调串联起来。
这点在Jscex中还是那么简单,直接按最简单的逻辑来进行即可:
// 显示模态对话框 $await($("#dialog-confirm").dialogAsync({ modal: true })); // 发起AJAX请求 var response = $await($.ajaxAsync({ url: "...", dataType: "text" })); // 继续做事
而无需:
// 继续做事 $("#dialog-confirm").dialog({ modal: true, close: function () { // 发起AJAX请求 $.ajax({ url: "...", dataType: "text", success: function () { // 继续做事 } }); });
经常会听到有些朋友谈起,说在实际开发过程中很少遇到异步场景。但在我看来,实在可谓遍地是异步,这种观念的差别只是在于是否经过了“抽象”。不加抽象地使用技术平台为我们提供的异步操作,会让我们的思维被它所禁锢。在JavaScript编程中浸淫太久了,可能就会忘记我们从最初是如何编程的。Jscex的目标,便是将这些东西回归自然,将逻辑以最自然的方式表达出来。循环?那就用for或是while吧,在函数之间跳来跳去是做什么的?
我从来不担心的Jscex的实用价值。Jscex来自C#,F#以及Scala等现成的理念,各种开发模式都是被翻来覆去讨论过,总结过,验证过的。这些语言其实都能实现与JavaScript类似的编程模式,但它们不需要,因为语言特性让程序员可以使用更简单直接的做法来解决问题。Jscex只是将这些现成的内容,从其他模式带到JavaScript编程领域上而已。
如今我唯一担心的,只是那些被禁锢的编程思维。
老赵你的about要改改了: 目前就职于盛大创新院产品开发部,研究员。