、JavaScript是單線程
為什么是單線:因為JavaScript主要用于DOM操作和用戶交互,如果說是多線程,一個線程要在某個DOM節點上添加元素,另一個線程是要刪除這個節點。這樣就存在一個問題,到底是以哪個線程為主呢?為了避免復雜性,設計者一開始就把它設定為單線程。
?二、任務隊列
因為是單線程,所以JavaScript執行任務就需要一個一個的來,這樣就造成大量任務排隊執行,效率很低,為了解決這個問題就提到了同步任務+異步任務。
同步任務:就是主線程執行的任務。
異步任務:異步任務是不進入主線程,而是進入“任務隊列”中。
JavaScript運行機制:就是主線程執行的是同步任務,主線程執行完畢后執行任務隊列中的事件(任務隊列中放置的是異步任務)
主線程從"任務隊列"中讀取事件,這個過程是循環不斷的,所以整個的這種運行機制又稱為Event Loop(事件循環)
三、異步任務有哪些
定時器、網絡請求(http)、Promise、I/o、UI渲染
所周知,JavaScript 是一門單線程語言,雖然在 html5 中提出了 Web-Worker ,但這并未改變 JavaScript 是單線程這一核心。可看HTML規范中的這段話:
To coordinate events, user interaction, scripts, rendering, networking, and so forth, user agents must use event loops as described in this section. There are two kinds of event loops: those for browsing contexts, and those for workers.
為了協調事件、用戶交互、腳本、UI 渲染和網絡處理等行為,用戶引擎必須使用 event loops。Event Loop 包含兩類:一類是基于 Browsing Context ,一種是基于 Worker ,二者是獨立運行的。 下面本文用一個例子,著重講解下基于 Browsing Context 的事件循環機制。
來看下面這段 JavaScript 代碼:
setTimeout(function() { console.log('setTimeout'); }, 0);//前端全棧交流學習圈:866109386 //幫助1-3年前端人員,突破技術,提升思維 Promise.resolve().then(function() { console.log('promise1'); }).then(function() { console.log('promise2'); }); console.log('script end');
先猜測一下這段代碼的輸出順序是什么,再去瀏覽器控制臺輸入一下,看看實際輸出的順序和你猜測出的順序是否一致,如果一致,那就說明,你對 JavaScript 的事件循環機制還是有一定了解的,繼續往下看可以鞏固下你的知識;而如果實際輸出的順序和你的猜測不一致,那么本文下面的部分會為你答疑解惑。
任務隊列
所有的任務可以分為同步任務和異步任務,同步任務,顧名思義,就是立即執行的任務,同步任務一般會直接進入到主線程中執行;而異步任務,就是異步執行的任務,比如ajax網絡請求,setTimeout 定時函數等都屬于異步任務,異步任務會通過任務隊列( Event Queue )的機制來進行協調。具體的可以用下面的圖來大致說明一下:
同步和異步任務分別進入不同的執行環境,同步的進入主線程,即主執行棧,異步的進入 Event Queue 。主線程內的任務執行完畢為空,會去 Event Queue 讀取對應的任務,推入主線程執行。 上述過程的不斷重復就是我們說的 Event Loop (事件循環)。
在事件循環中,每進行一次循環操作稱為tick,通過閱讀規范可知,每一次 tick 的任務處理模型是比較復雜的,其關鍵的步驟可以總結如下:
主線程重復執行上述步驟
可以用一張圖來說明下流程:
這里相信有人會想問,什么是 microtasks ?規范中規定,task分為兩大類, 分別是 Macro Task (宏任務)和 Micro Task(微任務), 并且每個宏任務結束后, 都要清空所有的微任務,這里的 Macro Task也是我們常說的 task ,有些文章并沒有對其做區分,后面文章中所提及的task皆看做宏任務( macro task)。
(macro)task 主要包含:script( 整體代碼)、setTimeout、setInterval、I/O、UI 交互事件、setImmediate(Node.js 環境)
microtask主要包含:Promise、MutaionObserver、process.nextTick(Node.js 環境)
setTimeout/Promise 等API便是任務源,而進入任務隊列的是由他們指定的具體執行任務。來自不同任務源的任務會進入到不同的任務隊列。其中 setTimeout 與 setInterval 是同源的。
分析示例代碼
千言萬語,不如就著例子講來的清楚。下面我們可以按照規范,一步步執行解析下上面的例子,先貼一下例子代碼(免得你往上翻)。
console.log('script start'); setTimeout(function() { console.log('setTimeout'); }, 0);//前端全棧交流學習圈:866109386 //幫助1-3年前端人員,提升技術,突破思維 Promise.resolve().then(function() { console.log('promise1'); }).then(function() { console.log('promise2'); }); console.log('script end');
整體 script 作為第一個宏任務進入主線程,遇到 console.log,輸出 script start
遇到 setTimeout,其回調函數被分發到宏任務 Event Queue 中
遇到 Promise,其 then函數被分到到微任務 Event Queue 中,記為 then1,之后又遇到了 then 函數,將其分到微任務 Event Queue 中,記為 then2
遇到 console.log,輸出 script end
至此,Event Queue 中存在三個任務,如下表:
看看你掌握了沒
再來一個題目,來做個練習:
console.log('script start'); setTimeout(function() { console.log('timeout1'); }, 10); new Promise(resolve=> { console.log('promise1'); resolve(); setTimeout(()=> console.log('timeout2'), 10); }).then(function() { console.log('then1') }) console.log('script end');
這個題目就稍微有點復雜了,我們再分析下:
首先,事件循環從宏任務 (macrotask) 隊列開始,最初始,宏任務隊列中,只有一個 scrip t(整體代碼)任務;當遇到任務源 (task source) 時,則會先分發任務到對應的任務隊列中去。所以,就和上面例子類似,首先遇到了console.log,輸出 script start; 接著往下走,遇到 setTimeout 任務源,將其分發到任務隊列中去,記為 timeout1; 接著遇到 promise,new promise 中的代碼立即執行,輸出 promise1, 然后執行 resolve ,遇到 setTimeout ,將其分發到任務隊列中去,記為 timemout2, 將其 then 分發到微任務隊列中去,記為 then1; 接著遇到 console.log 代碼,直接輸出 script end 接著檢查微任務隊列,發現有個 then1 微任務,執行,輸出then1 再檢查微任務隊列,發現已經清空,則開始檢查宏任務隊列,執行 timeout1,輸出 timeout1; 接著執行 timeout2,輸出 timeout2 至此,所有的都隊列都已清空,執行完畢。其輸出的順序依次是:script start, promise1, script end, then1, timeout1, timeout2
用流程圖看更清晰:
總結
有個小 tip:從規范來看,microtask 優先于 task 執行,所以如果有需要優先執行的邏輯,放入microtask 隊列會比 task 更早的被執行。
最后的最后,記住,JavaScript 是一門單線程語言,異步操作都是放到事件循環隊列里面,等待主執行棧來執行的,并沒有專門的異步執行線程。。
對前端的技術,架構技術感興趣的同學關注我的頭條號,并在后臺私信發送關鍵字:“前端”即可獲取免費的架構師學習資料
知識體系已整理好,歡迎免費領取。還有面試視頻分享可以免費獲取。關注我,可以獲得沒有的架構經驗哦!!
實際上變量和函數聲明在代碼里的位置是不會變的,而且是在編譯階段被 JavaScript 引擎放入內存中,一段 JavaScript 代碼在執行之前需要被 JavaScript 引擎編譯,編譯完成之后,才會進入執行階段。大致流程為:JavaScript 代碼片段 ——> 編譯階段 ——> 執行階段—>。
編譯階段,每段執行代碼會分為兩部分,第一部分為變量提升部分的代碼,第二部分為執行部分的代碼。經過編譯后,生成執行上下文(Execution context)和 可執行代碼。
執行上下文 是 JavaScript 執行一段代碼時的運行環境,比如調用一個函數,就會進入函數的執行上下文,從而確定該函數執行期間用到的如 this、變量、對象以及函數等。
執行上下文由 變量環境(Variable Environment) 和 **詞法環境(Lexical Environment)**對象 組成,變量環境保存了代碼中變量提升的內容,包括 var 定義和 function 定義的變量。而詞法環境保存 let 和 const 定義塊級作用域的變量。
塊級作用域就是通過詞法環境的棧結構來實現的,而變量提升是通過變量環境來實現,通過這兩者的結合,JavaScript 引擎也就同時支持了變量提升和塊級作用域了
變量查找過程:沿著詞法環境的棧頂向下查詢,如果在詞法環境中的某個塊中查找到了,就直接返回給 JavaScript 引擎,如果沒有查找到,那么繼續在變量環境中查找。
變量聲明提升補充:
調用棧是用來管理函數調用關系的一種數據結構。在函數調用的時候,JavaScript 引擎會創建函數執行上下文,而全局代碼下又有一個全局執行上下文,這些執行上下文會使用一種叫棧的數據結果來管理。
所以 JavaScript 的調用棧,其實就是 執行上下文棧 。舉例代碼執行,入棧如圖所示:
var a=2
function add(b,c){
return b+c
}
function addAll(b,c){
var d=10
result=add(b,c)
return a+result+d
}
addAll(3,6)
調用棧既然是一種數據結構,所以是存在大小的,超出了棧大小就會出現棧溢出報錯,比如斐波那契數列,執行10000次,超過了最大棧調用大小(Maximum call stack size exceeded)。
function Fibonacci2 (n , ac1=1 , ac2=1) {
if( n <=1 ) {return ac2};
return Fibonacci2 (n - 1, ac2, ac1 + ac2);
}
Fibonacci2(10000) // Maximum call stack size exceeded
該函數是遞歸的,雖然只有一種函數調用,但是還是會一直創建執行上下文壓入調用棧中,導致超過最大調用棧大小報錯,可以通過 Chrome 調式看到 Call Stack 的情況
總結:
所以,斐波那契數列函數優化的手段就是使用循環來減少函數調用,從而減少函數執行上下文的創建壓入棧的情況,就可以解決棧溢出的報錯了。(遞歸尾部優化無法解決問題,Chrome瀏覽器還是棧溢出),使用蹦床函數來解決:
function runStack (n) {
if (n===0) return 100;
return runStack.bind(null, n- 2); // 返回自身的一個版本
}
// 蹦床函數,避免遞歸
function trampoline(f) {
while (f && f instanceof Function) {
f=f();
}
return f;
}
trampoline(runStack(1000000))
可以看到,調用棧中一直是保持3個執行上下文而已,多余的都及時的pop掉了。
每個執行上下文的變量環境中,都包含了一個外部引用,用來指向外部的執行上下文,我們把這個外部的引用稱為 outer。
當一段代碼使用一個變量是,JavaScript 引擎首先會在“當前的執行上下文”中查找該變量,如果找不到就會繼續在 outer 所指向的執行上下文中查找。我們把這個查找的鏈條就稱為作用域鏈。
詞法作用域就是指作用域是由代碼中函數聲明的位置來決定的,所以詞法作用域是靜態的作用域,通過它就能夠預測代碼在執行過程中如何查找標識符。詞法作用域是代碼階段決定好的,和函數是怎么調用的沒有關系。
有詞法作用域的規則可以知道,內部函數總是可以訪問他們的外部函數中的變量,當外部函數執行完畢后,pop stack了,遺留下了外部環境形成的閉包 Closure 環境,該環境內存中還保存著那些可以訪問的變量,類似一個專屬背包,除了內部函數訪問,氣氛方式無法訪問該專屬背包,我們就包這個背包稱為外部函數的閉包(那些內部函數引用外部函數的變量依然保存在內存中,我們把這些變量的集合稱為閉包)。
如果引用閉包的函數是一個全局變量,那么閉包會一直存在知道頁面關閉;如果這個閉包以后不再使用的話,就會造成內存泄漏。
如果引用閉包的函數是一個局部變量,等函數銷毀后,下次 JavaScript 引擎執行垃圾回收時,判斷閉包這塊內容如果不再被使用了,那么 JavaScript 引擎的垃圾回收器就會回收這塊的內存。
使用閉包的原則:如果閉包會一直使用,那么它可以作為全局變量而存在;但如果使用頻率不高,而且占用內存有比較大的話,那就盡量讓它成為一個局部變量。
let a={ name: 'this解釋' }
function foo() {
console.log(this.name)
}
foo.bind(a)() //=> 'this解釋''
參考資源:《瀏覽器的工作原理與實踐》極客時間-李兵
*請認真填寫需求信息,我們會在24小時內與您取得聯系。