深入理解JavaScript同步与异步机制及异步编程最佳实践

作者:木棉

时间:2026-10-29

浏览:317

本文深入解析JavaScript异步执行原理,涵盖Event Loop工作机制、setTimeout精度限制及Ajax底层实现。针对常见的“回调地狱”问题,系统介绍了命名函数、async.js并行处理、Promise链式调用以及基于事件和中介者模式的解耦方案,帮助开发者编写更清晰、低耦合的异步代码。

文章目录

JavaScript异步执行原理

JavaScript的一大优势在于其对异步代码的处理方式。异步任务会被放入事件队列中,等待当前所有同步代码执行完毕后再行处理,从而避免阻塞主线程。然而,对于初学者而言,编写正确的异步代码往往存在难度。本文将澄清相关概念,消除你在理解异步机制时的困惑。

JavaScript中最基础的异步函数包括setTimeout和setInterval。其中,setTimeout用于在指定时间后执行给定的回调函数,它接收两个参数:第一个是回调函数,第二个是以毫秒为单位的延迟时间。具体用法示例如下:


console.log( "a" );
setTimeout(function() {
  console.log( "c" )
}, 500 );
setTimeout(function() {
  console.log( "d" )
}, 500 );
setTimeout(function() {
  console.log( "e" )
}, 500 );
console.log( "b" );

如预期所示,控制台首先输出“a”和“b”,大约500毫秒后,依次输出“c”、“d”和“e”。这里使用“大约”一词,是因为setTimeout的执行时间实际上具有不确定性。HTML5规范中对此有明确说明:

  • “该API无法保证计时器会精确地按时运行。由于CPU负载或其他任务导致的延迟是预期内的现象。”

有趣的是,只有当同一执行栈中剩余的所有同步代码执行完毕后,超时回调才会被触发。这意味着,如果设置了超时任务,但同时执行了一个耗时较长的同步函数,那么在该长耗时函数执行结束前,超时任务甚至不会开始计时。实际上,像setTimeout和setInterval这样的异步函数,其回调会被压入一个称为Event Loop(事件循环)的队列中。

Event Loop本质上是一个回调函数队列。当异步操作完成时,其对应的回调函数会被推入该队列。JavaScript引擎会在当前执行栈清空后,才开始处理事件循环中的回调。这表明JavaScript并非多线程语言,尽管其行为表现可能类似。事件循环遵循先进先出(FIFO)原则,即回调按照它们被加入队列的顺序依次执行。Node.js选择JavaScript作为开发语言,部分原因正是这种异步编程模型使得代码编写相对简洁。

Ajax与底层通信机制

Ajax

异步JavaScript与XML(AJAX)彻底改变了Web开发的格局。浏览器无需重新加载整个页面即可动态更新内容。虽然在不同浏览器中实现AJAX功能可能繁琐且耗时,但得益于jQuery等库的支持,我们现在能够以简单优雅的方式实现客户端与服务器端的通信。

我们可以利用jQuery提供的跨浏览器接口$.ajax轻松获取数据,但这往往掩盖了底层的具体运作机制。例如:


var data;
$.ajax({
  url: "some/url/1",
  success: function( data ) {
    // But, this will!
    console.log( data );
  }
})
// Oops, this won't work...
console.log( data );

一个常见的错误是在调用$.ajax后立即使用data变量,但实际上:


xmlhttp.open( "GET", "some/ur/1", true );
xmlhttp.onreadystatechange = function( data ) {
  if ( xmlhttp.readyState === 4 ) {
    console.log( data );
  }
};
xmlhttp.send( null );

底层的XmlHttpRequest对象发起请求,并设置回调函数以处理XHR的readystatechange事件。随后执行XHR的send方法。在XHR运行期间,当其readyState属性发生变化时,会触发readystatechange事件。只有当XHR从远程服务器完全接收响应后,回调函数才会被触发执行。

解决回调地狱的策略

处理异步代码

异步编程很容易陷入所谓的“回调地狱”(Callback Hell)。由于JavaScript中几乎所有的异步函数都依赖回调,连续执行多个异步操作会导致层层嵌套的回调函数,从而使代码变得极其复杂且难以维护。

Node.js中的许多函数也是异步的,因此类似以下的代码非常常见:


var fs = require( "fs" );
fs.exists( "index.js", function() {
  fs.readFile( "index.js", "utf8", function( err, contents ) {
    contents = someFunction( contents ); // do something with contents
    fs.writeFile( "index.js", "utf8", function() {
      console.log( "whew! Done finally..." );
    });
  });
});
console.log( "executing..." );

在前端客户端代码中,以下模式也同样普遍:

 


GMaps.geocode({
  address: fromAddress,
  callback: function( results, status ) {
    if ( status == "OK" ) {
      fromLatLng = results[0].geometry.location;
      GMaps.geocode({
        address: toAddress,
        callback: function( results, status ) {
          if ( status == "OK" ) {
            toLatLng = results[0].geometry.location;
            map.getRoutes({
              origin: [ fromLatLng.lat(), fromLatLng.lng() ],
              destination: [ toLatLng.lat(), toLatLng.lng() ],
              travelMode: "driving",
              unitSystem: "imperial",
              callback: function( e ){
                console.log( "ANNNND FINALLY here's the directions..." );
                // do something with e
              }
            });
          }
        }
      });
    }
  }
});

Nested callbacks can get really nasty, but there are several solutions to this style of coding.

嵌套回调容易引入代码中的“坏味道”,但你可以尝试以下几种风格来解决这一问题。

  • The problem isn't with the language itself; it's with the way programmers use the language — Async Javascript.

没有糟糕的语言,只有糟糕的程序员——异步JavaScript

使用命名函数

命名函数

清除嵌套回调的一个便捷方案是避免使用超过两层的嵌套。与其传递匿名函数作为回调,不如传递一个命名函数:


var fromLatLng, toLatLng;
var routeDone = function( e ){
  console.log( "ANNNND FINALLY here's the directions..." );
  // do something with e
};
var toAddressDone = function( results, status ) {
  if ( status == "OK" ) {
    toLatLng = results[0].geometry.location;
    map.getRoutes({
      origin: [ fromLatLng.lat(), fromLatLng.lng() ],
      destination: [ toLatLng.lat(), toLatLng.lng() ],
      travelMode: "driving",
      unitSystem: "imperial",
      callback: routeDone
    });
  }
};
var fromAddressDone = function( results, status ) {
  if ( status == "OK" ) {
    fromLatLng = results[0].geometry.location;
    GMaps.geocode({
      address: toAddress,
      callback: toAddressDone
    });
  }
};
GMaps.geocode({
  address: fromAddress,
  callback: fromAddressDone
});

此外,async.js库可以帮助我们处理多重Ajax请求/响应。例如:


async.parallel([
  function( done ) {
    GMaps.geocode({
      address: toAddress,
      callback: function( result ) {
        done( null, result );
      }
    });
  },
  function( done ) {
    GMaps.geocode({
      address: fromAddress,
      callback: function( result ) {
        done( null, result );
      }
    });
  }
], function( errors, results ) {
  getRoute( results[0], results[1] );
});

这段代码执行了两个异步函数,每个函数接收一个名为“done”的回调函数,并在函数结束时调用它。当两个“done”回调都执行完毕后,parallel函数的最终回调将被调用,用于处理这两个异步函数产生的结果或错误。

Promises模型

Promises模型 引自 CommonJS/A:

  • Promise表示一个异步操作完成后返回的最终结果。

有许多库实现了Promise模型,其中jQuery已经提供了一个可用且优秀的Promise API。jQuery在1.5版本中引入了Deferred对象,你可以在返回Promise的函数中使用jQuery.Deferred的构造结果。返回Promise的函数用于执行某种异步操作,并在完成后解决延迟状态。


var geocode = function( address ) {
  var dfd = new $.Deferred();
  GMaps.geocode({
    address: address,
    callback: function( response, status ) {
      return dfd.resolve( response );
    }
  });
  return dfd.promise();
};
var getRoute = function( fromLatLng, toLatLng ) {
  var dfd = new $.Deferred();
  map.getRoutes({
    origin: [ fromLatLng.lat(), fromLatLng.lng() ],
    destination: [ toLatLng.lat(), toLatLng.lng() ],
    travelMode: "driving",
    unitSystem: "imperial",
    callback: function( e ) {
      return dfd.resolve( e );
    }
  });
  return dfd.promise();
};
var doSomethingCoolWithDirections = function( route ) {
  // do something with route
};
$.when( geocode( fromAddress ), geocode( toAddress ) ).
  then(function( fromLatLng, toLatLng ) {
    getRoute( fromLatLng, toLatLng ).then( doSomethingCoolWithDirections );
  });

这允许你先执行两个异步函数,等待它们的结果,然后再利用前两次调用的结果来执行另一个函数。

  • Promise表示一个异步操作完成后返回的最终结果。

基于事件与中介者模式

在这段代码中,geocode方法被执行了两次并返回了Promise。异步函数随后执行,并在其回调中调用resolve。一旦两次调用resolve完成,then方法将会执行,接收之前两次调用geocode的返回结果。这些结果随后被传入getRoute方法,该方法也返回一个Promise。最终,当getRoute的Promise解决后,doSomethingCoolWithDirections回调将被执行。 事件 事件是另一种在异步回调完成后进行通信的方式。一个对象可以作为发射器派发事件,而另一个对象则监听这些事件。这种事件处理方式称为观察者模式。backbone.js库通过withBackbone.Events实现了此类功能模块。


var SomeModel = Backbone.Model.extend({
  url: "/someurl"
});
var SomeView = Backbone.View.extend({
  initialize: function() {
    this.model.on( "reset", this.render, this );
    this.model.fetch();
  },
  render: function( data ) {
    // do something with data
  }
});
var view = new SomeView({
  model: new SomeModel()
});

还有其他用于发射事件的混合示例和库,例如jQuery Event Emitter、EventEmitter、monologue.js以及Node.js内置的EventEmitter模块。

  • 事件循环是一个回调函数的队列。

另一种类似的派发消息的方式称为中介者模式,postal.js库即采用此方式。在中介者模式中,存在一个中间人负责所有对象的监听和事件派发。在此模式下,对象之间不直接联系,从而实现了对象间的解耦。

绝不要将Promise暴露给公共API。这不仅关系到API用户对Promise的使用方式,也使得代码重构更加困难。不过,将内部使用的Promise与外部接口的事件相结合,可以使应用具有更低的耦合度且更易于测试。

在之前的例子中,doSomethingCoolWithDirections回调在两个geocode函数完成后执行。随后,doSomethingCoolWithDirections从getRoute接收响应,并将其作为消息发送出去。


var doSomethingCoolWithDirections = function( route ) {
  postal.channel( "ui" ).publish( "directions.done", {
    route: route
  });
};

这使得应用的其他部分无需直接引用发起请求的对象,即可响应异步回调。在获取命令时,页面中可能有多个区域需要更新。在典型的jQuery Ajax过程中,当接收到的命令发生变化时,相应的回调可能需要进行调整。这可能导致代码难以维护,但通过使用消息机制,处理UI多个区域的更新会变得简单得多。


var UI = function() {
  this.channel = postal.channel( "ui" );
  this.channel.subscribe( "directions.done", this.updateDirections ).withContext( this );
};
UI.prototype.updateDirections = function( data ) {
  // The route is available on data.route, now just update the UI
};
app.ui = new UI();

其他基于中介者模式传送消息的库包括amplify、PubSubJS和radio.js。

结论

JavaScript使得编写异步代码变得容易。使用Promise、事件或命名函数可以避免“回调地狱”。欲获取更多关于JavaScript异步编程的信息,请点击Async JavaScript: Build More Responsive Apps with Less。更多实例托管在GitHub上,地址为NetTutsAsyncJS,欢迎Clone!

实用工具推荐

电子包浆

绿化、压缩、噪点和虚拟水印,自由调节做旧程度。

使用 ›
图片压缩

选择画质和输出格式,预览压缩前后的大小。

使用 ›
相关文章