JavaScript 回调变量作用域问题
介绍
如果您曾经遇到过此 JavaScript 问题,那么您并不孤单 - 这个问题困扰了很多人,而且一开始很难理解如何修复它。让我们考虑这个例子:
var array = [ ... ]; // An array with some objects
for( var i = 0; i < array.length; ++i )
{
$.doSthWithCallbacks( function() {
array[i].something = 42;
});
}
虽然这段代码看起来完全没问题,但它表明对 JavaScript 的一个非常基本的概念存在误解。现在,如果你精通 JavaScript,这个错误应该很容易发现。但对于大多数人来说,情况并非如此 - 有些人实际上可以花费数小时来弄清楚为什么他们的代码不起作用。
解释
还记得你读过的第一篇 JavaScript 教程吗?其中提到 JavaScript 是异步的。这意味着在某些情况下,代码可能不会按顺序执行。使用依赖于外部事件的内部 API 时通常会出现这种情况。例如,在 HTTP 请求完成后或完成其他处理后处理响应。
那么接下来会发生什么呢?doSthWithCallbacks (使用回调的所有 JavaScript 函数的通用表达式)安排回调函数在稍后阶段执行。但for循环不只是安排一个回调。它安排了array.length个回调,而且它们肯定不会在同一个for循环迭代中完成。这些回调中的每一个都将在稍后不可预测的时间执行,当经过多个for迭代时,if i 的值不同,并且还安排了多个其他回调。
通常,回调直到for循环完成才会执行,此时i恰好等于array.length - 1。因此,每次执行任何回调时,它都会修改数组的最后一个值,而不是它所安排的for循环迭代的值。当然,正如我所说,回调何时执行是不可预测的,取决于 JavaScript 解释器使用的多个因素、调用回调的函数及其输入数据。一个例子是带有成功回调的 HTTP 请求,该回调不会在服务器发送响应之前执行,该响应可能是几毫秒到几分钟之间的任何时间间隔。
如何解决
我将向您介绍两种解决该问题的方法。这两种方法在性能和内存消耗方面都非常高效。第一种方法更容易理解,但需要在较高范围内定义函数,这使得代码的可读性稍差,因为您必须查找该函数。第二种解决方案是我个人最喜欢的,但更难理解,尤其是当您第一次看到这样的构造时。还有其他解决方案,但目前这些是最快的,并且受所有主流浏览器和 JavaScript 解释器支持。
本质上,这两种方法都执行相同的任务,只是方式不同。它们所做的是创建一个单独的回调函数,并在只有它们可用的范围内使用它们自己的i值副本。
函数中的闭包
这种方法相对容易理解,因此我不会详细介绍它。内联闭包不是这种情况,所以我将深入介绍它。callbackClosure 函数返回一个函数,该函数使用i的显式副本作为参数来调用实际的回调。
var array = [ ... ]; // An array with some objects
function callbackClosure(i, callback) {
return function() {
return callback(i);
}
}
for( var i = 0; i < array.length; ++i )
{
API.doSthWithCallbacks( callbackClosure( i, function(i) {
array[i].something = 42;
}) );
}
由于每个函数都声明了自己的范围,并且i具有基本原子类型(int),因此它不是作为引用传递,而是作为副本传递(与对象不同),从而确保针对正确的值执行实际的回调。
内联闭包
这引出了我最喜欢的 JavaScript 技巧。这是通过声明一个自称为匿名函数来实现的,它通常如下所示:
(function() {
// Something declared here will only be available to the function below.
// Code here is executed only once upon the creation of the inner function
return function(callbackArguments) {
// Actual callback here
};
})(); // The last brackets execute the outer function
请注意,外部函数仅用于封装内部函数,并为内部函数创建单独的变量范围。此外,外部函数返回Function类型的值,该类型正是回调应有的类型。因此,将其应用于前面的示例,我们得到以下结果:
var array = [ ... ]; // An array with some objects
for( var i = 0; i < array.length; ++i )
{
API.doSthWithCallbacks( (function() {
var j = i; // j is a copy of i only available to the scope of the inner function
return function() {
array[j].something = 42;
}
})() );
}
例如,如果您必须进行一些异步处理,并且应该有一些聚合代码仅在所有回调完成后才运行,那么您需要做的就是知道您安排了多少个回调并计算其中完成了多少个。如果count等于length,则表示您当前正在处理最后一个回调。
var array = [ ... ]; // An array with some objects
var count = 0, length = array.length;
for( var i = 0; i < array.length; ++i )
{
API.doSthWithCallbacks( (function() {
var j = i; // A copy of i only available to the scope of the inner function
return function() {
array[j].something = 42;
++count;
if( count == length ) {
// Code executed only after all the processing tasks have been completed
}
}
})() );
}
现在,在这一点上很容易感到困惑。++count操作是原子的吗?可能会发生竞争条件,代码可能会执行多次,或者更糟的是,根本不执行。有些人考虑使用诸如互斥锁或信号量之类的东西。但这是不对的。
虽然 JavaScript 是异步的,但它不是多线程的。事实上,虽然无法预测何时执行回调,但由于 JavaScript 仅在单个线程中运行,因此可以保证不会发生竞争条件。(顺便说一句,这并不意味着没有办法在 JavaScript 中运行多个线程。请参阅Web 类型的 JavaScript 的Web Workers API ) 。
ES6
ECMAScript 6 引入了let关键字,它允许你声明一个作用域为最近的封闭块的变量,而不是像var那样声明一个全局变量。因此,只需将var替换为let即可解决闭包问题:
var array = [ ... ]; // An array with some objects
for( let i = 0; i < array.length; ++i )
{
$.doSthWithCallbacks( function() {
array[i].something = 42;
});
}
很棒,不是吗?如果你可以使用 ES6,你的代码看起来会好很多,而不需要立即调用匿名内联函数来创建一个新的作用域。
练习问题
我准备了一个练习题来演示这个问题,如果你愿意,你可以试一试。尝试时,只需编辑指定部分内的代码。有多种方法可以做到这一点,但最好的方法之一是使用我向你展示的第二种技术。
关于作者
Itay Grudev 是一名学生,目前正在英国阿伯丁大学攻读计算机科学和物理学学位。
Itay 最感兴趣的是 Linux、安全、电子和业余无线电。他热爱开源和免费软件。他是一位才华横溢的开发人员,也是那些花时间将 i++改为++i的人之一,他热衷于效率和优美的代码。他最喜欢的技术是C++、Qt和Ruby on Rails。
免责声明:本内容来源于第三方作者授权、网友推荐或互联网整理,旨在为广大用户提供学习与参考之用。所有文本和图片版权归原创网站或作者本人所有,其观点并不代表本站立场。如有任何版权侵犯或转载不当之情况,请与我们取得联系,我们将尽快进行相关处理与修改。感谢您的理解与支持!
请先 登录后发表评论 ~