深入理解JavaScript堆栈、事件循环、执行上下文和作用域以及闭包
1. 堆棧
在JavaScript中,內存堆是內存分配的地方,調用棧是代碼執行的地方。
原始類型的保存方式:在變量中保存的是值本身,所以原始類型也被稱之為值類型。
對象類型的保存方式:在變量中保存的是對象的“引用”,所以對象類型也被稱之為引用類型。
![[CleanShot 2024-01-02 at 14.56.33@2x.png]]
調用棧理解非常簡單,當遇見一個方法時推入調用棧中,執行一個方法彈出棧,每一個方法稱為一個調用幀。
2. 事件循環
理解了堆棧之后,接著來看一下與之相關的事件循環。
首先需要明確的是JavaScript是單線程語言,所有代碼都執行在一個線程中,這通常會導致一個問題,當一個方法耗時過長,整個頁面隨之卡住,所以為了避免這種情況發生,JavaScript中存在事件循環的機制(并非JavaScript創造),來循環執行事件,堵塞的事件通過循環在后期再來判斷是否執行完成,比如讀取接口,后期再來看接口是否請求完成,請求完成之后再執行對應的回調函數(接口請求是瀏覽器提供的能力,不占用單線程)。
事件循環也就是將任務分為同步任務和異步任務,任務按照順序進行執行。
事件循環中一個重要概念是宏任務和微任務,宏任務也就是線程中首先一輪執行的函數,微任務也就是宏任務里面的任務,類似進程和線程的關系,宏任務是進程,微任務是線程,下面來看一下三者之間的關系:
事件循環,其實循環的就是宏任務和微任務,當宏任務中有微任務時,執行里面的微任務。
下面來看一下在JavaScript中具體哪些函數是宏任務,哪些是微任務:
- macro-task(宏任務):包括整體代碼script,setTimeout,setInterval
- micro-task(微任務):Promise,process.nextTick(node代碼, 類似vue中this.$nextTick)
具體來看一下執行流程:
- 整體script作為第一個宏任務進入主線程;
- 遇到
setTimeout、setInterval,其回調函數被分發到宏任務事件隊列中; - 遇到
process.nextTick(),其回調函數被分發到微任務事件隊列中; - 遇到
Promise,new Promise函數體內容直接執行。then等回調部分被分發到微任務事件隊列中; - 微任務在宏任務執行后開始執行,比如微任務屬于第一個宏任務,那么第一個宏任務執行完,就執行第一個宏任務里面的微任務,也就是說
script里面要是包含微任務,那么是先于setTimeout等第二輪執行的宏任務的; - 第一輪執行完成后,開始第二輪,也就是
setTimeout、setInterval回調函數里面的內容,屬于第二輪宏任務,如果里面包含微任務,那么緊接著回調函數里面內容執行完之后開始執行; - 如果微任務里面還包含微任務,那么是緊接著外層的微任務開始執行的。
注意在node有一些不同,存在下面的優先級順序:process.nextTick() > Promise.then() > setTimeout > setImmediate
下面來看一個具體的例子:
console.log('1');
setTimeout(function() {
console.log('2');
process.nextTick(function() {
console.log('3');
})
new Promise(function(resolve) {
console.log('4');
resolve();
}).then(function() {
console.log('5')
})
})
process.nextTick(function() {
console.log('6');
})
new Promise(function(resolve) {
console.log('7');
resolve();
}).then(function() {
console.log('8')
})
setTimeout(function() {
console.log('9');
process.nextTick(function() {
console.log('10');
})
new Promise(function(resolve) {
console.log('11');
resolve();
}).then(function() {
console.log('12')
})
})
第一輪事件循環流程分析如下:
- 整體script作為第一個宏任務進入主線程,遇到
console.log,輸出1。 - 遇到
setTimeout,其回調函數被分發到宏任務Event Queue中。我們暫且記為setTimeout1。 - 遇到
process.nextTick(),其回調函數被分發到微任務Event Queue中。我們記為process1。 - 遇到
Promise,new Promise直接執行,輸出7。then被分發到微任務Event Queue中。我們記為then1。 - 又遇到了
setTimeout,其回調函數被分發到宏任務Event Queue中,我們記為setTimeout2。
| 宏任務Event Queue | 微任務Event Queue |
|---|---|
| setTimeout1 | process1 |
| setTimeout2 | then1 |
- 上表是第一輪事件循環宏任務結束時各Event Queue的情況,此時已經輸出了1和7。
- 我們發現了
process1和then1兩個微任務。 - 執行
process1,輸出6。 - 執行
then1,輸出8。
好了,第一輪事件循環正式結束,這一輪的結果是輸出1,7,6,8。那么第二輪時間循環從setTimeout1宏任務開始:
- 首先輸出2。接下來遇到了
process.nextTick(),同樣將其分發到微任務Event Queue中,記為process2。new Promise立即執行輸出4,then也分發到微任務Event Queue中,記為then2。
| 宏任務Event Queue | 微任務Event Queue |
|---|---|
| setTimeout2 | process2 |
| then2 |
- 第二輪事件循環宏任務結束,我們發現有
process2和then2兩個微任務可以執行。 - 輸出3。
- 輸出5。
- 第二輪事件循環結束,第二輪輸出2,4,3,5。
- 第三輪事件循環開始,此時只剩setTimeout2了,執行。
- 直接輸出9。
- 將
process.nextTick()分發到微任務Event Queue中。記為process3。 - 直接執行
new Promise,輸出11。 - 將
then分發到微任務Event Queue中,記為then3。
| 宏任務Event Queue | 微任務Event Queue |
|---|---|
| process3 | |
| then3 |
- 第三輪事件循環宏任務執行結束,執行兩個微任務
process3和then3。 - 輸出10。
- 輸出12。
- 第三輪事件循環結束,第三輪輸出
9,11,10,12。
整段代碼,共進行了三次事件循環,完整的輸出為1,7,6,8,2,4,3,5,9,11,10,12。 (請注意,node環境下的事件監聽依賴libuv與前端環境不完全相同,輸出順序可能會有誤差)。
3. 執行上下文
接著來看一下執行上下文,簡而言之,執行上下文是評估和執行 JavaScript 代碼的環境的抽象概念。每當 Javascript 代碼在運行的時候,它都是在執行上下文中運行。
JavaScript 中有三種執行上下文類型:
-
全局執行上下文 — 這是默認或者說基礎的上下文,任何不在函數內部的代碼都在全局上下文中。它會執行兩件事:創建一個全局的 window 對象(瀏覽器的情況下),并且設置
this的值等于這個全局對象。一個程序中只會有一個全局執行上下文。 - 函數執行上下文 — 每當一個函數被調用時, 都會為該函數創建一個新的上下文。每個函數都有它自己的執行上下文,不過是在函數被調用時創建的。函數上下文可以有任意多個。每當一個新的執行上下文被創建,它會按定義的順序(將在后文討論)執行一系列步驟。
-
Eval 函數執行上下文 — 執行在
eval函數內部的代碼也會有它屬于自己的執行上下文,但由于 JavaScript 開發者并不經常使用eval,所以在這里不會討論。
總結一下,執行上下文大體分為全局和函數執行上下文,也就是執行環境,函數可以讀取外部函數的變量,通常也稱為閉包,通過這個原理,相比靜態語言,可以更靈活的獲取外部的參數。
執行上下文的不同,直接導致 this 值內容的不同。
同時一個執行上下文將會創建一個上面的執行棧,而不是所有的執行上下文的所有方法共用一個執行棧。
4. 作用域
作用域這個內容非常簡單,基本上所有語言都存在作用域,在JavaScript中,需要注意一點,函數中創建的值是在創建的時候獲得的,而不是調用,通過代碼來看一下:
let x = 10
function fn() {
x = 20
console.log(x)
}
function foo() {
x = 30
fn() // 20
}
foo()
上面代碼打印的值仍然是20,因為創建 fn 函數時,對應的作用域里面的值為20,而不是調用 fn 時,foo函數作用域里面的值。
這里有一個注意點,我們來看下面的代碼:
let x = 10
function fn() {
console.log(x)
}
function foo() {
x = 30
fn() // 30
}
foo()
上面的代碼會打印30,這是怎么回事,不是說再創建的位置取值嗎?
答案是,確實是在創建的位置,但是先執行的foo函數,把外層的x的值變更了,下面的代碼能解釋這個問題:
let x = 10
function fn() {
console.log(x)
}
function foo() {
let x = 30
fn() // 10
}
foo()
可以看到,打印的其實并不是foo函數里的值,而是創建函數時的值。
接著我們要理一下,什么是創建時的值,這里要引出一個概念,作用域鏈,也就是取值的鏈條:
- 現在當前作用域查找a,如果有則獲取并結束,如果沒有則繼續;
- 如果當前作用域是全局作用域,則證明a未定義,結束,否則繼續;
- (不是全局作用域,那就是函數作用域)將創建該函數的作用域作為當前作用域;
- 跳轉到第一步。
var a = 10
function fn() {
var b = 20
function bar() {
console.log(a) // 10
console.log(b) // 20
}
return bar
}
var x = fn()
var b = 200
x()
總結一下
函數上下文環境是在函數執行時創建的,同時在上下文中生成了對應的變量,同一個函數根據傳遞進來的參數不同,里面的變量也會不同;
而作用域是函數創建時就產生了,作用域作用域,說白了就是這個函數自己的地盤,無論是否調用,反正這個函數都擁有這個地盤了;
只有當調用時才會創建上下文環境,并且可能不止一個,比如通過傳遞不同參數,可能會創建多個上下文環境,上下文環境說白了就是在這個環境中變量的值是什么,以便使用。
5. 閉包
前面鋪墊了那么多內容,主要是用于引出閉包,閉包就是能夠讀取其他函數內部變量的函數。 在javascript中,只有函數內部的子函數才能讀取局部變量,所以閉包可以理解成“定義在一個函數內部的函數“。 在本質上,閉包是將函數內部和函數外部連接起來的橋梁。
下面我們來看看閉包運用的兩種形式:
第一,函數作為返回值:
function fn() {
var max = 100
return function bar(x) {
if (x > max) {
console.log(x)
}
}
}
var f1 = fn()
f1(115)
上面返回的內部函數就是一個閉包,它可以讀取其外部fn函數的max值,從這種情況來說,下面的情況也是閉包:
var max = 100
function fn() {
console.log(max)
}
fn()
從上面兩段代碼可以看出,所有的函數其實只要函數內部能夠讀取了其外部的變量,都可以稱為閉包,也就是說,所有函數都是閉包,因為一個函數最少也是可以讀取全局環境下的變量的,只是第二段代碼通常不是閉包的常見使用形式,常見的使用形式還是將函數作為返回值
第二,函數作為參數傳遞:
var max = 10
var fn = function (x) {
if (x > 100) {
console.log(x) // 不打印任何東西
}
}
;(function (f) {
var max = 100
f(15)
})(fn)
函數作為參數傳遞,進入另一個函數作為另一個函數的內容,此時傳遞的這個函數就是一個閉包,注意一下,這里的max根據前面的作用域原則,是讀取函數定義時的max,而不是調用時。
參考文章
- 阮一峰《JavaScript 運行機制詳解:再談Event Loop》:https://www.ruanyifeng.com/blog/2014/10/event-loop.html
- 異步和 event-loop:https://github.com/wangfupeng1988/js-async-tutorial/blob/master/part1-basic/02-event-loop.md
總結
以上是生活随笔為你收集整理的深入理解JavaScript堆栈、事件循环、执行上下文和作用域以及闭包的全部內容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: [GXYCTF2019]Ping Pin
- 下一篇: 面试Java时碰到过的那些问题