網站搜尋

javascript - 為什麼我在函數內修改變數後它還是沒有改變?- 非同步程式碼參考


根據以下範例,為什麼在所有情況下 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 jQuery
var outerScopeVar;
$.post('loldog', function(response) {
    outerScopeVar = response;
});
alert(outerScopeVar);
// Node.js example
var outerScopeVar;
fs.readFile('./catdog.html', function(err, data) {
    outerScopeVar = data;
});
console.log(outerScopeVar);
// with promises
var outerScopeVar;
myPromise.then(function (response) {
    outerScopeVar = response;
});
console.log(outerScopeVar);
// with observables
var outerScopeVar;
myObservable.subscribe(function (value) {
    outerScopeVar = value;
});
console.log(outerScopeVar);
// geolocation API
var outerScopeVar;
navigator.geolocation.getCurrentPosition(function (pos) {
    outerScopeVar = pos;
});
console.log(outerScopeVar);

為什麼在所有這些例子中它都輸出 undefined?我不想要解決方法,我想知道 為什麼會發生這種情況。

注意: 這是一個關於 JavaScript 非同步性 的經典問題。隨意改善這個問題並添加更多社群容易理解的簡化範例。

答案#1

一個字的回答:非同步性。

前言

這個主題在 Stack Overflow 上已經被討論過至少幾千次。因此,首先我想指出一些非常有用的資源:

  • @Felix Kling 對「我如何從非同步呼叫中返回回應?」的回答。請參考他對同步與非同步流程的精彩解釋,以及「重構程式碼」部分。
    @Benjamin Gruenbaum 也在同一討論串中花了很多心力解釋非同步性。

  • @Matt Esch 對「從 fs.readFile 獲取數據」的回答也非常簡單地解釋了非同步性。

手上問題的答案

讓我們先追蹤常見行為。在所有範例中,outerScopeVar 都是在 函數 內被修改。那個函數顯然不是立即執行的;它正被指派或作為參數傳遞。這就是我們所說的 回呼函數。

現在問題是,那個回呼函數什麼時候被呼叫?

這取決於情況。我們再嘗試追蹤一些常見行為:

  • img.onload 可能會在 未來的某個時候 被調用,當(且如果)圖片已成功加載時。
  • setTimeout 可能會在延遲時間結束之後的某個時刻被調用,前提是尚未被 clearTimeout 取消。注意:即使使用 0 作為延遲時間,所有瀏覽器仍有最低的超時延遲上限(HTML5 規範中規定為 4 毫秒)。
  • jQuery $.post 的回呼函式可能會在 將來的某個時候 被呼叫,當(且如果)Ajax 請求已成功完成時。
  • Node.js 的 fs.readFile 可能會在文件成功讀取後的 未來某個時間 被調用,或者拋出錯誤。

在所有情況下,我們都有一個回調,它可能會在未來的某個時間運行。這個「未來的某個時間」就是我們所說的非同步流程。

非同步執行會被推到同步流程之外。也就是說,當同步程式碼堆疊正在執行時,非同步程式碼永遠不會執行。這就是 JavaScript 是單線程的意思。

更具體地說,當 JS 引擎閒置時——沒有執行一堆(非)同步代碼——它會輪詢可能觸發非同步回調的事件(例如,到期的定時器、收到的網絡響應)並一個接一個地執行它們。這被稱為事件循環。

也就是,被手繪紅色形狀標出的非同步程式碼,可能只會在其各自程式區塊中所有剩餘的同步程式碼執行完後才執行:

簡而言之,回呼函數是同步創建的,但異步執行。在不知道異步函數已經執行之前,你無法依賴它的執行,那麼應該如何做呢?

這真的很簡單。依賴非同步函數執行的邏輯應該從這個非同步函數內部啟動/調用。例如,將 alert 和 console.log 移到回呼函數內部,將會輸出預期的結果,因為在那時結果已經可用。

實作你自己的回呼邏輯

你經常需要對非同步函數的結果做更多操作,或者根據非同步函數被呼叫的地方對結果做不同的處理。讓我們來看看一個稍微複雜一點的例子:

var outerScopeVar;
helloCatAsync();
alert(outerScopeVar);

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

注意:我正在使用 setTimeout 配合隨機延遲作為一個通用的非同步函數;相同的範例也適用於 Ajax、readFile、onload 以及其他任何非同步流程。

這個例子明顯和其他例子一樣存在同樣的問題;它沒有等到異步函數執行完畢。

讓我們通過實現自己的一個回調系統來解決它。首先,我們擺脫那個在這種情況下完全沒用的醜陋 outerScopeVar。然後我們新增一個參數來接收函數參數,也就是我們的回調函數。當異步操作完成時,我們調用這個回調函數,並傳入結果。實現方式(請按照註解順序閱讀):

// 1. Call helloCatAsync passing a callback function,
//    which will be called receiving the result from the async operation
helloCatAsync(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 an argument from the helloCatAsync call
function helloCatAsync(callback) {
    // 3. Start async operation:
    setTimeout(function() {
        // 4. Finished async operation,
        //    call the callback, passing the result as an argument
        callback('Nya');
    }, Math.random() * 2000);
}

上述範例的程式碼片段:

// 1. Call helloCatAsync passing a callback function,
//    which will be called receiving the result from the async operation
console.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 an argument from the helloCatAsync call
function 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 argument
        callback('Nya');
    }, Math.random() * 2000);
}

在實際使用案例中,大多數情況下,DOM API 和大多數函式庫已經提供了回呼功能(在這個示範例子中的 helloCatAsync 實作)。你只需要傳遞回呼函數,並理解它將在同步流程之外執行,並重構你的程式碼以適應這一點。

你也會注意到,由於非同步的特性,無法從非同步流程中將值return回定義回呼的同步流程,因為非同步回呼是在同步程式碼已經執行完畢很久之後才被執行的。

你不能從非同步回呼函式中 return 一個值,你必須使用回呼模式,或者……Promises。

承諾

雖然有方法可以用原生 JS 避免回呼地獄,但 promises 的人氣正在上升,目前正在 ES6 中標準化(參見 Promise - MDN)。

Promises(又稱 Futures)提供了更線性,因此更易讀的非同步程式碼,但解釋它們的全部功能超出了這個問題的範圍。相反,我將留給有興趣的人以下這些優秀的資源:

  • JavaScript 承諾 - HTML5 Rocks
  • 你誤解了承諾的意義 - domenic.me

關於 JavaScript 非同步的更多閱讀資料

  • 《Node 的藝術 - 回調函數》通過原生 JS 範例和 Node.js 代碼很好地解釋了異步代碼和回調函數。

注意:我已將此答案標記為社群維基。因此,任何擁有至少 100 點聲望的人都可以編輯並改進它!如果你願意,也歡迎改進此答案或提交一個全新的答案。

我想把這個問題轉換成一個典範主題,以解答與 Ajax 無關的非同步問題(針對 Ajax 有「如何從 AJAX 呼叫返回回應?」這個問題),因此這個主題需要你的幫助,讓它盡可能好且有用!

答案#2

Fabrício 的回答非常中肯;但我想透過使用一些不太技術性的方式,透過類比來補充他的答案,以幫助解釋非同步的概念。

一個比喻...

昨天,我正在做的工作需要一些同事的資訊。我打電話給他;以下是我們的對話內容:

我:嗨,Bob,我需要知道我們上週是怎麼foo了bar的。Jim 想要一份報告,而你是唯一知道細節的人。

Bob:當然可以,但我大約需要30分鐘?

我:太好了,Bob。當你拿到訊息時給我回個電話!

在這時,我掛掉了電話。因為我需要從Bob那裡獲得資訊來完成我的報告,我先放下報告去喝咖啡,然後我補看了一些電子郵件。40分鐘後(Bob很慢),Bob回電並給了我所需的資訊。此時,我恢復了報告的工作,因為我已經擁有了所有需要的資訊。

想像一下,如果對話是這樣進行的;

我:嗨 Bob,我需要知道我們上週是如何 foo 的 bar 的。Jim 想要一份報告,而你是唯一知道細節的人。

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,這是一個 非常慢 的操作。這個回呼函數將在這個緩慢操作完成後執行,但在此期間,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() { /* 做某事 */ } 是回呼函式;要修正 所有 範例,我們所要做的就是將需要操作回應的程式碼放進去!

* 技術上你也可以使用 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() 的函數註冊一個回調函數,然後將寬度提示(alert)移動到該回調函數中;

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)。

答案#3

這裡有一個更簡明的答案,適合尋找快速參考的人,同時也提供了一些使用 promises 和 async/await 的範例。

從天真的方法開始(這種方法不起作用),針對一個呼叫非同步方法(在此例中為 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 以上版本)以及許多現代瀏覽器中,但也在像 Bluebird 和 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 提供的功能類似於帶有 Deferred 的承諾(promises)。

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

getMessage().done(function(message) {
  console.log(message);  
});

非同步/等待

如果你的 JavaScript 環境支持 async 和 await(例如 Node.js 7.6 以上版本),那麼你可以在 async 函數中同步使用 promises:

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();

答案#4

明顯地說,這個杯子代表 outerScopeVar。

非同步函數就像...

答案#5

其他的答案都很出色,我只是想提供一個直接的回答。僅限於 jQuery 非同步呼叫

所有的 AJAX 調用(包括 $.get、$.post 或 $.ajax)都是非同步的。

考慮到你的例子

var outerScopeVar;  //line 1
$.post('loldog', function(response) {  //line 2
    outerScopeVar = 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 complete
    outerScopeVar = response;
});
alert("Lets wait for some time here! Waiting is fun");  //line 3
alert(outerScopeVar);  //line 4

現在當你執行這個時,你會在第3行看到一個提醒。現在等待一段時間,直到你確定 POST 請求已經返回一些值。然後當你在提醒框上點擊確定時,下個提醒將會顯示預期的值,因為你已經等待了它。

在現實生活的情境中,程式碼變成了,

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

所有依賴非同步呼叫的程式碼,都被移到非同步區塊內,或透過等待非同步呼叫來執行。

答案#6

在所有這些情況下,outerScopeVar 都是 異步地 修改或賦值的,或者是 在稍後的時間發生(等待或監聽某個事件發生),當前執行不會等待。因此,在所有這些情況中,當前的執行流程會導致 outerScopeVar=undefined。

讓我們討論每個例子(我標記了那些被稱為非同步或延遲以等待某些事件發生的部分):

1.

在這裡,我們註冊了一個事件監聽器,該監聽器會在特定事件發生時執行。這裡是圖片的加載。然後當前的執行會繼續進行下一行 img.src='lolcat.png'; 和 alert(outerScopeVar);,同時該事件可能尚未發生。也就是說,函數 img.onload 會等待所引用的圖片加載完成,這是異步的。這將發生在所有以下示例中——事件可能不同。

2.

這裡超時事件扮演的角色是,在指定時間後調用處理程序。這裡是 0,但它仍然會註冊一個異步事件,該事件將被加入到 事件隊列 的最後位置以執行,這就保證了延遲。

3.

4.

Node 可以被視為一種非同步編碼的王者。這裡標記的函數被註冊為回調處理器,將在讀取指定文件後執行。

5.

明顯的承諾(某事將在未來完成)是非同步的。參見 JavaScript 中 Deferred、Promise 和 Future 的區別是什麼?

https://www.quora.com/在-Javascript-中-Promise-和-Callback-的區別是什麼