深入理解JavaScript同步与异步机制及异步编程最佳实践
本文深入解析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!
JS中将数组和对象混合组装为JSON字符串
2026-11-05 16:41:42
JS运算符优先级与结合性的区别是什么?
2026-11-05 13:22:02
JS Map怎么只遍历Key或Value
2026-11-05 13:00:36
D3.js 与 ECharts 选型对比:灵活性 vs 开发效率
2026-11-05 09:45:01
为什么JS中typeof []返回object:数组类型检测误区解析
2026-11-04 19:50:47
