为什么我的变量在函数内部修改后没有改变?-异步代码引用

给出以下示例,为什么outerScopeVar在所有情况下都未定义?

var outerScopeVar;
var img = document.createElement('img');img.onload = function() {outerScopeVar = this.width;};img.src = 'lolcat.png';alert(outerScopeVar);
var outerScopeVar;setTimeout(function() {outerScopeVar = 'Hello Asynchronous World!';}, 0);alert(outerScopeVar);
// Example using some jQueryvar outerScopeVar;$.post('loldog', function(response) {outerScopeVar = response;});alert(outerScopeVar);
// Node.js examplevar outerScopeVar;fs.readFile('./catdog.html', function(err, data) {outerScopeVar = data;});console.log(outerScopeVar);
// with promisesvar outerScopeVar;myPromise.then(function (response) {outerScopeVar = response;});console.log(outerScopeVar);
// with observablesvar outerScopeVar;myObservable.subscribe(function (value) {outerScopeVar = value;});console.log(outerScopeVar);
// geolocation APIvar outerScopeVar;navigator.geolocation.getCurrentPosition(function (pos) {outerScopeVar = pos;});console.log(outerScopeVar);

为什么在所有这些示例中输出undefined?我不想要变通方法,我想知道为什么这正在发生。


备注:这是JavaScript异步性的一个规范问题。请随时改进此问题并添加更多社区可以识别的简化示例。

282555 次浏览

一个字的答案:异步

前言

这个主题已经在Stack Overflow中迭代了至少几千次。因此,首先我想指出一些非常有用的资源:

  • @Felix Kling回答“如何从异步调用返回响应?”。请参阅他解释同步和异步流的出色答案,以及“重构代码”部分。
    @Benjamin Gruenbaum也花了很多精力在同一线程中解释异步性。

  • @Matt Esch对“从fs.read文件中获取数据”的回答也以一种简单的方式很好地解释了异步性。


手头问题的答案

让我们首先跟踪常见行为。在所有示例中,outerScopeVar函数内部被修改。该函数显然不会立即执行,它被分配或作为参数传递。这就是我们所说的回调

现在的问题是,什么时候调用回调?

这取决于具体情况。让我们再次尝试跟踪一些常见行为:

  • img.onload可以调用在未来的某个时候,当(如果)图像已成功加载时。
  • setTimeout可以调用在未来的某个时候,在延迟过期并且超时未被clearTimeout取消之后。注意:即使使用0作为延迟,所有浏览器都有最小超时延迟上限(在HTML5规范中指定为4ms)。
  • 当(如果)Ajax请求成功完成时,jQuery$.post的回调可以调用在未来的某个时候
  • Node.js的fs.readFile可以被称为在未来的某个时候,当文件被成功读取或抛出错误时。

在所有情况下,我们都有一个可能运行在未来的某个时候的回调。这个“将来某个时候”就是我们所说的异步流

异步执行被推出同步流之外。也就是说,异步代码将在同步代码栈执行时从未执行。这就是JavaScript单线程的含义。

更具体地说,当JS引擎空闲时——不执行一堆(a)同步代码——它会轮询可能触发异步回调的事件(例如过期超时、收到网络响应)并一个接一个地执行它们。这被视为事件循环

也就是说,手绘红色形状中突出显示的异步代码只能在其各自代码块中的所有剩余同步代码执行后执行:

异步代码突出显示

简而言之,回调函数是同步创建但异步执行的。你不能依赖异步函数的执行,直到你知道它已经执行,以及如何做到这一点?

这很简单,真的。依赖于异步函数执行的逻辑应该从这个异步函数内部启动/调用。例如,将alertconsole.log也移动到回调函数内部将输出预期的结果,因为该结果在该点可用。

实现自己的回调逻辑

通常你需要对异步函数的结果做更多的事情,或者根据调用异步函数的位置对结果做不同的事情。让我们处理一个更复杂的例子:

var outerScopeVar;helloCatAsync();alert(outerScopeVar);
function helloCatAsync() {setTimeout(function() {outerScopeVar = 'Nya';}, Math.random() * 2000);}

备注:我使用带有随机延迟的setTimeout作为通用异步函数,相同的示例适用于Ajax、readFileonload和任何其他异步流。

此示例显然与其他示例存在相同的问题,它不会等到异步函数执行。

让我们来解决它实现我们自己的回调系统。首先,我们摆脱了丑陋的outerScopeVar,在这种情况下它完全无用。然后我们添加一个接受函数参数的参数,即我们的回调。当异步操作完成时,我们调用此回调传递结果。实现(请按顺序阅读注释):

// 1. Call helloCatAsync passing a callback function,//    which will be called receiving the result from the async operationhelloCatAsync(function(result) {// 5. Received the result from the async function,//    now do whatever you want with it:alert(result);});
// 2. The "callback" parameter is a reference to the function which//    was passed as argument from the helloCatAsync callfunction helloCatAsync(callback) {// 3. Start async operation:setTimeout(function() {// 4. Finished async operation,//    call the callback passing the result as argumentcallback('Nya');}, Math.random() * 2000);}

上面示例的代码片段:

// 1. Call helloCatAsync passing a callback function,//    which will be called receiving the result from the async operationconsole.log("1. function called...")helloCatAsync(function(result) {// 5. Received the result from the async function,//    now do whatever you want with it:console.log("5. result is: ", result);});
// 2. The "callback" parameter is a reference to the function which//    was passed as argument from the helloCatAsync callfunction helloCatAsync(callback) {console.log("2. callback here is the function passed as argument above...")// 3. Start async operation:setTimeout(function() {console.log("3. start async operation...")console.log("4. finished async operation, calling the callback, passing the result...")// 4. Finished async operation,//    call the callback passing the result as argumentcallback('Nya');}, Math.random() * 2000);}

在实际用例中,DOM API和大多数库通常已经提供了回调功能(本示例中的helloCatAsync实现)。您只需要传递回调函数并理解它将在同步流之外执行,并重组您的代码以适应这一点。

您还会注意到,由于异步特性,不可能将值从异步流return返回到定义回调的同步流,因为异步回调是在同步代码完成执行后很久才执行的。

而不是从异步回调中return值,您将不得不使用回调模式,或… Promises。

Promises

尽管有一些方法可以通过vanilla JS来保持回调地狱,但Promise越来越受欢迎,目前正在ES6中标准化(参见Promise-MDN)。

Promises(又名Futures)提供了对异步代码的更线性,因此令人愉快的阅读,但是解释它们的整个功能超出了这个问题的范围。相反,我将这些优秀的资源留给感兴趣的人:


更多关于JavaScript异步的阅读材料


备注:我已将此答案标记为社区Wiki,因此任何拥有至少100个声誉的人都可以编辑和改进它!请随时改进此答案,或者如果您愿意,也可以提交一个全新的答案。

我想把这个问题变成一个规范的主题来回答与Ajax无关的异步问题(有如何从AJAX调用返回响应?),因此这个主题需要您的帮助尽可能好和有用!

Fabrício的回答是正确的;但是我想用一些不那么技术性的东西来补充他的回答,它集中在一个类比上,以帮助解释异步性的概念


一个类比…

昨天,我正在做的工作需要一位同事提供一些信息。我给他打了电话;谈话是这样进行的:

:嗨,鲍勃,我想知道我们上周是怎么做的。吉姆想要一份关于它的报告,你是唯一知道细节的人。

鲍勃:没问题,不过大概需要30分钟?

太好了,鲍勃。你得到消息后给我回个电话!

这时,我挂了电话。由于我需要Bob的信息来完成我的报告,我离开了报告,去喝了杯咖啡,然后我赶上了一些电子邮件。40分钟后(Bob很慢),Bob回电话给了我需要的信息。此时,我继续我的报告工作,因为我有我需要的所有信息。


想象一下,如果谈话是这样进行的;

:嗨,鲍勃,我想知道我们上周是怎么做的。吉姆想要一份关于它的报告,你是唯一知道细节的人。

鲍勃:没问题,不过大概需要30分钟?

:太好了,鲍勃。我会等的。

我坐在那里等着。等了又等。等了40分钟。除了等待什么都不做。最终,鲍勃给了我信息,我们挂了电话,我完成了我的报告。但我失去了40分钟的生产力。


这是异步与同步行为

这正是我们问题中所有示例中发生的事情。加载图像、从磁盘加载文件以及通过AJAX请求页面都是缓慢的操作(在现代计算环境中)。

JavaScript允许您注册一个回调函数,该函数将在慢操作完成时执行,而不是等待让这些慢操作完成。然而,在此期间,JavaScript将继续执行其他代码。JavaScript在等待慢操作完成时执行其他代码这一事实使得行为异步。如果JavaScript在执行任何其他代码之前等待操作完成,这将是同步行为。

var outerScopeVar;var img = document.createElement('img');
// Here we register the callback function.img.onload = function() {// Code within this function will be executed once the image has loaded.outerScopeVar = this.width;};
// But, while the image is loading, JavaScript continues executing, and// processes the following lines of JavaScript.img.src = 'lolcat.png';alert(outerScopeVar);

在上面的代码中,我们要求JavaScript加载lolcat.png,这是一个slooow操作。回调函数将在这个缓慢的操作完成后执行,但与此同时,JavaScript将继续处理下一行代码;即alert(outerScopeVar)

这就是为什么我们看到显示undefined;的警报,因为alert()是立即处理的,而不是在加载图像之后。

为了修复我们的代码,我们所要做的就是将alert(outerScopeVar)代码移动到回调函数中。因此,我们不再需要声明为全局变量的outerScopeVar变量。

var img = document.createElement('img');
img.onload = function() {var localScopeVar = this.width;alert(localScopeVar);};
img.src = 'lolcat.png';

您将总是看到回调被指定为函数,因为这是JavaScript中唯一的*方式来定义某些代码,但直到稍后才执行它。

因此,在我们所有的示例中,function() { /* Do something */ }是回调;要修复所有的示例,我们所要做的就是将需要操作响应的代码移动到那里!

*从技术上讲,你也可以使用eval(),但是eval()


我如何让我的来电者等待?

您目前可能有一些类似的代码;

function getWidthOfImage(src) {var outerScopeVar;
var img = document.createElement('img');img.onload = function() {outerScopeVar = this.width;};img.src = src;return outerScopeVar;}
var width = getWidthOfImage('lolcat.png');alert(width);

但是,我们现在知道return outerScopeVar立即发生;在onload回调函数更新变量之前。这导致getWidthOfImage()返回undefined,并警告undefined

为了解决这个问题,我们需要允许调用getWidthOfImage()的函数注册一个回调,然后将宽度的警报移动到该回调中;

function getWidthOfImage(src, cb) {var img = document.createElement('img');img.onload = function() {cb(this.width);};img.src = src;}
getWidthOfImage('lolcat.png', function (width) {alert(width);});

…和以前一样,请注意我们已经能够删除全局变量(在本例中为width)。

这里有一个更简洁的答案,供正在寻找快速参考的人以及一些使用Promise和async/wait的示例。

对于调用异步方法(在本例中为setTimeout)并返回消息的函数,从简单的方法(不起作用)开始:

function getMessage() {var outerScopeVar;setTimeout(function() {outerScopeVar = 'Hello asynchronous world!';}, 0);return outerScopeVar;}console.log(getMessage());

undefined在这种情况下被记录,因为getMessage在调用setTimeout回调并更新outerScopeVar之前返回。

解决它的两种主要方法是使用回调承诺

回调

这里的更改是getMessage接受callback参数,一旦可用,将调用该参数以将结果传递回调用代码。

function getMessage(callback) {setTimeout(function() {callback('Hello asynchronous world!');}, 0);}getMessage(function(message) {console.log(message);});

Promises

Promises提供了一种比回调更灵活的替代方案,因为它们可以自然组合以协调多个异步操作。承诺/A+标准实现在node.js(0.12+)和许多当前浏览器中原生提供,但也在蓝鸟Q等库中实现。

function getMessage() {return new Promise(function(resolve, reject) {setTimeout(function() {resolve('Hello asynchronous world!');}, 0);});}
getMessage().then(function(message) {console.log(message);});

jQuery延迟

jQuery提供的功能类似于其Deferreds的Promise。

function getMessage() {var deferred = $.Deferred();setTimeout(function() {deferred.resolve('Hello asynchronous world!');}, 0);return deferred.promise();}
getMessage().done(function(message) {console.log(message);});

异步/等待

如果您的JavaScript环境包含对asyncawait的支持(如Node.js7.6+),那么您可以在async函数中同步使用Promise:

function getMessage () {return new Promise(function(resolve, reject) {setTimeout(function() {resolve('Hello asynchronous world!');}, 0);});}
async function main() {let message = await getMessage();console.log(message);}
main();

在所有这些情况下,outerScopeVar被修改或分配了一个值异步稍后发生(等待或侦听某个事件发生),当前执行不会等待。所以所有这些情况下当前执行流程都导致outerScopeVar = undefined

让我们讨论每个示例(我标记了异步调用或延迟某些事件发生的部分):

1.

在此处输入图片描述

这里我们注册一个事件列表,它将在该特定事件上执行。这里加载图像。然后当前执行与下一行img.src = 'lolcat.png';alert(outerScopeVar);连续,同时事件可能不会发生。即,功能img.onload等待引用的图像异步加载。这将发生以下所有示例-事件可能不同。

2.

2

这里超时事件扮演角色,它将在指定时间后调用处理程序。这里是0,但它仍然注册了一个异步事件它将被添加到Event Queue的最后一个位置以执行,这使得保证延迟。

3.

在此处输入图像描述这一次ajax回调。

4.

在此处输入图片描述

节点可以被视为异步编码之王。这里标记的函数注册为回调处理程序,将在读取指定文件后执行。

5.

在此处输入图片描述

明显的承诺(将来会做的事情)是异步的。参见JavaScript中的Deferred、Promise和Future有什么区别?

https://www.quora.com/Whats-the-difference-between-a-promise-and-a-callback-in-Javascript

显然,杯子代表outerScopeVar

异步函数就像…

异步调用咖啡

其他答案都很好,我只想对此提供一个直截了当的答案。仅限于jQuery异步调用

所有ajax调用(包括$.get$.post$.ajax)都是异步的。

考虑到你的例子

var outerScopeVar;  //line 1$.post('loldog', function(response) {  //line 2outerScopeVar = response;});alert(outerScopeVar);  //line 3

代码从第1行开始执行,在第2行声明变量和触发器以及异步调用(即post请求),并从第3行继续执行,而无需等待post请求完成其执行。

假设post请求需要10秒才能完成,outerScopeVar的值只会在这10秒之后设置。

为了尝试,

var outerScopeVar; //line 1$.post('loldog', function(response) {  //line 2, takes 10 seconds to completeouterScopeVar = response;});alert("Lets wait for some time here! Waiting is fun");  //line 3alert(outerScopeVar);  //line 4

现在,当您执行此操作时,您将在第3行收到警报。现在等待一段时间,直到您确定发布请求已返回一些值。然后,当您单击警报框上的确定时,下一个警报将打印期望值,因为您等待它。

在现实生活中,代码变成了,

var outerScopeVar;$.post('loldog', function(response) {outerScopeVar = response;alert(outerScopeVar);});

所有依赖于异步调用的代码都在异步块内移动,或者通过等待异步调用。

答案是:异步性。

为什么需要异步?

JavaScript是单线程的,这意味着脚本的两位不能同时运行;它们必须一个接一个地运行。在浏览器中,JavaScript与一系列其他东西共享一个线程,这些东西因浏览器而异。但通常JavaScript与绘画、更新样式和处理用户操作(例如突出显示文本和与表单控件交互)在同一个队列中。其中一项活动会延迟其他活动。

你可能已经使用事件和回调来解决这个问题。以下是事件:

var img1 = document.querySelector('.img-1');
img1.addEventListener('load', function() {// image loadedconsole.log("Loaded");});
img1.addEventListener('error', function() {// error caughtconsole.log("Error printed");});
<img class="img-1" src="#" alt="img">

This isn't sneezy at all. We get the image, add a couple of listeners, then JavaScript can stop executing until one of those listeners is called.

Unfortunately, in the example above, it's possible that the events happened before we started listening for them, so we need to work around that using the "complete" property of images:

var img1 = document.querySelector('.img-1');
function loaded() {// image loadedconsole.log("Loaded");}
if (img1.complete) {loaded();} else {img1.addEventListener('load', loaded);}
img1.addEventListener('error', function() {// error caughtconsole.log("Error printed");});
<img class="img-1" src="#" alt="img">

This doesn't catch images that errored before we got a chance to listen for them; unfortunately, the DOM doesn't give us a way to do that. Also, this is loading one image. Things get even more complex if we want to know when a set of images have loaded.

Events aren't always the best way

Events are great for things that can happen multiple times on the same object— keyup, touchstart etc. With those events, you don't really care about what happened before you attached the listener.

The two main ways to do it correctly: are callbacks and promises.

Callbacks

Callbacks are functions that are passed inside the arguments of other functions, this procedure is valid in JavaScript because functions are objects and objects can be passed as arguments to functions. The basic structure of the callback function looks something like this:

function getMessage(callback) {callback();}
function showMessage() {console.log("Hello world! I am a callback");}getMessage(showMessage);

Promise

尽管有一些方法可以通过vanilla JS来避免回调地狱,但Promise越来越受欢迎,目前正在ES6(见Promise)中进行标准化。

承诺是一个占位符,表示异步操作的最终结果(值)

  • 承诺占位符将被替换为结果值(如果成功)或失败原因(如果不成功)

如果你不需要知道什么时候发生了什么,而只需要知道它是否发生了,那么一个承诺就是你所寻找的。

承诺有点像事件侦听器,除了:

  • 一个承诺只能成功或失败一次
  • 一个承诺不能从失败变成成功,反之亦然
  • 一旦你有了结果,承诺是不变的
  • 如果一个Promise成功或失败了,你稍后添加一个成功/失败回调,正确的回调将被调用
  • 事件发生在您添加回调之前并不重要

注意:始终从Promise中的函数返回结果,否则后续函数将无法执行任何操作。

Promise术语

一个Promise可以是:

  • 完成:与Promise相关的操作成功
    • 异步操作已完成
    • 承诺是有价值的
    • 承诺不会再变了
  • 拒绝:与Promise相关的操作失败
    • 异步操作失败
    • 承诺永远不会兑现
    • Promise有一个原因来说明操作失败的原因
    • 承诺不会再变了
  • 待定:尚未满足或拒绝
    • 异步操作还没完成
    • 可以过渡到满足或拒绝
  • 已解决:已被满足或拒绝,因此是不可变的

如何创建Promise

function getMessage() {return new Promise(function(resolve, reject) {setTimeout(function() {resolve('Hello world! I am a promise');}, 0);});}
getMessage().then(function(message) {console.log(message);});