BT

使用Jscex改善JavaScript异步编程体验

作者 赵劼 发布于 2011年7月27日 | 注意:GTLC全球技术领导力峰会,500+CTO技聚重新定义技术领导力!

JavaScript是互联网时代编程语言的霸主,统领浏览器至今已有许多年头,而这股风潮很可能随着HTML 5的兴起而愈演愈烈。如今JavaScript更是在Node.js的帮助下进军服务器编程领域。“单线程”和“无阻塞”是JavaScript的天性,因此任何需要“耗时”的操作,例如等待、网络通信、磁盘IO都只能提供“异步”的编程接口。尽管这对服务器的伸缩性和客户端的响应能力都大有脾益,但是异步接口在使用上要比传统的线性编程困难许多,因此也诞生了如jQuery Deferred这样的辅助类库。Jscex的主要目的也是简化异步编程,但它使用了一种与传统辅助类库截然不同的方式,尽可能地将异步编程体验带领到新的高度。

JavaScript编程几乎总是伴随着异步操作,传统的异步操作会在操作完成之后,使用回调函数传回结果,而回调函数中则包含了后续的工作。这也是造成异步编程困难的主要原因:我们一直习惯于“线性”地编写代码逻辑,但是大量异步操作所带来的回调函数,会把我们的算法分解地支离破碎。此时我们不能用if来实现逻辑分支,也不能用while/for/do来实现循环,更不用提异步操作之间的组合、错误处理以及取消操作了。

快速入门:排序动画

我们先来看一个简单的例子。“冒泡排序”是最常见的排序算法之一,它的JavaScript实现如下:

var compare = function (x, y) {
    return x - y;
}

var swap = function (array, i, j) {
    var t = array[i];
    array[i] = array[j];
    array[j] = t;
}

var bubbleSort = function (array) {
    for (var i = 0; i < array.length; i++) {
        for (var j = 0; j < array.length - i; j++) {
            if (compare(array[j], array[j + 1]) > 0) {
                swap(array, j, j + 1);
            }
        }
    }
}

由于某些原因——例如教学所需,我们希望能够通过动画来直观地感受不同排序算法之间的差异。将一个排序算法改写为动画效果的“基本策略”十分简单:

  1. 在每次元素“交换”和“比较”操作时暂停一小会儿(因为它们是排序算法的主要耗时所在)。
  2. 在元素“交换”过后重绘图像。

只需增加这样两个“简单”的功能,便可以形成算法的动画效果。但实际上,实现这个策略并没有听上去那么容易。在其它许多语言或是运行环境中,我们可以使用sleep方法来暂停当前线程。这对代码的逻辑结构的影响极小。但是在JavaScript中,我们只有setTimeout可以做到“延迟”执行某个操作。setTimeout需要与回调函数配合使用,但这会严重破坏算法的逻辑结构,例如,我们再也无法使用for来实现哪怕是最最简单的循环操作了。因此,排序算法的动画似乎只能这么写:

// 异步操作签名约定:
// function (arg1, arg2, ..., callback) {
//     异步操作完成后使用callback回传结果
// }

var compareAsync = function (x, y, callback) {
    // 延迟10毫秒返回结果
    setTimeout(10, function () {
        callback(x - y);
    });
}

var swapAsync = function (a, i, j, callback) {
    // 交换元素
    var t = a[i]; a[i] = a[j]; a[j] = t;
    // 重绘
    repaint(a);
    // 延迟20毫秒才返回
    setTimeout(20, callback);
}

// 外部循环,从下标为i的元素开始处理
var outerLoopAsync = function (array, i, callback) {
    // 如果i还在数组长度范围内
    if (i < array.length) {
        // 则进入内部循环,与下标为i的元素进行比较
        innerLoopAsync(array, i, 0, function () {
            // 内部循环结束以后,在外部循环中处理i的下一个元素
            outerLoopAsync(array, i + 1, callback);
        });
    } else {
        // i超出数组长度,表示外层循环结束
        callback();
    }
}

// 内部循环,从下标j开始,与下标为i的元素进行比较
var innerLoopAsync = function (array, i, j, callback) {
    // 如果j在合适范围内
    if (j < array.length - i) {
        // 则比较下标j及其相邻元素
        compareAsync(array[j], array[j + 1], function (r) {
            // 如果次序不对
            if (r > 0) {
                // 则交换及其相邻元素
                swapAsync(array, j, j + 1, function () {
                    // 交换之后,则重复内层循环比较下标j的下一个元a素
                    innerLoopAsync(array, i, j + 1, callback);
                });
            } else {
                // 假如次序已经正确·,则直接重复内存循环比较下标j的下一个元a素
                innerLoopAsync(array, i, j + 1, callback);
            }
        });
    } else {
        // j已经超出范围,一个元素已经处于合适的位置,内层循环结束
        callback();
    }
}

// 冒泡排序主方法
var bubbleSortAsync = function (array, callback) {
    // 从第一个元素开始执行外部循环,
    // 外部循环结束则意味着排序完毕
    outerLoop(array, 0, callback || function () { });
}

// 调用
var array = ...; // 初始化数组
bubbleSortAsync(array);

相信您也可以看得出来,如果使用传统回调的方式来实现一个冒泡排序动画会有多么麻烦。而“支离破碎”所导致的更严重的问题,是代码“语义”方面的损失。例如,新来一位开发人员想要维护这段代码,他能够看出上面这段代码是“冒泡排序”吗?如果您给出“冒泡排序”的动画,又能轻易地将算法“说明”给别人理解吗?如果需要简单补充一些功能,又该将新代码添加在何处?使用传统线性编程的优势之一,在于容易快速编写出逻辑清晰而“内聚”的实现,即使需要补充一些功能,则可以通过局部变量将状态修改控制至极小。我们几乎可以这么说,基于回调函数的异步编程,让许多传统程序设计中总结出来的实践与模式付诸东流。

不过有了Jscex以后世界便大不一样了,它将编程体验变得“如初见般美好”:

// 异步的比较操作 
var compareAsync = eval(Jscex.compile("async", function (x, y) {
    $await(Jscex.Async.sleep(10)); // 等待10毫秒
    return x - y;
}));

// 异步的交换操作
var swapAsync = eval(Jscex.compile("async", function (array, i, j) {
    var t = array[i];
    array[i] = array[j];
    array[j] = t;

    repaint(array); // 重绘

    $await(Jscex.Async.sleep(20)); // 等待20毫秒
}));

// 异步的冒泡排序 
var bubbleSortAsync = eval(Jscex.compile("async", function (array) {
    for (var i = 0; i < array.length; i++) {
        for (var j = 0; j < array.length - i; j++) {
            // 执行异步的比较操作
            var r = $await(compareAsync(array[j], array[j + 1]));
            if (r > 0) {
                // 执行异步的交换操作
                $await(swapAsync(array, j, j + 1));
            }
        }
    }
}));

// 调用
var array = ...; // 初始化数组
bubbleSortAsync(array).start();

以上这段代码几乎不用做任何解释,因为它完全便是在标准的“冒泡排序”算法之上,增加了之前所提到的“基本策略”。这便是Jscex改进异步编程体验的手段:程序员编写最自然的代码,并使用$await来执行其中的异步操作。Jscex提供的编译器(即compile方法)会将一个普通的JavaScript函数编译为“回调函数”组织起来的异步实现,做到“线性编码,异步执行”的效果。

您可以在此观察冒泡排序的动画效果(需要IE9,Chrome,Firefox等支持Canvas的浏览器)。这张页面里还实现了选择排序快速排序算法的动画,都是基于Jscex的优雅实现。如果您感兴趣,也可以使用传统的、基于回调的方式来编写这些算法动画,然后跟页面中的代码实现进行对比,便可以更好地了解Jscex的优势。

使用Jscex开发异步程序

Jscex可以在任何支持JavaScript(ECMAScript 3)的运行环境里执行,例如,包括IE 6在内的现代浏览器,服务器端的Node.js,以及如Rhino一样的JavaScript引擎等等,它们的区别仅仅在于“引入Jscex脚本文件”的方式不同而已。Jscex模块化十分细致,在使用时需要引入不少文件,部分原因也是由于JavaScript环境至今还缺少一个包管理机制所造成的:

  • lib/json2.js:由Douglas Crockfod编写的JSON生成器,对于原生不支持JSON.stringify方法的JavaScript环境(例如早期版本的IE),则需要引入该文件。
  • lib/uglifyjs-parser.js:UglifyJS项目(jQuery项目官方使用的压缩工具)所使用的的JavaScript解析器,这是LISP项目parse-js的 JavaScript 移植,它负责Jscex中的语法解析工作。
  • src/jscex.js:JIT编译器实现,负责在运行时生成代码。这也是Jscex.compile方法的具体实现所在。

以上三个文件构成了Jscex的编译器核心,它们只需在开发环境中使用(例如在页面引用它们),目的只是为了提供近乎原生JavaScript的开发体验。对于Jscex来说,它的首要原则(没有之一)便是“保证JavaScript程序员的传统开发体验”。而对于开发和生产环境都必不可少的只有以下两个文件:

  • src/jscex.builderBase.js:Jscex中“构造器”的公用部分。
  • src/jscex.async.js:Jscex的“异步构造器”,用于支持异步程序开发。

这两个文件在精简和gzip之后,只有3KB左右大小,几乎不会给应用程序带来什么影响。

如果您要编写一个Jscex异步函数,则只需要将一个普通的函数定义放到一段“架子”代码中即可:

// 普通函数
var giveMeFive = function (arg0, arg1, ..., argN) {
    // 实现
    return 5;
};

// Jscex异步函数
var giveMeFiveAsync = eval(Jscex.compile("async", function (arg0, arg1, ..., argN) {
    // 实现
    return 5;
}));

Jscex.compile方法会根据它获得的“构造器名称(即async)”和“函数对象”生成其对应的“新函数”的代码,而这段代码会立即被eval执行。这段“架子代码”看上去略显冗余,如果您觉得输入麻烦也可以将其保存为编辑器的“代码片段(Code Snippet)”,因为它在Jscex使用过程中几乎不会有任何变化,我们也无需过于关注其含义。

“架子代码”的另一个作用是“区分”普通函数和异步函数。例如上面的代码中,giveMeFive会返回5,但giveMeFiveAsync在执行后返回的其实是一个“将会返回5”的Future对象——在Jscex中我们将其称为“任务”。除非我们通过start方法启动这个任务(Jscex异步函数中使用$await操作在需要时会调用start方法),则函数里的代码永远不会执行。因此,普通函数和异步函数在功能、含义和表现上都有不同,而通过“架子代码”的便能很方便地把它们区分开来。

在一个Jscex异步函数中,我们用$await操作来表示“等待任务返回结果(或出错),如果它还未执行,则同时启动这个任务”。$await的参数是一个Jscex任务对象,我们可以把任意的异步操作轻松地封装为一个Jscex任务。例如在Jscex的异步类库中就内置了Jscex.Async.sleep函数,它封装了setTimeout函数。显然,执行任何一个Jscex异步函数,您都可以得到这样一个标准的异步任务对象。

除了在Jscex异步函数中通过$await来操作之外,我们也可以手动调用任务的start方法来启动一个任务。Jscex异步任务模型虽然简单,但它是Jscex异步编程的基石,它让“编译器”的核心功能变得小巧、简单和紧凑,许多功能以及使用模式都能在“类库”层面扩展出来。在今后的文章中,我们也会了解如何将一个异步操作封装为Jscex任务,以及围绕这个任务模型进行开发和扩展。

平易近人的编译器和eval

从我之前的经验来看,一些朋友可能会被“编译器”的字样吓到,认为Jscex是一个“重型”的解决方案。还有一些朋友在脑海里深深印有“eval很邪恶”的印象,于是同样望而却步。其实这些都是对Jscex的误解,这里我打算着重解释一下这方面的问题。

如今“编译器”其实并不是什么特别神秘的东西,事实上可能您早就在使用针对JavaScript的编译器了。例如,Google的Closure Compiler便是这样一个东西。Closure Compiler会接受一段JavaScript代码,并输出其“等价”并“精简”后的代码。Closure Compiler的作用是“减小文件体积”,而Jscex的作用便是将一个JavaScript函数转化成一个新的函数,以符合某些场景(如异步编程)的需要而已。另一方面,Jscex的转换操作也涉及代码解析,语法树的优化以及新代码的输出,因此无论从功能还是从实现角度来说,Jscex的核心都是一个标准的“编译器”。

传统的编译器往往会给开发人员在代码执行之前增加一个额外步骤(编译),这对编程体验是一种损害。JavaScript程序员往往习惯于“修改后刷新页面”便能立即看到结果,但是如某些将C#或Java语言转化为JavaScript的解决方案,往往都需要开发人员在“刷新页面”之前重新生成一遍JavaScript代码。Jscex则不然,正如之前提到的那样,Jscex的首要原则是“尽可能保证JavaScript程序员的传统开发体验”。Jscex编译器的一大特色,便是“在运行时生成代码”。Jscex只是JavaScript开发中所使用的类库,它几乎不会对“JavaScript编程”本身有任何改变。换句话说,开发人员编写的就是JavaScript代码,它的载体就是普通的JavaScript文件,文件加载也好,代码执行行为也罢,都和普通的JavaScript开发一样。当您修改了Jscex异步函数的实现之后,Jscex.compile方法在代码执行时自然会生成新的函数代码,因此并不会给开发人员增加任何额外负担。

Jscex.compile生成的代码会由eval执行,有朋友会认为这么做会影响性能或是安全性。但事实上,无论是eval还是Jscex.compile,都只是为了保证开发过程中的体验(修改后立即生效)。真正在生产环境里执行的代码,是不会出现eval和Jscex.compile的,因为Jscex还提供了一个AOT编译器(相对于在运行时生成代码的JIT编译器而言)。

AOT编译器也是一段JavaScript代码,使用Node.js执行。使用方法为:

node scripts/jscexc.js --input input_file --output output_file

AOT编译器会静态分析输入的脚本文件,找出其中的eval与Jscex.compile函数调用,直接将“动态编译”的结果写入eval处。例如compareAsync的原始代码:

var compareAsync = eval(Jscex.compile("async", function (x, y) {
    $await(Jscex.Async.sleep(10)); 
    return x - y;
}));

编译后的代码便会成为如下形式,目前您无需理解这段代码的含义。Jscex对最终编译输出的代码经过精心设计,尽可能地让其保留可读性及可调式性,这点在今后的文章中也会加以说明和演示。

var compareAsync = (function (x, y) {
    var $_b = Jscex.builders["async"];
    return $_b.Start(this,
        $_b.Delay(function () {
            return $_b.Bind(Jscex.Async.sleep(10), function () {
                return $_b.Return(x - y);
            });
        })
    );
});

原始代码在经过AOT编译之后,不仅在运行性能方面有所提高(节省了编译和动态执行的开销,并可以在ECMAScript 5的Strict Mode下运行),还能让代码摆脱Jscex编译器执行。在排除了编译器代码之后,Jscex的异步类库在精简和压缩后只有3KB左右大小,十分适合互联网产品使用。

总结

异步编程的困难有目共睹,因此为了辅助异步程序开发出现过许多尝试。在JavaScript编程领域,大部分解决方案都是设法通过提供一些API来更好地组织错综复杂的回调函数,但Jscex走的是另外一条道路。Jscex的目的,是希望尽可能保留JavaScript程序员原有的编程习惯及逻辑组织方式,让“编译器”来生成那些包含了回调函数的代码。类似的功能已经在F#和Scala中获得了成功,也即将在下个版本的C#里出现,而Jscex则是将其引入至JavaScript编程中。

Jscex基于BSD授权协议开源,代码库放置在GitHub上,并同步至SNDA Code

评价本文

专业度
风格

您好,朋友!

您需要 注册一个InfoQ账号 或者 才能进行评论。在您完成注册后还需要进行一些设置。

获得来自InfoQ的更多体验。

告诉我们您的想法

允许的HTML标签: a,b,br,blockquote,i,li,pre,u,ul,p

当有人回复此评论时请E-mail通知我

复杂 by 刘 灿

看上去有点复杂,看样子还是自己的功力不够。

语法不优美! by zhanwang lin

我记得dojo里面也有defered类,jquery是不也是借鉴那里的? 
现在的jquery写出来的异步确实比较支离破碎的。
 不过看了这篇文章,觉得这个东西还是挺复杂,不好学,语法一点都不优美!

Re: 语法不优美! by withyou gakaki

靠谱 和coffeescript融合在一起吧。。

前台的伪线程 by wang tao

跟 Ext.util.TaskRunner 好像有点像

Re: 语法不优美! by Zhao Jeffrey

语法不优美?Jscex哪里有语法,你写的完全是JavaScript代码。

我也不知道复杂在什么地方,我觉得基本什么需要学的啊,呵呵。

Re: 前台的伪线程 by Zhao Jeffrey

大家的目的都差不多,关键是方式不同,Jscex的方式应该算是最易写易读的。

复杂 by 我的女孩 在那里

1. 没有详细的API,学习起来比较累。
2. 复杂,即使是一个 Javascript 老鸟学习起来也要花费较多的时间。
3. 语法不像 JQuery 那样优美,Jquery 中的延迟调用一个方法就可以了 $.delay();
4. 建议老赵优化一下,让 Jscex 更加大众化,像其他 JS 框架一样。

Re: 复杂 by Zhao Jeffrey

1、它基本只有一个API。
2、就只有一个API一点不复杂啊,您觉得哪里复杂呢?
3、它基本只有一个$await()。
4、您觉得哪方面需要优化呢?还有Jscex不是框架,是和jQuery一样的类库。

牛人啊 by sheng yoshio

牛人, 以前也一直想自己尝试去实现这样的机制。 看了楼主的实现, 我还是打消了这念头吧。
很不错, 代码可读性很好。

炒别人的冷饭的一个东西。没劲,而且语法很不优美! by zhuang miao

炒别人的冷饭的一个东西。没劲,而且语法很不优美!

谢谢分享 by fang sn00py

谢谢分享~

语法糖 by 赵 震一

其实老赵的意思是通过一个类库来实现本该属于语法糖的功能吧

Re: 语法糖 by Zhao Jeffrey

可以这么理解。

Re: 炒别人的冷饭的一个东西。没劲,而且语法很不优美! by Zhao Jeffrey

绝对创新,请指出炒冷饭的地方。唯一的语法就是Jscex.compile,不知道需要怎么优美?

框架的推广, demo很重要. by jovi huang

框架的推广, 文档和示例很重要.很多推广的好的框架比如extjs, demo拿来直接改改都可以用的.这样才让人愿意去试和用.
用冒泡算法举例还是让人有点云里雾里, 大部分用js的应用是很少涉及到冒泡的, 很多js程序员也不太熟悉.
说到异步, 难道会有比一个传统的ajax的调用更异步吗?

功能确实能达到了,但用起来不爽. by kingii sea

功能确实能达到了,但用起来不爽.

常规的冒泡排序异步代码跟jscex的代码在结构上并不等价,看上去像故意写复杂了 by cao wensheng

另外:
// 外部循环结束则意味着排序完毕
outerLoop(array, 0, callback || function () { });
应该是
outerLoopAsync(array, 0, callback || function () { });
吧?

Re: 常规的冒泡排序异步代码跟jscex的代码在结构上并不等价,看上去像故意写复杂了 by cao wensheng

试了一下,差不多还真得这么多代码才行。

允许的HTML标签: a,b,br,blockquote,i,li,pre,u,ul,p

当有人回复此评论时请E-mail通知我

允许的HTML标签: a,b,br,blockquote,i,li,pre,u,ul,p

当有人回复此评论时请E-mail通知我

18 讨论
提供反馈
错误报告
商务合作
内容合作
Marketing
InfoQ.com及所有内容,版权所有 © 2006-2016 C4Media Inc. InfoQ.com 服务器由 Contegix提供, 我们最信赖的ISP伙伴。
北京创新网媒广告有限公司 京ICP备09022563号-7 隐私政策
BT