一、理解 JavaScript 的作用域、作用域鏈和內(nèi)部原理
作用域
javascript 擁有一套設(shè)計良好的規(guī)則來存儲變量,并且之后可以方便地找到這些變量,這套規(guī)則被稱為作用域。
作用域就是代碼的執(zhí)行環(huán)境,全局執(zhí)行環(huán)境就是全局作用域,函數(shù)的執(zhí)行環(huán)境就是私有作用域,它們都是棧內(nèi)存。
作用域鏈
當代碼在一個環(huán)境中執(zhí)行時,會創(chuàng)建變量對象的一個作用域鏈(作用域形成的鏈條),由于變量的查找是沿著作用域鏈來實現(xiàn)的,所以也稱作用域鏈為變量查找的機制。
- 作用域鏈的前端,始終都是當前執(zhí)行的代碼所在環(huán)境的變量對象
- 作用域鏈中的下一個對象來自于外部環(huán)境,而在下一個變量對象則來自下一個外部環(huán)境,一直到全局執(zhí)行環(huán)境
- 全局執(zhí)行環(huán)境的變量對象始終都是作用域鏈上的最后一個對象
內(nèi)部環(huán)境可以通過作用域鏈訪問所有外部環(huán)境,但外部環(huán)境不能訪問內(nèi)部環(huán)境的任何變量和函數(shù)。
內(nèi)部原理
-
編譯
以 var a = 2;為例,說明 javascript 的內(nèi)部編譯過程,主要包括以下三步:
-
分詞(tokenizing)
把由字符組成的字符串分解成有意義的代碼塊,這些代碼塊被稱為詞法單元(token)
var a = 2;被分解成為下面這些詞法單元:var、a、=、2、;。這些詞法單元組成了一個詞法單元流數(shù)組
[ "var": "keyword", "a": "identifier", "=": "assignment", "2": "integer", ";": "eos" (end of statement) ] -
解析(parsing)
把詞法單元流數(shù)組轉(zhuǎn)換成一個由元素逐級嵌套所組成的代表程序語法結(jié)構(gòu)的樹,這個樹被稱為“抽象語法樹” (Abstract Syntax Tree, AST)
var a = 2;的抽象語法樹中有一個叫 VariableDeclaration 的頂級節(jié)點,接下來是一個叫 Identifier(它的值是 a)的子節(jié)點,以及一個叫 AssignmentExpression 的子節(jié)點,且該節(jié)點有一個叫 Numericliteral(它的值是 2)的子節(jié)點
{ operation: "=", left: { keyword: "var", right: "a" } right: "2" } -
代碼生成
將 AST 轉(zhuǎn)換為可執(zhí)行代碼的過程被稱為代碼生成
var a=2;的抽象語法樹轉(zhuǎn)為一組機器指令,用來創(chuàng)建一個叫作 a 的變量(包括分配內(nèi)存等),并將值 2 儲存在 a 中
實際上,javascript 引擎的編譯過程要復(fù)雜得多,包括大量優(yōu)化操作,上面的三個步驟是編譯過程的基本概述
任何代碼片段在執(zhí)行前都要進行編譯,大部分情況下編譯發(fā)生在代碼執(zhí)行前的幾微秒。javascript 編譯器首先會對 var a=2;這段程序進行編譯,然后做好執(zhí)行它的準備,并且通常馬上就會執(zhí)行它
-
-
執(zhí)行
簡而言之,編譯過程就是編譯器把程序分解成詞法單元(token),然后把詞法單元解析成語法樹(AST),再把語法樹變成機器指令等待執(zhí)行的過程
實際上,代碼進行編譯,還要執(zhí)行。下面仍然以 var a = 2;為例,深入說明編譯和執(zhí)行過程
-
編譯
編譯器查找作用域是否已經(jīng)有一個名稱為 a 的變量存在于同一個作用域的集合中。如果是,編譯器會忽略該聲明,繼續(xù)進行編譯;否則它會要求作用域在當前作用域的集合中聲明一個新的變量,并命名為 a
編譯器將 var a = 2;這個代碼片段編譯成用于執(zhí)行的機器指令
依據(jù)編譯器的編譯原理,javascript 中的重復(fù)聲明是合法的
// test在作用域中首次出現(xiàn),所以聲明新變量,并將20賦值給test var test = 20 // test在作用域中已經(jīng)存在,直接使用,將20的賦值替換成30 var test = 30 -
執(zhí)行
引擎運行時會首先查詢作用域,在當前的作用域集合中是否存在一個叫作 a 的變量。如果是,引擎就會使用這個變量;如果否,引擎會繼續(xù)查找該變量
如果引擎最終找到了變量 a,就會將 2 賦值給它。否則引擎會拋出一個異常
-
-
查詢
在引擎執(zhí)行的第一步操作中,對變量 a 進行了查詢,這種查詢叫做 LHS 查詢。實際上,引擎查詢共分為兩種:LHS 查詢和 RHS 查詢
從字面意思去理解,當變量出現(xiàn)在賦值操作的左側(cè)時進行 LHS 查詢,出現(xiàn)在右側(cè)時進行 RHS 查詢
更準確地講,RHS 查詢與簡單地查找某個變量的值沒什么區(qū)別,而 LHS 查詢則是試圖找到變量的容器本身,從而可以對其賦值
function foo(a) { console.log(a) // 2 } foo(2)這段代碼中,總共包括 4 個查詢,分別是:
1、foo(...)對 foo 進行了 RHS 引用
2、函數(shù)傳參 a = 2 對 a 進行了 LHS 引用
3、console.log(...)對 console 對象進行了 RHS 引用,并檢查其是否有一個 log 的方法
4、console.log(a)對 a 進行了 RHS 引用,并把得到的值傳給了 console.log(...)
-
嵌套
在當前作用域中無法找到某個變量時,引擎就會在外層嵌套的作用域中繼續(xù)查找,直到找到該變量,或抵達最外層的作用域(也就是全局作用域)為止
function foo(a) { console.log(a + b) } var b = 2 foo(2) // 4行 RHS 引用,沒有找到;接著,引擎在全局作用域中查找 b,成功找到后,對其進行 RHS 引用,將 2 賦值給 b
-
異常
為什么區(qū)分 LHS 和 RHS 是一件重要的事情?因為在變量還沒有聲明(在任何作用域中都無法找到變量)的情況下,這兩種查詢的行為不一樣
-
RHS
- 如果 RHS 查詢失敗,引擎會拋出 ReferenceError(引用錯誤)異常
// 對b進行RHS查詢時,無法找到該變量。也就是說,這是一個“未聲明”的變量 function foo(a) { a = b } foo() // ReferenceError: b is not defined- 如果 RHS 查詢找到了一個變量,但嘗試對變量的值進行不合理操作,比如對一個非函數(shù)類型值進行函數(shù)調(diào)用,或者引用 null 或 undefined 中的屬性,引擎會拋出另外一種類型異常:TypeError(類型錯誤)異常
function foo() { var b = 0 b() } foo() // TypeError: b is not a function -
LHS
- 當引擎執(zhí)行 LHS 查詢時,如果無法找到變量,全局作用域會創(chuàng)建一個具有該名稱的變量,并將其返還給引擎
function foo() { a = 1 } foo() console.log(a) // 1- 如果在嚴格模式中 LHS 查詢失敗時,并不會創(chuàng)建并返回一個全局變量,引擎會拋出同 RHS 查詢失敗時類似的 ReferenceError 異常
function foo() { 'use strict' a = 1 } foo() console.log(a) // ReferenceError: a is not defined
-
-
原理
function foo(a) { console.log(a) } foo(2)以上面這個代碼片段來說明作用域的內(nèi)部原理,分為以下幾步:
【1】引擎需要為 foo(...)函數(shù)進行 RHS 引用,在全局作用域中查找 foo。成功找到并執(zhí)行
【2】引擎需要進行 foo 函數(shù)的傳參 a=2,為 a 進行 LHS 引用,在 foo 函數(shù)作用域中查找 a。成功找到,并把 2 賦值給 a
【3】引擎需要執(zhí)行 console.log(...),為 console 對象進行 RHS 引用,在 foo 函數(shù)作用域中查找 console 對象。由于 console 是個內(nèi)置對象,被成功找到
【4】引擎在 console 對象中查找 log(...)方法,成功找到
【5】引擎需要執(zhí)行 console.log(a),對 a 進行 RHS 引用,在 foo 函數(shù)作用域中查找 a,成功找到并執(zhí)行
【6】于是,引擎把 a 的值,也就是 2 傳到 console.log(...)中
【7】最終,控制臺輸出 2
二、理解詞法作用域和動態(tài)作用域
詞法作用域
編譯器的第一個工作階段叫作分詞,就是把由字符組成的字符串分解成詞法單元。這個概念是理解詞法作用域的基礎(chǔ)
簡單地說,詞法作用域就是定義在詞法階段的作用域,是由寫代碼時將變量和塊作用域?qū)懺谀睦飦頉Q定的,因此當詞法分析器處理代碼時會保持作用域不變
- 關(guān)系
無論函數(shù)在哪里被調(diào)用,也無論它如何被調(diào)用,它的詞法作用域都只由函數(shù)被聲明時所處的位置決定
function foo(a) {
var b = a * 2
function bar(c) {
console.log(a, b, c)
}
bar(b * 3)
}
foo(2) // 2 4 12
在這個例子中有三個逐級嵌套的作用域。為了幫助理解,可以將它們想象成幾個逐級包含的氣泡

作用域氣泡由其對應(yīng)的作用域塊代碼寫在哪里決定,它們是逐級包含的
氣泡 1 包含著整個全局作用域,其中只有一個標識符:foo
氣泡 2 包含著 foo 所創(chuàng)建的作用域,其中有三個標識符:a、bar 和 b
氣泡 3 包含著 bar 所創(chuàng)建的作用域,其中只有一個標識符:c
- 查找
作用域氣泡的結(jié)構(gòu)和互相之間的位置關(guān)系給引擎提供了足夠的位置信息,引擎用這些信息來查找標識符的位置
在代碼片段中,引擎執(zhí)行 console.log(...)聲明,并查找 a、b 和 c 三個變量的引用。它首先從最內(nèi)部的作用域,也就是 bar(...)函數(shù)的作用域開始查找。引擎無法在這里找到 a,因此會去上一級到所嵌套的 foo(...)的作用域中繼續(xù)查找。在這里找到了 a,因此引擎使用了這個引用。對 b 來講也一樣。而對 c 來說,引擎在 bar(...)中找到了它
[注意]詞法作用域查找只會查找一級標識符,如果代碼引用了 foo.bar.baz,詞法作用域查找只會試圖查找 foo 標識符,找到這個變量后,對象屬性訪問規(guī)則分別接管對 bar 和 baz 屬性的訪問
foo = {
bar: {
baz: 1
}
}
console.log(foo.bar.baz) // 1
- 遮蔽
作用域查找從運行時所處的最內(nèi)部作用域開始,逐級向外或者說向上進行,直到遇見第一個匹配的標識符為止
在多層的嵌套作用域中可以定義同名的標識符,這叫作“遮蔽效應(yīng)”,內(nèi)部的標識符“遮蔽”了外部的標識符
var a = 0
function test() {
var a = 1
console.log(a) // 1
}
test()
全局變量會自動為全局對象的屬性,因此可以不直接通過全局對象的詞法名稱,而是間接地通過對全局對象屬性的引用來對其進行訪問
var a = 0
function test() {
var a = 1
console.log(window.a) //0
}
test()
通過這種技術(shù)可以訪問那些被同名變量所遮蔽的全局變量。但非全局的變量如果被遮蔽了,無論如何都無法被訪問到
動態(tài)作用域
javascript 使用的是詞法作用域,它最重要的特征是它的定義過程發(fā)生在代碼的書寫階段
那為什么要介紹動態(tài)作用域呢?實際上動態(tài)作用域是 javascript 另一個重要機制 this 的表親。作用域混亂多數(shù)是因為詞法作用域和 this 機制相混淆,傻傻分不清楚
動態(tài)作用域并不關(guān)心函數(shù)和作用域是如何聲明以及在任何處聲明的,只關(guān)心它們從何處調(diào)用。換句話說,作用域鏈是基于調(diào)用棧的,而不是代碼中的作用域嵌套
var a = 2
function foo() {
console.log(a)
}
function bar() {
var a = 3
foo()
}
bar()
【1】如果處于詞法作用域,也就是現(xiàn)在的 javascript 環(huán)境。變量 a 首先在 foo()函數(shù)中查找,沒有找到。于是順著作用域鏈到全局作用域中查找,找到并賦值為 2。所以控制臺輸出 2
【2】如果處于動態(tài)作用域,同樣地,變量 a 首先在 foo()中查找,沒有找到。這里會順著調(diào)用棧在調(diào)用 foo()函數(shù)的地方,也就是 bar()函數(shù)中查找,找到并賦值為 3。所以控制臺輸出 3
兩種作用域的區(qū)別,簡而言之,詞法作用域是在定義時確定的,而動態(tài)作用域是在運行時確定的
三、理解 JavaScript 的執(zhí)行上下文棧,可以應(yīng)用堆棧信息快速定位問題
執(zhí)行上下文
- 全局執(zhí)行上下文: 這是默認的、最基礎(chǔ)的執(zhí)行上下文。不在任何函數(shù)中的代碼都位于全局執(zhí)行上下文中。它做了兩件事:1. 創(chuàng)建一個全局對象,在瀏覽器中這個全局對象就是 window 對象。2. 將 this 指針指向這個全局對象。一個程序中只能存在一個全局執(zhí)行上下文。
- 函數(shù)執(zhí)行上下文: 每次調(diào)用函數(shù)時,都會為該函數(shù)創(chuàng)建一個新的執(zhí)行上下文。每個函數(shù)都擁有自己的執(zhí)行上下文,但是只有在函數(shù)被調(diào)用的時候才會被創(chuàng)建。一個程序中可以存在任意數(shù)量的函數(shù)執(zhí)行上下文。每當一個新的執(zhí)行上下文被創(chuàng)建,它都會按照特定的順序執(zhí)行一系列步驟,具體過程將在本文后面討論。
- Eval 函數(shù)執(zhí)行上下文: 運行在 eval 函數(shù)中的代碼也獲得了自己的執(zhí)行上下文,但由于 Javascript 開發(fā)人員不常用 eval 函數(shù),所以在這里不再討論。
執(zhí)行棧
執(zhí)行棧,在其他編程語言中也被叫做調(diào)用棧,具有 LIFO(后進先出)結(jié)構(gòu),用于存儲在代碼執(zhí)行期間創(chuàng)建的所有執(zhí)行上下文。
當 JavaScript 引擎首次讀取你的腳本時,它會創(chuàng)建一個全局執(zhí)行上下文并將其推入當前的執(zhí)行棧。每當發(fā)生一個函數(shù)調(diào)用,引擎都會為該函數(shù)創(chuàng)建一個新的執(zhí)行上下文并將其推到當前執(zhí)行棧的頂端。
引擎會運行執(zhí)行上下文在執(zhí)行棧頂端的函數(shù),當此函數(shù)運行完成后,其對應(yīng)的執(zhí)行上下文將會從執(zhí)行棧中彈出,上下文控制權(quán)將移到當前執(zhí)行棧的下一個執(zhí)行上下文。
讓我們通過下面的代碼示例來理解這一點:
let a = 'Hello World!';
function first() {
console.log('Inside first function');
second();
console.log('Again inside first function');
}
function second() {
console.log('Inside second function');
}
first();
console.log('Inside Global Execution Context');

當上述代碼在瀏覽器中加載時,JavaScript 引擎會創(chuàng)建一個全局執(zhí)行上下文并且將它推入當前的執(zhí)行棧。當調(diào)用 first() 函數(shù)時,JavaScript 引擎為該函數(shù)創(chuàng)建了一個新的執(zhí)行上下文并將其推到當前執(zhí)行棧的頂端。
當在 first() 函數(shù)中調(diào)用 second() 函數(shù)時,Javascript 引擎為該函數(shù)創(chuàng)建了一個新的執(zhí)行上下文并將其推到當前執(zhí)行棧的頂端。當 second() 函數(shù)執(zhí)行完成后,它的執(zhí)行上下文從當前執(zhí)行棧中彈出,上下文控制權(quán)將移到當前執(zhí)行棧的下一個執(zhí)行上下文,即 first() 函數(shù)的執(zhí)行上下文。
當 first() 函數(shù)執(zhí)行完成后,它的執(zhí)行上下文從當前執(zhí)行棧中彈出,上下文控制權(quán)將移到全局執(zhí)行上下文。一旦所有代碼執(zhí)行完畢,Javascript 引擎把全局執(zhí)行上下文從執(zhí)行棧中移除。
執(zhí)行上下文是如何被創(chuàng)建的
到目前為止,我們已經(jīng)看到了 JavaScript 引擎如何管理執(zhí)行上下文,現(xiàn)在就讓我們來理解 JavaScript 引擎是如何創(chuàng)建執(zhí)行上下文的。
執(zhí)行上下文分兩個階段創(chuàng)建: 1)創(chuàng)建階段; 2)執(zhí)行階段
創(chuàng)建階段
在任意的 JavaScript 代碼被執(zhí)行前,執(zhí)行上下文處于創(chuàng)建階段。在創(chuàng)建階段中總共發(fā)生了三件事情:
- 確定 this 的值,也被稱為 This Binding 。
- LexicalEnvironment(詞法環(huán)境) 組件被創(chuàng)建。
- VariableEnvironment(變量環(huán)境) 組件被創(chuàng)建。
因此,執(zhí)行上下文可以在概念上表示如下:
ExecutionContext = {
ThisBinding = <this value>,
LexicalEnvironment = { ... },
VariableEnvironment = { ... },
}
This Binding:
在全局執(zhí)行上下文中, this 的值指向全局對象,在瀏覽器中, this 的值指向 window 對象。
在函數(shù)執(zhí)行上下文中, this 的值取決于函數(shù)的調(diào)用方式。如果它被一個對象引用調(diào)用,那么 this 的值被設(shè)置為該對象,否則 this 的值被設(shè)置為全局對象或 undefined (嚴格模式下)。例如:
let person = {
name: 'peter',
birthYear: 1994,
calcAge: function() {
console.log(2018 - this.birthYear);
}
}
person.calcAge();
// 'this' 指向 'person', 因為 'calcAge' 是被 'person' 對象引用調(diào)用的。
let calculateAge = person.calcAge;
calculateAge();
// 'this' 指向全局 window 對象,因為沒有給出任何對象引用
詞法環(huán)境(Lexical Environment)
官方 ES6 文檔將詞法環(huán)境定義為:
詞法環(huán)境是一種規(guī)范類型,基于 ECMAScript 代碼的詞法嵌套結(jié)構(gòu)來定義標識符與特定變量和函數(shù)的關(guān)聯(lián)關(guān)系。詞法環(huán)境由環(huán)境記錄(environment record)和可能為空引用(null)的外部詞法環(huán)境組成。
簡而言之,詞法環(huán)境是一個包含 標識符變量映射 的結(jié)構(gòu)。(這里的 標識符 表示變量/函數(shù)的名稱, 變量 是對實際對象【包括函數(shù)類型對象】或原始值的引用)
在詞法環(huán)境中,有兩個組成部分:(1) 環(huán)境記錄(environment record) (2) 對外部環(huán)境的引用
- 環(huán)境記錄 是存儲變量和函數(shù)聲明的實際位置。
- 對外部環(huán)境的引用 意味著它可以訪問其外部詞法環(huán)境。
詞法環(huán)境有兩種類型:
- 全局環(huán)境(在全局執(zhí)行上下文中)是一個沒有外部環(huán)境的詞法環(huán)境。全局環(huán)境的外部環(huán)境引用為 null 。它擁有一個全局對象(window 對象)及其關(guān)聯(lián)的方法和屬性(例如數(shù)組方法)以及任何用戶自定義的全局變量,
this的值指向這個全局對象。 - 函數(shù)環(huán)境,用戶在函數(shù)中定義的變量被存儲在 環(huán)境記錄 中。對外部環(huán)境的引用可以是全局環(huán)境,也可以是包含內(nèi)部函數(shù)的外部函數(shù)環(huán)境。
注意:對于 函數(shù)環(huán)境 而言, 環(huán)境記錄 還包含了一個 arguments 對象,該對象包含了索引和傳遞給函數(shù)的參數(shù)之間的映射以及傳遞給函數(shù)的參數(shù)的 長度(數(shù)量) 。例如,下面函數(shù)的 arguments 對象如下所示:
function foo(a, b) {
var c = a + b;
}
foo(2, 3);
// arguments 對象
Arguments: {0: 2, 1: 3, length: 2},
環(huán)境記錄同樣有兩種類型(如下所示):
- 聲明性環(huán)境記錄 存儲變量、函數(shù)和參數(shù)。一個函數(shù)環(huán)境包含聲明性環(huán)境記錄。
- 對象環(huán)境記錄 用于定義在全局執(zhí)行上下文中出現(xiàn)的變量和函數(shù)的關(guān)聯(lián)。全局環(huán)境包含對象環(huán)境記錄。
抽象地說,詞法環(huán)境在偽代碼中看起來像這樣:
GlobalExectionContext = {
LexicalEnvironment: {
EnvironmentRecord: {
Type: "Object",
// 標識符綁定在這里
outer: <null>
}
}
}
FunctionExectionContext = {
LexicalEnvironment: {
EnvironmentRecord: {
Type: "Declarative",
// 標識符綁定在這里
outer: <Global or outer function environment reference>
}
}
}
變量環(huán)境:
它也是一個詞法環(huán)境,其 EnvironmentRecord 包含了由 VariableStatements 在此執(zhí)行上下文創(chuàng)建的綁定。
如上所述,變量環(huán)境也是一個詞法環(huán)境,因此它具有上面定義的詞法環(huán)境的所有屬性。
在 ES6 中, LexicalEnvironment 組件和 VariableEnvironment 組件的區(qū)別在于前者用于存儲函數(shù)聲明和變量( let 和 const )綁定,而后者僅用于存儲變量( var )綁定。
讓我們結(jié)合一些代碼示例來理解上述概念:
let a = 20;
const b = 30;
var c;
function multiply(e, f) {
var g = 20;
return e *f *g;
}
c = multiply(20, 30);
執(zhí)行上下文如下所示:
GlobalExectionContext = {
ThisBinding: <Global Object>,
LexicalEnvironment: {
EnvironmentRecord: {
Type: "Object",
// 標識符綁定在這里
a: < uninitialized >,
b: < uninitialized >,
multiply: < func >
}
outer: <null>
},
VariableEnvironment: {
EnvironmentRecord: {
Type: "Object",
// 標識符綁定在這里
c: undefined,
}
outer: <null>
}
}
FunctionExectionContext = {
ThisBinding: <Global Object>,
LexicalEnvironment: {
EnvironmentRecord: {
Type: "Declarative",
// 標識符綁定在這里
Arguments: {0: 20, 1: 30, length: 2},
},
outer: <GlobalLexicalEnvironment>
},
VariableEnvironment: {
EnvironmentRecord: {
Type: "Declarative",
// 標識符綁定在這里
g: undefined
},
outer: <GlobalLexicalEnvironment>
}
}
注意:只有在遇到函數(shù) multiply 的調(diào)用時才會創(chuàng)建函數(shù)執(zhí)行上下文。
你可能已經(jīng)注意到了 let 和 const 定義的變量沒有任何與之關(guān)聯(lián)的值,但 var 定義的變量設(shè)置為 undefined 。
這是因為在創(chuàng)建階段,代碼會被掃描并解析變量和函數(shù)聲明,其中函數(shù)聲明存儲在環(huán)境中,而變量會被設(shè)置為 undefined (在 var 的情況下)或保持未初始化(在 let 和 const 的情況下)。
這就是為什么你可以在聲明之前訪問 var 定義的變量(盡管是 undefined ),但如果在聲明之前訪問 let 和 const 定義的變量就會提示引用錯誤的原因。
這就是我們所謂的變量提升。
執(zhí)行階段
這是整篇文章中最簡單的部分。在此階段,完成對所有變量的分配,最后執(zhí)行代碼。
注:在執(zhí)行階段,如果 Javascript 引擎在源代碼中聲明的實際位置找不到 let 變量的值,那么將為其分配 undefined 值。
錯誤堆棧的裁剪
Node.js 才支持這個特性,通過 Error.captureStackTrace 來實現(xiàn),Error.captureStackTrace 接收一個 object 作為第 1 個參數(shù),以及可選的 function 作為第 2 個參數(shù)。其作用是捕獲當前的調(diào)用棧并對其進行裁剪,捕獲到的調(diào)用棧會記錄在第 1 個參數(shù)的 stack 屬性上,裁剪的參照點是第 2 個參數(shù),也就是說,此函數(shù)之前的調(diào)用會被記錄到調(diào)用棧上面,而之后的不會。
讓我們用代碼來說明,首先,把當前的調(diào)用棧捕獲并放到 myObj 上:
const myObj = {};
function c() {}
function b() {
// 把當前調(diào)用棧寫到 myObj 上
Error.captureStackTrace(myObj);
c();
}
function a() {
b();
}
// 調(diào)用函數(shù) a
a();
// 打印 myObj.stack
console.log(myObj.stack);
// 輸出會是這樣
// at b (repl:3:7) <-- Since it was called inside B, the B call is the last entry in the stack
// at a (repl:2:1)
// at repl:1:1 <-- Node internals below this line
// at realRunInThisContextScript (vm.js:22:35)
// at sigintHandlersWrap (vm.js:98:12)
// at ContextifyScript.Script.runInThisContext (vm.js:24:12)
// at REPLServer.defaultEval (repl.js:313:29)
// at bound (domain.js:280:14)
// at REPLServer.runBound [as eval] (domain.js:293:12)
// at REPLServer.onLine (repl.js:513:10)
上面的調(diào)用棧中只有 a -> b,因為我們在 b 調(diào)用 c 之前就捕獲了調(diào)用?!,F(xiàn)在對上面的代碼稍作修改,然后看看會發(fā)生什么:
const myObj = {};
function d() {
// 我們把當前調(diào)用棧存儲到 myObj 上,但是會去掉 b 和 b 之后的部分
Error.captureStackTrace(myObj, b);
}
function c() {
d();
}
function b() {
c();
}
function a() {
b();
}
// 執(zhí)行代碼
a();
// 打印 myObj.stack
console.log(myObj.stack);
// 輸出如下
// at a (repl:2:1) <-- As you can see here we only get frames before b was called
// at repl:1:1 <-- Node internals below this line
// at realRunInThisContextScript (vm.js:22:35)
// at sigintHandlersWrap (vm.js:98:12)
// at ContextifyScript.Script.runInThisContext (vm.js:24:12)
// at REPLServer.defaultEval (repl.js:313:29)
// at bound (domain.js:280:14)
// at REPLServer.runBound [as eval] (domain.js:293:12)
// at REPLServer.onLine (repl.js:513:10)
// at emitOne (events.js:101:20)
在這段代碼里面,因為我們在調(diào)用 Error.captureStackTrace 的時候傳入了 b,這樣 b 之后的調(diào)用棧都會被隱藏。
現(xiàn)在你可能會問,知道這些到底有啥用?如果你想對用戶隱藏跟他業(yè)務(wù)無關(guān)的錯誤堆棧(比如某個庫的內(nèi)部實現(xiàn))就可以試用這個技巧。
錯誤調(diào)試
1.Error對象和錯誤處理
當程序運行出現(xiàn)錯誤時, 通常會拋出一個 Error 對象. Error 對象可以作為用戶自定義錯誤對象繼承的原型.
Error.prototype 對象包含如下屬性:
constructor–指向?qū)嵗臉?gòu)造函數(shù)
message–錯誤信息
name–錯誤的名字(類型)
上述是 Error.prototype 的標準屬性, 此外, 不同的運行環(huán)境都有其特定的屬性. 在例如 Node, Firefox, Chrome, Edge, IE 10+, Opera 以及 Safari 6+
這樣的環(huán)境中, Error 對象具備 stack 屬性, 該屬性包含了錯誤的堆棧軌跡. 一個錯誤實例的堆棧軌跡包含了自構(gòu)造函數(shù)之后的所有堆棧結(jié)構(gòu).
2.如何查看調(diào)用棧
只查看調(diào)用棧:console.trace
a()
function a() {
b()
}
function b() {
c()
}
function c() {
let aa = 1
}
console.trace()
3.debugger打斷點形式
四、this 的原理以及幾種不同使用場景的取值
作為對象方法調(diào)用
在 JavaScript 中,函數(shù)也是對象,因此函數(shù)可以作為一個對象的屬性,此時該函數(shù)被稱為該對象的方法,在使用這種調(diào)用方式時,this 被自然綁定到該對象
var test = {
a:0,
b:0,
get:function(){
return this.a;
}
}
作為函數(shù)調(diào)用
函數(shù)也可以直接被調(diào)用,此時 this 綁定到全局對象。在瀏覽器中,window 就是該全局對象。比如下面的例子:函數(shù)被調(diào)用時,this 被綁定到全局對象,
接下來執(zhí)行賦值語句,相當于隱式的聲明了一個全局變量,這顯然不是調(diào)用者希望的。
function makeNoSense(x) {
this.x = x;
}
作為構(gòu)造函數(shù)調(diào)用
javaScript 支持面向?qū)ο笫骄幊?,與主流的面向?qū)ο笫骄幊陶Z言不同,JavaScript 并沒有類(class)的概念,而是使用基于原型(prototype)的繼承方式。
相應(yīng)的,JavaScript 中的構(gòu)造函數(shù)也很特殊,如果不使用 new 調(diào)用,則和普通函數(shù)一樣。作為又一項約定俗成的準則,構(gòu)造函數(shù)以大寫字母開頭,
提醒調(diào)用者使用正確的方式調(diào)用。如果調(diào)用正確,this 綁定到新創(chuàng)建的對象上。
function Point(x, y){
this.x = x;
this.y = y;
}
在call或者apply,bind中調(diào)用
讓我們再一次重申,在 JavaScript 中函數(shù)也是對象,對象則有方法,apply 和 call 就是函數(shù)對象的方法。
這兩個方法異常強大,他們允許切換函數(shù)執(zhí)行的上下文環(huán)境(context),即 this 綁定的對象。
很多 JavaScript 中的技巧以及類庫都用到了該方法。讓我們看一個具體的例子:
function Point(x, y){
this.x = x;
this.y = y;
this.moveTo = function(x, y){
this.x = x;
this.y = y;
}
}
var p1 = new Point(0, 0);
var p2 = {x: 0, y: 0};
p1.moveTo(1, 1);
p1.moveTo.apply(p2, [10, 10])
五、閉包的實現(xiàn)原理和作用,可以列舉幾個開發(fā)中閉包的實際應(yīng)用
閉包的概念
- 指有權(quán)訪問另一個函數(shù)作用域中的變量的函數(shù),一般情況就是在一個函數(shù)中包含另一個函數(shù)。
閉包的作用
- 訪問函數(shù)內(nèi)部變量、保持函數(shù)在環(huán)境中一直存在,不會被垃圾回收機制處理
因為函數(shù)內(nèi)部聲明 的變量是局部的,只能在函數(shù)內(nèi)部訪問到,但是函數(shù)外部的變量是對函數(shù)內(nèi)部可見的,這就是作用域鏈的特點了。
子級可以向父級查找變量,逐級查找,找到為止
因此我們可以在函數(shù)內(nèi)部再創(chuàng)建一個函數(shù),這樣對內(nèi)部的函數(shù)來說,外層函數(shù)的變量都是可見的,然后我們就可以訪問到他的變量了。
function bar(){
//外層函數(shù)聲明的變量
var value=1;
function foo(){
console.log(value);
}
return foo();
};
var bar2=bar;
//實際上bar()函數(shù)并沒有因為執(zhí)行完就被垃圾回收機制處理掉
//這就是閉包的作用,調(diào)用bar()函數(shù),就會執(zhí)行里面的foo函數(shù),foo這時就會訪問到外層的變量
bar2();
foo()包含bar()內(nèi)部作用域的閉包,使得該作用域能夠一直存活,不會被垃圾回收機制處理掉,這就是閉包的作用,以供foo()在任何時間進行引用。
閉包的優(yōu)點
- 方便調(diào)用上下文中聲明的局部變量
- 邏輯緊密,可以在一個函數(shù)中再創(chuàng)建個函數(shù),避免了傳參的問題
閉包的缺點
- 因為使用閉包,可以使函數(shù)在執(zhí)行完后不被銷毀,保留在內(nèi)存中,如果大量使用閉包就會造成內(nèi)存泄露,內(nèi)存消耗很大
閉包在實際中的應(yīng)用
function addFn(a,b){
return(function(){
console.log(a+"+"+b);
})
}
var test =addFn(a,b);
setTimeout(test,3000);
一般setTimeout的第一個參數(shù)是個函數(shù),但是不能傳值。如果想傳值進去,可以調(diào)用一個函數(shù)返回一個內(nèi)部函數(shù)的調(diào)用,將內(nèi)部函數(shù)的調(diào)用傳給setTimeout。內(nèi)部函數(shù)執(zhí)行所需的參數(shù),外部函數(shù)傳給他,在setTimeout函數(shù)中也可以訪問到外部函數(shù)。
六、理解堆棧溢出和內(nèi)存泄漏的原理,如何防止
內(nèi)存泄露
- 申請的內(nèi)存執(zhí)行完后沒有及時的清理或者銷毀,占用空閑內(nèi)存,內(nèi)存泄露過多的話,就會導(dǎo)致后面的程序申請不到內(nèi)存。因此內(nèi)存泄露會導(dǎo)致內(nèi)部內(nèi)存溢出
堆棧溢出
- 內(nèi)存空間已經(jīng)被申請完,沒有足夠的內(nèi)存提供了
標記清除法
在一些編程軟件中,比如c語言中,需要使用malloc來申請內(nèi)存空間,再使用free釋放掉,需要手動清除。而js中是有自己的垃圾回收機制的,一般常用的垃圾收集方法就是標記清除。
標記清除法:在一個變量進入執(zhí)行環(huán)境后就給它添加一個標記:進入環(huán)境,進入環(huán)境的變量不會被釋放,因為只要執(zhí)行流進入響應(yīng)的環(huán)境,就可能用到他們。當變量離開環(huán)境后,則將其標記為“離開環(huán)境”。
常見的內(nèi)存泄露的原因
- 全局變量引起的內(nèi)存泄露
- 閉包
- 沒有被清除的計時器
解決方法
- 減少不必要的全局變量
- 減少閉包的使用(因為閉包會導(dǎo)致內(nèi)存泄露)
- 避免死循環(huán)的發(fā)生
七、如何處理循環(huán)的異步操作
使用自執(zhí)行函數(shù)
1、當自執(zhí)行函數(shù)在循環(huán)當中使用時,自執(zhí)行函數(shù)會在循環(huán)結(jié)束之后才會運行。比如你在自執(zhí)行函數(shù)外面定義一個數(shù)組,在自執(zhí)行函數(shù)當中給這個數(shù)組追加內(nèi)容,你在自執(zhí)行函數(shù)之外輸出時,會發(fā)現(xiàn)這個數(shù)組當中什么都沒有,這就是因為自執(zhí)行函數(shù)會在循環(huán)運行完后才會執(zhí)行。
2、當自執(zhí)行函數(shù)在循環(huán)當中使用時,要是自執(zhí)行函數(shù)當中嵌套ajax,那么循環(huán)當中的下標i就不會傳進ajax當中,需要在ajax外面把下標i賦值給一個變量,在ajax中直接調(diào)用這個變量就可以了。
例子:
$.ajax({
type: "GET",
dataType: "json",
url: "***",
success: function(data) {
//console.log(data);
for (var i = 0; i < data.length; i++) {
(function(i, abbreviation) {
$.ajax({
type: "GET",
url: "/api/faults?abbreviation=" + encodeURI(abbreviation),
dataType: "json",
success: function(result) {
//獲取數(shù)據(jù)后做的事情
}
})
})(i, data[i].abbreviation);
}
}
});
使用遞歸函數(shù)
所謂的遞歸函數(shù)就是在函數(shù)體內(nèi)調(diào)用本函數(shù)。使用遞歸函數(shù)一定要注意,處理不當就會進入死循環(huán)。
const asyncDeal = (i) = > {
if (i < 3) {
$.get('/api/changeParts/change_part_standard?part=' + data[i].change_part_name, function(res) {
//獲取數(shù)據(jù)后做的事情
i++;
asyncDeal(i);
})
} else {
//異步完成后做的事情
}
};
asyncDeal(0);
使用async/await
- async/await特點
async/await更加語義化,async 是“異步”的簡寫,async function 用于申明一個 function 是異步的; await,可以認為是async wait的簡寫, 用于等待一個異步方法執(zhí)行完成;
async/await是一個用同步思維解決異步問題的方案(等結(jié)果出來之后,代碼才會繼續(xù)往下執(zhí)行)
可以通過多層 async function 的同步寫法代替?zhèn)鹘y(tǒng)的callback嵌套
- async function語法
自動將常規(guī)函數(shù)轉(zhuǎn)換成Promise,返回值也是一個Promise對象
只有async函數(shù)內(nèi)部的異步操作執(zhí)行完,才會執(zhí)行then方法指定的回調(diào)函數(shù)
異步函數(shù)內(nèi)部可以使用await
- await語法
await 放置在Promise調(diào)用之前,await 強制后面點代碼等待,直到Promise對象resolve,得到resolve的值作為await表達式的運算結(jié)果
await只能在async函數(shù)內(nèi)部使用,用在普通函數(shù)里就會報錯
const asyncFunc = function(i) {
return new Promise(function(resolve) {
$.get(url, function(res) {
resolve(res);
})
});
}
const asyncDeal = async function() {
for (let i = 0; i < data.length; i++) {
let res = await asyncFunc(i);
//獲取數(shù)據(jù)后做的事情
}
}
asyncDeal();
八、理解模塊化解決的實際問題,可列舉幾個模塊化方案并理解其中原理
CommonJS規(guī)范(同步加載模塊)
允許模塊通過require方法來同步加載所要依賴的其他模塊,然后通過exports或module.exports來導(dǎo)出需要暴露的接口。
使用方式:
// 導(dǎo)入
require("module");
require("../app.js");
// 導(dǎo)出
exports.getStoreInfo = function() {};
module.exports = someValue;
優(yōu)點:
- 簡單容易使用
- 服務(wù)器端模塊便于復(fù)用
缺點:
- 同步加載方式不適合在瀏覽器環(huán)境中使用,同步意味著阻塞加載,瀏覽器資源是異步加載的
- 不能非阻塞的并行加載多個模塊
為什么瀏覽器不能使用同步加載,服務(wù)端可以?
- 因為模塊都放在服務(wù)器端,對于服務(wù)端來說模塊加載時
- 而對于瀏覽器端,因為模塊都放在服務(wù)器端,加載的時間還取決于網(wǎng)速的快慢等因素,如果需要等很長時間,整個應(yīng)用就會被阻塞。
- 因此,瀏覽器端的模塊,不能采用"同步加載"(CommonJs),只能采用"異步加載"(AMD)。
參照CommonJs模塊代表node.js的模塊系統(tǒng)
AMD(異步加載模塊)
采用異步方式加載模塊,模塊的加載不影響后面語句的運行。所有依賴模塊的語句,都定義在一個回調(diào)函數(shù)中,等到加載完成之后,回調(diào)函數(shù)才執(zhí)行。
使用實例:
// 定義
define("module", ["dep1", "dep2"], function(d1, d2) {...});
// 加載模塊
require(["module", "../app"], function(module, app) {...});
加載模塊require([module], callback);第一個參數(shù)[module],是一個數(shù)組,里面的成員就是要加載的模塊;第二個參數(shù)callback是加載成功之后的回調(diào)函數(shù)。
優(yōu)點:
- 適合在瀏覽器環(huán)境中異步加載模塊
- 可以并行加載多個模塊
缺點:
- 提高了開發(fā)成本,代碼的閱讀和書寫比較困難,模塊定義方式的語義不順暢
- 不符合通用的模塊化思維方式,是一種妥協(xié)的實現(xiàn)
實現(xiàn)AMD規(guī)范代表require.js
RequireJS對模塊的態(tài)度是預(yù)執(zhí)行。由于 RequireJS 是執(zhí)行的 AMD 規(guī)范, 因此所有的依賴模塊都是先執(zhí)行;即RequireJS是預(yù)先把依賴的模塊執(zhí)行,相當于把require提前了
RequireJS執(zhí)行流程:
- require函數(shù)檢查依賴的模塊,根據(jù)配置文件,獲取js文件的實際路徑
- 根據(jù)js文件實際路徑,在dom中插入script節(jié)點,并綁定onload事件來獲取該模塊加載完成的通知。
- 依賴script全部加載完成后,調(diào)用回調(diào)函數(shù)
CMD規(guī)范(異步加載模塊)
CMD規(guī)范和AMD很相似,簡單,并與CommonJS和Node.js的 Modules 規(guī)范保持了很大的兼容性;在CMD規(guī)范中,一個模塊就是一個文件。
定義模塊使用全局函數(shù)define,其接收 factory 參數(shù),factory 可以是一個函數(shù),也可以是一個對象或字符串;
factory 是一個函數(shù),有三個參數(shù),function(require, exports, module):
- require 是一個方法,接受模塊標識作為唯一參數(shù),用來獲取其他模塊提供的接口:require(id)
- exports 是一個對象,用來向外提供模塊接口
- module 是一個對象,上面存儲了與當前模塊相關(guān)聯(lián)的一些屬性和方法
實例:
define(function(require, exports, module) {
var a = require('./a');
a.doSomething();
// 依賴就近書寫,什么時候用到什么時候引入
var b = require('./b');
b.doSomething();
});
優(yōu)點:
- 依賴就近,延遲執(zhí)行
- 可以很容易在 Node.js 中運行
缺點:
- 依賴 SPM 打包,模塊的加載邏輯偏重
- 實現(xiàn)代表庫sea.js:SeaJS對模塊的態(tài)度是懶執(zhí)行, SeaJS只會在真正需要使用(依賴)模塊時才執(zhí)行該模塊
AMD 與 CMD 的區(qū)別
- 對于依賴的模塊,AMD 是提前執(zhí)行,CMD 是延遲執(zhí)行。不過 RequireJS 從2.0開始,也改成了可以延遲執(zhí)行(根據(jù)寫法不同,處理方式不同)。CMD 推崇 as lazy as possible.
- AMD推崇依賴前置;CMD推崇依賴就近,只有在用到某個模塊的時候再去require。
// AMD
define(['./a', './b'], function(a, b) { // 依賴必須一開始就寫好
a.doSomething()
// 此處略去 100 行
b.doSomething()
...
});
// CMD
define(function(require, exports, module) {
var a = require('./a')
a.doSomething()
// 此處略去 100 行
var b = require('./b')
// 依賴可以就近書寫
b.doSomething()
// ...
});
UMD
- UMD是AMD和CommonJS的糅合
- AMD 以瀏覽器第一原則發(fā)展異步加載模塊。
- CommonJS 模塊以服務(wù)器第一原則發(fā)展,選擇同步加載,它的模塊無需包裝。
- UMD先判斷是否支持Node.js的模塊(exports)是否存在,存在則使用Node.js模塊模式;在判斷是否支持AMD(define是否存在),存在則使用AMD方式加載模塊。
(function (window, factory) {
if (typeof exports === 'object') {
module.exports = factory();
} else if (typeof define === 'function' && define.amd) {
define(factory);
} else {
window.eventUtil = factory();
}
})(this, function () {
//module ...
});
ES6模塊化
- ES6 在語言標準的層面上,實現(xiàn)了模塊功能,而且實現(xiàn)得相當簡單,完全可以取代 CommonJS 和 AMD 規(guī)范,成為瀏覽器和服務(wù)器通用的模塊解決方案。
- ES6 模塊設(shè)計思想:盡量的靜態(tài)化、使得編譯時就能確定模塊的依賴關(guān)系,以及輸入和輸出的變量(CommonJS和AMD模塊,都只能在運行時確定這些東西)。
使用方式:
// 導(dǎo)入
import "/app";
import React from “react”;
import { Component } from “react”;
// 導(dǎo)出
export function multiply() {...};
export var year = 2018;
export default ...
...
優(yōu)點:
- 容易進行靜態(tài)分析
- 面向未來的 EcmaScript 標準
缺點: - 原生瀏覽器端還沒有實現(xiàn)該標準
- 全新的命令字,新版的 Node.js才支持。
回到問題“require與import的區(qū)別”
require使用與CommonJs規(guī)范,import使用于Es6模塊規(guī)范;所以兩者的區(qū)別實質(zhì)是兩種規(guī)范的區(qū)別;
CommonJS:
- 對于基本數(shù)據(jù)類型,屬于復(fù)制。即會被模塊緩存;同時,在另一個模塊可以對該模塊輸出的變量重新賦值。
- 對于復(fù)雜數(shù)據(jù)類型,屬于淺拷貝。由于兩個模塊引用的對象指向同一個內(nèi)存空間,因此對該模塊的值做修改時會影響另一個模塊。
- 當使用require命令加載某個模塊時,就會運行整個模塊的代碼。
- 當使用require命令加載同一個模塊時,不會再執(zhí)行該模塊,而是取到緩存之中的值。也就是說,CommonJS模塊無論加載多少次,都只會在第一次加載時運行一次,以后再加載,就返回第一次運行的結(jié)果,除非手動清除系統(tǒng)緩存。
- 循環(huán)加載時,屬于加載時執(zhí)行。即腳本代碼在require的時候,就會全部執(zhí)行。一旦出現(xiàn)某個模塊被"循環(huán)加載",就只輸出已經(jīng)執(zhí)行的部分,還未執(zhí)行的部分不會輸出。
ES6模塊
- ES6模塊中的值屬于【動態(tài)只讀引用】。
- 對于只讀來說,即不允許修改引入變量的值,import的變量是只讀的,不論是基本數(shù)據(jù)類型還是復(fù)雜數(shù)據(jù)類型。當模塊遇到import命令時,就會生成一個只讀引用。等到腳本真正執(zhí)行時,再根據(jù)這個只讀引用,到被加載的那個模塊里面去取值。
- 對于動態(tài)來說,原始值發(fā)生變化,import加載的值也會發(fā)生變化。不論是基本數(shù)據(jù)類型還是復(fù)雜數(shù)據(jù)類型。
- 循環(huán)加載時,ES6模塊是動態(tài)引用。只要兩個模塊之間存在某個引用,代碼就能夠執(zhí)行。
最后:require/exports 是必要通用且必須的;因為事實上,目前你編寫的 import/export 最終都是編譯為 require/exports 來執(zhí)行的。