漫話JavaScript與異步·第三話——Generator:化異步爲同步

1、Promise並不是完美

我在上一話中介紹了Promise,這種模式加強了事件訂閱機制,很好地解決了控制反轉帶來的信任問題、硬編碼回調執行順序形成的「回調金字塔」問題,無疑大大提升了前端開發體驗。但有了Promise就能完美地解決異步問題了嗎?並無。html

首先,Promise仍然須要經過then方法註冊回調,雖然只有一層,但沿着Promise鏈一長串寫下來,仍是有些讓人頭暈。前端

更大的問題在於Promise的錯誤處理比較麻煩,由於Promise鏈中拋出的錯誤會一直傳到鏈尾,但在鏈尾捕獲的錯誤卻不必定清楚來源。並且,鏈中拋出的錯誤會fail掉後面的整個Promise鏈,若是要在鏈中及時捕獲並處理錯誤,就須要給每一個Promise註冊一個錯誤處理回調。噢,又是一堆回調!程序員

那麼最理想的異步寫法是怎樣的呢?像同步語句那樣直觀地按順序執行,卻又不會阻塞主線程,最好還能用try-catch直接捕捉拋出的錯誤。也就是說,「化異步爲同步」!es6

癡心妄想?ajax

我在第一話裏提到,異步和同步之間的鴻溝在於:同步語句的執行時機是「如今」,而異步語句的執行時機在「將來」。爲了填平鴻溝,若是一個異步操做要寫成同步的形式,那麼同步代碼就必須有「等待」的能力,等到「將來」變成「如今」的那一刻,再繼續執行後面的語句。算法

在不阻塞主線程的前提下,這可能嗎?框架

聽起來不太可能。幸虧,Generator(生成器)爲JS帶來了這種超能力!異步

 

2、「暫停/繼續」魔法

ES6引入的新特性中,Generator多是其中最強大也最難理解的之一,即便看了阮一峯老師列舉的大量示例代碼,知道了它的所有API,也還是不得要領,這是由於Generator的行爲方式突破了咱們所熟知的JS運行規則。可一旦掌握了它,它就能賦予咱們巨大的能量,極大地提高代碼質量、開發效率,以及FEer的幸福指數。async

咱們先來簡單回顧一下,ES6以前的JS運行規則是怎樣的呢?函數

1. JS是單線程執行,只有一個主線程

2. 宿主環境提供了一個事件隊列,隨着事件被觸發,相應的回調函數被放入隊列,排隊等待執行 

3. 函數內的代碼從上到下順序執行;若是遇到函數調用,就先進入被調用的函數執行,待其返回後,用返回值替代函數調用語句,而後繼續順序執行

對於一個FEer來講,平常開發中理解到這個程度已經夠用了,直到他嘗試使用Generator……

function* gen() { let count = 0; while(true) { let msg = yield ++count; console.log(msg); } } let iter = gen(); console.log(iter.next().value); // 1
console.log(iter.next('magic').value); // 'magic' // 2

等等,gen明明是個function,執行它時卻不執行裏面的代碼,而是返回一個Iterator對象?代碼執行到yield處居然能夠暫停?暫停之後,居然能夠恢復繼續執行?說好的單線程呢?另外,暫停/恢復執行時,還能夠傳出/傳入數據?怎麼肥四?難道ES6對JS作了什麼魔改?

其實Generator並無改變JS運行的基本規則,不過套用上面的naive JS觀已經不足以解釋其實現邏輯了,是時候掏出終年在書架上吃灰的計算機基礎,重溫那些考完試就忘掉的知識。

  

3、法力的祕密——棧與堆

(注:這個部分包含了大量的我的理解,未必準確,歡迎指教)

理解Generator的關鍵點在於理解函數執行時,內存裏發生了什麼

一個JS程序的內存分爲代碼區、棧區、堆區和隊列區,從MDN借圖一張以說明(圖中沒有畫出代碼區):

隊列(Queue)就是FEer所熟知的事件循環隊列。

代碼區保存着所有JS源代碼被引擎編譯成的機器碼(以V8爲例)。

棧(stack)保存着每一個函數執行所需的上下文,一個棧元素被稱爲一個棧幀,一個棧幀對應一個函數。

對於引用類型的數據,在棧幀裏只保存引用,而真正的數據存放在堆(Heap)裏。堆與棧不一樣的是,棧內存由JS引擎自動管理,入棧時分配空間,出棧時回收,很是清楚明瞭;而堆是程序員經過new操做符手動向操做系統申請的內存空間(固然,用字面量語法建立對象也算),什麼時候該回收沒那麼明晰,因此須要一套垃圾收集(GC)算法來專門作這件事。

扯了一堆預備知識,終於能夠回到Generator的正題了:

普通函數在被調用時,JS引擎會建立一個棧幀,在裏面準備好局部變量函數參數臨時值代碼執行的位置(也就是說這個函數的第一行對應到代碼區裏的第幾行機器碼),在當前棧幀裏設置好返回位置,而後將新幀壓入棧頂。待函數執行結束後,這個棧幀將被彈出棧而後銷燬,返回值會被傳給上一個棧幀。

當執行到yield語句時,Generator的棧幀一樣會被彈出棧外,但Generator在這裏耍了個花招——它在堆裏保存了棧幀的引用(或拷貝)!這樣當iter.next方法被調用時,JS引擎便不會從新建立一個棧幀,而是把堆裏的棧幀直接入棧。由於棧幀裏保存了函數執行所需的所有上下文以及當前執行的位置,因此當這一切都被恢復如初之時,就好像程序從本來暫停的地方繼續向前執行了。

而由於每次yield和iter.next都對應一次出棧和入棧,因此能夠直接利用已有的棧機制,實現值的傳出和傳入

這就是Generator魔法背後的祕密!

 

4、終極方案:Promise+Generator

Generator的這種特性對於異步來講,意味着什麼呢?

意味着,咱們終於得到了一種在不阻塞主線程的前提下實現「同步等待」的方法!

爲便於說明,先上一段直接使用回調的代碼:

let it = gen(); // 得到迭代器 function request() { ajax({ url: 'www.someurl.com', onSuccess(res){ it.next(res); // 恢復Generator運行,同時向其中塞入異步返回的結果 } }); } function* gen() { let response = yield request(); console.log(response.text); } it.next(); // 啓動Generator

注意let response = yield request()這行代碼,是否是頗有同步的感受?就是這個Feel!

咱們來仔細分析下這段代碼是如何運行的。首先,最後一行it.next()使得Generator內部的代碼從頭開始執行,執行到yield語句時,暫停,此時能夠把yield想象成return,Generator的棧幀須要被彈出,會先計算yield右邊的表達式,即執行request函數調用,以得到用於返回給上一級棧幀的值。固然request函數沒有返回值,但它發送了一個異步ajax請求,並註冊了一個onSuccess回調,表示在請求返回結果時,恢復Generator的棧幀並繼續運行代碼,並把結果做爲參數塞給Generator,準確地說是塞到yield所在的地方,這樣response變量就得到了ajax的返回值。

能夠看出,這裏yield的功能設計得很是巧妙,好像它能夠「賦值」給response。

更妙的是,迭代器不但能夠.next,還能夠.throw,即把錯誤也拋入Generator,讓後者來處理。也就是說,在Generator裏使用try-catch語句捕獲異步錯誤,再也不是夢!

先別急着激動,上面的代碼仍是too young too simple,要真正發揮Generator處理異步的威力,還得結合他的好兄弟——Promise一塊兒上陣。代碼以下:

function request() {  // 此處的request返回的是一個Promise
    return new Promise((resolve, reject) => { ajax({ url: 'www.someurl.com', onSuccess(res) { resolve(res); }, onFail(err) { reject(err); } }); }); } let it = gen(); let p = it.next().value;  // p是yield返回的Promise
p.then(res => it.next(res), err => it.throw(err)  // 發生錯誤時,將錯誤拋入生成器
); function* gen() { try { let response = yield request(); console.log(response.text); } catch (error) { console.log('Ooops, ', error.message);  // 能夠捕獲Promise拋進來的錯誤!
 } }

這種寫法完美結合了Promise和Generator的優勢,能夠說是FEer們求之不得的超級武器。

但聰明的你必定看得出來,這種寫法套路很是固定,當Promise對象一多時,就須要寫許多相似於p.then(res => ...., err => ...)這樣的重複語句,因此人們爲了偷懶,就把這種套路給提煉成了一個更加精簡的語法,那就是傳說中的async/await

async funtion fetch() { try { let response = await request();  // request定義同上一端段示例代碼
 console.log(response.text); } catch (error) { console.log('Ooops, ', error.message); } }

fetch();

這這這。。。就靠攏同步風格的程度而言,我以爲async/await已經到了登峯造極的地步~

順便說一句,著名Node.js框架Koa2正是要求中間件使用這種寫法,足見其強大和可愛。

前端們,擦亮手中的新銳武器,準備迎接來自異步的高難度挑戰吧!

 

寫在最後

距離發表第二話(Promise)已通過去大半年了,本來設想的終章——第三話(Generator),卻遲遲未能動筆,由於筆者一直沒能弄懂Generator這個行爲怪異的傢伙到底是如何存在於JS世界的,又如何成爲「回調地獄」的終極解決方案?直到回頭彌補了一些計算機基礎知識,才最終突破了理解上的障礙,把Generator的前因後果想清楚,從而敢應用到實際工做中。因此說,基礎是很重要的,這是永不過期的真理。前端發展很是迅速,框架、工具突飛猛進,只有基礎紮實,才能從容應對,任他風起雲涌,我自穩坐釣魚臺。

相關文章
相關標籤/搜索