筆者最近在對原生JS的知識作系統梳理,由於我以爲JS做爲前端工程師的根本技術,學再多遍都不爲過。打算來作一個系列,一共分三次發,以一系列的問題爲驅動,固然也會有追問和擴展,內容系統且完整,對初中級選手會有很好的提高,高級選手也會獲得複習和鞏固。這是本系列的第三篇。前端
本次分享的主題是JS執行原理
和深刻異步
,是一塊比較系統且有深度的內容,相信對進階的小夥伴是一個很大的提高。以前說過要寫設計模式
,但筆者愈來愈感受這是一塊系統性的工程,算上實際的應用場景,知識體量不會亞於JS
自己,所以我打算以後另外開一個專題程序設計模式靈魂之問
,敬請關注。node
網上的資料基本是這樣說的: 基本數據類型用棧
存儲,引用數據類型用堆
存儲。git
看起來沒有錯誤,但其實是有問題的。能夠考慮一下閉包的狀況,若是變量存在棧中,那函數調用完棧頂空間銷燬
,閉包變量不就沒了嗎?程序員
其實仍是須要補充一句:github
閉包變量是存在堆內存中的。算法
具體而言,如下數據類型存儲在棧中:編程
而全部的對象數據類型存放在堆中。json
值得注意的是,對於賦值
操做,原始類型的數據直接完整地複製變量值,對象數據類型的數據則是複製引用地址。小程序
所以會有下面的狀況:後端
let obj = { a: 1 };
let newObj = obj;
newObj.a = 2;
console.log(obj.a);//變成了2
複製代碼
之因此會這樣,是由於 obj 和 newObj 是同一份堆空間的地址,改變newObj,等於改變了共同的堆內存,這時候經過 obj 來獲取這塊內存的值固然會改變。
固然,你可能會問: 爲何不所有用棧來保存呢?
首先,對於系統棧來講,它的功能除了保存變量以外,還有建立並切換函數執行上下文的功能。舉個例子:
function f(a) {
console.log(a);
}
function func(a) {
f(a);
}
func(1);
複製代碼
假設用ESP指針來保存當前的執行狀態,在系統棧中會產生以下的過程:
調用func, 將 func 函數的上下文壓棧,ESP指向棧頂。
執行func,又調用f函數,將 f 函數的上下文壓棧,ESP 指針上移。
執行完 f 函數,將ESP 下移,f函數對應的棧頂空間被回收。
執行完 func,ESP 下移,func對應的空間被回收。
圖示以下:
所以你也看到了,若是採用棧來存儲相對基本類型更加複雜的對象數據,那麼切換上下文的開銷將變得巨大!
不過堆內存雖然空間大,能存放大量的數據,但與此同時垃圾內存的回收會帶來更大的開銷,下一篇就來分析一下堆內存究竟是如何進行垃圾回收並進行優化的。
JS 語言不像 C/C++, 讓程序員本身去開闢或者釋放內存,而是相似Java,採用本身的一套垃圾回收算法進行自動的內存管理。做爲一名資深的前端工程師,對於JS內存回收的機制是須要很是清楚, 以便於在極端的環境下可以分析出系統性能的瓶頸,另外一方面,學習這其中的機制,也對咱們深刻理解JS的閉包特性、以及對內存的高效使用,都有很大的幫助。
在其餘的後端語言中,如Java/Go, 對於內存的使用沒有什麼限制,可是JS不同,V8只能使用系統的一部份內存,具體來講,在64
位系統下,V8最多隻能分配1.4G
, 在 32 位系統中,最多隻能分配0.7G
。你想一想在前端這樣的大內存需求其實並不大,但對於後端而言,nodejs若是遇到一個2G多的文件,那麼將沒法所有將其讀入內存進行各類操做了。
咱們知道對於棧內存而言,當ESP指針下移,也就是上下文切換以後,棧頂的空間會自動被回收。但對於堆內存而言就比較複雜了,咱們下面着重分析堆內存的垃圾回收。
上一篇咱們提到過了,全部的對象類型的數據在JS中都是經過堆進行空間分配的。當咱們構造一個對象進行賦值操做的時候,其實相應的內存已經分配到了堆上。你能夠不斷的這樣建立對象,讓 V8 爲它分配空間,直到堆的大小達到上限。
那麼問題來了,V8 爲何要給它設置內存上限?明明個人機器大幾十G的內存,只能讓我用這麼一點?
究其根本,是由兩個因素所共同決定的,一個是JS單線程的執行機制,另外一個是JS垃圾回收機制的限制。
首先JS是單線程運行的,這意味着一旦進入到垃圾回收,那麼其它的各類運行邏輯都要暫停; 另外一方面垃圾回收實際上是很是耗時間的操做,V8 官方是這樣形容的:
以 1.5GB 的垃圾回收堆內存爲例,V8 作一次小的垃圾回收須要50ms 以上,作一次非增量式(ps:後面會解釋)的垃圾回收甚至要 1s 以上。
可見其耗時之久,並且在這麼長的時間內,咱們的JS代碼執行會一直沒有響應,形成應用卡頓,致使應用性能和響應能力直線降低。所以,V8 作了一個簡單粗暴的選擇,那就是限制堆內存,也算是一種權衡的手段,由於大部分狀況是不會遇到操做幾個G內存這樣的場景的。
不過,若是你想調整這個內存的限制也不是不行。配置命令以下:
// 這是調整老生代這部分的內存,單位是MB。後面會詳細介紹新生代和老生代內存
node --max-old-space-size=2048 xxx.js
複製代碼
或者
// 這是調整新生代這部分的內存,單位是 KB。
node --max-new-space-size=2048 xxx.js
複製代碼
V8 把堆內存分紅了兩部分進行處理——新生代內存和老生代內存。顧名思義,新生代就是臨時分配的內存,存活時間短, 老生代是常駐內存,存活的時間長。V8 的堆內存,也就是兩個內存之和。
根據這兩種不一樣種類的堆內存,V8 採用了不一樣的回收策略,來根據不一樣的場景作針對性的優化。
首先是新生代的內存,剛剛已經介紹了調整新生代內存的方法,那它的內存默認限制是多少?在 64 位和 32 位系統下分別爲 32MB 和 16MB。夠小吧,不過也很好理解,新生代中的變量存活時間短,來了立刻就走,不容易產生太大的內存負擔,所以能夠將它設的足夠小。
那好了,新生代的垃圾回收是怎麼作的呢?
首先將新生代內存空間一分爲二:
其中From部分表示正在使用的內存,To 是目前閒置的內存。
當進行垃圾回收時,V8 將From部分的對象檢查一遍,若是是存活對象那麼複製到To內存中(在To內存中按照順序從頭放置的),若是是非存活對象直接回收便可。
當全部的From中的存活對象按照順序進入到To內存以後,From 和 To 二者的角色對調
,From如今被閒置,To爲正在使用,如此循環。
那你極可能會問了,直接將非存活對象回收了不就萬事大吉了嘛,爲何還要後面的一系列操做?
注意,我剛剛特別說明了,在To內存中按照順序從頭放置的,這是爲了應對這樣的場景:
深色的小方塊表明存活對象,白色部分表示待分配的內存,因爲堆內存是連續分配的,這樣零零散散的空間可能會致使稍微大一點的對象沒有辦法進行空間分配,這種零散的空間也叫作內存碎片。剛剛介紹的新生代垃圾回收算法也叫Scavenge算法。
Scavenge 算法主要就是解決內存碎片的問題,在進行一頓複製以後,To空間變成了這個樣子:
是否是整齊了許多?這樣就大大方便了後續連續空間的分配。
不過Scavenge 算法的劣勢也很是明顯,就是內存只能使用新生代內存的一半,可是它只存放生命週期短的對象,這種對象通常不多
,所以時間
性能很是優秀。
剛剛介紹了新生代的回收方式,那麼新生代中的變量若是通過屢次回收後依然存在,那麼就會被放入到老生代內存
中,這種現象就叫晉升
。
發生晉升其實不僅是這一種緣由,咱們來梳理一下會有那些狀況觸發晉升:
如今進入到老生代的垃圾回收機制當中,老生代中累積的變量空間通常都是很大的,固然不能用Scavenge
算法啦,浪費一半空間不說,對龐大的內存空間進行復制豈不是勞民傷財?
那麼對於老生代而言,到底是採起怎樣的策略進行垃圾回收的呢?
第一步,進行標記-清除。這個過程在《JavaScript高級程序設計(第三版)》中有過詳細的介紹,主要分紅兩個階段,即標記階段和清除階段。首先會遍歷堆中的全部對象,對它們作上標記,而後對於代碼環境中使用的變量
以及被強引用
的變量取消標記,剩下的就是要刪除的變量了,在隨後的清除階段
對其進行空間的回收。
固然這又會引起內存碎片的問題,存活對象的空間不連續對後續的空間分配形成障礙。老生代又是如何處理這個問題的呢?
第二步,整理內存碎片。V8 的解決方式很是簡單粗暴,在清除階段結束後,把存活的對象所有往一端靠攏。
因爲是移動對象,它的執行速度不可能很快,事實上也是整個過程當中最耗時間的部分。
因爲JS的單線程機制,V8 在進行垃圾回收的時候,不可避免地會阻塞業務邏輯的執行,假若老生代的垃圾回收任務很重,那麼耗時會很是可怕,嚴重影響應用的性能。那這個時候爲了不這樣問題,V8 採起了增量標記的方案,即將一口氣完成的標記任務分爲不少小的部分完成,每作完一個小的部分就"歇"一下,就js應用邏輯執行一下子,而後再執行下面的部分,若是循環,直到標記階段完成才進入內存碎片的整理上面來。其實這個過程跟React Fiber的思路有點像,這裏就不展開了。
通過增量標記以後,垃圾回收過程對JS應用的阻塞時間減小到原來了1 / 6, 能夠看到,這是一個很是成功的改進。
JS垃圾回收的原理就介紹到這裏了,其實理解起來是很是簡單的,重要的是理解它爲何要這麼作
,而不只僅是如何作的
,但願這篇總結可以對你有所啓發。
前端相對來講是一個比較新興的領域,所以各類前端框架和工具層出不窮,讓人眼花繚亂,尤爲是各大廠商推出小程序
以後各自制定標準
,讓前端開發的工做更加繁瑣,在此背景下爲了抹平平臺之間的差別,誕生的各類編譯工具/框架
也數不勝數。但不管如何,想要遇上這些框架和工具的更新速度是很是難的,即便遇上了也很難產生本身的技術積澱
,一個更好的方式即是學習那些本質的知識
,抓住上層應用中不變的底層機制
,這樣咱們便能輕鬆理解上層的框架而不只僅是被動地使用,甚至可以在適當的場景下本身造出輪子,以知足開發效率的需求。
站在 V8 的角度,理解其中的執行機制,也可以幫助咱們理解不少的上層應用,包括Babel、Eslint、前端框架的底層機制。那麼,一段 JavaScript 代碼放在 V8 當中到底是如何執行的呢?
首先須要明白的是,機器是讀不懂 JS 代碼,機器只能理解特定的機器碼,那若是要讓 JS 的邏輯在機器上運行起來,就必須將 JS 的代碼翻譯成機器碼,而後讓機器識別。JS屬於解釋型語言,對於解釋型的語言說,解釋器會對源代碼作以下分析:
而後解釋器根據字節碼來執行程序。但 JS 整個執行的過程其實會比這個更加複雜,接下來就來一一地拆解。
生成 AST 分爲兩步——詞法分析和語法分析。
詞法分析即分詞,它的工做就是將一行行的代碼分解成一個個token。 好比下面一行代碼:
let name = 'sanyuan'
複製代碼
其中會把句子分解成四個部分:
即解析成了四個token,這就是詞法分析的做用。
接下來語法分析階段,將生成的這些 token 數據,根據必定的語法規則轉化爲AST。舉個例子:
let name = 'sanyuan'
console.log(name)
複製代碼
最後生成的 AST 是這樣的:
當生成了 AST 以後,編譯器/解釋器後續的工做都要依靠 AST 而不是源代碼。順便補充一句,babel 的工做原理就是將 ES6 的代碼解析生成ES6的AST
,而後將 ES6 的 AST 轉換爲 ES5 的AST
,最後纔將 ES5 的 AST 轉化爲具體的 ES5 代碼。因爲本文着重闡述原理,關於 babel 編譯的細節就不展開了,推薦你們去讀一讀荒山的babel文章, 幫你打開新世界的大門: )
回到 V8 自己,生成 AST 後,接下來會生成執行上下文,關於執行上下文,能夠參考上上篇《JavaScript內存機制之問——數據是如何存儲的?》中對於上下文壓棧出棧過程的講解。
開頭就已經提到過了,生成 AST 以後,直接經過 V8 的解釋器(也叫Ignition)來生成字節碼。可是字節碼
並不能讓機器直接運行,那你可能就會說了,不能執行還轉成字節碼幹嗎,直接把 AST 轉換成機器碼不就得了,讓機器直接執行。確實,在 V8 的早期是這麼作的,但後來由於機器碼的體積太大,引起了嚴重的內存佔用問題。
給一張對比圖讓你們直觀地感覺如下三者代碼量的差別:
很容易得出,字節碼是比機器碼輕量得多的代碼。那 V8 爲何要使用字節碼,字節碼究竟是個什麼東西?
字節碼是介於AST 和 機器碼之間的一種代碼,可是與特定類型的機器碼無關,字節碼須要經過解釋器將其轉換爲機器碼而後執行。
字節碼仍然須要轉換爲機器碼,但和原來不一樣的是,如今不用一次性將所有的字節碼都轉換成機器碼,而是經過解釋器來逐行執行字節碼,省去了生成二進制文件的操做,這樣就大大下降了內存的壓力。
接下來,就進入到字節碼解釋執行的階段啦!
在執行字節碼的過程當中,若是發現某一部分代碼重複出現,那麼 V8 將它記作熱點代碼
(HotSpot),而後將這麼代碼編譯成機器碼
保存起來,這個用來編譯的工具就是V8的編譯器
(也叫作TurboFan
) , 所以在這樣的機制下,代碼執行的時間越久,那麼執行效率會愈來愈高,由於有愈來愈多的字節碼被標記爲熱點代碼
,遇到它們時直接執行相應的機器碼,不用再次將轉換爲機器碼。
其實當你聽到有人說 JS 就是一門解釋器語言的時候,其實這個說法是有問題的。由於字節碼不只配合瞭解釋器,並且還和編譯器打交道,因此 JS 並非徹底的解釋型語言。而編譯器和解釋器的 根本區別在於前者會編譯生成二進制文件但後者不會。
而且,這種字節碼跟編譯器和解釋器結合的技術,咱們稱之爲即時編譯
, 也就是咱們常常聽到的JIT
。
這就是 V8 中執行一段JS代碼的整個過程,梳理一下:
AST
關於這個問題的拆解就到這裏,但願對你有所啓發。
在 JS 中,大部分的任務都是在主線程上執行,常見的任務有:
爲了讓這些事件有條不紊地進行,JS引擎須要對之執行的順序作必定的安排,V8 其實採用的是一種隊列
的方式來存儲這些任務, 即先進來的先執行。模擬以下:
bool keep_running = true;
void MainTherad(){
for(;;){
//執行隊列中的任務
Task task = task_queue.takeTask();
ProcessTask(task);
//執行延遲隊列中的任務
ProcessDelayTask()
if(!keep_running) //若是設置了退出標誌,那麼直接退出線程循環
break;
}
}
複製代碼
這裏用到了一個 for 循環,將隊列中的任務一一取出,而後執行,這個很好理解。可是其中包含了兩種任務隊列,除了上述提到的任務隊列, 還有一個延遲隊列,它專門處理諸如setTimeout/setInterval這樣的定時器回調任務。
上述提到的,普通任務隊列和延遲隊列中的任務,都屬於宏任務。
對於每一個宏任務而言,其內部都有一個微任務隊列。那爲何要引入微任務?微任務在何時執行呢?
其實引入微任務的初衷是爲了解決異步回調的問題。想想,對於異步回調的處理,有多少種方式?總結起來有兩點:
若是採用第一種方式,那麼執行回調的時機應該是在前面全部的宏任務
完成以後,假若如今的任務隊列很是長,那麼回調遲遲得不到執行,形成應用卡頓
。
爲了規避這樣的問題,V8 引入了第二種方式,這就是微任務
的解決方式。在每個宏任務中定義一個微任務隊列,當該宏任務執行完成,會檢查其中的微任務隊列,若是爲空則直接執行下一個宏任務,若是不爲空,則依次執行微任務
,執行完成纔去執行下一個宏任務。
常見的微任務有MutationObserver、Promise.then(或.reject) 以及以 Promise 爲基礎開發的其餘技術(好比fetch API), 還包括 V8 的垃圾回收過程。
Ok, 這即是宏任務
和微任務
的概念,接下來正式介紹JS很是重要的運行機制——EventLoop。
幹講理論不容易理解,讓咱們直接以一個例子開始吧:
console.log('start');
setTimeout(() => {
console.log('timeout');
});
Promise.resolve().then(() => {
console.log('resolve');
});
console.log('end');
複製代碼
咱們來分析一下:
所以最後的順序是:
start
end
resolve
timeout
複製代碼
這樣就帶你們直觀地感覺到了瀏覽器環境下 EventLoop 的執行流程。不過,這只是其中的一部分狀況,接下來咱們來作一個更完整的總結。
最後給你們留一道題目練習:
Promise.resolve().then(()=>{
console.log('Promise1')
setTimeout(()=>{
console.log('setTimeout2')
},0)
});
setTimeout(()=>{
console.log('setTimeout1')
Promise.resolve().then(()=>{
console.log('Promise2')
})
},0);
console.log('start');
// start
// Promise1
// setTimeout1
// Promise2
// setTimeout2
複製代碼
nodejs 和 瀏覽器的 eventLoop 仍是有很大差異的,值得單獨拿出來講一說。
不知你是否看過關於 nodejs 中 eventLoop 的一些文章, 是否被這些流程圖搞得眼花繚亂、一頭霧水:
看到這你不用緊張,這裏會拋開這些晦澀的流程圖,以最清晰淺顯的方式來一步步拆解 nodejs 的事件循環機制。
首先,梳理一下 nodejs 三個很是重要的執行階段:
執行 定時器回調
的階段。檢查定時器,若是到了時間,就執行回調。這些定時器就是setTimeout、setInterval。這個階段暫且叫它timer
。
輪詢(英文叫poll
)階段。由於在node代碼中不免會有異步操做,好比文件I/O,網絡I/O等等,那麼當這些異步操做作完了,就會來通知JS主線程,怎麼通知呢?就是經過'data'、
'connect'等事件使得事件循環到達 poll
階段。到達了這個階段後:
若是當前已經存在定時器,並且有定時器到時間了,拿出來執行,eventLoop 將回到timer階段。
若是沒有定時器, 會去看回調函數隊列。
不爲空
,拿出隊列中的方法依次執行爲空
,檢查是否有 setImmdiate
的回調
check階段
(下面會說)沒有則繼續等待
,至關於阻塞了一段時間(阻塞時間是有上限的), 等待 callback 函數加入隊列,加入後會馬上執行。一段時間後自動進入 check 階段
。執行 setImmdiate
的回調。這三個階段爲一個循環過程。不過如今的eventLoop並不完整,咱們如今就來一一地完善。
首先,當第 1 階段結束後,可能並不會當即等待到異步事件的響應,這時候 nodejs 會進入到 I/O異常的回調階段
。好比說 TCP 鏈接遇到ECONNREFUSED,就會在這個時候執行回調。
而且在 check 階段結束後還會進入到 關閉事件的回調階段
。若是一個 socket 或句柄(handle)被忽然關閉,例如 socket.destroy(), 'close' 事件的回調就會在這個階段執行。
梳理一下,nodejs 的 eventLoop 分爲下面的幾個階段:
是否是清晰了許多?
好,咱們以上次的練習題來實踐一把:
setTimeout(()=>{
console.log('timer1')
Promise.resolve().then(function() {
console.log('promise1')
})
}, 0)
setTimeout(()=>{
console.log('timer2')
Promise.resolve().then(function() {
console.log('promise2')
})
}, 0)
複製代碼
這裏我要說,node版本 >= 11和在 11 如下的會有不一樣的表現。
首先說 node 版本 >= 11的,它會和瀏覽器表現一致,一個定時器運行完當即運行相應的微任務。
timer1
promise1
time2
promise2
複製代碼
而 node 版本小於 11 的狀況下,對於定時器的處理是:
若第一個定時器任務出隊並執行完,發現隊首的任務仍然是一個定時器,那麼就將微任務暫時保存,
直接去執行
新的定時器任務,當新的定時器任務執行完後,再一一執行
中途產生的微任務。
所以會打印出這樣的結果:
timer1
timer2
promise1
promise2
複製代碼
二者最主要的區別在於瀏覽器中的微任務是在每一個相應的宏任務
中執行的,而nodejs中的微任務是在不一樣階段之間
執行的。
process.nextTick 是一個獨立於 eventLoop 的任務隊列。
在每個 eventLoop 階段完成後會去檢查這個隊列,若是裏面有任務,會讓這部分任務優先於微任務
執行。
在聽到 nodejs 相關的特性時,常常會對 異步I/O
、非阻塞I/O
有所耳聞,聽起來好像是差很少的意思,但實際上是兩碼事,下面咱們就以原理的角度來剖析一下對 nodejs 來講,這兩種技術底層是如何實現的?
首先,我想有必要把 I/O 的概念解釋一下。I/O 即Input/Output, 輸入和輸出的意思。在瀏覽器端,只有一種 I/O,那就是利用 Ajax 發送網絡請求,而後讀取返回的內容,這屬於網絡I/O
。回到 nodejs 中,其實這種的 I/O 的場景就更加普遍了,主要分爲兩種:
阻塞
和非阻塞
I/O 實際上是針對操做系統內核而言的,而不是 nodejs 自己。阻塞 I/O 的特色就是必定要等到操做系統完成全部操做後才表示調用結束,而非阻塞 I/O 是調用後立馬返回,不用等操做系統內核完成操做。
對前者而言,在操做系統進行 I/O 的操做的過程當中,咱們的應用程序實際上是一直處於等待狀態的,什麼都作不了。那若是換成非阻塞I/O
,調用返回後咱們的 nodejs 應用程序能夠完成其餘的事情,而操做系統同時也在進行 I/O。這樣就把等待的時間充分利用了起來,提升了執行效率,可是同時又會產生一個問題,nodejs 應用程序怎麼知道操做系統已經完成了 I/O 操做呢?
爲了讓 nodejs 知道操做系統已經作完 I/O 操做,須要重複地去操做系統那裏判斷一下是否完成,這種重複判斷的方式就是輪詢
。對於輪詢而言,有如下這麼幾種方案:
一直輪詢檢查I/O狀態,直到 I/O 完成。這是最原始的方式,也是性能最低的,會讓 CPU 一直耗用在等待上面。其實跟阻塞 I/O 的效果是同樣的。
遍歷文件描述符(即 文件I/O 時操做系統和 nodejs 之間的文件憑證)的方式來肯定 I/O 是否完成,I/O完成則文件描述符的狀態改變。但 CPU 輪詢消耗仍是很大。
epoll模式。即在進入輪詢的時候若是I/O未完成CPU就休眠,完成以後喚醒CPU。
總之,CPU要麼重複檢查I/O,要麼重複檢查文件描述符,要麼休眠,都得不到很好的利用,咱們但願的是:
nodejs 應用程序發起 I/O 調用後能夠直接去執行別的邏輯,操做系統默默地作完 I/O 以後給 nodejs 發一個完成信號,nodejs 執行回調操做。
這是理想的狀況,也是異步 I/O 的效果,那如何實現這樣的效果呢?
Linux 原生存在這樣的一種方式,即(AIO), 但兩個致命的缺陷:
是否是沒有辦法了呢?在單線程的狀況下確實是這樣,可是若是把思路放開一點,利用多線程來考慮這個問題,就變得輕鬆多了。咱們可讓一個進程進行計算操做,另一些進行 I/O 調用,I/O 完成後把信號傳給計算的線程,進而執行回調,這不就行了嗎?沒錯,異步 I/O 就是使用這樣的線程池來實現的。
只不過在不一樣的系統下面表現會有所差別,在 Linux 下能夠直接使用線程池來完成,在Window系統下則採用 IOCP 這個系統API(其內部仍是用線程池完成的)。
有了操做系統的支持,那 nodejs 如何來對接這些操做系統從而實現異步 I/O 呢?
以文件爲 I/O 咱們以一段代碼爲例:
let fs = require('fs');
fs.readFile('/test.txt', function (err, data) {
console.log(data);
});
複製代碼
執行代碼的過程當中大概發生了這些事情:
重點來了!libuv 中是如何來進行進行系統調用的呢?也就是 uv_fs_open() 中作了些什麼?
以Windows系統爲例來講,在這個函數的調用過程當中,咱們建立了一個文件I/O的請求對象,並往裏面注入了回調函數。
req_wrap->object_->Set(oncomplete_sym, callback);
複製代碼
req_wrap 即是這個請求對象,req_wrap 中 object_ 的 oncomplete_sym 屬性對應的值即是咱們 nodejs 應用程序代碼中傳入的回調函數。
在這個對象包裝完成後,QueueUserWorkItem() 方法將這個對象推動線程池中等待執行。
好,至此如今js的調用就直接返回了,咱們的 js 應用程序代碼能夠繼續往下執行
,固然,當前的 I/O
操做同時也在線程池中將被執行,這不就完成了異步麼:)
等等,別高興太早,回調都還沒執行呢!接下來即是執行回調通知的環節。
事實上如今線程池中的 I/O 不管是阻塞仍是非阻塞都已經無所謂了,由於異步的目的已經達成。重要的是 I/O 完成後會發生什麼。
在介紹後續的故事以前,給你們介紹兩個重要的方法: GetQueuedCompletionStatus
和 PostQueuedCompletionStatus
。
還記得以前講過的 eventLoop 嗎?在每個Tick當中會調用GetQueuedCompletionStatus
檢查線程池中是否有執行完的請求,若是有則表示時機已經成熟,能夠執行回調了。
PostQueuedCompletionStatus
方法則是向 IOCP 提交狀態,告訴它當前I/O完成了。
名字比較長,先介紹是爲了讓你們混個臉熟,至少後面出來不會感到太突兀:)
咱們言歸正傳,把後面的過程串聯起來。
當對應線程中的 I/O 完成後,會將得到的結果存儲
起來,保存到相應的請求對象
中,而後調用PostQueuedCompletionStatus()
向 IOCP 提交執行完成的狀態,而且將線程還給操做系統。一旦 EventLoop 的輪詢操做中,調用GetQueuedCompletionStatus
檢測到了完成的狀態,就會把請求對象
塞給I/O觀察者(以前埋下伏筆,現在終於閃亮登場)。
I/O 觀察者如今的行爲就是取出請求對象
的存儲結果
,同時也取出它的oncomplete_sym
屬性,即回調函數(不懂這個屬性的回看第1步的操做)。將前者做爲函數參數傳入後者,並執行後者。 這裏,回調函數就成功執行啦!
總結 :
阻塞
和非阻塞
I/O 實際上是針對操做系統內核而言的。阻塞 I/O 的特色就是必定要等到操做系統完成全部操做後才表示調用結束,而非阻塞 I/O 是調用後立馬返回,不用等操做系統內核完成操做。EventLoop
、I/O 觀察者
,請求對象
、線程池
四大要素相互配合,共同實現。關於 JS 單線程
、EventLoop
以及異步 I/O
這些底層的特性,咱們以前作過了詳細的拆解,不在贅述。在探究了底層機制以後,咱們還須要對代碼的組織方式有所理解,這是離咱們最平常開發最接近的部分,異步代碼的組織方式直接決定了開發
和維護
的效率
,其重要性也不可小覷。儘管底層機制沒變,但異步代碼的組織方式卻隨着 ES 標準的發展,一步步發生了巨大的變革
。接着讓咱們來一探究竟吧!
相信不少 nodejs 的初學者都或多或少踩過這樣的坑,node 中不少原生的 api 就是諸如這樣的:
fs.readFile('xxx', (err, data) => {
});
複製代碼
典型的高階函數,將回調函數做爲函數參數傳給了readFile。但長此以往,就會發現,這種傳入回調的方式也存在大坑, 好比下面這樣:
fs.readFile('1.json', (err, data) => {
fs.readFile('2.json', (err, data) => {
fs.readFile('3.json', (err, data) => {
fs.readFile('4.json', (err, data) => {
});
});
});
});
複製代碼
回調當中嵌套回調,也稱回調地獄
。這種代碼的可讀性和可維護性都是很是差的,由於嵌套的層級太多。並且還有一個嚴重的問題,就是每次任務可能會失敗,須要在回調裏面對每一個任務的失敗狀況進行處理,增長了代碼的混亂程度。
ES6 中新增的 Promise 就很好了解決了回調地獄
的問題,同時了合併了錯誤處理。寫出來的代碼相似於下面這樣:
readFilePromise('1.json').then(data => {
return readFilePromise('2.json')
}).then(data => {
return readFilePromise('3.json')
}).then(data => {
return readFilePromise('4.json')
});
複製代碼
以鏈式調用的方式避免了大量的嵌套,也符合人的線性思惟方式,大大方便了異步編程。
利用協程完成 Generator 函數,用 co 庫讓代碼依次執行完,同時以同步的方式書寫,也讓異步操做按順序執行。
co(function* () {
const r1 = yield readFilePromise('1.json');
const r2 = yield readFilePromise('2.json');
const r3 = yield readFilePromise('3.json');
const r4 = yield readFilePromise('4.json');
})
複製代碼
這是 ES7 中新增的關鍵字,凡是加上 async 的函數都默認返回一個 Promise 對象,而更重要的是 async + await 也能讓異步代碼以同步的方式來書寫,而不須要藉助第三方庫的支持。
const readFileAsync = async function () {
const f1 = await readFilePromise('1.json')
const f2 = await readFilePromise('2.json')
const f3 = await readFilePromise('3.json')
const f4 = await readFilePromise('4.json')
}
複製代碼
這四種經典的異步編程方式就簡單回顧完了,因爲是鳥瞰大局,我以爲知道是什麼
比瞭解細節
要重要, 所以也沒有展開。不過不要緊,接下來,讓咱們針對這些具體的解決方案,一步步深刻異步編程,理解其中的本質。
回調函數
的方式其實內部利用了發佈-訂閱
模式,在這裏咱們以模擬實現 node 中的 Event 模塊爲例來寫實現回調函數的機制。
function EventEmitter() {
this.events = new Map();
}
複製代碼
這個 EventEmitter 一共須要實現這些方法: addListener
, removeListener
, once
, removeAllListener
, emit
。
首先是addListener:
// once 參數表示是否只是觸發一次
const wrapCallback = (fn, once = false) => ({ callback: fn, once });
EventEmitter.prototype.addListener = function (type, fn, once = false) {
let handler = this.events.get(type);
if (!handler) {
// 爲 type 事件綁定回調
this.events.set(type, wrapCallback(fn, once));
} else if (handler && typeof handler.callback === 'function') {
// 目前 type 事件只有一個回調
this.events.set(type, [handler, wrapCallback(fn, once)]);
} else {
// 目前 type 事件回調數 >= 2
handler.push(wrapCallback(fn, once));
}
}
複製代碼
removeLisener 的實現以下:
EventEmitter.prototype.removeListener = function (type, listener) {
let handler = this.events.get(type);
if (!handler) return;
if (!Array.isArray(handler)) {
if (handler.callback === listener.callback) this.events.delete(type);
else return;
}
for (let i = 0; i < handler.length; i++) {
let item = handler[i];
if (item.callback === listener.callback) {
// 刪除該回調,注意數組塌陷的問題,即後面的元素會往前挪一位。i 要 --
handler.splice(i, 1);
i--;
if (handler.length === 1) {
// 長度爲 1 就不用數組存了
this.events.set(type, handler[0]);
}
}
}
}
複製代碼
once 實現思路很簡單,先調用 addListener 添加上了once標記的回調對象, 而後在 emit 的時候遍歷回調列表,將標記了once: true的項remove掉便可。
EventEmitter.prototype.once = function (type, fn) {
this.addListener(type, fn, true);
}
EventEmitter.prototype.emit = function (type, ...args) {
let handler = this.events.get(type);
if (!handler) return;
if (Array.isArray(handler)) {
// 遍歷列表,執行回調
handler.map(item => {
item.callback.apply(this, args);
// 標記的 once: true 的項直接移除
if (item.once) this.removeListener(type, item);
})
} else {
// 只有一個回調則直接執行
handler.callback.apply(this, args);
}
return true;
}
複製代碼
最後是 removeAllListener:
EventEmitter.prototype.removeAllListener = function (type) {
let handler = this.events.get(type);
if (!handler) return;
else this.events.delete(type);
}
複製代碼
如今咱們測試一下:
let e = new EventEmitter();
e.addListener('type', () => {
console.log("type事件觸發!");
})
e.addListener('type', () => {
console.log("WOW!type事件又觸發了!");
})
function f() {
console.log("type事件我只觸發一次");
}
e.once('type', f)
e.emit('type');
e.emit('type');
e.removeAllListener('type');
e.emit('type');
// type事件觸發!
// WOW!type事件又觸發了!
// type事件我只觸發一次
// type事件觸發!
// WOW!type事件又觸發了!
複製代碼
OK,一個簡易的 Event 就這樣實現完成了,爲何說它簡易呢?由於還有不少細節的部分沒有考慮:
參數少
的狀況下,call 的性能優於 apply,反之 apply 的性能更好。所以在執行回調時候能夠根據狀況調用 call 或者 apply。回調列表的最大值
,當超過最大值的時候,應該選擇部分回調進行刪除操做。魯棒性
有待提升。對於參數的校驗
不少地方直接忽略掉了。不過,這個案例的目的只是帶你們掌握核心的原理,若是在這裏洋洋灑灑寫三四百行意義也不大,有興趣的能夠去看看Node中 Event 模塊 的源碼,裏面對各類細節和邊界狀況作了詳細的處理。
首先,什麼是回調地獄:
這兩種問題在回調函數時代尤其突出。Promise 的誕生就是爲了解決這兩個問題。
Promise 利用了三大技術手段來解決回調地獄
:
首先來舉個例子:
let readFilePromise = (filename) => {
fs.readFile(filename, (err, data) => {
if(err) {
reject(err);
}else {
resolve(data);
}
})
}
readFilePromise('1.json').then(data => {
return readFilePromise('2.json')
});
複製代碼
看到沒有,回調函數不是直接聲明的,而是在經過後面的 then 方法傳入的,即延遲傳入。這就是回調函數延遲綁定
。
而後咱們作如下微調:
let x = readFilePromise('1.json').then(data => {
return readFilePromise('2.json')//這是返回的Promise
});
x.then(/* 內部邏輯省略 */)
複製代碼
咱們會根據 then 中回調函數的傳入值建立不一樣類型的Promise, 而後把返回的 Promise 穿透到外層, 以供後續的調用。這裏的 x 指的就是內部返回的 Promise,而後在 x 後面能夠依次完成鏈式調用。
這即是返回值穿透
的效果。
這兩種技術一塊兒做用即可以將深層的嵌套回調寫成下面的形式:
readFilePromise('1.json').then(data => {
return readFilePromise('2.json');
}).then(data => {
return readFilePromise('3.json');
}).then(data => {
return readFilePromise('4.json');
});
複製代碼
這樣就顯得清爽了許多,更重要的是,它更符合人的線性思惟模式,開發體驗也更好。
兩種技術結合產生了鏈式調用
的效果。
這解決的是多層嵌套的問題,那另外一個問題,即每次任務執行結束後分別處理成功和失敗
的狀況怎麼解決的呢?
Promise 採用了錯誤冒泡
的方式。其實很簡單理解,咱們來看看效果:
readFilePromise('1.json').then(data => {
return readFilePromise('2.json');
}).then(data => {
return readFilePromise('3.json');
}).then(data => {
return readFilePromise('4.json');
}).catch(err => {
// xxx
})
複製代碼
這樣前面產生的錯誤會一直向後傳遞,被 catch 接收到,就不用頻繁地檢查錯誤了。
在這裏,若是你尚未接觸過 Promise, 務必去看看 MDN 文檔,瞭解使用方式,否則後面很會懵。
Promise 中的執行函數是同步進行的,可是裏面存在着異步操做,在異步操做結束後會調用 resolve 方法,或者中途遇到錯誤調用 reject 方法,這二者都是做爲微任務進入到 EventLoop 中。可是你有沒有想過,Promise 爲何要引入微任務的方式來進行回調操做?
回到問題自己,其實就是如何處理回調的問題。總結起來有三種方式:
宏任務隊列
的隊尾。當前宏任務中
的最後面。第一種方式顯然不可取,由於同步的問題很是明顯,會讓整個腳本阻塞住,當前任務等待,後面的任務都沒法獲得執行,而這部分等待的時間
是能夠拿來完成其餘事情的,致使 CPU 的利用率很是低,並且還有另一個致命的問題,就是沒法實現延遲綁定
的效果。
若是採用第二種方式,那麼執行回調(resolve/reject)的時機應該是在前面全部的宏任務
完成以後,假若如今的任務隊列很是長,那麼回調遲遲得不到執行,形成應用卡頓
。
爲了解決上述方案的問題,另外也考慮到延遲綁定
的需求,Promise 採起第三種方式, 即引入微任務
, 即把 resolve(reject) 回調的執行放在當前宏任務的末尾。
這樣,利用微任務
解決了兩大痛點:
好,Promise 的基本實現思想已經講清楚了,相信你們已經知道了它爲何這麼設計
,接下來就讓咱們一步步弄清楚它內部究竟是怎麼設計的
。
從如今開始,咱們就來動手實現一個功能完整的Promise,一步步深挖其中的細節。咱們先從鏈式調用開始。
首先寫出初版的代碼:
//定義三種狀態
const PENDING = "pending";
const FULFILLED = "fulfilled";
const REJECTED = "rejected";
function MyPromise(executor) {
let self = this; // 緩存當前promise實例
self.value = null;
self.error = null;
self.status = PENDING;
self.onFulfilled = null; //成功的回調函數
self.onRejected = null; //失敗的回調函數
const resolve = (value) => {
if(self.status !== PENDING) return;
setTimeout(() => {
self.status = FULFILLED;
self.value = value;
self.onFulfilled(self.value);//resolve時執行成功回調
});
};
const reject = (error) => {
if(self.status !== PENDING) return;
setTimeout(() => {
self.status = REJECTED;
self.error = error;
self.onRejected(self.error);//resolve時執行成功回調
});
};
executor(resolve, reject);
}
MyPromise.prototype.then = function(onFulfilled, onRejected) {
if (this.status === PENDING) {
this.onFulfilled = onFulfilled;
this.onRejected = onRejected;
} else if (this.status === FULFILLED) {
//若是狀態是fulfilled,直接執行成功回調,並將成功值傳入
onFulfilled(this.value)
} else {
//若是狀態是rejected,直接執行失敗回調,並將失敗緣由傳入
onRejected(this.error)
}
return this;
}
複製代碼
能夠看到,Promise 的本質是一個有限狀態機
,存在三種狀態:
狀態改變規則以下圖:
對於 Promise 而言,狀態的改變不可逆
,即由等待態變爲其餘的狀態後,就沒法再改變了。
不過,回到目前這一版的 Promise, 仍是存在一些問題的。
首先只能執行一個回調函數,對於多個回調的綁定就無能爲力,好比下面這樣:
let promise1 = new MyPromise((resolve, reject) => {
fs.readFile('./001.txt', (err, data) => {
if(!err){
resolve(data);
}else {
reject(err);
}
})
});
let x1 = promise1.then(data => {
console.log("第一次展現", data.toString());
});
let x2 = promise1.then(data => {
console.log("第二次展現", data.toString());
});
let x3 = promise1.then(data => {
console.log("第三次展現", data.toString());
});
複製代碼
這裏我綁定了三個回調,想要在 resolve() 以後一塊兒執行,那怎麼辦呢?
須要將 onFulfilled
和 onRejected
改成數組,調用 resolve 時將其中的方法拿出來一一執行便可。
self.onFulfilledCallbacks = [];
self.onRejectedCallbacks = [];
複製代碼
MyPromise.prototype.then = function(onFulfilled, onRejected) {
if (this.status === PENDING) {
this.onFulfilledCallbacks.push(onFulfilled);
this.onRejectedCallbacks.push(onRejected);
} else if (this.status === FULFILLED) {
onFulfilled(this.value);
} else {
onRejected(this.error);
}
return this;
}
複製代碼
接下來將 resolve 和 reject 方法中執行回調的部分進行修改:
// resolve 中
self.onFulfilledCallbacks.forEach((callback) => callback(self.value));
//reject 中
self.onRejectedCallbacks.forEach((callback) => callback(self.error));
複製代碼
咱們採用目前的代碼來進行測試:
let fs = require('fs');
let readFilePromise = (filename) => {
return new MyPromise((resolve, reject) => {
fs.readFile(filename, (err, data) => {
if(!err){
resolve(data);
}else {
reject(err);
}
})
})
}
readFilePromise('./001.txt').then(data => {
console.log(data.toString());
return readFilePromise('./002.txt');
}).then(data => {
console.log(data.toString());
})
// 001.txt的內容
// 001.txt的內容
複製代碼
咦?怎麼打印了兩個 001
,第二次不是讀的 002
文件嗎?
問題出在這裏:
MyPromise.prototype.then = function(onFulfilled, onRejected) {
//...
return this;
}
複製代碼
這麼寫每次返回的都是第一個 Promise。then 函數當中返回的第二個 Promise 直接被無視了!
說明 then 當中的實現還須要改進, 咱們如今須要對 then 中返回值重視起來。
MyPromise.prototype.then = function (onFulfilled, onRejected) {
let bridgePromise;
let self = this;
if (self.status === PENDING) {
return bridgePromise = new MyPromise((resolve, reject) => {
self.onFulfilledCallbacks.push((value) => {
try {
// 看到了嗎?要拿到 then 中回調返回的結果。
let x = onFulfilled(value);
resolve(x);
} catch (e) {
reject(e);
}
});
self.onRejectedCallbacks.push((error) => {
try {
let x = onRejected(error);
resolve(x);
} catch (e) {
reject(e);
}
});
});
}
//...
}
複製代碼
倘若當前狀態爲 PENDING,將回調數組中添加如上的函數,當 Promise 狀態變化後,會遍歷相應回調數組並執行回調。
可是這段程度仍是存在一些問題:
x
)是一個 Promise, 直接給 resolve 了,這是咱們不但願看到的。怎麼來解決這兩個問題呢?
先對參數不傳的狀況作判斷:
// 成功回調不傳給它一個默認函數
onFulfilled = typeof onFulfilled === "function" ? onFulfilled : value => value;
// 對於失敗回調直接拋錯
onRejected = typeof onRejected === "function" ? onRejected : error => { throw error };
複製代碼
而後對返回Promise
的狀況進行處理:
function resolvePromise(bridgePromise, x, resolve, reject) {
//若是x是一個promise
if (x instanceof MyPromise) {
// 拆解這個 promise ,直到返回值不爲 promise 爲止
if (x.status === PENDING) {
x.then(y => {
resolvePromise(bridgePromise, y, resolve, reject);
}, error => {
reject(error);
});
} else {
x.then(resolve, reject);
}
} else {
// 非 Promise 的話直接 resolve 便可
resolve(x);
}
}
複製代碼
而後在 then 的方法實現中做以下修改:
resolve(x) -> resolvePromise(bridgePromise, x, resolve, reject);
複製代碼
在這裏你們好好體會一下拆解 Promise 的過程,其實不難理解,我要強調的是其中的遞歸調用始終傳入的resolve
和reject
這兩個參數是什麼含義,其實他們控制的是最開始傳入的bridgePromise
的狀態,這一點很是重要。
緊接着,咱們實現一下當 Promise 狀態不爲 PENDING 時的邏輯。
成功狀態下調用then:
if (self.status === FULFILLED) {
return bridgePromise = new MyPromise((resolve, reject) => {
try {
// 狀態變爲成功,會有相應的 self.value
let x = onFulfilled(self.value);
// 暫時能夠理解爲 resolve(x),後面具體實現中有拆解的過程
resolvePromise(bridgePromise, x, resolve, reject);
} catch (e) {
reject(e);
}
})
}
複製代碼
失敗狀態下調用then:
if (self.status === REJECTED) {
return bridgePromise = new MyPromise((resolve, reject) => {
try {
// 狀態變爲失敗,會有相應的 self.error
let x = onRejected(self.error);
resolvePromise(bridgePromise, x, resolve, reject);
} catch (e) {
reject(e);
}
});
}
複製代碼
Promise A+中規定成功和失敗的回調都是微任務,因爲瀏覽器中 JS 觸碰不到底層微任務的分配,能夠直接拿 setTimeout
(屬於宏任務的範疇) 來模擬,用 setTimeout
將須要執行的任務包裹 ,固然,上面的 resolve 實現也是同理, 你們注意一下便可,其實並非真正的微任務。
if (self.status === FULFILLED) {
return bridgePromise = new MyPromise((resolve, reject) => {
setTimeout(() => {
//...
})
}
複製代碼
if (self.status === REJECTED) {
return bridgePromise = new MyPromise((resolve, reject) => {
setTimeout(() => {
//...
})
}
複製代碼
好了,到這裏, 咱們基本實現了 then 方法,如今咱們拿剛剛的測試代碼作一下測試, 依次打印以下:
001.txt的內容
002.txt的內容
複製代碼
能夠看到,已經能夠順利地完成鏈式調用。
如今來實現 catch 方法:
Promise.prototype.catch = function (onRejected) {
return this.then(null, onRejected);
}
複製代碼
對,就是這麼幾行,catch 本來就是 then 方法的語法糖。
相比於實現來說,更重要的是理解其中錯誤冒泡的機制,即中途一旦發生錯誤,能夠在最後用 catch 捕獲錯誤。
咱們回顧一下 Promise 的運做流程也不難理解,貼上一行關鍵的代碼:
// then 的實現中
onRejected = typeof onRejected === "function" ? onRejected : error => { throw error };
複製代碼
一旦其中有一個PENDING狀態
的 Promise 出現錯誤後狀態必然會變爲失敗
, 而後執行 onRejected
函數,而這個 onRejected 執行又會拋錯,把新的 Promise 狀態變爲失敗
,新的 Promise 狀態變爲失敗後又會執行onRejected
......就這樣一直拋下去,直到用catch
捕獲到這個錯誤,才中止往下拋。
這就是 Promise 的錯誤冒泡機制
。
至此,Promise 三大法寶: 回調函數延遲綁定
、回調返回值穿透
和錯誤冒泡
。
實現 resolve 靜態方法有三個要點:
採用它的最終狀態
做爲本身的狀態
。具體實現以下:
Promise.resolve = (param) => {
if(param instanceof Promise) return param;
return new Promise((resolve, reject) => {
if(param && param.then && typeof param.then === 'function') {
// param 狀態變爲成功會調用resolve,將新 Promise 的狀態變爲成功,反之亦然
param.then(resolve, reject);
}else {
resolve(param);
}
})
}
複製代碼
Promise.reject 中傳入的參數會做爲一個 reason 原封不動地往下傳, 實現以下:
Promise.reject = function (reason) {
return new Promise((resolve, reject) => {
reject(reason);
});
}
複製代碼
不管當前 Promise 是成功仍是失敗,調用finally
以後都會執行 finally 中傳入的函數,而且將值原封不動的往下傳。
Promise.prototype.finally = function(callback) {
this.then(value => {
return Promise.resolve(callback()).then(() => {
return value;
})
}, error => {
return Promise.resolve(callback()).then(() => {
throw error;
})
})
}
複製代碼
對於 all 方法而言,須要完成下面的核心功能:
直接進行resolve
。有一個
promise失敗,那麼Promise.all返回的promise對象失敗。數組
具體實現以下:
Promise.all = function(promises) {
return new Promise((resolve, reject) => {
let result = [];
let index = 0;
let len = promises.length;
if(len === 0) {
resolve(result);
return;
}
for(let i = 0; i < len; i++) {
// 爲何不直接 promise[i].then, 由於promise[i]可能不是一個promise
Promise.resolve(promise[i]).then(data => {
result[i] = data;
index++;
if(index === len) resolve(result);
}).catch(err => {
reject(err);
})
}
})
}
複製代碼
race 的實現相比之下就簡單一些,只要有一個 promise 執行完,直接 resolve 並中止執行。
Promise.race = function(promises) {
return new Promise((resolve, reject) => {
let len = promises.length;
if(len === 0) return;
for(let i = 0; i < len; i++) {
Promise.resolve(promise[i]).then(data => {
resolve(data);
return;
}).catch(err => {
reject(err);
return;
})
}
})
}
複製代碼
到此爲止,一個完整的 Promise 就被咱們實現完啦。從原理到細節,咱們一步步拆解和實現,但願你們在知道 Promise 設計上的幾大亮點以後,也能本身手動實現一個Promise,讓本身的思惟層次和動手能力更上一層樓!
生成器(Generator)是 ES6 中的新語法,相對於以前的異步語法,上手的難度仍是比較大的。所以這裏咱們先來好好熟悉一下 Generator 語法。
上面是生成器函數?
生成器是一個帶星號
的"函數"(注意:它並非真正的函數),能夠經過yield
關鍵字暫停執行
和恢復執行
的
舉個例子:
function* gen() {
console.log("enter");
let a = yield 1;
let b = yield (function () {return 2})();
return 3;
}
var g = gen() // 阻塞住,不會執行任何語句
console.log(typeof g) // object 看到了嗎?不是"function"
console.log(g.next())
console.log(g.next())
console.log(g.next())
console.log(g.next())
// enter
// { value: 1, done: false }
// { value: 2, done: false }
// { value: 3, done: true }
// { value: undefined, done: true }
複製代碼
由此能夠看到,生成器的執行有這樣幾個關鍵點:
value
和 done
。value 爲當前 yield 後面的結果
,done 表示是否執行完
,遇到了return
後,done
會由false
變爲true
。當一個生成器要調用另外一個生成器時,使用 yield* 就變得十分方便。好比下面的例子:
function* gen1() {
yield 1;
yield 4;
}
function* gen2() {
yield 2;
yield 3;
}
複製代碼
咱們想要按照1234
的順序執行,如何來作呢?
在 gen1
中,修改以下:
function* gen1() {
yield 1;
yield* gen2();
yield 4;
}
複製代碼
這樣修改以後,以後依次調用next
便可。
可能你會比較好奇,生成器到底是如何讓函數暫停, 又會如何恢復的呢?接下來咱們就來對其中的執行機制——協程
一探究竟。
協程是一種比線程更加輕量級的存在,協程處在線程的環境中,一個線程能夠存在多個協程
,能夠將協程理解爲線程中的一個個任務。不像進程和線程,協程並不受操做系統的管理,而是被具體的應用程序代碼所控制。
那你可能要問了,JS 不是單線程執行的嗎,開這麼多協程難道能夠一塊兒執行嗎?
答案是:並不能。一個線程一次只能執行一個協程。好比當前執行 A 協程,另外還有一個 B 協程,若是想要執行 B 的任務,就必須在 A 協程中將 JS 線程的控制權轉交給 B協程
,那麼如今 B 執行,A 就至關於處於暫停的狀態。
舉個具體的例子:
function* A() {
console.log("我是A");
yield B(); // A停住,在這裏轉交線程執行權給B
console.log("結束了");
}
function B() {
console.log("我是B");
return 100;// 返回,而且將線程執行權還給A
}
let gen = A();
gen.next();
gen.next();
// 我是A
// 我是B
// 結束了
複製代碼
在這個過程當中,A 將執行權交給 B,也就是 A 啓動 B
,咱們也稱 A 是 B 的父協程。所以 B 當中最後return 100
實際上是將 100 傳給了父協程。
須要強調的是,對於協程來講,它並不受操做系統的控制,徹底由用戶自定義切換,所以並無進程/線程上下文切換
的開銷,這是高性能
的重要緣由。
OK, 原理就說到這裏。可能你還會有疑問: 這個生成器不就暫停-恢復、暫停-恢復這樣執行的嗎?它和異步有什麼關係?並且,每次執行都要調用next,能不能讓它一次性執行完畢呢?下一節咱們就來仔細拆解這些問題。
這裏面其實有兩個問題:
Generator
如何跟異步
產生關係?Generator
按順序執行完畢?要想知道 Generator
跟異步的關係,首先帶你們搞清楚一個概念——thunk函數(即偏函數
),雖然這只是實現二者關係的方式之一。(另外一種方式是Promise
, 後面會講到)
舉個例子,好比咱們如今要判斷數據類型。能夠寫以下的判斷邏輯:
let isString = (obj) => {
return Object.prototype.toString.call(obj) === '[object String]';
};
let isFunction = (obj) => {
return Object.prototype.toString.call(obj) === '[object Function]';
};
let isArray = (obj) => {
return Object.prototype.toString.call(obj) === '[object Array]';
};
let isSet = (obj) => {
return Object.prototype.toString.call(obj) === '[object Set]';
};
// ...
複製代碼
能夠看到,出現了很是多重複的邏輯。咱們將它們作一下封裝:
let isType = (type) => {
return (obj) => {
return Object.prototype.toString.call(obj) === `[object ${type}]`;
}
}
複製代碼
如今咱們這樣作便可:
let isString = isType('String');
let isFunction = isType('Function');
//...
複製代碼
相應的 isString
和isFunction
是由isType
生產出來的函數,但它們依然能夠判斷出參數是否爲String(Function),並且代碼簡潔了很多。
isString("123");
isFunction(val => val);
複製代碼
isType這樣的函數咱們稱爲thunk 函數。它的核心邏輯是接收必定的參數,生產出定製化的函數,而後使用定製化的函數去完成功能。thunk函數的實現會比單個的判斷函數複雜一點點,但就是這一點點的複雜,大大方便了後續的操做。
以文件操做
爲例,咱們來看看 異步操做 如何應用於Generator
。
const readFileThunk = (filename) => {
return (callback) => {
fs.readFile(filename, callback);
}
}
複製代碼
readFileThunk
就是一個thunk函數
。異步操做核心的一環就是綁定回調函數,而thunk函數
能夠幫咱們作到。首先傳入文件名,而後生成一個針對某個文件的定製化函數。這個函數中傳入回調,這個回調就會成爲異步操做的回調。這樣就讓 Generator
和異步
關聯起來了。
緊接者咱們作以下的操做:
const gen = function* () {
const data1 = yield readFileThunk('001.txt')
console.log(data1.toString())
const data2 = yield readFileThunk('002.txt')
console.log(data2.toString)
}
複製代碼
接着咱們讓它執行完:
let g = gen();
// 第一步: 因爲進場是暫停的,咱們調用next,讓它開始執行。
// next返回值中有一個value值,這個value是yield後面的結果,放在這裏也就是是thunk函數生成的定製化函數,裏面須要傳一個回調函數做爲參數
g.next().value((err, data1) => {
// 第二步: 拿到上一次獲得的結果,調用next, 將結果做爲參數傳入,程序繼續執行。
// 同理,value傳入回調
g.next(data1).value((err, data2) => {
g.next(data2);
})
})
複製代碼
打印結果以下:
001.txt的內容
002.txt的內容
複製代碼
上面嵌套的狀況還算簡單,若是任務多起來,就會產生不少層的嵌套,可操做性不強,有必要把執行的代碼封裝一下:
function run(gen){
const next = (err, data) => {
let res = gen.next(data);
if(res.done) return;
res.value(next);
}
next();
}
run(g);
複製代碼
Ok,再次執行,依然打印正確的結果。代碼雖然就這麼幾行,但包含了遞歸的過程,好好體會一下。
這是經過thunk
完成異步操做的狀況。
仍是拿上面的例子,用Promise
來實現就輕鬆一些:
const readFilePromise = (filename) => {
return new Promise((resolve, reject) => {
fs.readFile(filename, (err, data) => {
if(err) {
reject(err);
}else {
resolve(data);
}
})
}).then(res => res);
}
const gen = function* () {
const data1 = yield readFilePromise('001.txt')
console.log(data1.toString())
const data2 = yield readFilePromise('002.txt')
console.log(data2.toString)
}
複製代碼
執行的代碼以下:
let g = gen();
function getGenPromise(gen, data) {
return gen.next(data).value;
}
getGenPromise(g).then(data1 => {
return getGenPromise(g, data1);
}).then(data2 => {
return getGenPromise(g, data2)
})
複製代碼
打印結果以下:
001.txt的內容
002.txt的內容
複製代碼
一樣,咱們能夠對執行Generator
的代碼加以封裝:
function run(g) {
const next = (data) => {
let res = g.next();
if(res.done) return;
res.value.then(data => {
next(data);
})
}
next();
}
複製代碼
一樣能輸出正確的結果。代碼很是精煉,但願能參照剛剛鏈式調用的例子,仔細體會一下遞歸調用的過程。
以上咱們針對 thunk 函數
和Promise
兩種Generator異步操做
的一次性執行完畢作了封裝,但實際場景中已經存在成熟的工具包了,若是大名鼎鼎的co庫, 其實核心原理就是咱們已經手寫過了(就是剛剛封裝的Promise狀況下的執行代碼),只不過源碼會各類邊界狀況作了處理。使用起來很是簡單:
const co = require('co');
let g = gen();
co(g).then(res =>{
console.log(res);
})
複製代碼
打印結果以下:
001.txt的內容
002.txt的內容
100
複製代碼
簡單幾行代碼就完成了Generator
全部的操做,真不愧co
和Generator
天生一對啊!
async/await
被稱爲 JS 中異步終極解決方案。它既可以像 co + Generator 同樣用同步的方式來書寫異步代碼,又獲得底層的語法支持,無需藉助任何第三方庫。接下來,咱們從原理的角度來從新審視這個語法糖背後究竟作了些什麼。
什麼是 async ?
MDN 的定義: async 是一個經過異步執行並隱式返回 Promise 做爲結果的函數。
注意重點: 返回結果爲Promise。
舉個例子:
async function func() {
return 100;
}
console.log(func());
// Promise {<resolved>: 100}
複製代碼
這就是隱式返回 Promise 的效果。
咱們來看看 await
作了些什麼事情。
以一段代碼爲例:
async function test() {
console.log(100)
let x = await 200
console.log(x)
console.log(200)
}
console.log(0)
test()
console.log(300)
複製代碼
咱們來分析一下這段程序。首先代碼同步執行,打印出0
,而後將test
壓入執行棧,打印出100
, 下面注意了,遇到了關鍵角色await。
放個慢鏡頭:
await 100;
複製代碼
被 JS 引擎轉換成一個 Promise :
let promise = new Promise((resolve,reject) => {
resolve(100);
})
複製代碼
這裏調用了 resolve,resolve的任務進入微任務隊列。
而後,JS 引擎將暫停當前協程的運行,把線程的執行權交給父協程
(父協程不懂是什麼的,上上篇纔講,回去補課)。
回到父協程中,父協程的第一件事情就是對await
返回的Promise
調用then
, 來監聽這個 Promise 的狀態改變 。
promise.then(value => {
// 相關邏輯,在resolve 執行以後來調用
})
複製代碼
而後往下執行,打印出300
。
根據EventLoop
機制,當前主線程的宏任務完成,如今檢查微任務隊列
, 發現還有一個Promise的 resolve,執行,如今父協程在then
中傳入的回調執行。咱們來看看這個回調具體作的是什麼。
promise.then(value => {
// 1. 將線程的執行權交給test協程
// 2. 把 value 值傳遞給 test 協程
})
複製代碼
Ok, 如今執行權到了test協程
手上,test 接收到父協程
傳來的200, 賦值給 a ,而後依次執行後面的語句,打印200
、200
。
最後的輸出爲:
0
100
300
200
200
複製代碼
總結一下,async/await
利用協程
和Promise
實現了同步方式編寫異步代碼的效果,其中Generator
是對協程
的一種實現,雖然語法簡單,但引擎在背後作了大量的工做,咱們也對這些工做作了一一的拆解。用async/await
寫出的代碼也更加優雅、美觀,相比於以前的Promise
不斷調用then的方式,語義化更加明顯,相比於co + Generator
性能更高,上手成本也更低,不愧是JS異步終極解決方案!
問題:對於異步代碼,forEach 並不能保證按順序執行。
舉個例子:
async function test() {
let arr = [4, 2, 1]
arr.forEach(async item => {
const res = await handle(item)
console.log(res)
})
console.log('結束')
}
function handle(x) {
return new Promise((resolve, reject) => {
setTimeout(() => {
resolve(x)
}, 1000 * x)
})
}
test()
複製代碼
咱們指望的結果是:
4
2
1
結束
複製代碼
可是實際上會輸出:
結束
1
2
4
複製代碼
這是爲何呢?我想咱們有必要看看forEach
底層怎麼實現的。
// 核心邏輯
for (var i = 0; i < length; i++) {
if (i in array) {
var element = array[i];
callback(element, i, array);
}
}
複製代碼
能夠看到,forEach 拿過來直接執行了,這就致使它沒法保證異步任務的執行順序。好比後面的任務用時短,那麼就又可能搶在前面的任務以前執行。
如何來解決這個問題呢?
其實也很簡單, 咱們利用for...of
就能輕鬆解決。
async function test() {
let arr = [4, 2, 1]
for(const item of arr) {
const res = await handle(item)
console.log(res)
}
console.log('結束')
}
複製代碼
好了,這個問題看起來好像很簡單就能搞定,你有想過這麼作爲何能夠成功嗎?
其實,for...of並不像forEach那麼簡單粗暴的方式去遍歷執行,而是採用一種特別的手段——迭代器
去遍歷。
首先,對於數組來說,它是一種可迭代數據類型
。那什麼是可迭代數據類型
呢?
原生具備[Symbol.iterator]屬性數據類型爲可迭代數據類型。如數組、類數組(如arguments、NodeList)、Set和Map。
可迭代對象能夠經過迭代器進行遍歷。
let arr = [4, 2, 1];
// 這就是迭代器
let iterator = arr[Symbol.iterator]();
console.log(iterator.next());
console.log(iterator.next());
console.log(iterator.next());
console.log(iterator.next());
// {value: 4, done: false}
// {value: 2, done: false}
// {value: 1, done: false}
// {value: undefined, done: true}
複製代碼
所以,咱們的代碼能夠這樣來組織:
async function test() {
let arr = [4, 2, 1]
let iterator = arr[Symbol.iterator]();
let res = iterator.next();
while(!res.done) {
let value = res.value;
console.log(value);
await handle(value);
res = iterater.next();
}
console.log('結束')
}
// 4
// 2
// 1
// 結束
複製代碼
多個任務成功地按順序執行!其實剛剛的for...of循環代碼就是這段代碼的語法糖。
回頭再看看用iterator遍歷[4,2,1]這個數組的代碼。
let arr = [4, 2, 1];
// 迭代器
let iterator = arr[Symbol.iterator]();
console.log(iterator.next());
console.log(iterator.next());
console.log(iterator.next());
console.log(iterator.next());
// {value: 4, done: false}
// {value: 2, done: false}
// {value: 1, done: false}
// {value: undefined, done: true}
複製代碼
咦?返回值有value
和done
屬性,生成器也能夠調用 next,返回的也是這樣的數據結構,這麼巧?!
沒錯,生成器自己就是一個迭代器。
既然屬於迭代器,那它就能夠用for...of遍歷了吧?
固然沒錯,不信來寫一個簡單的斐波那契數列(50之內):
function* fibonacci(){
let [prev, cur] = [0, 1];
console.log(cur);
while(true) {
[prev, cur] = [cur, prev + cur];
yield cur;
}
}
for(let item of fibonacci()) {
if(item > 50) break;
console.log(item);
}
// 1
// 1
// 2
// 3
// 5
// 8
// 13
// 21
// 34
複製代碼
是否是很是酷炫?這就是迭代器的魅力:)同時又對生成器
有了更深刻的理解,沒想到咱們的老熟人Generator
還有這樣的身份。
以上即是本文的所有內容,但願對你有所啓發。
當你看到這裏的時候,可能會吐槽,JS 就這麼點內容嗎,就這麼幾篇就講完了?
首先我要說的是,看完這個系列,我並不能保證你能掌握掉JS的全部內容
,我也相信沒有哪個系列會涵蓋一門語言全部的知識點,並且學習原本就是一個不斷循環和迭代的過程,假若哪天你以爲本身精通了,所有了如指掌,沒有必要繼續學了,那纔是真正的悲哀。
所以,若是這個系列對你能產生某種啓發
,彌補你的一部分知識盲區
,或者對以前模糊的概念從新理解,從而有了深入的認識
,我以爲這些文章的價值也就真正發揮出來了。
另外就是這個系列還會不斷的增添內容,將以前有所疏漏的地方一一補充上來,把這個系列打造得更加完整和系統,也歡迎你們給我提提後續內容方面的需求。
在前端
這條路摸爬滾打也有一段時間了,接下來,給你們分享一下我這些年的心得和體會吧。
首先,對前端來說,不像 Java,C++這些編程領域中科班出身的人那麼多,一部分緣由是前端領域的知識學校基本不教,另外一方面科班畢業的大部分並不想作這一塊的工做。
這就致使半路出家
的前端er很是多。可是也並不用氣餒,我是這麼以爲的:
成爲一個真正專業的人,不在於你是否是拿到了科班文憑,甚至不在於你掌握了多少亮眼的技術,而在於你的大腦中是否有完整的知識體系。
這一點很是重要,這份知識體系至關因而你大腦中的操做系統,有了這個系統,用當今比較時髦的詞來形容就是有了體系化的思考能力
,在應對複雜的問題纔會站在更高的高度對各個方面採起綜合性的權衡和取捨,或者在應對新技術的時候有足夠的自信和能力去快速拿下,讓這個系統更加堅固,總之這個系統會在很長一段時間伴隨和影響本身,若是很差好建設一下,如走馬觀花通常隨便學一堆技術棧,或者三天打魚兩天曬網
, 沒有持續深刻學習的毅力,結果就是大腦中至關於缺乏一個完整的操做系統,實際上是挺可悲的一件事情。之前的我老是對各類技術趨之若鶩,巴不得掌握全部的技術棧,所以也老是由於時間不夠、效果很差而焦慮。最後的結論就是:從一開始關注點就錯了,關注點不該在於眼花繚亂的技術,而在於自身系統的建設,這樣就並不會爲xxx技術我不會而感到焦慮了,相反會爲本身點滴的頓悟和進步感到興奮和知足。
不知道何時想通了這件事情,多是之前踩過太多的坑,另一個緣由也在於本人的危機感是比較強的,纔會有一系列的掙扎和思考。
基於以上的信念
, 我開始了這份知識體系的建設,進度天天不斷地推動,進而也就讓你們可以看到眼前的原生JS靈魂之問了。這個系列的由來我應該說清楚了,可能你又要問了,不是天天進度都在推動麼?那完成的東西放在哪呢?
OK,這就得具體介紹一下我這份知識體系了,我把它放在了GitHub上,雖然是一個並不起眼的開源項目,可是也將是凝聚我很長一段時間心血的系統建設工程
。
目前的大綱梳理以下:
圖中用紅旗標記了已經完成的部分,即便如此,在以後也會作更多的補充,讓知識結構更加完善。
若是這個項目對你有那麼一絲啓發或者幫助,也請幫忙給項目點一個star
, 很是感謝!
另外,個人掘金小冊已經上線,點擊進入
參考文獻:
《深刻淺出nodejs》樸靈著。
極客時間《瀏覽器工做原理與實踐》
瀏覽器與Node的事件循環(Event Loop)有何區別?
《JavaScript高級程序設計(第三版)》