摘要: 了解 JS 引擎運作原理。
- 作者:前端小智
- 原文: 搞懂 JavaScript 引擎運作原理
經授權轉載,版權歸原作者所有。
一些名詞
JS 引擎 — 一個讀取代碼并運作的引擎,沒有單一的“JS 引擎”;每個浏覽器都有自己的引擎,如谷歌有 V。
作用域 — 可以從中通路變量的“區域”。
詞法作用域— 在詞法階段的作用域,換句話說,詞法作用域是由你在寫代碼時将變量和塊作用域寫在哪裡來決定的,是以當詞法分析器處理代碼時會保持作用域不變。
塊作用域 — 由花括号{}建立的範圍
作用域鍊 — 函數可以上升到它的外部環境(詞法上)來搜尋一個變量,它可以一直向上查找,直到它到達全局作用域。
同步 — 一次執行一件事, “同步”引擎一次隻執行一行,JavaScript 是同步的。
異步 — 同時做多個事,JS 通過浏覽器 API模拟異步行為
事件循環(Event Loop) - 浏覽器 API 完成函數調用的過程,将回調函數推送到回調隊列(callback queue),然後當堆棧為空時,它将回調函數推送到調用堆棧。
堆棧 —一種資料結構,隻能将元素推入并彈出頂部元素。 想想堆疊一個字形的大廈; 你不能删除中間塊,後進先出。
堆 — 變量存儲在記憶體中。
調用堆棧 — 函數調用的隊列,它實作了堆棧資料類型,這意味着一次可以運作一個函數。 調用函數将其推入堆棧并從函數傳回将其彈出堆棧。
執行上下文 — 當函數放入到調用堆棧時由 JS 建立的環境。
閉包 — 當在另一個函數内建立一個函數時,它“記住”它在以後調用時建立的環境。
垃圾收集 — 當記憶體中的變量被自動删除時,因為它不再使用,引擎要處理掉它。
變量的提升— 當變量記憶體沒有指派時會被提升到全局的頂部并設定為
undefined
。
this —由 JavaScript 為每個新的執行上下文自動建立的變量/關鍵字。
調用堆棧(Call Stack)
看看下面的代碼:
var myOtherVar = 10;
function a() {
console.log("myVar", myVar);
b();
}
function b() {
console.log("myOtherVar", myOtherVar);
c();
}
function c() {
console.log("Hello world!");
}
a();
var myVar = 5;
有幾個點需要注意:
- 變量聲明的位置(一個在上,一個在下)
- 函數
調用下面定義的函數a
, 函數 b 調用函數b
c
當它被執行時你期望發生什麼? 是否發生錯誤,因為
b
在
a
之後聲明或者一切正常?
console.log
列印的變量又是怎麼樣?
以下是列印結果:
"myVar" undefined
"myOtherVar" 10
"Hello world!"
來分解一下上述的執行步驟。
1. 變量和函數聲明(建立階段)
第一步是在記憶體中為所有變量和函數配置設定空間。 但請注意,除了
undefined
之外,尚未為變量配置設定值。 是以,
myVar
在被列印時的值是
undefined
,因為 JS 引擎從頂部開始逐行執行代碼。
函數與變量不一樣,函數可以一次聲明和初始化,這意味着它們可以在任何地方被調用。
是以以上代碼看起來像這樣子:
var myOtherVar = undefined
var myVar = undefined
function a() {...}
function b() {...}
function c() {...}
這些都存在于 JS 建立的全局上下文中,因為它位于全局空間中。
在全局上下文中,JS 還添加了:
- 全局對象(浏覽器中是
對象,NodeJs 中是window
對象)global
- this 指向全局對象
2. 執行
接下來,JS 引擎會逐行執行代碼。
myOtherVar = 10`在全局上下文中,`myOtherVar`被指派為`10
已經建立了所有函數,下一步是執行函數
a()
每次調用函數時,都會為該函數建立一個新的上下文(重複步驟 1),并将其放入調用堆棧。
function a() {
console.log("myVar", myVar);
b();
}
如下步驟:
- 建立新的函數上下文
-
函數裡面沒有聲明變量和函數a
- 函數内部建立了
并指向全局對象(window)this
- 接着引用了外部變量
,myVar
屬于全局作用域的。myVar
- 接着調用函數
,函數b
的過程跟b
一樣,這裡不做分析。a
下面調用堆棧的執行示意圖:

- 建立全局上下文,全局變量和函數。
- 每個函數的調用,會建立一個上下文,外部環境的引用及
this
- 函數執行結束後會從堆棧中彈出,并且它的執行上下文被垃圾收集回收(閉包除外)。
- 當調用堆棧為空時,它将從事件隊列中擷取事件。
作用域及作用域鍊
在前面的示例中,所有内容都是全局作用域的,這意味着我們可以從代碼中的任何位置通路它。 現在,介紹下私有作用域以及如何定義作用域。
函數/詞法作用域
考慮如下代碼:
function a() {
var myOtherVar = "inside A";
b();
}
function b() {
var myVar = "inside B";
console.log("myOtherVar:", myOtherVar);
function c() {
console.log("myVar:", myVar);
}
c();
}
var myOtherVar = "global otherVar";
var myVar = "global myVar";
a();
需要注意以下幾點:
- 全局作用域和函數内部都聲明了變量
-
現在在函數c
中聲明b
列印結果如下:
myOtherVar: "global otherVar";
myVar: "inside B";
執行步驟:
- 全局建立和聲明 - 建立記憶體中的所有函數和變量以及全局對象和
this
- 執行 - 它逐行讀取代碼,給變量指派,并執行函數 a
- 函數 a建立一個新的上下文并被放入堆棧,在上下文中建立變量
,然後調用函數 bmyOtherVar
- 函數 b 也會建立一個新的上下文,同樣也被放入堆棧中
5,函數b 的上下文中建立了
myVar
變量,并聲明函數 c
上面提到每個新上下文會建立的外部引用,外部引用取決于函數在代碼中聲明的位置。
- 函數 b試圖列印
,但這個變量并不存在于函數 b中,函數 b 就會使用它的外部引用上作用域鍊向上找。由于函數 b是全局聲明的,而不是在函數 a内部聲明的,是以它使用全局變量 myOtherVar。myOtherVar
- 函數 c執行步驟一樣。由于函數 c本身沒有變量
,是以它它通過作用域鍊向上找,也就是函數 b,因為myVar
是函數 b内部聲明過。myVar
下面是執行示意圖:
請記住,外部引用是單向的,它不是雙向關系。例如,函數 b不能直接跳到函數 c的上下文中并從那裡擷取變量。
最好将它看作一個隻能在一個方向上運作的鍊(範圍鍊)。
- a -> global
- c -> b -> global
在上面的圖中,你可能注意到,函數是建立新作用域的一種方式。(除了全局作用域)然而,還有另一種方法可以建立新的作用域,就是塊作用域。
塊作用域
下面代碼中,我們有兩個變量和兩個循環,在循環重新聲明相同的變量,會列印什麼(反正我是做錯了)?
function loopScope() {
var i = 50;
var j = 99;
for (var i = 0; i < 10; i++) {}
console.log("i =", i);
for (let j = 0; j < 10; j++) {}
console.log("j =", j);
}
loopScope();
列印結果:
i = 10;
j = 99;
第一個循環覆寫了
var i
,對于不知情的開發人員來說,這可能會導緻 bug。
第二個循環,每次疊代建立了自己作用域和變量。 這是因為它使用
let
關鍵字,它與
var
相同,隻是
let
有自己的塊作用域。 另一個關鍵字是
const
,它與
let
相同,但
const
常量且無法更改(指記憶體位址)。
塊作用域由大括号 {} 建立的作用域
再看一個例子:
function blockScope() {
let a = 5;
{
const blockedVar = "blocked";
var b = 11;
a = 9000;
}
console.log("a =", a);
console.log("b =", b);
console.log("blockedVar =", blockedVar);
}
blockScope();
a = 9000
b = 11
ReferenceError: blockedVar is not defined
-
是塊作用域,但它在函數中,而不是嵌套的,本例中使用a
是一樣的。var
- 對于塊作用域的變量,它的行為類似于函數,注意
可以在外部通路,但是var b
不能。const blockedVar
- 在塊内部,從作用域鍊向上找到
并将a
更改為let a
9000
使用塊作用域可以使代碼更清晰,更安全,應該盡可能地使用它。
事件循環(Event Loop)
接下來看看事件循環。 這是回調,事件和浏覽器 API 工作的地方
我們沒有過多讨論的事情是堆,也叫全局記憶體。它是變量存儲的地方。由于了解 JS 引擎是如何實作其資料存儲的實際用途并不多,是以我們不在這裡讨論它。
來個異步代碼:
function logMessage2() {
console.log("Message 2");
}
console.log("Message 1");
setTimeout(logMessage2, 1000);
console.log("Message 3");
上述代碼主要是将一些 message 列印到控制台。 利用
setTimeout
函數來延遲一條消息。 我們知道 js 是同步,來看看輸出結果
Message 1
Message 3
Message 2
- 列印 Message 1
- 調用 setTimeout
- 列印 Message 3
- 列印 Message 2
它記錄消息 3
稍後,它會記錄消息 2
setTimeout
是一個 API,和大多數浏覽器 API 一樣,當它被調用時,它會向浏覽器發送一些資料和回調。我們這邊是延遲一秒列印 Message 2。
調用完
setTimeout
後,我們的代碼繼續運作,沒有暫停,列印 Message 3 并執行一些必須先執行的操作。
浏覽器等待一秒鐘,它就會将資料傳遞給我們的回調函數并将其添加到事件/回調隊列中( event/callback queue)。 然後停留在隊列中,隻有當調用堆棧(call stack)為空時才會被壓入堆棧。
代碼示例
要熟悉 JS 引擎,最好的方法就是使用它,再來些有意義的例子。
簡單的閉包
這個例子中 有一個傳回函數的函數,并在傳回的函數中使用外部的變量, 這稱為閉包。
function exponent(x) {
return function(y) {
//和math.pow() 或者x的y次方是一樣的
return y ** x;
};
}
const square = exponent(2);
console.log(square(2), square(3)); // 4, 9
console.log(exponent(3)(2)); // 8
塊代碼
我們使用無限循環将将調用堆棧塞滿,會發生什麼,回調隊列被會阻塞,因為隻能在調用堆棧為空時添加回調隊列。
function blockingCode() {
const startTime = new Date().getSeconds();
// 延遲函數250毫秒
setTimeout(function() {
const calledAt = new Date().getSeconds();
const diff = calledAt - startTime;
// 列印調用此函數所需的時間
console.log(`Callback called after: ${diff} seconds`);
}, 250);
// 用循環阻塞堆棧2秒鐘
while (true) {
const currentTime = new Date().getSeconds();
// 2 秒後退出
if (currentTime - startTime >= 2) break;
}
}
blockingCode(); // 'Callback called after: 2 seconds'
我們試圖在
250毫秒
之後調用一個函數,但因為我們的循環阻塞了堆棧所花了
兩秒鐘
,是以回調函數實際是兩秒後才會執行,這是 JavaScript 應用程式中的常見錯誤。
setTimeout
不能保證在設定的時間之後調用函數。相反,更好的描述是,在至少經過這段時間之後調用這個函數。
延遲函數
當
setTimeout
的設定為 0,情況是怎麼樣?
function defer() {
setTimeout(() => console.log("timeout with 0 delay!"), 0);
console.log("after timeout");
console.log("last log");
}
defer();
你可能期望它被立即調用,但是,事實并非如此。
執行結果:
after timeout
last log
timeout with 0 delay!
它會立即被推到回調隊列,但它仍然會等待調用堆棧為空才會執行。
用閉包來緩存
Memoization是緩存函數調用結果的過程。
例如,有一個添加兩個數字的函數
add
。調用
add(1,2)
傳回
3
,當再次使用相同的參數
add(1,2)調
用它,這次不是重新計算,而是記住 1
+ 2是3
的結果并直接傳回對應的結果。
Memoization
可以提高代碼運作速度,是一個很好的工具。
我們可以使用閉包實作一個簡單的memoize函數。
// 緩存函數,接收一個函數
const memoize = func => {
// 緩存對象
// keys 是 arguments, values are results
const cache = {};
// 傳回一個新的函數
// it remembers the cache object & func (closure)
// ...args is any number of arguments
return (...args) => {
// 将參數轉換為字元串,以便我們可以存儲它
const argStr = JSON.stringify(args);
// 如果已經存,則列印
console.log("cache", cache, !!cache[argStr]);
cache[argStr] = cache[argStr] || func(...args);
return cache[argStr];
};
};
const add = memoize((a, b) => a + b);
console.log("first add call: ", add(1, 2));
console.log("second add call", add(1, 2));
cache {} false
first add call: 3
cache { '[1,2]': 3 } true
second add call 3
第一次
add
方法,緩存對象是空的,它調用我們的傳入函數來擷取值
3
.然後它将
args/value
鍵值對存儲在緩存對象中。
在第二次調用中,緩存中已經有了,查找到并傳回值。
對于
add
函數來說,有無緩存看起來無關緊要,甚至效率更低,但是對于一些複雜的計算,它可以節省很多時間。這個示例并不是一個完美的緩存示例,而是閉包的實際應用。
關于Fundebug
專注于JavaScript、微信小程式、微信小遊戲、支付寶小程式、React Native、Node.js和Java線上應用實時BUG監控。 自從2016年雙十一正式上線,Fundebug累計處理了10億+錯誤事件,付費客戶有陽光保險、核桃程式設計、荔枝FM、掌門1對1、微脈、青團社等衆多品牌企業。歡迎大家
免費試用!