回调模式

函数是对象,也就意味着函数可以当作参数传入另外一个函数中。给函数writeCode()传入一个函数参数introduceBugs(),在某个时刻writeCode()执行了(或调用了)introduceBugs(),在这种情况下,我们称introduceBugs()是一个“回调函数”,简称“回调”:

  1. function writeCode(callback) {
  2. // 做点什么……
  3. callback();
  4. // ……
  5. }
  6. function introduceBugs() {
  7. // ……
  8. }
  9. writeCode(introduceBugs);

注意introduceBugs()作为参数传入writeCode()时,函数后面是不带括号的。括号的意思是执行函数,而这里我们希望传入一个引用,让writeCode()在合适的时机执行它(调用它)。

回调的例子

我们从一个例子开始,首先介绍无回调的情况,然后再进行修改。假设你有一个通用的函数,用来完成某种复杂的逻辑并返回一大段数据。假设这个通用函数叫findNodes(),用来对DOM树进行遍历,并返回页面节点:

  1. var findNodes = function () {
  2. var i = 100000, // 大量耗时的循环
  3. nodes = [], // 存储结果
  4. found; // 标示下找到的节点
  5. while (i) {
  6. i -= 1;
  7. // 这里是复杂的逻辑……
  8. nodes.push(found);
  9. }
  10. return nodes;
  11. };

保持这个函数的功能的通用性,让它只返回DOM节点组成的数组,而不对节点进行操作是一个很好的思想。可以将操作节点的逻辑放入另外一个函数中,比如hide()函数,这个函数用来隐藏页面中的节点元素:

  1. var hide = function (nodes) {
  2. var i = 0, max = nodes.length;
  3. for (; i < max; i += 1) {
  4. nodes[i].style.display = "none";
  5. }
  6. };
  7. // 执行函数
  8. hide(findNodes());

这个实现的效率并不高,因为它将findNodes()所返回的节点数组重新遍历了一遍。更高效的办法是在findNodes()中选择元素的时候就直接应用hide()操作,这样就能避免第二次的遍历,从而提高效率。但如果将hide()的逻辑写死在findNodes()的函数体内,findNodes()就变得不再通用了,因为修改逻辑和遍历逻辑耦合在一起了。这时候如果使用回调模式,就可以将隐藏节点的逻辑写入回调函数,将其传入findNodes()中适时执行:

  1. // 重构后的findNodes()接受一个回调函数
  2. var findNodes = function (callback) {
  3. var i = 100000,
  4. nodes = [],
  5. found;
  6. // 检查回调函数是否可以执行
  7. if (typeof callback !== "function") {
  8. callback = false;
  9. }
  10. while (i) {
  11. i -= 1;
  12. // 这里是复杂的逻辑……
  13. // 回调:
  14. if (callback) {
  15. callback(found);
  16. }
  17. nodes.push(found);
  18. }
  19. return nodes;
  20. };

这里的实现比较直接,findNodes()多作了一个额外工作,就是检查回调函数是否存在,如果存在的话就执行它。回调函数是可选的,因此修改后的findNodes()仍然可以和之前一样使用,是可以兼容旧代码和旧API的。

这时hide()的实现就非常简单了,因为它不用对元素列表做任何遍历了:

  1. // 回调函数
  2. var hide = function (node) {
  3. node.style.display = "none";
  4. };
  5. // 找到节点并隐藏它们
  6. findNodes(hide);

回调函数可以是事先定义好的,像上面的代码一样,也可以是一个在调用函数时创建的匿名函数,比如这段代码,我们利用同样的通用函数findNodes()来完成显示元素的操作:

  1. // 传入匿名回调函数
  2. findNodes(function (node) {
  3. node.style.display = "block";
  4. });

回调和作用域

在上一个例子中,执行回调函数的写法是:

  1. callback(parameters);

尽管这种写法很简单,而且可以适用于大多数的情况,但还有一些场景,回调函数不是匿名函数或者全局函数,而是对象的方法,如果这种情况下回调函数中使用了this指向它所属的对象,则回调逻辑就可能不是我们期望的那样。

假设回调函数是paint(),它是myapp的一个方法:

  1. var myapp = {};
  2. myapp.color = "green";
  3. myapp.paint = function (node) {
  4. node.style.color = this.color;
  5. };

函数findNodes()大致如下:

  1. var findNodes = function (callback) {
  2. // ...
  3. if (typeof callback === "function") {
  4. callback(found);
  5. }
  6. // ...
  7. };

当你调用findNodes(myapp.paint)时,运行结果和我们期望的不一致,因为this.color未定义。这时候this指向的是全局对象,因为findNodes()是全局函数。如果findNodes()是dom对象的方法(类似dom.findNodes()),那么回调函数内的this指向该dom,而不是myapp

解决办法是,除了传入回调函数,还需将回调函数所属的对象当作参数传进去:

  1. findNodes(myapp.paint, myapp);

同样需要修改findNodes()的逻辑,增加对传入的对象的绑定:

  1. var findNodes = function (callback, callback_obj) {
  2. //...
  3. if (typeof callback === "function") {
  4. callback.call(callback_obj, found);
  5. }
  6. // ...
  7. };

在后续的章节会对call()apply()有更详细的讲述。

其实还有一种替代写法,就是将函数名称以字符串传入findNodes(),这样就不必再写一次对象了,也就是说:

  1. findNodes(myapp.paint, myapp);

可以写成:

  1. findNodes("paint", myapp);

findNodes()中的逻辑则需要修改为:

  1. var findNodes = function (callback, callback_obj) {
  2. if (typeof callback === "string") {
  3. callback = callback_obj[callback];
  4. }
  5. //...
  6. if (typeof callback === "function") {
  7. callback.call(callback_obj, found);
  8. }
  9. // ...
  10. };

异步事件监听

JavaScript中的回调模式已经是我们的家常便饭了,比如,如果你给网页中的元素绑定事件,则需要提供回调函数的引用,以便事件发生时能调用到它。这里有一个简单的例子,我们将console.log()作为回调函数绑定到了document的点击事件上:

  1. document.addEventListener("click", console.log, false);

客户端浏览器中的大多数编程都是事件驱动的,当网页下载完成,则触发load事件,当用户和页面产生交互时也会触发多种事件,比如clickkeypressmouseovermousemove等等。JavaScript天生适合事件驱动编程,因为回调模式能够让程序“异步”执行,换句话说,就是让程序不按顺序执行。

“不要打电话给我,我会打给你”,这是好莱坞很有名的一句台词,可能很多人会对同一个角色说这句话,而电影中的主角不可能同时应答这些人的电话呼叫。在JavaScript的异步事件模型中也是同样的道理,不同的是,电影中是留下电话号码,JavaScript中是提供一个在适当的时机被调用的回调函数。有时甚至可以提供比实际需要更多的回调函数,因为可能某个特定的事件永远不会发生。比如,假设用户一直不点击“购买”,那么你之前写的用来验证信用卡号格式的函数就永远不会被调用执行。(译注:这段话有点不好翻译,前面的比喻看不懂。后面有两个方面的意思,一方面指回调函数并不一定会被执行,如果事件不发生,那么回调函数就永远不会被执行;另一方面指可以通过多个事件来绑定同一个回调函数,因为你无法确定用户会触发哪一个事件,比如到底是键盘操作还是鼠标操作。)

延时

另外一个最常用的回调模式是在调用延时函数的时候。延时函数是浏览器window对象的方法,共有两个:setTimeout()setInterval()。这两个方法的参数都是回调函数。

  1. var thePlotThickens = function () {
  2. console.log('500ms later...');
  3. };
  4. setTimeout(thePlotThickens, 500);

再次提醒,函数名thePlotThickens是作为变量传入setTimeout的,它不带括号,如果带括号的话就被立即执行了,而这里只是用到这个函数的引用,以便在setTimeout()的逻辑中调用它。也可以传入字符串"thePlotThickens()",但这是一种反模式,和eval()一样不推荐使用。

类库中的回调

回调模式非常简单,但又很强大,可以信手拈来灵活运用,因此这种模式在类库的设计中也非常得宠。类库的代码要尽可能保持通用和可复用,而回调模式则可帮助库的作者达成这个目标。你不必预料并实现你所想到的所有情形,这会让类库变得臃肿,而且大多数用户并不需要这些多余的特性支持。相反,你将精力放在核心功能的实现上,提供回调的入口作为“钩子”,可以让类库的方法变得可扩展、可定制。