最近在跟團隊內的小夥伴們一塊兒學習和研究Vue.js的源碼,其中有一塊是nextTick函數的實現,這個函數的主要做用就是使用宏任務或微任務來異步執行界面的渲染更新操做等等,因此我原本是打算深刻研究一下JavaScript的宏任務和微任務的,可是後來我發現我連JavaScript基本的運行機制都沒太搞懂。javascript
帶着這些疑問,我開始了長達一個多月的探索之旅。幸運的是,我找到了一些很是棒的學習資料,它們讓我受益不淺,也解答了個人很大一部分問題,這就是我今天要分享給你們的個人學習成果,我會在這一篇博客裏把我看過的這些學習資料中所講到的重點知識所有都包含進來,以幫助你們更快更全面地理解JavaScript基本的運行機制。html
話很少說,咱們進入正文。java
在一個經典的計算機系統架構中,程序在運行時會把分配到的內存劃分紅四個區塊,分別是:Code區塊、Static/Global區塊、Stack區塊以及Heap區塊。ajax
Code區塊:用於裝載程序運行的指令,其實就是你編寫的代碼最終編譯成的機器指令;編程
Static/Global區塊(如下簡稱:Static區塊):用於存放全局變量。定義在函數內的變量只能在該函數內可見,在函數外是沒法直接訪問到的,可是定義在這裏的變量能夠在任何函數中都可以訪問獲得;數組
Stack區塊:即Call Stack,用於存放函數運行時的數據信息。包括:函數調用時的參數、函數內定義的變量、函數運行結束後返回的地址等等;瀏覽器
Heap區塊:函數運行時的基本數據類型的數據會直接保存在Stack中,而對象類型的數據則會在Heap區塊中分配內存進行存儲,而後返回分配內存的起始地址以保存在Stack中聲明的變量中以便後續訪問。網絡
咱們目前只須要關注Stack區塊便可。Stack是一個典型的棧類型數據結構(FILO:First In Last Out)。當JavaScript中的函數運行時,會往Stack棧中Push一段數據,這段數據咱們稱之爲Stack Frame,當函數運行結束後,會將該函數對應的Stack Frame數據段Pop出棧。因此,函數間的嵌套調用就會在Stack棧中堆疊一摞的Stack Frame數據段。爲了讓你有一個更清晰直觀的認識,接下來咱們來看一段代碼(示例一):數據結構
function foo() {
console.log('foo');
}
function bar() {
foo();
console.log('bar');
}
function baz() {
bar();
console.log('baz');
}
baz();
複製代碼
這段代碼很簡單,它的運行結果就是依次打印出:foo、bar和baz。咱們來看一下這段代碼在運行過程當中Stack區塊的變化狀況。閉包
第0步:程序準備執行,分配並劃份內存空間,將代碼指令裝載進Code區塊並開始執行。假設此時代碼塊的執行函數名爲main,那麼JavaScript Runtime會先將Stack Frame(main)壓入Stack棧中,而後開始調用baz函數。
第1步:調用baz函數,將Stack Frame(baz)壓入Stack棧中。
第2步:baz調用bar函數。將Stack Frame(bar)壓入Stack棧中。
第3步:bar調用foo函數。將Stack Frame(foo)壓入Stack棧中。
第4步:foo調用console.log函數。將Stack Frame(log)壓入Stack棧中。
第5步:console.log函數在控制檯打印出‘foo’,執行完畢後將Stack Frame(log)推出Stack棧。
第6步:foo函數執行完畢,將Stack Frame(foo)推出Stack棧。
第7步:bar調用console.log函數。將Stack Frame(log)壓入Stack棧中。
第8步:console.log函數在控制檯打印出‘bar’,執行完畢後將Stack Frame(log)推出Stack棧。
第9步:bar函數執行完畢,將Stack Frame(bar)推出Stack棧。
第10步:baz調用console.log函數。將Stack Frame(log)壓入Stack棧中。
第11步:console.log函數在控制檯打印出‘baz’,執行完畢後將Stack Frame(log)推出Stack棧。
第12步:baz函數執行完畢,將Stack Frame(baz)推出Stack棧。
第13步:程序運行結束,將Stack Frame(main)推出Stack棧,Code區塊和Stack區塊均使用完畢等待被GC回收。
看到這裏,你應該已經對JavaScript的Call Stack有了一個更清晰直觀的認識了。
接下來,咱們來聊一聊JavaScript中的「報錯」。相信你們在瀏覽器中開發時都碰到過報錯的狀況,這時候瀏覽器終端會輸出一段報錯信息,裏面包含了錯誤發生時的Stack棧中的函數調用鏈路狀況。例如,我把上面的代碼改爲這樣(示例二):
function foo() {
throw new Error('error from foo');
}
function bar() {
foo();
}
function baz() {
bar();
}
baz();
複製代碼
代碼執行後,會在瀏覽器終端打印出下面這樣的報錯信息。
基於Stack這樣的設計,編譯器就可以很輕鬆地定位發生錯誤時的函數調用鏈路狀況,咱們也就可以很方便地排查發生錯誤的緣由了。
不少人也碰到過棧溢出(Stack Overflow)的問題。那麼爲何會有棧溢出的狀況發生呢?由於Stack棧的大小是在程序運行開始前就已經肯定下來不可變動的,因此當你往棧中存放的數據超出棧的最大容量時,就會發生棧溢出的狀況。一般的緣由都是由於代碼的Bug致使函數無限循環嵌套調用,如同下面這個示例(示例三)所示:
咱們都知道,JavaScript是一門單線程(single-threaded)的語言,單線程就意味着「JavaScript Runtime只有一個Call Stack」,也意味着「JavaScript Runtime同一時間只能作一件事情」,來看看下面這段代碼(示例四):
let arr = [0, 1, 2, 3, 4, 5];
/* 平方值 */
function square(arr) {
return arr.map((item) => item * item);
}
let res1 = square(arr);
console.log(res1); // [0, 1, 4, 9, 16, 25]
/* 立方值 */
function cube(arr) {
return arr.map((item) => item * item * item);
}
let res2 = cube(arr);
console.log(res2); // [0, 1, 8, 27, 64, 125]
複製代碼
這段代碼很簡單,給定一個arr數組,分別計算輸出數組中每個數值求平方和求立方以後的結果數組。這段代碼在JavaScript中必然是順序執行的,先求平方再求立方,可是咱們不妨設想一下,由於square和cube函數作的事情互不相干,那麼咱們能不能讓它們並行執行以提升運行效率呢?在這裏由於arr數組很短,兩個函數的計算邏輯也很簡單,因此這段代碼運行起來很是地快,可是若是arr數組很是地大,square和cube方法又進行了一些很是耗時的複雜計算的話,那麼咱們的設想就變得很是地有意義了。可是,可行嗎?答案是:No。以前我說過,JavaScript Runtime是單線程的,它同一時間只能作一件事情。因此咱們寫的JavaScript代碼只能單向串行執行,沒法並行執行(這裏暫不考慮Web Workers等技術)。
可是,若是是這樣的話,那麼咱們在代碼中使用setTimeout函數時,就必須等待setTimeout指定的延遲時長事後執行回調函數,而後才能繼續執行後面的代碼,使用ajax發送請求也是一樣的狀況,咱們必須等到請求結果返回後執行回調函數,代碼才能繼續日後走。可是咱們知道這些都不是真實的狀況,那麼爲何會存在這樣的矛盾點呢?
先不着急揭曉答案,咱們先來研究一下setTimeout函數。
setTimeout函數基本的功能,就是接收一個回調函數和一個delay延遲時長(默認爲0),而後在delay時長事後執行回調函數。來看一下下面的這段代碼和它的運行結果(示例五):
function foo () {
console.log('one');
setTimeout(function inner() {
console.log('two')
}, 0);
console.log('three');
}
foo();
複製代碼
也許有些同窗會對運行結果感到很意外,'three'居然在'two'以前被打印出來,咱們都知道setTimeout能夠延遲執行一段函數,可是爲何延遲時長設置爲0都不能讓inner函數當即被執行呢?爲了探究這個問題,咱們來看一下這段代碼在運行過程當中Stack區塊的變化狀況:
咱們重點關注上面的第4步、第5步和第9步。能夠看到,當第4 ~ 5步調用setTimeout函數後,Stack Frame(setTimeout)莫名消失了,它接收的回調函數inner在此時並無被執行,程序繼續日後走從而打印出'three'。當第8步foo函數執行完畢,也就是看似整段代碼執行結束後,第9步inner函數又莫名出如今了Stack棧中並開始執行,inner函數運行完畢後整段代碼才真正地運行結束。
咱們再來看看另一個例子(示例六):
function foo() {
let start = Date.now();
console.log('start');
setTimeout(function inner() {
console.log('inner: ' + (Date.now() - start));
}, 2000);
while((Date.now() - start) < 1500);
console.log('end: ' + (Date.now() - start));
}
foo();
複製代碼
這段代碼的運行結果有兩點值得咱們關注。第一點,foo函數由於包含了一行空while語句而執行了1500ms,可是setTimeout中的inner函數彷佛並無受到任何影響,仍然在2秒鐘以後開始執行,說明foo函數的執行和setTimeout的計時操做是在並行執行的。第二,inner函數打印的時間差並非剛恰好等於2000ms,而是2002ms,並且若是你運行這段代碼的話你就會發現,你打印的結果極可能跟我不同,可是必定是大於等於2000ms的一個值。
著名的v8引擎是Chrome和NodeJS背後使用的JavaScript Runtime引擎,而你在它的源碼裏是搜不到setTimeout、DOM、Ajax等字樣的,由於它自己只包含了heap和stack,其餘的setTimeout、DOM、Ajax等相關的功能都是由瀏覽器基於v8引擎之上所構建和提供的WebAPIs功能。這些WebAPIs和v8引擎同樣都是用C++編寫的,它們會以獨立的線程的方式提供服務,因此咱們的JavaScript Runtime是單線程的沒錯,可是當咱們調用這些WebAPIs時,它們就會另起一個獨立的線程來完成各自的工做,這樣咱們的JavaScript代碼纔有了併發的效果。
v8引擎的結構圖以下所示:
而瀏覽器的全貌圖是這樣子的:
首先介紹一下WebAPIs部分,瀏覽器會維護一個事件映射表(Event Table),它記錄着事件與回調函數之間的映射關係。若是你想監聽某個DOM的click事件的話,那你就必須先在該DOM上註冊click事件,而後當該DOM接收到click事件時纔會有回調函數被執行,若是某個事件沒有被綁定回調函數的話,那麼該事件發生時就如同石沉大海同樣什麼也不會發生。Ajax也是同樣,若是不添加返回響應時的回調函數的話,那麼就會變成單純的發送一個HTTP請求,也不會有後續的回調函數處理響應內容了。setTimeout自沒必要說,它必需要設置一個回調函數纔有意義。總而言之,這些事件與回調函數之間的映射關係都會被瀏覽器記錄在Event Table表裏,以便當對應事件發生時能執行對應的回調函數。
接下來是消息隊列Message Queue(簡稱MQ),有些文章稱之爲Event Queue或者Callback Queue,說的都是同一個東西。MQ是一個典型的FIFO(First In First Out)的消息隊列,新消息會被加入到隊列的尾部,消息的執行順序與加入隊列的順序相同。每一條消息都有與之綁定的一個函數,當隊首的消息被處理時,消息對應的函數就會把消息當作輸入參數並開始執行。剛剛Event Table中記錄的事件發生時,就會往MQ隊列中加入一條消息,而後等待被執行。
接下來咱們就要觸及到整篇文章的重點和核心了,那就是Event Loop。剛剛咱們說到,消息已經被加入到MQ隊列中,那麼消息何時會被處理呢?這時候就該Event Loop登場了。
Event Loop實際作的事情很是地簡單:它會持續不斷地檢查Call Stack是否爲空,若是爲空的話就檢查MQ隊列是否有待處理的消息,若是有的話就從MQ隊列的隊首取出一條消息並執行消息綁定的函數,若是沒有的話就同步監控MQ隊列是否有新的消息加入,一旦發現就當即取出並執行消息綁定的函數。整個過程不斷重複。
知道了Event Loop的運行機制以後,以前的幾個疑問就迎刃而解了。
首先看下示例五的setTimeout神祕消失和離奇閃現事件。我如今把第4步、第5步、第8步和第9步的完整截圖發出來給你們看看:
第5步中,咱們調用了瀏覽器提供的setTimeout方法,隨即啓動一個單獨的線程作計時操做,而後往Event Table中加入一條記錄。這裏因爲delay參數設置爲0,因此事件會被當即觸發,而後往MQ隊列中加入一條消息,因爲此時Call Stack還不爲空,因此消息會在MQ隊列中等待。第8步中,foo函數執行完畢,Call Stack被清空,Event Loop發現Call Stack爲空以後當即檢查MQ隊列,發現有一條待處理的消息,因而從隊列中取出消息並開始執行消息綁定的函數,也就是inner函數,最後inner函數執行完畢,至此整個程序運行結束。你們能夠在這兒看到完整的過程。
再來看下示例六的兩個問題點。第一點答案已經揭曉了,咱們的JavaScript Runtime和setTimeout是在兩個獨立的線程上並行執行的。關於第二點,我相信有些同窗已經知道答案了,由於添加在setTimeout中的回調函數在倒計時結束以後並不會被當即執行(即使delay參數被設置爲0),而是須要先將消息添加到MQ隊列的隊尾,而後等待排在前面的消息所有被處理完畢後才能開始執行,這個過程總歸要花點時間,因此一般setTimeout回調函數執行時的實際delay時長都要大於指定的delay時長。一樣給出示例六的完整運行過程。
順便提一下,瀏覽器的每個tab(iframe標籤和Web Workers一樣如此)都擁有本身獨立的Event Loop以及一整套的Runtime運行環境,包括Call Stack、Heap、Message Queue、Render Queue(後面會提到)等等,這樣就保證了即使某一個tab由於執行了某種耗時的操做被阻塞,其餘的tab也可以正常運做,而不會說直接致使整個瀏覽器被卡死。不一樣Runtime之間的通信方式能夠看這裏。
JavaScript號稱是一門「single-threaded(單線程)、non-blocking(非阻塞)、asynchronous(異步的)、concurrent(併發的)」編程語言。這確實是事實但也不盡然。說它是事實是由於瀏覽器將網絡請求、文件操做(NodeJS)等幾乎全部耗時的操做都以獨立線程(concurrent)和異步回調(asynchronous)的形式提供給咱們使用,因此咱們的JavaScript Runtime主線程能夠持續高效不間斷地執行咱們的JS代碼,這就是非阻塞(non-blocking)的含義。單線程(single-threaded)的JavaScript Runtime是優點也是劣勢。優點在於它簡化了咱們編寫代碼的方式,使得咱們能夠不用考慮複雜的併發問題。劣勢在於一旦有耗時的操做佔據了JavaScript Runtime主線程的話,就會致使MQ隊列中的消息沒法獲得及時的處理,還會阻塞UI渲染線程的執行,進而影響到頁面的流暢性。
咱們將上面的示例六稍做改動,此次咱們把setTimeout的delay參數設置爲500ms,來看看會發生些什麼(示例七):
function foo() {
let start = Date.now();
console.log('start');
setTimeout(function inner() {
console.log('inner: ' + (Date.now() - start));
}, 500);
while((Date.now() - start) < 1500);
console.log('end: ' + (Date.now() - start));
}
foo();
複製代碼
能夠看到,總體代碼的運行耗時依然是1500ms不變,可是咱們發現inner函數執行時時間也過去了1500ms,而並無像咱們指望的那樣在500ms後就執行,緣由就是由於while((Date.now() - start) < 1500);
是一句同步的操做,它的執行會佔據JavaScript Runtime主線程和Call Stack調用棧,進而致使即使inner函數對應的消息在500ms以後就已經在MQ隊列中等待,可是因爲此時Call Stack並不爲空,因此inner函數就沒法被Event Loop及時Pick進入Call Stack執行,它不得不等到1500ms事後Call Stack被清空,而後才能被執行。實際的運行效果請你們自行查看。
剛剛咱們有提到,若是JavaScript Runtime主線程被阻塞的話,一樣會影響到UI渲染線程的執行,而一旦UI渲染線程被阻塞,用戶就沒法在頁面上執行點擊、滑動等操做了。這到底是爲何呢?
原來,在瀏覽器的實現中,UI渲染操做(或者說是DOM更新操做)一樣是以隊列的形式處理的。相似於Message Queue,瀏覽器會維護一個Render Queue(簡稱RQ)來專門存放UI渲染消息,並且它跟MQ同樣,必須等到Call Stack爲空時才能被處理,不一樣的是,它的處理優先級是要高於MQ的。界面刷新的頻次通常是每秒鐘60次,也就是每16.67ms會執行一次,因此Event Loop每隔16.67ms就查看一下RQ隊列是否有待處理的消息,若是有的話就檢查Call Stack是否爲空,爲空就從RQ隊列取出消息並處理,不然就繼續等待直至Call Stack被清空,而後再處理RQ隊列中的UI渲染消息。
我相信你們都碰到過頁面卡頓的狀況,緣由就在這裏了。我以前發的連接工具叫作loupe,是一個專門用來觀察JavaScript Runtime的工具網站,打開它並點擊左上角的圖標就能夠展開設置面板,裏面能夠設置代碼運行時停頓的時長,還能夠模擬UI渲染操做,勾中以後就能夠查看當主線程代碼運行時,UI渲染消息被阻塞的過程了。咱們仍是以示例六爲例,來看看實際的運行效果:
咱們已經知道,JavaScript Runtime主線程的阻塞會致使RQ隊列和MQ隊列中的消息沒法被及時處理,因此咱們要儘可能避免執行一些同步耗時的操做,要給到這些隊列中的消息被處理的機會。
一樣,會阻礙隊列消息被及時處理的還有隊列自己被阻塞的狀況。比較典型的場景是在document的onscroll事件上綁定了回調函數,因爲onscroll事件觸發的頻次一樣是每秒60次,因此當用戶滾動頁面時,很容易就會把MQ隊列塞滿,若是回調函數裏還執行了一些UI渲染等耗時的操做的話,那簡直就是災難性的,畢竟UI渲染線程和JavaScript Runtime主線程是沒法並行執行的(運行效果傳送門)。
至此個人分享就結束了,感謝Philip Roberts在2014年歐洲JSConf上精彩的演講,是他讓我真正搞明白了JavaScript的Event Loop到底是如何工做的,以前提到的loupe也是他的傑做,附上油管連接和優酷連接以供各位看官享用:)
最近又看了一篇博客(The JavaScript Event Loop: Explained),引起了我對於閉包的思考,考慮以下代碼:
function foo() {
let a = {
name: 'Chris, Z',
gender: 'Man',
};
let b = 'Baby';
let c = 1024;
let d = true;
setTimeout(function inner() {
console.log(a);
console.log(b);
console.log(c);
console.log(d);
});
}
foo();
複製代碼
按照咱們以前所說的,當foo執行完畢後它對應的stack frame(foo)就被移出Call Stack棧而不復存在了,可是咱們也知道inner函數執行時是可以訪問到foo函數內定義的abcd變量的,這不是矛盾了嗎? 我其實也沒找到具體的資料解釋這一塊Runtime引擎是怎麼處理的,因此我大膽地設想了幾種可能的作法:
stack frame(foo)出棧時確實被內存回收了,可是Runtime引擎在這裏作了優化,inner函數會將abcd變量的值拷貝下來保存到某個地方,因爲a變量指向了堆中的一個對象,b變量指向了堆中的一個字符串常量,它們都是引用值,因此當inner函數將ab變量的引用地址值保存下來時,stack frame(foo)中聲明的ab變量自己就能夠被放心地回收了,ab變量所指向的堆地址因爲仍然被inner函數所引用而不會被GC回收,進而能夠在inner函數執行時被引用到。而cd變量就更簡單了,它們只是原始類型而已,直接被inner函數拷貝保存下來就能夠了,既不會影響stack frame(foo)的內存回收,也不會影響inner函數執行時引用到cd變量的值。
stack frame(foo)並不會真正出棧(邏輯上已經出棧,但物理上仍然佔據棧內存),inner函數也無需在執行前保存它引用的變量值。那麼此時Call Stack在內存空間上就會造成「空洞」,只不過Runtime引擎會很好地處理這種狀況,不會讓後續的stack frame入棧和出棧感覺到「空洞」的存在而已。
前面的跟作法二同樣,只不過Call Stack會直接用跳過stack frame(foo)的一個新地址做爲起始地址開始構建,這樣就不會造成「空洞」了。
固然,上面的這些都只是我我的的猜測而已,若是誰有確切的答案還望不吝賜教。