众所周知(这也忒夸张了吧?),Javascript 通过事件驱动机制,在单线程模型下,以异步的形式来实现非阻塞的 IO 操作.这种模式使得 JavaScript 在处理事务时非常高效,但这带来了很多问题,比如异常处理困难,函数嵌套过深.下面介绍几种目前已知的实现异步操作的解决方案.
[TOC](操蛋,不支持 TOC)
一,回调函数
这是最古老的一种异步解决方案:通过参数传入回调,未来调用回调时让函数的调用者判断发生了什么.
直接偷懒上阮大神的例子:
假定有两个函数 f1 和 f2,后者等待前者的执行结果.
如果 f1 是一个很耗时的任务,可以考虑改写 f1,把 f2 写成 f1 的回调函数.
function f1(callback) {setTimeout(function() { // f1的任务代码
callback();
},
1000);
}
执行代码就变成下面这样:
f1(f2);
采用这种方式,我们把同步操作变成了异步操作,f1 不会堵塞程序运行,相当于先执行程序的主要逻辑,将耗时的操作推迟执行. 回调函数的优点是简单,容易理解和部署,缺点是不利于代码的阅读和维护,各个部分之间高度耦合,流程会很混乱. 也许你觉得上面的流程还算清晰.那是因为我等初级菜鸟还没见过世面,试想在前端领域打怪升级的过程中,遇到了下面的代码:
doA(function() {
doB();
doC(function() {
doD();
}) doE();
});
doF();
要想理清上述代码中函数的执行顺序,还真得停下来分析很久,正确的执行顺序是 doA->doF->doB->doC->doE->doD.
回调函数的优点是简单,容易理解和部署,缺点是不利于代码的阅读和维护,程序的流程会很混乱,而且每个任务只能指定一个回调函数.
二,事件发布 / 订阅模式(观察者模式)
事件监听模式是一种广泛应用于异步编程的模式,是回调函数的事件化,任务的执行不取决于代码的顺序,而取决于某个事件是否发生.这种设计模式常被成为发布 / 订阅模式或者观察者模式.
浏览器原生支持事件,如 Ajax 请求获取响应,与 DOM 的交互等,这些事件天生就是异步执行的.在后端的 Node 环境中也自带了 events 模块,Node 中事件发布 / 订阅的模式及其简单,使用事件发射器即可,示例代码如下:
//订阅
emitter.on("event1",
function(message) {
console.log(message);
});
//发布
emitter.emit('event1', "I am message!");
我们也可以自己实现一个事件发射器,代码实现参考了《JavaScript 设计模式与开发实践》
var event = {
clientList: [],
listen: function(key, fn) {
if (!this.clientList[key]) {
this.clientList[key] = [];
}
this.clientList[key].push(fn); //订阅的消息添加进缓存列表
},
trigger: function() {
var key = Array.prototype.shift.call(arguments),
//提取第一个参数为事件名称
fns = this.clientList[key];
if (!fns || fns.length === 0) { //如果没有绑定对应的消息
return false;
}
for (var i = 0,
fn; fn = fns[i++];) {
fn.apply(this, arguments); //带上剩余的参数
}
},
remove: function(key, fn) {
var fns = this.clientList[key];
if (!fns) { //如果key对应的消息没人订阅,则直接返回
return false;
}
if (!fn) { //如果没有传入具体的回调函数,表示需要取消key对应消息的所有订阅
fns && (fns.length = 0);
} else {
for (var i = fns.length - 1; i >= 0; i--) { //反向遍历订阅的回调函数列表
var _fn = fns[i];
if (_fn === fn) {
fns.splice(i, 1); //删除订阅者的回调函数
}
}
}
}
};
只有这个事件订阅发布对象没有多大作用,我们要做的是给任意的对象都能添加上发布 - 订阅的功能:
在 ES6 中可以使用
Object.assign(target,source)
方法合并对象功能.如果不支持 ES6 可以自行设计一个拷贝函数如下:
var installEvent = function(obj) {
for (var i in event) {
if (event.hasOwnProperty(i)) obj[i] = event[i];
}
};
上述的函数就能给任意对象添加上事件发布 - 订阅功能.下面我们测试一下,假如你家里养了一只喵星人,现在它饿了.
var Cat = {};
//Object.assign(Cat,event);
installEvent(Cat);
Cat.listen('hungry',
function() {
console.log("铲屎的,快把朕的小鱼干拿来!")
});
Cat.trigger('hungry'); //铲屎的,快把朕的小鱼干拿来!
自定义发布 - 订阅模式介绍完了.
这种方法的优点是比较容易理解,可以绑定多个事件,每个事件可以指定多个回调函数.缺点是整个程序都要变成事件驱动型,运行流程会变得很不清晰.
三,使用 Promise 对象
ES6 标准中实现的 Promise 是异步编程的一种解决方案,比传统的解决方案--回调函数和事件--更合理和更强大.
所谓
Promise
,就是一个对象,用来传递异步操作的消息.它代表了某个未来才会知道结果的事件,并且这个事件提供统一的 API,各种异步操作都可以用同样的方法进行处理.
Promise
对象有以下两个特点. (1)对象的状态不受外界影响.
Promise
对象代表一个异步操作,有三种状态:
Pending
(进行中),
Resolved
(已完成,又称 Fulfilled)和
Rejected
(已失败).只有异步操作的结果,可以决定当前是哪一种状态,任何其他操作都无法改变这个状态 (2)一旦状态改变,就不会再变,任何时候都可以得到这个结果.
Promise
对象的状态改变,只有两种可能:从
Pending
变为
Resolved
和从
Pending
变为
Rejected
.只要这两种情况发生,状态就凝固了,不会再变了,会一直保持这个结果.就算改变已经发生了,你再对
Promise
对象添加回调函数,也会立即得到这个结果.这与事件(Event)完全不同,事件的特点是,如果你错过了它,再去监听,是得不到结果的. 有了
Promise
对象,就可以将异步操作以同步操作的流程表达出来,避免了层层嵌套的回调函数. 下面以一个 Ajax 请求为例,中有这样一个流程,首先根据 accesstoken 获取用户名,然后可以根据用户名获取用户收藏的主题,如果我们想得到某个用户收藏的主题数量就要进行两次请求.如果不使用 Promise 对象, 以 Jquery 的 ajax 请求为例:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>
Promise
</title>
</head>
<body>
</body>
<script type="text/javascript" src="http://apps.bdimg.com/libs/jquery/1.7.2/jquery.min.js">
</script>
<script type="text/javascript">
$.post("https://cnodejs.org/api/v1/accesstoken", {
accesstoken: "XXXXXXXXXXXXXXXXXXXXXXXXXXX"
},
function(res1) {
$.get("https://cnodejs.org/api/v1/topic_collect/" + res1.loginname,
function(res2) {
alert(res2.data.length);
});
});
</script>
</html>
从上述代码中可以看出,两次请求相互嵌套,如果改成用 Promise 对象实现:
function post(url, para) {
return new Promise(function(resolve, reject) {
$.post(url, para, resolve);
});
}
function get(url, para) {
return new Promise(function(resolve, reject) {
$.get(url, para, resolve);
});
}
var p1 = post("https://cnodejs.org/api/v1/accesstoken", {
accesstoken: "XXXXXXXXXXXXXXXXXXXXXXXXXXXXX"
});
var p2 = p1.then(function(res) {
return get("https://cnodejs.org/api/v1/topic_collect/" + res.loginname, {});
});
p2.then(function(res) {
alert(res.data.length);
});
可以看到前面代码中的嵌套被解开了,(也许有人会说,这代码还变长了,坑爹吗这是,请不要在意这些细节,这里仅举例说明).关于 Promise 对象的具体用法还有很多知识点,建议查找相关资料深入阅读,这里仅介绍它作为异步编程的一种解决方案.
四,使用 Generator 函数
关于 Generator 函数的概念可以参考阮大神的, Generator 可以理解为可在运行中转移控制权给其他代码,并在需要的时候返回继续执行的函数, 看下面一个简单的例子:
function * helloWorldGenerator() {
yield 'hello';
yield 'world';
yield 'ending';
}
var hw = helloWorldGenerator();
console.log(hw.next());
console.log(hw.next());
console.log(hw.next());
console.log(hw.next());
// { value: 'hello', done: false }
// { value: 'world', done: false }
// { value: 'ending', done: false }
// { value: undefined, done: true }
Generator 函数的调用方法与普通函数一样,也是在函数名后面加上一对圆括号.不同的是,调用 Generator 函数后,该函数并不执行,返回的也不是函数运行结果,而是一个遍历器对象(Iterator Object).
下一步,必须调用遍历器对象的 next 方法,使得指针移向下一个状态.也就是说,每次调用
next
方法,内部指针就从函数头部或上一次停下来的地方开始执行,直到遇到下一个
yield
语句(或
return
语句)为止.换言之,Generator 函数是分段执行的,
yield
语句是暂停执行的标记,而
next
方法可以恢复执行. Generator 函数的暂停执行的效果,意味着可以把异步操作写在 yield 语句里面,等到调用 next 方法时再往后执行.这实际上等同于不需要写回调函数了,因为异步操作的后续操作可以放在 yield 语句下面,反正要等到调用 next 方法时再执行.所以,Generator 函数的一个重要实际意义就是用来处理异步操作,改写回调函数.
如果有一个多步操作非常耗时,采用回调函数,可能会写成下面这样.
step1(function(value1) {
step2(value1,
function(value2) {
step3(value2,
function(value3) {
step4(value3,
function(value4) {
// Do something with value4
});
});
});
});
采用 Promise 改写上面的代码.(下面的代码使用了 Promise 的函数库 Q)
Q.fcall(step1).then(step2).then(step3).then(step4).then(function(value4) {
// Do something with value4
},
function(error) {
// Handle any error from step1 through step4
}).done();
上面代码已经把回调函数,改成了直线执行的形式,但是加入了大量 Promise 的语法.Generator 函数可以进一步改善代码运行流程.
function * longRunningTask() {
try {
var value1 = yield step1();
var value2 = yield step2(value1);
var value3 = yield step3(value2);
var value4 = yield step4(value3);
// Do something with value4
} catch(e) {
// Handle any error from step1 through step4
}
}
如果只有 Generator 函数,任务并不会自动执行,因此需要再编写一个函数,按次序自动执行所有步骤.
scheduler(longRunningTask());
function scheduler(task) {
setTimeout(function() {
var taskObj = task.next(task.value);
// 如果Generator函数未结束,就继续调用
if (!taskObj.done) {
task.value = taskObj.value scheduler(task);
}
},
0);
}
五,使用 async 函数
在 ES7(还未正式标准化)中引入了 Async 函数的概念, async 函数的实现就是将 Generator 函数和自动执行器包装在一个函数中.如果把上面 Generator 实现异步的操作改成 async 函数,代码如下:
async
function longRunningTask() {
try {
var value1 = await step1();
var value2 = await step2(value1);
var value3 = await step3(value2);
var value4 = await step4(value3);
// Do something with value4
} catch(e) {
// Handle any error from step1 through step4
}
}
正如阮一峰在博客中所述,异步编程的语法目标,就是怎样让它更像同步编程,使用 async/await 的方法,使得异步编程与同步编程看起来相差无几了.
六,借助流程控制库
随着 Node 开发的流行,NPM 社区中出现了很多流程控制库可以供开发者直接使用,其中很流行的就是 async 库,该库提供了一些流程控制方法,注意这里所说的 async 并不是标题五中所述的 async 函数.而是第三方封装好的库.其官方文档见 http://caolan.github.io/async/docs.html
async 为流程控制主要提供了 waterfall(瀑布式),series(串行),parallel(并行)
如果需要执行的任务紧密结合.下一个任务需要上一个任务的结果做输入,应该使用瀑布式
如果多个任务必须依次执行,而且之间没有数据交换,应该使用串行执行
如果多个任务之间没有任何依赖,而且执行顺序没有要去,应该使用并行执行
关于 async 控制流程的基本用法可以参考官方文档或者
下面我举一个例子说明:假设我们有个需求,返回 100 加 1 再减 2 再乘 3 最后除以 4 的结果,而且每个任务需要分解执行.
1. 使用回调函数
function add(fn) {
var num = 100;
var result = num + 1;
fn(result)
}
function minus(num, fn) {
var result = num - 2;
fn(result);
}
function multiply(num, fn) {
var result = num * 3;
fn(result);
}
function divide(num, fn) {
var result = num / 4;
fn(result);
}
add(function(value1) {
minus(value1,
function(value2) {
multiply(value2,
function(value3) {
divide(value3,
function(value4) {
console.log(value4);
});
});
});
});
从上面的结果可以看到回调嵌套很深.
2. 使用 async 库的流程控制
由于后面的任务依赖前面的任务执行的结果,所以这里要使用 watefall 方式.
var async = require("async");
function add(callback) {
var num = 100;
var result = num + 1;
callback(null, result);
}
function minus(num, callback) {
var result = num - 2;
callback(null, result);
}
function multiply(num, callback) {
var result = num * 3;
callback(null, result);
}
function divide(num, callback) {
var result = num / 4;
callback(null, result);
}
async.waterfall([add, minus, multiply, divide],
function(err, result) {
console.log(result);
});
可以看到使用流程控制避免了嵌套.
七,使用 Web Workers
Web Worker 是 HTML5 新标准中新添加的一个功能,Web Worker 的基本原理就是在当前 javascript 的主线程中,使用 Worker 类加载一个 javascript 文件来开辟一个新的线程,起到互不阻塞执行的效果,并且提供主线程和新线程之间数据交换的接口:postMessage,onmessage.其数据交互过程也类似于事件发布 / 监听模式,异能实现异步操作.下面的示例来自于红宝书, 实现了一个数组排序功能.
页面代码:
<!DOCTYPE html>
<html>
<head>
<title>
Web Worker Example
</title>
</head>
<body>
">
<script>
(function() {
var data = [23, 4, 7, 9, 2, 14, 6, 651, 87, 41, 7798, 24],
worker = new Worker("WebWorkerExample01.js");
worker.onmessage = function(event) {
alert(event.data);
};
worker.postMessage(data);
})();
</script>
</body>
</html>
Web Worker 内部代码
self.onmessage = function(event) {
var data = event.data;
data.sort(function(a, b) {
return a - b;
});
self.postMessage(data);
};
把比较消耗时间的操作,转交给 Worker 操作就不会阻塞用户界面了,遗憾的是 Web Worker 不能进行 DOM 操作.
参考文献
《You Don't Know JS:Async&Performance》
《JavaScript 设计模式与开发实践》- 曾探
《深入浅出 NodeJS》- 朴灵
《ES6 标准入门 - 第二版》- 阮一峰
《JavaScript Web 应用开发》-Nicolas Bevacqua
《JavaScript 高级程序设计第 3 版》
来源: