JavaScript异步编程

简介

JavaScript是一种单线程执行的脚本语言,为了不让一段JavaScript代码执行时间过久,阻塞UI的渲染或者是鼠标事件处理,通常会采用一种异步的编程模式。这里就跟大家一起了解一下JavaScript的异步编程模式。

 

一、JavaScript的异步编程模式

1.1 为什么要异步编程

一开始就说过,JavaScript是一种单线程执行的脚本语言(这可能是由于历史原因或为了简单而采取的设计)。它的单线程表现在任何一个函数都要从头到尾执行完毕之后,才会执行另一个函数,界面的更新、鼠标事件的处理、计时器(setTimeout、setInterval等)的执行也需要先排队,后串行执行。假如有一段JavaScript从头到尾执行时间比较长,那么在执行期间任何UI更新都会被阻塞,界面事件处理也会停止响应。这种情况下就需要异步编程模式,目的就是把代码的运行打散或者让IO调用(例如AJAX)在后台运行,让界面更新和事件处理能够及时地运行。

下面是一个同步与异步执行的例子(在线测试链接http://jsfiddle.net/ghostoy/RPQgj/):

<div id="output"></div>

<button onclick="updateSync ()">Run Sync</button>

<button onclick="updateAsync ()">Run Async</button>

<script>

function updateSync() {
    for (var i = 0; i < 1000; i++) {
        document.getElementById('output').innerHTML = i;
    }
}

function updateAsync() {
    var i = 0;

    function updateLater() {
        document.getElementById('output').innerHTML = (i++);
        if (i < 1000) {
            setTimeout(updateLater, 0);
        }
    }

    updateLater();
}
</script>



点击"Run Sync"按钮会调用updateSync的同步函数,逻辑非常简单,循环体内每次更新output结点的内容为i。如果在其他多线程模型下的语言,你可能会看到界面上以非常快的速度显示从0到999后停止。但是在JavaScript中,你会感觉按钮按下去的时候卡了一下,然后看到一个最终结果999,而没有中间过程,这就是因为在updateSync函数运行过程中UI更新被阻塞,只有当它结束退出后才会更新UI。如果你让这个函数的运行时间增加一下(例如把上限改为1 000 000),你会看到更明显的停顿,在停顿期间点击另一个按钮是没有任何反应的,只有结束之后才会处理另一个按钮的点击事件。

另一个按钮"Run Async"会调用updateAsync函数,它是一个异步函数,乍一看逻辑比较复杂,函数里先声明了一个局部变量i和嵌套函数updateLater(关于内嵌函数的介绍请看JavaScript世界的一等公民-函数),然后调用了updateLater,在这个函数中先是更新output结点的内容为i,然后通过setTimeout让updateLater函数异步执行。这个函数的运行后,你会看到UI界面上从0到999快速地更新过程,这就是异步执行的结果。

可见,在JavaScript中异步编程甚至是一种必要的编程模式。

 

1.2 异步编程的优缺点

异步编程的优点是显而易见的,异步编程你可以实现前面例子中一边运行一边更新的效果;或是利用异步IO让UI运行更加流畅,比如通过XMLHTTPRequest的异步接口获取网络数据,在获取完成后再更新界面,在异步获取数据的时候不会阻碍UI的更新。在众多HTML5设备API的设计中都充分采用了异步编程模式,例如W3C的File System APIFile APIIndexed Database APIWindows 8 APIPhoneGap API,服务端脚本Node JS API等等。

异步编程也有一些缺点,造成深度嵌套的函数调用,破坏了原有的简单逻辑,让代码难以读懂。

 

二、异步编程接口设计

 

2.1 W3C原生接口

W3C原生接口的设计经常采用回调函数和事件触发形式,前者在调用异步函数时直接传入回调函数作为参数,后者在原始对象上绑定事件处理函数,异步函数出错时一般不会抛出异常,而是通过调用错误回调函数或触发错误事件。从语义上看,回调函数形式是为了获取某一个函数的运行结果,而事件触发形式通常会用于表示某些状态变化(加载、出错、进度变化、收到消息等等)。个人或团队开发小型项目时可以参考这两种形式的接口设计。

 

回调函数:例如W3C的File System API中,在请求虚拟文件系统实例、读写文件等接口中,都采用了回调函数的形式:

requestFileSystem(TEMPORARY, 1024 * 1024, function(fs) {

         // 异步获取虚拟文件系统实例fs

fs.root.getFile("already_there.txt", null, function (f) {

         // 获取文件already_there.txt

             getAsText(f.file());

}, function(err) {

         // 获取文件出错

});

}, function(err) {

         // 获取虚拟文件系统失败

});

 

事件触发:例如W3C的XMLHTTPRequest(AJAX)就是一种通过事件触发这种形式实现,当AJAX请求成功或失败时触发onload、onerror事件:

var xhr = new XMLHTTPRequest();

xhr.onload = function() {

         // 加载成功时触发onload事件

};

xhr.onerror = function() {

         // 加载失败时触发onerror事件

};

xhr.open(‘GET', ‘/get-ajax', true);

xhr.send(null);

 

2.2 第三方异步接口设计

采用回调函数形式的接口写代码,会带来比较严重的函数嵌套问题,就像著名的LISP一样,引入大量有争议性的括号,让本来是前后顺序执行的代码段形式上变成了一层套一层的结构,影响了JavaScript代码逻辑的清晰性。解决这个问题,要让逻辑上的先后顺序执行的代码,在形式上也是顺序的,而不是嵌套的,这就需要更好的异步接口设计方案。

CommonJS是一个著名的JavaScript的开源组织,目标是设计与JS环境无关的标准接口,并提供像Ruby、Python类似的标准库函数。在CommonJS中有三个异步编程模式相关的接口提案:Promises/APromises/BPromises/D。Promise,中文意思为承诺,意思就是说承诺完成一个任务,在完成时告之是否执行成功,并返回结果。

这里我们只介绍最简单的异步接口Promises/A,在使用这种接口的函数时,函数的返回值是一个Promise对象,它有三种状态:不满足条件(unfulfilled)、满足条件(fulfilled)、失败(failed),顾名思义不满足条件状态就是异步函数刚刚调用,尚未真正执行时的状态,满足条件就是执行成功时的状态,失败就是执行失败的状态。它的接口函数也只有一个:

then(fulfilledHandler, errorHandler, progressHandler)

这三个参数分别是满足条件、失败以及进度有变化时的回调函数,他们的参数分别对应异步调用的结果,而then的返回值仍然是一个Promise对象,这个对象包含了上一步异步调用回调函数的返回值,因此可以链式地写下去,表现上成为顺序执行的逻辑。例如,假如W3C的File System API采用Promises/A的接口设计,2.1节的例子可以写作:

requestFileSystem(TEMPORARY, 1024 * 1024)

.then(function(fs) {

         // 异步获取虚拟文件系统实例fs

         return fs.root.getFile("already_there.txt", null);

})

.then(function(f) {

// 获取文件already_there.txt

    getAsText(f.file());

});



看是不是清楚多了?

实现Promises/A接口的JS库有很多,比如when.jsnode-promisepromised-io等,微软的Windows 8 Metro应用的接口设计也采用了相同的接口设计,详见Asynchronious Programming in JavaScript with "Promises"

 

2.3 异步同步化

第三方的异步接口一定程度上解决了代码逻辑与执行顺序不一致的问题,但是仍然有些情况下,让代码难以读懂。我们还以1.1节中的代码为例,updateAsync即使采用Promises API并不会更好理解,而代码实现的功能其实就是一个很简单的循环+更新的功能。这时候就需要一些异步同步化来帮助实现。

所谓异步同步化顾名思义就是采用同步形式的语法实现异步调用。这里简单地介绍一下老赵的Jscex,它是一个纯JavaScript实现的库,可以在任何浏览器或JavaScript环境中运行,不仅支持异步同步化的编程语法,还支持并行执行等特性。用Jscex来重写1.1节中的代码,将是这样(在线测试链接http://jsfiddle.net/ghostoy/ugxJJ/):

function updateAsync() {
    var update = eval(Jscex.compile('async', function() {

        for (var i = 0; i < 1000; i++) {
            document.getElementById('output').innerHTML = i;
            $await(Jscex.Async.sleep(0));  // sleep 0 ms to make it asynchronous
        }

    }));
    
    update().start();
}



其中update是用Jscex编译生成的函数,它会返回一个Jscex的Task对象,通过调用它的start方法来执行这个Task。Update函数的逻辑跟updateSync几乎一样,$await是Jscex增加的关键字,用于等待一个异步任务的调用结果,Jscex.Async.sleep是Jscex内建的一个异步任务,用于显式地等待几毫秒,加入这行语句之后会被Jscex编译器生成异步的代码,实现一边计算一边更新UI的效果,代码结构保持简洁清楚。

 

小结

JavaScript的异步编程模式不仅是一种趋势,而且是一种必要,因此作为HTML5开发者是非常有必要掌握的。采用第三方的异步编程库和异步同步化的方法,会让代码结构相对简洁,便于维护,推荐开发人员掌握一二,提高团队开发效率。

 


For more complete information about compiler optimizations, see our Optimization Notice.