1. 程式人生 > 其它 >JavaScript 非同步程式設計史

JavaScript 非同步程式設計史

前言

早期的 Web 應用中,與後臺進行互動時,需要進行form表單的提交,然後在頁面重新整理後給使用者反饋結果。在頁面重新整理過程中,後臺會重新返回一段html程式碼,這段html中的大部分內容與之前頁面基本相同,這勢必造成了流量的浪費,而且一來一回也延長了頁面的響應時間,總是會讓人覺得 Web 應用的體驗感比不上客戶端應用。

2004 年,AJAX即“AsynchronousJavaScriptand XML”技術橫空出世,讓 Web 應用的體驗得到了質的提升。再到 2006 年,jQuery 問世,將 Web 應用的開發體驗也提高到了新的臺階。

由於JavaScript語言單執行緒的特點,不管是事件的觸發還是 AJAX 都是通過回撥的方式進行非同步任務的觸發。如果我們想要線性的處理多個非同步任務,在程式碼中就會出現如下的情況:

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

我們經常將這種程式碼稱為:“回撥地獄”。

事件與回撥

眾所周知,JavaScript 的執行時是跑在單執行緒上的,是基於事件模型來進行非同步任務觸發的,不需要考慮共享記憶體加鎖的問題,繫結的事件會按照順序齊齊整整的觸發。要理解 JavaScript 的非同步任務,首先就要理解 JavaScript 的事件模型。

由於是非同步任務,我們需要組織一段程式碼放到未來執行(指定時間結束時或者事件觸發時),這一段程式碼我們通常放到一個匿名函式中,通常稱為回撥函式。

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

未來執行

前面說過回撥函式的執行是在未來,這就說明回撥中使用的變數並不是在回撥宣告階段就固定的。

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

這裡連續聲明瞭三個非同步任務,100毫秒後會輸出變數i的結果,按照正常的邏輯應該會輸出0、1、2這三個結果。

然而,事實並非如此,這也是我們剛開始接觸 JavaScript 的時候會遇到的問題,因為回撥函式的實際執行時機是在未來,所以輸出的i的值是迴圈結束時的值,三個非同步任務的結果一致,會輸出三個i = 3。

經歷過這個問題的同學,一般都知道,我們可以通過閉包的方式,或者重新宣告區域性變數的方式解決這個問題。

事件佇列

事件繫結之後,會將所有的回撥函式儲存起來,然後在執行過程中,會有另外的執行緒對這些非同步呼叫的回撥進行排程的處理,一旦滿足“觸發”條件就會將回調函式放入到對應的事件佇列(這裡只是簡單的理解成一個佇列,實際存在兩個事件佇列:巨集任務、微任務)中。

滿足觸發條件一般有以下幾種情況:

DOM 相關的操作進行的事件觸發,比如點選、移動、失焦等行為;
IO 相關的操作,檔案讀取完成、網路請求結束等;
時間相關的操作,到達定時任務的約定時間;

上面的這些行為發生時,程式碼中之前指定的回撥函式就會被放入一個任務佇列中,主執行緒一旦空閒,就會將其中的任務按照先進先出的流程一一執行。當有新的事件被觸發時,,所以 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.js、Bluebird。關於 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()方法會返回一個物件,物件有兩個屬性value、done:

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,讓迭代器進行自執行,直到執行完畢(也就是done為true)。

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 能夠讓非同步流程更加的簡單易讀,對開發者而言肯定是階段歡喜的一件事。

https://www.98891.com/article-69-1.html

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 更加清楚,一眼就能明白這是一個非同步操作。