JavaScript 異步編程史

前言

早期的 Web 應用中,與後臺進行交互時,須要進行 form 表單的提交,而後在頁面刷新後給用戶反饋結果。在頁面刷新過程當中,後臺會從新返回一段 HTML 代碼,這段 HTML 中的大部份內容與以前頁面基本相同,這勢必形成了流量的浪費,並且一來一回也延長了頁面的響應時間,老是會讓人以爲 Web 應用的體驗感比不上客戶端應用。html

2004 年,AJAX 即「Asynchronous JavaScript and XML」技術橫空出世,讓 Web 應用的體驗獲得了質的提高。再到 2006 年,jQuery 問世,將 Web 應用的開發體驗也提升到了新的臺階。編程

因爲 JavaScript 語言單線程的特色,不論是事件的觸發仍是 AJAX 都是經過回調的方式進行異步任務的觸發。若是咱們想要線性的處理多個異步任務,在代碼中就會出現以下的狀況:promise

getUser(token, function (user) {
  getClassID(user, function (id) {
    getClassName(id, function (name) {
      console.log(name)
    })
  })
})
複製代碼

咱們常常將這種代碼稱爲:「回調地獄」。瀏覽器

事件與回調

衆所周知,JavaScript 的運行時是跑在單線程上的,是基於事件模型來進行異步任務觸發的,不須要考慮共享內存加鎖的問題,綁定的事件會按照順序齊齊整整的觸發。要理解 JavaScript 的異步任務,首先就要理解 JavaScript 的事件模型。markdown

因爲是異步任務,咱們須要組織一段代碼放到將來運行(指定時間結束時或者事件觸發時),這一段代碼咱們一般放到一個匿名函數中,一般稱爲回調函數。網絡

setTimeout(function () {
  // 在指定時間結束時,觸發的回調
}, 800)
window.addEventListener("resize", function() {
  // 當瀏覽器視窗發生變化時,觸發的回調
})
複製代碼

將來運行

前面說過回調函數的運行是在將來,這就說明回調中使用的變量並非在回調聲明階段就固定的。閉包

for (var i = 0; i < 3; i++) {
  setTimeout(function () {
    console.log("i =", i)
  }, 100)
}
複製代碼

這裏連續聲明瞭三個異步任務,100毫秒 後會輸出變量 i 的結果,按照正常的邏輯應該會輸出 0、一、2 這三個結果。框架

然而,事實並不是如此,這也是咱們剛開始接觸 JavaScript 的時候會遇到的問題,由於回調函數的實際運行時機是在將來,因此輸出的 i 的值是循環結束時的值,三個異步任務的結果一致,會輸出三個 i = 3koa

經歷過這個問題的同窗,通常都知道,咱們能夠經過閉包的方式,或者從新聲明局部變量的方式解決這個問題。異步

事件隊列

事件綁定以後,會將全部的回調函數存儲起來,而後在運行過程當中,會有另外的線程對這些異步調用的回調進行調度的處理,一旦知足「觸發」條件就會將回調函數放入到對應的事件隊列(這裏只是簡單的理解成一個隊列,實際存在兩個事件隊列:宏任務、微任務)中。

知足觸發條件通常有如下幾種狀況:

  1. DOM 相關的操做進行的事件觸發,好比點擊、移動、失焦等行爲;
  2. IO 相關的操做,文件讀取完成、網絡請求結束等;
  3. 時間相關的操做,到達定時任務的約定時間;

上面的這些行爲發生時,代碼中以前指定的回調函數就會被放入一個任務隊列中,主線程一旦空閒,就會將其中的任務按照先進先出的流程一一執行。當有新的事件被觸發時,又會從新放入到回調中,如此循環🔄,因此 JavaScript 的這一機制一般被稱爲「事件循環機制」。

for (var i = 1; i <= 3; i++) {
  const x = i
  setTimeout(function () {
    console.log(`第${x}個setTimout被執行`)
  }, 100)
}
複製代碼

能夠看到,其運行順序知足隊列先進先出的特色,先聲明的先被執行。

線程的阻塞

因爲 JavaScript 單線程的特色,定時器其實並不可靠,當代碼遇到阻塞的狀況,即便事件到達了觸發的時間,也會一直等在主線程空閒纔會運行。

const start = Date.now()
setTimeout(function () {
  console.log(`實際等待時間: ${Date.now() - start}ms`)
}, 300)

// while循環讓線程阻塞 800ms
while(Date.now() - start < 800) {}
複製代碼

上面代碼中,定時器設置了 300ms 後觸發回調函數,若是代碼沒有遇到阻塞,正常狀況下會 300ms 後,會輸出等待時間。

可是咱們在還沒加了一個 while 循環,這個循環會在 800ms 後才結束,主線程一直被這個循環阻塞在這裏,致使時間到了回調函數也沒有正常運行。

Promise

事件回調的方式,在編碼的過程當中,就特別容易形成回調地獄。而 Promise 提供了一種更加線性的方式編寫異步代碼,有點相似於管道的機制。

// 回調地獄
getUser(token, function (user) {
  getClassID(user, function (id) {
    getClassName(id, function (name) {
      console.log(name)
    })
  })
})

// Promise
getUser(token).then(function (user) {
  return getClassID(user)
}).then(function (id) {
  return getClassName(id)
}).then(function (name) {
  console.log(name)
}).catch(function (err) {
  console.error('請求異常', err)
})
複製代碼

Promise 在不少語言中都有相似的實現,在 JavaScript 發展過程當中,比較著名的框架 jQuery、Dojo 也都進行過相似的實現。2009 年,推出的 CommonJS 規範中,基於 Dojo.Deffered 的實現方式,提出 Promise/A 規範。也是這一年 Node.js 橫空出世,Node.js 不少實現都是依照 CommonJS 規範來的,比較熟悉的就是其模塊化方案。

早期的 Node.js 中也實現了 Promise 對象,可是 2010 年的時候,Ry(Node.js 做者)認爲 Promise 是一種比較上層的實現,並且 Node.js 的開發原本就依賴於 V8 引擎,V8 引擎原生也沒有提供 Promise 的支持,因此後來 Node.js 的模塊使用了 error-first callback 的風格(cb(error, result))。

const fs = require('fs')
// 第一個參數爲 Error 對象,若是不爲空,則表示出現異常
fs.readFile('./README.txt', function (err, buffer) {
  if (err !== null) {
    return
  }
  console.log(buffer.toString())
})
複製代碼

這一決定也致使後來 Node.js 中出現了各式各樣的 Promise 類庫,比較出名的就是 Q.jsBluebird。關於 Promise 的實現,以前有寫過一篇文章,感興趣能夠看看:《手把手教你實現 Promise》

在 Node.js@8 以前,V8 原生的 Promise 實現有一些性能問題,致使原生 Promise 的性能甚至不如一些第三方的 Promise 庫。

因此,低版本的 Node.js 項目中,常常會將 Promise 進行全局的替換:

const Bulebird = require('bluebird')
global.Promise = Bulebird
複製代碼

Generator & co

Generator(生成器) 是 ES6 提供的一種新的函數類型,主要是用於定義一個能自我迭代的函數。經過 function * 的語法可以構造一個 Generator 函數,函數執行後會返回一個iteration(迭代器)對象,該對象具備一個 next() 方法,每次調用 next() 方法就會在 yield 關鍵詞前面暫停,直到再次調用 next() 方法。

function * forEach(array) {
  const len = array.length
  for (let i = 0; i < len; i ++) {
    yield i;
  }
}
const it = forEach([2, 4, 6])
it.next() // { value: 2, done: false }
it.next() // { value: 4, done: false }
it.next() // { value: 6, done: false }
it.next() // { value: undefined, done: true }
複製代碼

next() 方法會返回一個對象,對象有兩個屬性 valuedone

  • value:表示 yield 後面的值;
  • done:表示函數是否執行完畢;

因爲生成器函數具備中斷執行的特色,將生成器函數當作一個異步操做的容器,再配合上 Promise 對象的 then 方法能夠將交回異步邏輯的執行權,在每一個 yeild 後面都加上一個 Promise 對象,就能讓迭代器不停的往下執行。

function * gen(token) {
  const user = yield getUser(token)
  const cId = yield getClassID(user)
  const name = yield getClassName(cId)
  console.log(name)
}

const g = gen('xxxx-token')

// 執行 next 方法返回的 value 爲一個 Promise 對象
const { value: promise1 } = g.next()
promise1.then(user => {
  // 傳入第二個 next 方法的值,會被生成器中第一個 yield 關鍵詞前面的變量接受
  // 日後推也是如此,第三個 next 方法的值,會被第二個 yield 前面的變量接受
  // 只有第一個 next 方法的值會被拋棄
  const { value: promise2 } = gen.next(user).value
  promise2.then(cId => {
    const { value: promise3, done } = gen.next(cId).value
    // 依次前後傳遞,直到 next 方法返回的 done 爲 true
  })
})
複製代碼

咱們將上面的邏輯進行一下抽象,讓每一個 Promise 對象正常返回後,就自動調用 next,讓迭代器進行自執行,直到執行完畢(也就是 donetrue)。

function co(gen, ...args) {
  const g = gen(...args)
  function next(data) {
    const { value: promise, done } = g.next(data)
    if (done) return promise
    promise.then(res => {
      next(res) // 將 promise 的結果傳入下一個 yield
    })
  }
  
  next() // 開始自執行
}

co(gen, 'xxxx-token')
複製代碼

這也就是 koa 早期的核心庫 co 的實現邏輯,只是 co 進行了一些參數校驗與錯誤處理。經過 generator 加上 co 可以讓異步流程更加的簡單易讀,對開發者而言確定是階段歡喜的一件事。

async/await

async/await 能夠說是 JavaScript 異步編程的終極解決方案,其實本質上就是 Generator & co 的一個語法糖,只須要在異步的生成器函數前加上 async,而後將生成器函數內的 yield 替換爲 await

async function fun(token) {
  const user = await getUser(token)
  const cId = await getClassID(user)
  const name = await getClassName(cId)
  console.log(name)
}

fun()
複製代碼

async 函數將自執行器進行了內置,同時 await 後不限制爲 Promise 對象,能夠爲任意值,並且 async/await 在語義上比起生成器的 yield 更加清楚,一眼就能明白這是一個異步操做。

相關文章
相關標籤/搜索