前言
其實一開始對棧、堆的概念特別模糊,只知道好像跟記憶體有關,又好像事件迴圈也沾一點邊。面試薄荷的時候,面試官正好也問到了這個問題,當時只能大方的承認不會。痛定思痛,回去好好的研究一番。
我們將從JS的記憶體機制
以及事件機制
和大量的?(例子)
來了解棧、堆究竟是個什麼玩意。概念比較多,不用死讀,所有的?心裡想一遍,瀏覽器console看一遍就很清楚了。
let's go
JS記憶體機制
因為JavaScript具有自動垃圾回收機制,所以對於前端開發來說,記憶體空間並不是一個經常被提及的概念,很容易被大家忽視。特別是很多不專業的朋友在進入到前端之後,會對記憶體空間的認知比較模糊。
在JS中,每一個資料都需要一個記憶體空間。記憶體空間又被分為兩種,棧記憶體(stack)與堆記憶體(heap)。
棧記憶體一般儲存基礎資料型別
Number String Null Undefined Boolean
(es6新引入了一種資料型別,Symbol)
複製程式碼
最簡單的?
var a = 1
複製程式碼
我們定義一個變數a,系統自動分配儲存空間。我們可以直接操作儲存在棧記憶體空間的值,因此基礎資料型別都是按值訪問。
資料在棧記憶體中的儲存與使用方式類似於資料結構中的堆疊資料結構,遵循後進先出的原則。
堆記憶體一般儲存引用資料型別
堆記憶體的?
var b = { xi : 20 }
複製程式碼
與其他語言不同,JS的引用資料型別,比如陣列Array,它們值的大小是不固定的。引用資料型別的值是儲存在堆記憶體中的物件。JavaScript不允許直接訪問堆記憶體中的位置,因此我們不能直接操作物件的堆記憶體空間。看一下下面的圖,加深理解。
比較

var a1 = 0; // 棧
var a2 = 'this is string'; // 棧
var a3 = null; // 棧
var b = { m: 20 }; // 變數b存在於棧中,{m: 20} 作為物件存在於堆記憶體中
var c = [1, 2, 3]; // 變數c存在於棧中,[1, 2, 3] 作為物件存在於堆記憶體中
複製程式碼
因此當我們要訪問堆記憶體中的引用資料型別時,實際上我們首先是從棧中獲取了該物件的地址引用(或者地址指標),然後再從堆記憶體中取得我們需要的資料。
測試
var a = 20;
var b = a;
b = 30;
console.log(a)
複製程式碼
var m = { a: 10, b: 20 }
var n = m;
n.a = 15;
console.log(m.a)
複製程式碼
同學們自己在console裡打一遍,再結合下面的圖例,就很好理解了
記憶體機制我們瞭解了,又引出一個新的問題,棧裡只能存基礎資料型別嗎,我們經常用的function存在哪裡呢?
瀏覽器的事件機制
一個經常被搬上面試題的?
console.log(1)
let promise = new Promise(function(resolve,reject){
console.log(3)
resolve(100)
}).then(function(data){
console.log(100)
})
setTimeout(function(){
console.log(4);
})
console.log(2)
複製程式碼
上面這個demo的結果值是 1 3 2 100 4
物件放在heap(堆)裡,常見的基礎型別和函式放在stack(棧)裡,函式執行的時候在棧裡執行。棧裡函式執行的時候可能會調一些Dom操作,ajax操作和setTimeout定時器,這時候要等stack(棧)裡面的所有程式先走**(注意:棧裡的程式碼是先進後出)**,走完後再走WebAPIs,WebAPIs執行後的結果放在callback queue(回撥的佇列裡,注意:佇列裡的程式碼先放進去的先執行),也就是當棧裡面的程式走完之後,再從任務佇列中讀取事件,將佇列中的事件放到執行棧中依次執行,這個過程是迴圈不斷的。
- 1.所有同步任務都在主執行緒上執行,形成一個執行棧
- 2.主執行緒之外,還存在一個任務佇列。只要非同步任務有了執行結果,就在任務佇列之中放置一個事件。
- 3.一旦執行棧中的所有同步任務執行完畢,系統就會讀取任務佇列,將佇列中的事件放到執行棧中依次執行
- 4.主執行緒從任務佇列中讀取事件,這個過程是迴圈不斷的
概念又臭又長,沒關係,我們先粗略的掃一眼,接著往下看。
舉一個?說明棧的執行方式
var a = "aa";
function one(){
let a = 1;
two();
function two(){
let b = 2;
three();
function three(){
console.log(b)
}
}
}
console.log(a);
one();
複製程式碼
demo的結果是 aa 2
圖解
執行棧裡面最先放的是全域性作用域(程式碼執行有一個全域性文字的環境),然後再放one, one執行再把two放進來,two執行再把three放進來,一層疊一層。
最先走的肯定是three,因為two要是先銷燬了,那three的程式碼b就拿不到了,所以是先進後出(先進的後出),所以,three最先出,然後是two出,再是one出。
那佇列又是怎麼一回事呢?
再舉一個?
console.log(1);
console.log(2);
setTimeout(function(){
console.log(3);
})
setTimeout(function(){
console.log(4);
})
console.log(5);
複製程式碼
首先執行了棧裡的程式碼,1 2 5。 前面說到的settimeout會被放在佇列裡,當棧執行完了之後,從佇列裡新增到棧裡執行(此時是依次執行),得到 3 4
再再舉一個?
console.log(1);
console.log(2);
setTimeout(function(){
console.log(3);
setTimeout(function(){
console.log(6);
})
})
setTimeout(function(){
console.log(4);
setTimeout(function(){
console.log(7);
})
})
console.log(5)
複製程式碼
同樣,先執行棧裡的同步程式碼 1 2 5. 再同樣,最外層的settimeout會放在佇列裡,當棧裡面執行完成以後,放在棧中執行,3 4。 而巢狀的2個settimeout,會放在一個新的佇列中,去執行 6 7.
再再再看一個?
console.log(1);
console.log(2);
setTimeout(function(){
console.log(3);
setTimeout(function(){
console.log(6);
})
},400)
setTimeout(function(){
console.log(4);
setTimeout(function(){
console.log(7);
})
},100)
console.log(5)
複製程式碼
如上:這裡的順序是1,2,5,4,7,3,6。也就是隻要兩個set時間不一樣的時候 ,就set時間短的先走完,包括set裡面的回撥函式,再走set時間慢的。(因為只有當時間到了的時候,才會把set放到佇列裡面去)
setTimeout(function(){
console.log('setTimeout')
},0)
for(var i = 0;i<10;i++){
console.log(i)
}
複製程式碼
這個demo的結果是 0 1 2 3 4 5 6 7 8 9 setTimeout
所以,得出結論,永遠都是棧裡的程式碼先行執行,再從佇列中依次讀事件,加入棧中執行
stack(棧)裡面都走完之後,就會依次讀取任務佇列,將佇列中的事件放到執行棧中依次執行,這個時候棧中又出現了事件,這個事件又去呼叫了WebAPIs裡的非同步方法,那這些非同步方法會在再被呼叫的時候放在佇列裡,然後這個主執行緒(也就是stack)執行完後又將從任務佇列中依次讀取事件,這個過程是迴圈不斷的。
再回到我們的第一個?
console.log(1)
let promise = new Promise(function(resolve,reject){
console.log(3)
resolve(100)
}).then(function(data){
console.log(100)
})
setTimeout(function(){
console.log(4);
})
console.log(2)
複製程式碼
上面這個demo的結果值是 1 3 2 100 4
- 為什麼setTimeout要在Promise.then之後執行呢?
- 為什麼new Promise又在console.log(2)之前執行呢?
setTimeout是巨集任務,而Promise.then是微任務 這裡的new Promise()是同步的,所以是立即執行的。
這就要引入一個新的話題巨集任務和微任務(面試也會經常提及到)
巨集任務和微任務
參考 Tasks, microtasks, queues and schedules(https://jakearchibald.com/2015/tasks-microtasks-queues-and-schedules/?utm_source=html5weekly)
概念:微任務和巨集任務都是屬於佇列,而不是放在棧中
一個新的?
console.log('1');
setTimeout(function() {
console.log('setTimeout');
}, 0);
Promise.resolve().then(function() {
console.log('promise1');
}).then(function() {
console.log('promise2');
});
console.log('2');
複製程式碼
1 2 promise1 promise2 setTimeout
巨集任務(task)
瀏覽器為了能夠使得JS內部巨集任務與DOM任務能夠有序的執行,會在一個task執行結束後,在下一個 task 執行開始前,對頁面進行重新渲染 (task->渲染->task->…) 滑鼠點選會觸發一個事件回撥,需要執行一個巨集任務,然後解析HTMl。但是,setTimeout不一樣,setTimeout的作用是等待給定的時間後為它的回撥產生一個新的巨集任務。這就是為什麼列印‘setTimeout’在‘promise1 , promise2’之後。因為列印‘promise1 , promise2’是第一個巨集任務裡面的事情,而‘setTimeout’是另一個新的獨立的任務裡面列印的。
微任務 (Microtasks)
微任務通常來說就是需要在當前 task 執行結束後立即執行的任務 比如對一系列動作做出反饋,或者是需要非同步的執行任務而又不需要分配一個新的 task,這樣便可以減小一點效能的開銷。只要執行棧中沒有其他的js程式碼正在執行且每個巨集任務執行完,微任務佇列會立即執行。如果在微任務執行期間微任務佇列加入了新的微任務,會將新的微任務加入佇列尾部,之後也會被執行。微任務包括了mutation observe的回撥還有接下來的例子promise的回撥。
一旦一個pormise有了結果,或者早已有了結果(有了結果是指這個promise到了fulfilled或rejected狀態),他就會為它的回撥產生一個微任務,這就保證了回撥非同步的執行即使這個promise早已有了結果。所以對一個已經有了結果的**promise呼叫.then()**會立即產生一個微任務。這就是為什麼‘promise1’,'promise2’會列印在‘script end’之後,因為所有微任務執行的時候,當前執行棧的程式碼必須已經執行完畢。‘promise1’,'promise2’會列印在‘setTimeout’之前是因為所有微任務總會在下一個巨集任務之前全部執行完畢。
還是?
<div class="outer">
<div class="inner"></div>
</div>
複製程式碼
// elements
var outer = document.querySelector('.outer');
var inner = document.querySelector('.inner');
//監聽element屬性變化
new MutationObserver(function() {
console.log('mutate');
}).observe(outer, {
attributes: true
});
// click listener…
function onClick() {
console.log('click');
setTimeout(function() {
console.log('timeout');
}, 0);
Promise.resolve().then(function() {
console.log('promise');
});
outer.setAttribute('data-random', Math.random());
}
//
inner.addEventListener('click', onClick);
outer.addEventListener('click', onClick);
複製程式碼
click promise mutate click promise mutate (2) timeout
很好的解釋了,setTimeout會在微任務(Promise.then、MutationObserver.observe)執行完成之後,加入一個新的巨集任務中
多看一些?
console.log(1);
setTimeout(function(){
console.log(2);
Promise.resolve(1).then(function(){
console.log('promise1')
})
})
setTimeout(function(){
console.log(3)
Promise.resolve(1).then(function(){
console.log('promise2')
})
})
setTimeout(function(){
console.log(4)
Promise.resolve(1).then(function(){
console.log('promise3')
})
})
複製程式碼
1 2 promise1 3 promise2 4 promise3
console.log(1);
setTimeout(function(){
console.log(2);
Promise.resolve(1).then(function(){
console.log('promise1')
setTimeout(function(){
console.log(3)
Promise.resolve(1).then(function(){
console.log('promise2')
})
})
})
})
複製程式碼
1 2 promise1 3 promise2
總結回顧
-
棧:
- 儲存基礎資料型別
- 按值訪問
- 儲存的值大小固定
- 由系統自動分配記憶體空間
- 空間小,執行效率高
- 先進後出,後進先出
- 棧中的DOM,ajax,setTimeout會依次進入到佇列中,當棧中程式碼執行完畢後,再將佇列中的事件放到執行棧中依次執行。
- 微任務和巨集任務
-
堆:
- 儲存引用資料型別
- 按引用訪問
- 儲存的值大小不定,可動態調整
- 主要用來存放物件
- 空間大,但是執行效率相對較低
- 無序儲存,可根據引用直接獲取
廣而告之
本文釋出於薄荷前端週刊,歡迎Watch & Star ★,轉載請註明出處。