深入理解nodejs中的非同步程式設計

flydean發表於2021-01-16

簡介

因為javascript預設情況下是單執行緒的,這意味著程式碼不能建立新的執行緒來並行執行。但是對於最開始在瀏覽器中執行的javascript來說,單執行緒的同步執行環境顯然無法滿足頁面點選,滑鼠移動這些響應使用者的功能。於是瀏覽器實現了一組API,可以讓javascript以回撥的方式來非同步響應頁面的請求事件。

更進一步,nodejs引入了非阻塞的 I/O ,從而將非同步的概念擴充套件到了檔案訪問、網路呼叫等。

今天,我們將會深入的探討一下各種非同步程式設計的優缺點和發展趨勢。

同步非同步和阻塞非阻塞

在討論nodejs的非同步程式設計之前,讓我們來討論一個比較容易混淆的概念,那就是同步,非同步,阻塞和非阻塞。

所謂阻塞和非阻塞是指程式或者執行緒在進行操作或者資料讀寫的時候,是否需要等待,在等待的過程中能否進行其他的操作。

如果需要等待,並且等待過程中執行緒或程式無法進行其他操作,只能傻傻的等待,那麼我們就說這個操作是阻塞的。

反之,如果程式或者執行緒在進行操作或者資料讀寫的過程中,還可以進行其他的操作,那麼我們就說這個操作是非阻塞的。

同步和非同步,是指訪問資料的方式,同步是指需要主動讀取資料,這個讀取過程可能是阻塞或者是非阻塞的。而非同步是指並不需要主動去讀取資料,是被動的通知。

很明顯,javascript中的回撥是一個被動的通知,我們可以稱之為非同步呼叫。

javascript中的回撥

javascript中的回撥是非同步程式設計的一個非常典型的例子:

document.getElementById('button').addEventListener('click', () => {
  console.log('button clicked!');
})

上面的程式碼中,我們為button新增了一個click事件監聽器,如果監聽到了click事件,則會出發回撥函式,輸出相應的資訊。

回撥函式就是一個普通的函式,只不過它被作為引數傳遞給了addEventListener,並且只有事件觸發的時候才會被呼叫。

上篇文章我們講到的setTimeout和setInterval實際上都是非同步的回撥函式。

回撥函式的錯誤處理

在nodejs中怎麼處理回撥的錯誤資訊呢?nodejs採用了一個非常巧妙的辦法,在nodejs中,任何回撥函式中的第一個引數為錯誤物件,我們可以通過判斷這個錯誤物件的存在與否,來進行相應的錯誤處理。

fs.readFile('/檔案.json', (err, data) => {
  if (err !== null) {
    //處理錯誤
    console.log(err)
    return
  }

  //沒有錯誤,則處理資料。
  console.log(data)
})

回撥地獄

javascript的回撥雖然非常的優秀,它有效的解決了同步處理的問題。但是遺憾的是,如果我們需要依賴回撥函式的返回值來進行下一步的操作的時候,就會陷入這個回撥地獄。

叫回撥地獄有點誇張了,但是也是從一方面反映了回撥函式所存在的問題。

fs.readFile('/a.json', (err, data) => {
  if (err !== null) {
    fs.readFile('/b.json',(err,data) =>{
        //callback inside callback
    })
  }
})

怎麼解決呢?

別怕ES6引入了Promise,ES2017引入了Async/Await都可以解決這個問題。

ES6中的Promise

什麼是Promise

Promise 是非同步程式設計的一種解決方案,比傳統的解決方案“回撥函式和事件”更合理和更強大。

所謂Promise,簡單說就是一個容器,裡面儲存著某個未來才會結束的事件(通常是一個非同步操作)的結果。

從語法上說,Promise 是一個物件,從它可以獲取非同步操作的訊息。

Promise的特點

Promise有兩個特點:

  1. 物件的狀態不受外界影響。

Promise物件代表一個非同步操作,有三種狀態:Pending(進行中)、Resolved(已完成,又稱 Fulfilled)和Rejected(已失敗)。

只有非同步操作的結果,可以決定當前是哪一種狀態,任何其他操作都無法改變這個狀態。

  1. 一旦狀態改變,就不會再變,任何時候都可以得到這個結果。

Promise物件的狀態改變,只有兩種可能:從Pending變為Resolved和從Pending變為Rejected。

這與事件(Event)完全不同,事件的特點是,如果你錯過了它,再去監聽,是得不到結果的。

Promise的優點

Promise將非同步操作以同步操作的流程表達出來,避免了層層巢狀的回撥函式。

Promise物件提供統一的介面,使得控制非同步操作更加容易。

Promise的缺點

  1. 無法取消Promise,一旦新建它就會立即執行,無法中途取消。

  2. 如果不設定回撥函式,Promise內部丟擲的錯誤,不會反應到外部。

  3. 當處於Pending狀態時,無法得知目前進展到哪一個階段(剛剛開始還是即將完成)。

Promise的用法

Promise物件是一個建構函式,用來生成Promise例項:

var promise = new Promise(function(resolve, reject) { 
// ... some code 
if (/* 非同步操作成功 */){ 
resolve(value); 
} else { reject(error); } 
}
);

promise可以接then操作,then操作可以接兩個function引數,第一個function的引數就是構建Promise的時候resolve的value,第二個function的引數就是構建Promise的reject的error。

promise.then(function(value) { 
// success 
}, function(error) { 
// failure }
);

我們看一個具體的例子:

function timeout(ms){
    return new Promise(((resolve, reject) => {
        setTimeout(resolve,ms,'done');
    }))
}

timeout(100).then(value => console.log(value));

Promise中呼叫了一個setTimeout方法,並會定時觸發resolve方法,並傳入引數done。

最後程式輸出done。

Promise的執行順序

Promise一經建立就會立馬執行。但是Promise.then中的方法,則會等到一個呼叫週期過後再次呼叫,我們看下面的例子:

let promise = new Promise(((resolve, reject) => {
    console.log('Step1');
    resolve();
}));

promise.then(() => {
    console.log('Step3');
});

console.log('Step2');

輸出:
Step1
Step2
Step3

async和await

Promise當然很好,我們將回撥地獄轉換成了鏈式呼叫。我們用then來將多個Promise連線起來,前一個promise resolve的結果是下一個promise中then的引數。

鏈式呼叫有什麼缺點呢?

比如我們從一個promise中,resolve了一個值,我們需要根據這個值來進行一些業務邏輯的處理。

假如這個業務邏輯很長,我們就需要在下一個then中寫很長的業務邏輯程式碼。這樣讓我們的程式碼看起來非常的冗餘。

那麼有沒有什麼辦法可以直接返回promise中resolve的結果呢?

答案就是await。

當promise前面加上await的時候,呼叫的程式碼就會停止直到 promise 被解決或被拒絕。

注意await一定要放在async函式中,我們來看一個async和await的例子:

const logAsync = () => {
  return new Promise(resolve => {
    setTimeout(() => resolve('小馬哥'), 5000)
  })
}

上面我們定義了一個logAsync函式,該函式返回一個Promise,因為該Promise內部使用了setTimeout來resolve,所以我們可以將其看成是非同步的。

要是使用await得到resolve的值,我們需要將其放在一個async的函式中:

const doSomething = async () => {
  const resolveValue = await logAsync();
  console.log(resolveValue);
}

async的執行順序

await實際上是去等待promise的resolve結果我們把上面的例子結合起來:

const logAsync = () => {
    return new Promise(resolve => {
        setTimeout(() => resolve('小馬哥'), 1000)
    })
}

const doSomething = async () => {
    const resolveValue = await logAsync();
    console.log(resolveValue);
}

console.log('before')
doSomething();
console.log('after')

上面的例子輸出:

before
after
小馬哥

可以看到,aysnc是非同步執行的,並且它的順序是在當前這個週期之後。

async的特點

async會讓所有後面接的函式都變成Promise,即使後面的函式沒有顯示的返回Promise。

const asyncReturn = async () => {
    return 'async return'
}

asyncReturn().then(console.log)

因為只有Promise才能在後面接then,我們可以看出async將一個普通的函式封裝成了一個Promise:

const asyncReturn = async () => {
    return Promise.resolve('async return')
}

asyncReturn().then(console.log)

總結

promise避免了回撥地獄,它將callback inside callback改寫成了then的鏈式呼叫形式。

但是鏈式呼叫並不方便閱讀和除錯。於是出現了async和await。

async和await將鏈式呼叫改成了類似程式順序執行的語法,從而更加方便理解和除錯。

我們來看一個對比,先看下使用Promise的情況:

const getUserInfo = () => {
  return fetch('/users.json') // 獲取使用者列表
    .then(response => response.json()) // 解析 JSON
    .then(users => users[0]) // 選擇第一個使用者
    .then(user => fetch(`/users/${user.name}`)) // 獲取使用者資料
    .then(userResponse => userResponse.json()) // 解析 JSON
}

getUserInfo()

將其改寫成async和await:

const getUserInfo = async () => {
  const response = await fetch('/users.json') // 獲取使用者列表
  const users = await response.json() // 解析 JSON
  const user = users[0] // 選擇第一個使用者
  const userResponse = await fetch(`/users/${user.name}`) // 獲取使用者資料
  const userData = await userResponse.json() // 解析 JSON
  return userData
}

getUserInfo()

可以看到業務邏輯變得更加清晰。同時,我們獲取到了很多中間值,這樣也方便我們進行除錯。

本文作者:flydean程式那些事

本文連結:http://www.flydean.com/nodejs-async/

本文來源:flydean的部落格

歡迎關注我的公眾號:「程式那些事」最通俗的解讀,最深刻的乾貨,最簡潔的教程,眾多你不知道的小技巧等你來發現!

相關文章