写点什么

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

2011 年 7 月 27 日

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

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

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

复制代码
<span>var </span>compare = <span>function </span>(x, y) {
<span>return </span>x - y;
}
<span>var </span>swap = <span>function </span>(array, i, j) {
<span>var </span>t = array[i];
array[i] = array[j];
array[j] = t;
}
<span>var </span>bubbleSort = <span>function </span>(array) {
<span>for </span>(<span>var </span>i = 0; i < array.length; i++) {
<span>for </span>(<span>var </span>j = 0; j < array.length - i; j++) {
<span>if </span>(compare(array[j], array[j + 1]) > 0) {
swap(array, j, j + 1);
}
}
}
}

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

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

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

复制代码
<span>// 异步操作签名约定:
// function (arg1, arg2, ..., callback) {
// 异步操作完成后使用 callback 回传结果
// }
</span><span>var </span>compareAsync = <span>function </span>(x, y, callback) {
<span>// 延迟 10 毫秒返回结果
</span>setTimeout(10, <span>function </span>() {
callback(x - y);
});
}
<span>var </span>swapAsync = <span>function </span>(a, i, j, callback) {
<span>// 交换元素
</span><span>var </span>t = a[i]; a[i] = a[j]; a[j] = t;
<span>// 重绘
</span>repaint(a);
<span>// 延迟 20 毫秒才返回
</span>setTimeout(20, callback);
}
<span>// 外部循环,从下标为 i 的元素开始处理
</span><span>var </span>outerLoopAsync = <span>function </span>(array, i, callback) {
<span>// 如果 i 还在数组长度范围内
</span><span>if </span>(i < array.length) {
<span>// 则进入内部循环,与下标为 i 的元素进行比较
</span>innerLoopAsync(array, i, 0, <span>function </span>() {
<span>// 内部循环结束以后,在外部循环中处理 i 的下一个元素
</span>outerLoopAsync(array, i + 1, callback);
});
} <span>else </span>{
<span>// i 超出数组长度,表示外层循环结束
</span>callback();
}
}
<span>// 内部循环,从下标 j 开始,与下标为 i 的元素进行比较
</span><span>var </span>innerLoopAsync = <span>function </span>(array, i, j, callback) {
<span>// 如果 j 在合适范围内
</span><span>if </span>(j < array.length - i) {
<span>// 则比较下标 j 及其相邻元素
</span>compareAsync(array[j], array[j + 1], <span>function </span>(r) {
<span>// 如果次序不对
</span><span>if </span>(r > 0) {
<span>// 则交换及其相邻元素
</span>swapAsync(array, j, j + 1, <span>function </span>() {
<span>// 交换之后,则重复内层循环比较下标 j 的下一个元 a 素
</span>innerLoopAsync(array, i, j + 1, callback);
});
} <span>else </span>{
<span>// 假如次序已经正确·,则直接重复内存循环比较下标 j 的下一个元 a 素
</span>innerLoopAsync(array, i, j + 1, callback);
}
});
} <span>else </span>{
<span>// j 已经超出范围,一个元素已经处于合适的位置,内层循环结束
</span>callback();
}
}
<span>// 冒泡排序主方法
</span><span>var </span>bubbleSortAsync = <span>function </span>(array, callback) {
<span>// 从第一个元素开始执行外部循环,
// 外部循环结束则意味着排序完毕
</span>outerLoop(array, 0, callback || <span>function </span>() { });
}
<span>// 调用 </span>
<span>var</span> array = ...; <span>// 初始化数组 </span>
bubbleSortAsync(array);

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

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

复制代码
<span>// 异步的比较操作 </span>
<span>var </span>compareAsync = eval(Jscex.compile(<span>"async"</span>, <span>function </span>(x, y) {
$await(Jscex.Async.sleep(10)); <span>// 等待 10 毫秒
</span><span>return </span>x - y;
}));
<span>// 异步的交换操作 </span>
<span>var </span>swapAsync = eval(Jscex.compile(<span>"async"</span>, <span>function </span>(array, i, j) {
<span>var </span>t = array[i];
array[i] = array[j];
array[j] = t;
repaint(array); <span>// 重绘
</span>$await(Jscex.Async.sleep(20)); <span>// 等待 20 毫秒
</span>}));
<span>// 异步的冒泡排序 </span>
<span>var </span>bubbleSortAsync = eval(Jscex.compile(<span>"async"</span>, <span>function </span>(array) {
<span>for </span>(<span>var </span>i = 0; i < array.length; i++) {
<span>for </span>(<span>var </span>j = 0; j < array.length - i; j++) {
<span>// 执行异步的比较操作
</span><span>var </span>r = $await(compareAsync(array[j], array[j + 1]));
<span>if </span>(r > 0) {
<span>// 执行异步的交换操作
</span>$await(swapAsync(array, j, j + 1));
}
}
}
}));
<span>// 调用
</span><span>var </span>array = ...; <span>// 初始化数组
</span>bubbleSortAsync(array).start();

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

您可以在此观察冒泡排序的动画效果(需要IE9,Chrome,Firefox 等支持Canvas 的浏览器)。这张页面里还实现了选择排序快速排序算法的动画,都是基于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 异步函数,则只需要将一个普通的函数定义放到一段“架子”代码中即可:

复制代码
<span>// 普通函数
</span><span>var </span>giveMeFive = <span>function </span>(arg0, arg1, ..., argN) {
<span>// 实现 </span>
<span>return</span> 5;
};
<span>// Jscex 异步函数
</span><span>var </span>giveMeFiveAsync = eval(Jscex.compile(<span>"async"</span>, <span>function </span>(arg0, arg1, ..., argN) {
<span>// 实现 </span>
<span>return</span> 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 任务,以及围绕这个任务模型进行开发和扩展。

从我之前的经验来看,一些朋友可能会被“编译器”的字样吓到,认为 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_fileAOT 编译器会静态分析输入的脚本文件,找出其中的 eval 与 Jscex.compile 函数调用,直接将“动态编译”的结果写入 eval 处。例如 compareAsync 的原始代码:

复制代码
<span>var </span>compareAsync = eval(Jscex.compile(<span>"async"</span>, <span>function </span>(x, y) {
$await(Jscex.Async.sleep(10));
<span>return </span>x - y;
}));

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

复制代码
<span>var </span>compareAsync = (<span>function </span>(x, y) {
<span>var </span>$_b = Jscex.builders[<span>"async"</span>];
<span>return </span>$_b.Start(<span>this</span>,
$_b.Delay(<span>function </span>() {
<span>return </span>$_b.Bind(Jscex.Async.sleep(10), <span>function </span>() {
<span>return </span>$_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

2011 年 7 月 27 日 00:0017118
用户头像

发布了 157 篇内容, 共 43.4 次阅读, 收获喜欢 2 次。

关注

评论

发布
暂无评论
发现更多内容

游戏夜读 | 关卡设计为什么难?

game1night

认识数据产品经理(三 成为数据产品经理)

马踏飞机747

大数据 数据中台 数据分析 产品经理

猿灯塔-Phaser 使用介绍

猿灯塔

编程的门槛 - 抄作业的得与失

顿晓

编程门槛 编程思维 动手能力 抄作业

借助第一性原理开启中台建设

数字圣杯

数据中台 数字化转型

高效阅读,成就自我-《麦肯锡精英高效阅读法》读后感

顾强

读书笔记 读书 读书方式

终于有一款组件可以全面超越Apache POI

Geek_Willie

前后端分离 服务端 GrapeCity Documents

初探Electron,从入门到实践

Geek_Willie

前端 Electron SpreadJS

Spring 中不同依赖注入方式的对比与剖析

Deecyn

spring

《硅谷革命:成就苹果公司的疯狂往事》读后感

顾强

回“疫”录(15):在家SOHO,是你想要的工作方式吗?

小天同学

疫情 回忆录 现实纪录 纪实 远程办公

前浪的经验:区块链软件,一定也要去中心化

Michael Yuan

比特币 区块链 智能合约 以太坊 加密货币

故障的传播方式与隔离办法

Wales Kuo

爱是恒久忍耐,又有恩慈

泰稳@极客邦科技

身心健康 心理

反对996,但是选择996是一个怎样的矛盾心态?

顾强

职场 加班

面向页面的移动端架构设计

稻子

flutter ios android 前端架构 架构模式

21天养不成习惯,28天也不行。不要痴心妄想。

赵新龙

TGO鲲鹏会 习惯养成

编写制度的几点实用建议

石君

制度 编写制度 安全管理

高仿瑞幸小程序 08 创建第一个云函数

曾伟@喵先森

小程序 微信小程序 前端 移动

一杯茶的时间,上手 React 框架开发

图雀社区

Reac

回顾经典,Netflix的推荐系统架构

王喆

人工智能 学习 推荐系统 netflix

“字节”不断“跳动”,卡拉永远 OK?

无量靠谱

字节跳动 诺基亚 危机

Flink Weekly | 每周社区动态更新

Apache Flink

大数据 flink 流计算 实时计算 大数据处理

你竞争我得利之零售变革

孙苏勇

行业资讯

需求是被挖掘还是被创造出来的?

Neco.W

产品 互联网 需求

油管博主路透 3080Ti 参数、黄教主烤箱中拿出 DGX A100 预热发布会

神经星星

人工智能 互联网巨头 gpu 互联网 英伟达

从波音747学项目管理

顾强

项目管理 读书感悟 沟通

Dubbo集成Sentinel实现限流

Java收录阁

sentinel

有了容器为什么kubernetes还需要Pod?

架构师修行之路

Kubernetes 分布式 云原生 pod

如何快速更改qcow2镜像文件

奔跑的菜鸟

云计算

业务信息化操作系统(BIOS)——中台的核心产出物

孤岛旭日

中台 操作系统 企业信息化

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