JS 非同步程式設計六種方案

浪裡行舟發表於2019-01-13

前言

我們知道Javascript語言的執行環境是"單執行緒"。也就是指一次只能完成一件任務。如果有多個任務,就必須排隊,前面一個任務完成,再執行後面一個任務。

這種模式雖然實現起來比較簡單,執行環境相對單純,但是隻要有一個任務耗時很長,後面的任務都必須排隊等著,會拖延整個程式的執行。常見的瀏覽器無響應(假死),往往就是因為某一段Javascript程式碼長時間執行(比如死迴圈),導致整個頁面卡在這個地方,其他任務無法執行。

為了解決這個問題,Javascript語言將任務的執行模式分成兩種:同步和非同步。本文主要介紹非同步程式設計幾種辦法,並通過比較,得到最佳非同步程式設計的解決方案!

想閱讀更多優質文章請猛戳GitHub部落格

一、同步與非同步

我們可以通俗理解為非同步就是一個任務分成兩段,先執行第一段,然後轉而執行其他任務,等做好了準備,再回過頭執行第二段。排在非同步任務後面的程式碼,不用等待非同步任務結束會馬上執行,也就是說,非同步任務不具有”堵塞“效應。比如,有一個任務是讀取檔案進行處理,非同步的執行過程就是下面這樣

JS 非同步程式設計六種方案

這種不連續的執行,就叫做非同步。相應地,連續的執行,就叫做同步

JS 非同步程式設計六種方案

"非同步模式"非常重要。在瀏覽器端,耗時很長的操作都應該非同步執行,避免瀏覽器失去響應,最好的例子就是Ajax操作。在伺服器端,"非同步模式"甚至是唯一的模式,因為執行環境是單執行緒的,如果允許同步執行所有http請求,伺服器效能會急劇下降,很快就會失去響應。接下來介紹下非同步程式設計六種方法。

二、回撥函式(Callback)

回撥函式是非同步操作最基本的方法。以下程式碼就是一個回撥函式的例子:

ajax(url, () => {
    // 處理邏輯
})
複製程式碼

但是回撥函式有一個致命的弱點,就是容易寫出回撥地獄(Callback hell)。假設多個請求存在依賴性,你可能就會寫出如下程式碼:

ajax(url, () => {
    // 處理邏輯
    ajax(url1, () => {
        // 處理邏輯
        ajax(url2, () => {
            // 處理邏輯
        })
    })
})
複製程式碼

回撥函式的優點是簡單、容易理解和實現,缺點是不利於程式碼的閱讀和維護,各個部分之間高度耦合,使得程式結構混亂、流程難以追蹤(尤其是多個回撥函式巢狀的情況),而且每個任務只能指定一個回撥函式。此外它不能使用 try catch 捕獲錯誤,不能直接 return。

三、事件監聽

這種方式下,非同步任務的執行不取決於程式碼的順序,而取決於某個事件是否發生

下面是兩個函式f1和f2,程式設計的意圖是f2必須等到f1執行完成,才能執行。首先,為f1繫結一個事件(這裡採用的jQuery的寫法)

f1.on('done', f2);
複製程式碼

上面這行程式碼的意思是,當f1發生done事件,就執行f2。然後,對f1進行改寫:

function f1() {
  setTimeout(function () {
    // ...
    f1.trigger('done');
  }, 1000);
}
複製程式碼

上面程式碼中,f1.trigger('done')表示,執行完成後,立即觸發done事件,從而開始執行f2。

這種方法的優點是比較容易理解,可以繫結多個事件,每個事件可以指定多個回撥函式,而且可以"去耦合",有利於實現模組化。缺點是整個程式都要變成事件驅動型,執行流程會變得很不清晰。閱讀程式碼的時候,很難看出主流程。

四、釋出訂閱

我們假定,存在一個"訊號中心",某個任務執行完成,就向訊號中心"釋出"(publish)一個訊號,其他任務可以向訊號中心"訂閱"(subscribe)這個訊號,從而知道什麼時候自己可以開始執行。這就叫做"釋出/訂閱模式"(publish-subscribe pattern),又稱"觀察者模式"(observer pattern)。

首先,f2向訊號中心jQuery訂閱done訊號。

jQuery.subscribe('done', f2);
複製程式碼

然後,f1進行如下改寫:

function f1() {
  setTimeout(function () {
    // ...
    jQuery.publish('done');
  }, 1000);
}
複製程式碼

上面程式碼中,jQuery.publish('done')的意思是,f1執行完成後,向訊號中心jQuery釋出done訊號,從而引發f2的執行。 f2完成執行後,可以取消訂閱(unsubscribe)

jQuery.unsubscribe('done', f2);
複製程式碼

這種方法的性質與“事件監聽”類似,但是明顯優於後者。因為可以通過檢視“訊息中心”,瞭解存在多少訊號、每個訊號有多少訂閱者,從而監控程式的執行。

五、Promise/A+

Promise本意是承諾,在程式中的意思就是承諾我過一段時間後會給你一個結果。 什麼時候會用到過一段時間?答案是非同步操作,非同步是指可能比較長時間才有結果的才做,例如網路請求、讀取本地檔案等

1.Promise的三種狀態

  • Pending----Promise物件例項建立時候的初始狀態
  • Fulfilled----可以理解為成功的狀態
  • Rejected----可以理解為失敗的狀態

JS 非同步程式設計六種方案

這個承諾一旦從等待狀態變成為其他狀態就永遠不能更改狀態了,比如說一旦狀態變為 resolved 後,就不能再次改變為Fulfilled

let p = new Promise((resolve, reject) => {
  reject('reject')
  resolve('success')//無效程式碼不會執行
})
p.then(
  value => {
    console.log(value)
  },
  reason => {
    console.log(reason)//reject
  }
)
複製程式碼

當我們在構造 Promise 的時候,建構函式內部的程式碼是立即執行的

new Promise((resolve, reject) => {
  console.log('new Promise')
  resolve('success')
})
console.log('end')
// new Promise => end
複製程式碼

2.promise的鏈式呼叫

  • 每次呼叫返回的都是一個新的Promise例項(這就是then可用鏈式呼叫的原因)
  • 如果then中返回的是一個結果的話會把這個結果傳遞下一次then中的成功回撥
  • 如果then中出現異常,會走下一個then的失敗回撥
  • 在 then中使用了return,那麼 return 的值會被Promise.resolve() 包裝(見例1,2)
  • then中可以不傳遞引數,如果不傳遞會透到下一個then中(見例3)
  • catch 會捕獲到沒有捕獲的異常

接下來我們看幾個例子:

  // 例1
  Promise.resolve(1)
  .then(res => {
    console.log(res)
    return 2 //包裝成 Promise.resolve(2)
  })
  .catch(err => 3)
  .then(res => console.log(res))
複製程式碼
// 例2
Promise.resolve(1)
  .then(x => x + 1)
  .then(x => {
    throw new Error('My Error')
  })
  .catch(() => 1)
  .then(x => x + 1)
  .then(x => console.log(x)) //2
  .catch(console.error)
複製程式碼
// 例3
let fs = require('fs')
function read(url) {
  return new Promise((resolve, reject) => {
    fs.readFile(url, 'utf8', (err, data) => {
      if (err) reject(err)
      resolve(data)
    })
  })
}
read('./name.txt')
  .then(function(data) {
    throw new Error() //then中出現異常,會走下一個then的失敗回撥
  }) //由於下一個then沒有失敗回撥,就會繼續往下找,如果都沒有,就會被catch捕獲到
  .then(function(data) {
    console.log('data')
  })
  .then()
  .then(null, function(err) {
    console.log('then', err)// then error
  })
  .catch(function(err) {
    console.log('error')
  })
複製程式碼

Promise不僅能夠捕獲錯誤,而且也很好地解決了回撥地獄的問題,可以把之前的回撥地獄例子改寫為如下程式碼:

ajax(url)
  .then(res => {
      console.log(res)
      return ajax(url1)
  }).then(res => {
      console.log(res)
      return ajax(url2)
  }).then(res => console.log(res))
複製程式碼

它也是存在一些缺點的,比如無法取消 Promise,錯誤需要通過回撥函式捕獲。

六、生成器Generators/ yield

Generator 函式是 ES6 提供的一種非同步程式設計解決方案,語法行為與傳統函式完全不同,Generator 最大的特點就是可以控制函式的執行。

  • 語法上,首先可以把它理解成,Generator 函式是一個狀態機,封裝了多個內部狀態。
  • Generator 函式除了狀態機,還是一個遍歷器物件生成函式
  • 可暫停函式, yield可暫停,next方法可啟動,每次返回的是yield後的表示式結果
  • yield表示式本身沒有返回值,或者說總是返回undefined。next方法可以帶一個引數,該引數就會被當作上一個yield表示式的返回值

我們先來看個例子:

function *foo(x) {
  let y = 2 * (yield (x + 1))
  let z = yield (y / 3)
  return (x + y + z)
}
let it = foo(5)
console.log(it.next())   // => {value: 6, done: false}
console.log(it.next(12)) // => {value: 8, done: false}
console.log(it.next(13)) // => {value: 42, done: true}
複製程式碼

可能結果跟你想象不一致,接下來我們逐行程式碼分析:

  • 首先 Generator 函式呼叫和普通函式不同,它會返回一個迭代器
  • 當執行第一次 next 時,傳參會被忽略,並且函式暫停在 yield (x + 1) 處,所以返回 5 + 1 = 6
  • 當執行第二次 next 時,傳入的引數12就會被當作上一個yield表示式的返回值,如果你不傳參,yield 永遠返回 undefined。此時 let y = 2 * 12,所以第二個 yield 等於 2 * 12 / 3 = 8
  • 當執行第三次 next 時,傳入的引數13就會被當作上一個yield表示式的返回值,所以 z = 13, x = 5, y = 24,相加等於 42

我們再來看個例子:有三個本地檔案,分別1.txt,2.txt和3.txt,內容都只有一句話,下一個請求依賴上一個請求的結果,想通過Generator函式依次呼叫三個檔案

//1.txt檔案
2.txt
複製程式碼
//2.txt檔案
3.txt
複製程式碼
//3.txt檔案
結束
複製程式碼
let fs = require('fs')
function read(file) {
  return new Promise(function(resolve, reject) {
    fs.readFile(file, 'utf8', function(err, data) {
      if (err) reject(err)
      resolve(data)
    })
  })
}
function* r() {
  let r1 = yield read('./1.txt')
  let r2 = yield read(r1)
  let r3 = yield read(r2)
  console.log(r1)
  console.log(r2)
  console.log(r3)
}
let it = r()
let { value, done } = it.next()
value.then(function(data) { // value是個promise
  console.log(data) //data=>2.txt
  let { value, done } = it.next(data)
  value.then(function(data) {
    console.log(data) //data=>3.txt
    let { value, done } = it.next(data)
    value.then(function(data) {
      console.log(data) //data=>結束
    })
  })
})
// 2.txt=>3.txt=>結束
複製程式碼

從上例中我們看出手動迭代Generator 函式很麻煩,實現邏輯有點繞,而實際開發一般會配合 co 庫去使用。co是一個為Node.js和瀏覽器打造的基於生成器的流程控制工具,藉助於Promise,你可以使用更加優雅的方式編寫非阻塞程式碼

安裝co庫只需:npm install co

上面例子只需兩句話就可以輕鬆實現

function* r() {
  let r1 = yield read('./1.txt')
  let r2 = yield read(r1)
  let r3 = yield read(r2)
  console.log(r1)
  console.log(r2)
  console.log(r3)
}
let co = require('co')
co(r()).then(function(data) {
  console.log(data)
})
// 2.txt=>3.txt=>結束=>undefined
複製程式碼

我們可以通過 Generator 函式解決回撥地獄的問題,可以把之前的回撥地獄例子改寫為如下程式碼:

function *fetch() {
    yield ajax(url, () => {})
    yield ajax(url1, () => {})
    yield ajax(url2, () => {})
}
let it = fetch()
let result1 = it.next()
let result2 = it.next()
let result3 = it.next()
複製程式碼

七、async/await

1.Async/Await簡介

使用async/await,你可以輕鬆地達成之前使用生成器和co函式所做到的工作,它有如下特點:

  • async/await是基於Promise實現的,它不能用於普通的回撥函式。
  • async/await與Promise一樣,是非阻塞的。
  • async/await使得非同步程式碼看起來像同步程式碼,這正是它的魔力所在。

一個函式如果加上 async ,那麼該函式就會返回一個 Promise

async function async1() {
  return "1"
}
console.log(async1()) // -> Promise {<resolved>: "1"}
複製程式碼

Generator函式依次呼叫三個檔案那個例子用async/await寫法,只需幾句話便可實現

let fs = require('fs')
function read(file) {
  return new Promise(function(resolve, reject) {
    fs.readFile(file, 'utf8', function(err, data) {
      if (err) reject(err)
      resolve(data)
    })
  })
}
async function readResult(params) {
  try {
    let p1 = await read(params, 'utf8')//await後面跟的是一個Promise例項
    let p2 = await read(p1, 'utf8')
    let p3 = await read(p2, 'utf8')
    console.log('p1', p1)
    console.log('p2', p2)
    console.log('p3', p3)
    return p3
  } catch (error) {
    console.log(error)
  }
}
readResult('1.txt').then( // async函式返回的也是個promise
  data => {
    console.log(data)
  },
  err => console.log(err)
)
// p1 2.txt
// p2 3.txt
// p3 結束
// 結束
複製程式碼

2.Async/Await併發請求

如果請求兩個檔案,毫無關係,可以通過併發請求

let fs = require('fs')
function read(file) {
  return new Promise(function(resolve, reject) {
    fs.readFile(file, 'utf8', function(err, data) {
      if (err) reject(err)
      resolve(data)
    })
  })
}
function readAll() {
  read1()
  read2()//這個函式同步執行
}
async function read1() {
  let r = await read('1.txt','utf8')
  console.log(r)
}
async function read2() {
  let r = await read('2.txt','utf8')
  console.log(r)
}
readAll() // 2.txt 3.txt
複製程式碼

八、總結

1.JS 非同步程式設計進化史:callback -> promise -> generator -> async + await

2.async/await 函式的實現,就是將 Generator 函式和自動執行器,包裝在一個函式裡。

3.async/await可以說是非同步終極解決方案了。

(1) async/await函式相對於Promise,優勢體現在

  • 處理 then 的呼叫鏈,能夠更清晰準確的寫出程式碼
  • 並且也能優雅地解決回撥地獄問題。

當然async/await函式也存在一些缺點,因為 await 將非同步程式碼改造成了同步程式碼,如果多個非同步程式碼沒有依賴性卻使用了 await 會導致效能上的降低,程式碼沒有依賴性的話,完全可以使用 Promise.all 的方式。

(2) async/await函式對 Generator 函式的改進,體現在以下三點

  • 內建執行器。 Generator 函式的執行必須靠執行器,所以才有了 co 函式庫,而 async 函式自帶執行器。也就是說,async 函式的執行,與普通函式一模一樣,只要一行

  • 更廣的適用性。 co 函式庫約定,yield 命令後面只能是 Thunk 函式或 Promise 物件,而 async 函式的 await 命令後面,可以跟 Promise 物件和原始型別的值(數值、字串和布林值,但這時等同於同步操作)

  • 更好的語義。 async 和 await,比起星號和 yield,語義更清楚了。async 表示函式裡有非同步操作,await 表示緊跟在後面的表示式需要等待結果。

參考文章

相關文章