代碼解析參與者
需要了解變量是如何進行預解析的,首先要知道解析代碼的參與者,有三個:引擎、編譯器、作用域
-
編譯器
對 JavaScript 源代碼通過某種編譯原理進行解析,并生成可供引擎執行的代碼
-
作用域
負責保存變量以及提供對變量的查詢與訪問,并通過一套非常嚴格的規則,確定當前執行的代碼對這些變量的訪問權限
-
引擎
負責執行編譯器生成的代碼
現在我有如下的代碼 var a = 1;
下面我們將這段代碼分解,來看看瀏覽器是如何對這段代碼進行解析的:
-
遇到
var a
,編譯器會詢問作用域命名為a
的這個變量是是否已經有一個存在于當前作用域中如果含有,則編譯器會忽略 var a ,繼續進行編譯 如果沒有,編譯器會要求作用域在當前作用域中聲明一個新的變量,命名為 a
-
接下來,編譯器會開始生成可供引擎執行的代碼,這些代碼被用來處理
a = 2
這個賦值操作,引擎會在當前作用域中開始查找是否有a
這個變量(從當前作用域開始,層層向上查找,這就是作用域鏈)如果查找到了,就對 a 進行賦值 如果直到全局作用域都沒查找到,則會拋出異常
縮略版:變量的賦值操作會執行兩個動作,首先編譯器會在當前作用域中聲明一個變量(如果之前沒有聲明過),然后在運行時引擎會在作用域中查找該變量,如果能夠找到就會對它賦值
可以看到,步驟1是編譯器對代碼進行的處理,步驟2是引起對代碼進行的處理,步驟2才是 JavaScript 代碼真正開始執行的時刻,而步驟1則被稱為預解析
預解析
定義
在代碼開始按照順序從上到下執行之前( JavaScript 代碼運行之前),當前作用域下,會把帶有 var
和 function
關鍵字的事先聲明,并在當前作用域的內存中安排好(這也就是變量聲明提升和函數聲明提升的原因)
如果是變量,則賦值為
undefined
如果是函數聲明,則將整個函數塊放在代碼的最頂端
為何這里要說是在當前作用域呢?
因為存在函數,每聲明一個函數,就會為它自身新生成一個單獨的作用域,不通過特殊手段,外部是無法訪問到內部的變量的,這里又分為函數聲明和函數表達式兩種情況:
1. 當編譯器第一次遇到函數聲明時,只會對函數名進行預解析,并進行一次函數聲明提升,并不會對函數的內容進行處理,當引擎執行代碼到函數時,編譯器會再次開啟,對函數內的代碼進行一次新的預解析,且函數內也會存在變量聲明提升和函數聲明提升
2. 當引擎執行代碼時,遇到函數表達式然后對變量進行賦值,這里也會再次開啟編譯器,對函數內的代碼進行一次預解析,且函數內也會存在變量聲明提升和函數聲明提升
注意:其實這里使用“當前作用域”來描述不太合理,后面會講到這點
實例
介紹完了定義,那我們來看如下代碼:
實例1:變量的預解析
console.log(a); // undefined
var a = 1;
這里是對變量進行的預解析,實際上代碼的執行步驟是這樣的
/* ----- 編譯器進行的處理 ----- */
var a;
/* ----- 處理完畢,引擎開始執行整段代碼 ----- */
console.log(a);
a = 1;
實例2:變量的預解析和函數聲明的預解析
console.log(fn); // undefined
var fn = function () {
return 1;
}
console.log(fn); // 輸出函數的代碼片段
function fn () {
return 1;
}
同為函數,為什么輸出的結果不同呢?
因為一個是函數表達式賦值給變量,一個是函數聲明,這也就是定義里面說到的兩種情況,遇到了 var
關鍵字和遇到了 function
關鍵字
第一段代碼解析過程如下:
/* ----- 編譯器進行的處理 ----- */
var fn;
/* ----- 處理完畢,引擎開始執行整段代碼 ----- */
console.log(fn);
fn = function () {
return 1;
}
可以看到,是對 var fn
進行了一個處理,直接忽視了后面的代碼
第二段代碼解析過程如下:
/* ----- 編譯器進行的處理 ----- */
function fn () {
return 1;
}
/* ----- 處理完畢,引擎開始執行整段代碼 ----- */
console.log(fn);
可以看到,是將整個函數都放在了代碼的頂部,然后再去執行打印的操作
注意:說句題外話,這里打印的是函數本身,并不是函數的執行結果,所以這里輸出的是函數的代碼片段,而不是1
變量聲明提升和函數聲明提升的順序
既然這兩貨都會進行預解析,那肯定得來判斷一下它們是否存在先后順序,如果有,那誰的優先級更高呢?
首先來看看這段代碼
function fn () {
return 1;
}
console.log(fn()); // 2
function fn () {
return 2;
}
console.log(fn()); // 2
var fn = function () {
return 1;
}
console.log(fn()); // 1
var fn = function () {
return 2;
}
console.log(fn()); // 2
第一個 console 的輸出結果是不同的,原因就是變量聲明提升和函數聲明提升它們的解析機制是不同的(回顧一下上面的知識,與這里的知識無關)
第二個 console 的輸出結果都為2,由于都為函數聲明或者函數表達式,那么它們不存在預解析時的先后順序,而是從上往下,代碼依次執行時,進行的重新賦值
那么我們再來看看看看這兩段代碼
var fn = function () {
return 1;
}
function fn () {
return 2;
}
console.log(fn());
function fn () {
return 2;
}
var fn = function () {
return 1;
}
console.log(fn());
根據上面的結果,如果說函數聲明提升和變量聲明提升不存在預解析的排序,而是按照代碼執行時的執行順序來進行重新賦值的,那么這兩段代碼 console 出來的內容應該不同
如果嘗試在瀏覽器中 console 出結果,會發現結果是相同的,都為1,這說明變量聲明提升和函數聲明提升是存在先后順序的,而且函數聲明提升是在變量聲明提升之前,優先級更高
我們再來看一段誤導性很強代碼
console.log(fn); // 函數代碼片段
var fn = 1;
function fn () {
return 2;
}
我們來對代碼進行解析
/* ----- 編譯器進行的處理 ----- */
function fn () {
return 2;
}
var fn;
/* ----- 處理完畢,引擎開始執行整段代碼 ----- */
console.log(fn);
fn = 1;
很多人以為,變量聲明提升既然是在函數聲明之前,那么這段代碼的輸出結果應該為 undefined
,那么我在這里告訴你,var fn
并不會覆蓋掉原來的函數聲明,其實你可以使用另外一種方式驗證一下
var a = 1;
var a;
console.log(a); // 1
這里在對變量 a
賦值后,又重新聲明了一次,可是輸出結果還是為1
所以那段誤導性很強的代碼并不能說明變量聲明提升比函數聲明提升的優先級高,這是由 ECMAScript 制定的代碼規范,那就是函數聲明提升比變量聲明提升優先級高
遺留問題
簡單介紹完了代碼的解析模式,那么來說一下上面遺留的問題,之前在定義里面提到說使用“當前作用域”這個說法不太合理,那么請看下面的代碼
<script>
var a = 1;
</script>
<script>
console.log(a); // 1
</script>
通過這行代碼我們可以知道,實際上這兩個 <script>
標簽共享的是同一個作用域,那么看看下面這個代碼
<script>
console.log(a); // 報錯:Uncaught ReferenceError: a is not defined
</script>
<script>
var a = 1;
</script>
雖然它們都是共享的同一個作用域,但是進行代碼預解析的步驟是不同的,首先會對第一個 <script>
標簽內的代碼進行預解析,然后去執行它;執行完后才會對第二個 <script>
標簽內的代碼進行預解析,再執行
<script>
var a = 1;
</script>
<script>
console.log(a); // 1
</script>
這里可以獲得 a 的結果,說明不同 <script> 標簽,它們共享的是同一個作用域
那么如果修改一下代碼順序,又會是什么樣子呢?
<script>
console.log(a); // Uncaught ReferenceError: a is not defined
</script>
<script>
var a = 1;
</script>
雖然存在預解析,但是這里卻報出了異常,這是因為不同 <script> 標簽,它們要分別去進行預解析,在第一個script標簽執行的時候,并沒有聲明 a 這個變量,所以會導致報錯
在不同的script標簽內,代碼的執行順序是不同的,如果處于不同的script標簽內,雖
由于這兩個代碼存在不同的代碼塊,雖然他們的作用域是公用的,但是在解析上面這塊代碼時,并沒有開始進行下面一塊代碼的預解析,所以會導致報錯
再來看一段代碼
console.log(a); // Uncaught ReferenceError: a is not defined
function fn () {
a = 1;
}
fn();
剛剛也說過,如果在直接使用一個未聲明的變量,在非嚴格模式下,會導致這個變量成為一個全局變量從而污染全局作用域,那么在這里,為什么 console.log(a) 會報錯呢?
這里也存在一個預解析的問題,在script標簽進行預解析的時候,只會對fn進行一個預解析,并不會去對函數內部的變量進行解析,只有當執行函數的時候,才會對函數內部的作用域進行一次新的預解析,所以這里 a 變量沒有聲明且根本無法獲取到,所以報錯
小結
只有聲明本身會被提升,而賦值或其他運行邏輯會留在原地
每個作用域都會進行提升操作
函數聲明先被提升,然后才是變量聲明
不同 script 標簽是分開解析的
引擎的查詢方式
預解析洋洋灑灑的寫了這么多,也只是講解了 var a = 1
這段代碼里面的 var a
的機制,并沒有講解到賦值,那么接下來就來說說引擎又是如何在作用域中對變量進行查詢以及賦值的
分類
引擎查詢方式分為兩種:
RHS 查詢:查找某個變量的值
LHS 查詢:找到變量的容器本身,從而可以對其賦值
如果查找的目的是對變量進行賦值,那么就會使用 LHS 查詢;如果目的是獲取變量的值,就會使用 RHS 查詢
光說定義不太好理解,那么來看看這段代碼,從實例中理解一下引擎的查詢機制
實例
實例一
a = 2;
這里是對 a 進行了一次 LHS 查詢,因為我需要獲取到這個變量本身,然后才能對其進行賦值操作
實例二
console.log(a);
這里進行了兩次 RHS 查詢,第一次對 console
這個對象進行 RHS 查詢,獲取它身上是否有 log
這個方法,然后對 a
的值進行一次 RHS 查詢,獲取到 a
的值,從而使值在控制臺顯示
實例三
function fn (a) {
return a;
}
fn(1);
這里就是既有 LHS 查詢也有 RHS 查詢,引擎執行到 fn(1)
這里,得知要去執行一段函數,便會去找這個函數,由于需要獲取到函數的值才能執行,所以會對函數進行一次 RHS 查詢;接著得知函數內有參數,要對函數的參數賦值,所以需要進行一次 LHS 查詢,拿到函數作用域中的變量 a
然后對其賦值;最后碰到了 return a
,得知需要將 a
的值返回,那么這里還要進行一次 RHS 查詢去獲取 a
的值
這些例子也簡單的說明了上述的定義,那就是,需要獲取變量本身時,執行 LHS 查詢;需要獲取到變量的值時,執行 RHS 查詢
那么花了這么大篇幅去講解引擎的查詢機制到底對我們學習 JavaScript 有沒有幫助呢?答案是有的。理解查詢方式不僅可以讓我們去理解代碼的執行機制,也可以輕松的理解瀏覽器拋出的異常信息,下來我們就來看看
為什么區分 LHS 和 RHS 是一件重要的事情?
因為變量還沒有聲明(在任何作用域中都無法找到該變量)的情況下,這兩種查詢的行為是不一樣的。不一樣的行為會帶來不同的結果,出現錯誤時瀏覽器的報錯信息也會不同,理解這個對于理解瀏覽器的報錯會有很大的幫助
ReferenceError異常
RHS 查詢實例
考慮如下代碼
function fn (a) {
console.log( a + b ); // Uncaught ReferenceError: b is not defined
b = a;
}
fn( 2 );
執行函數時,需要輸出 a + b
的值,所以需要對 b
進行一次 RHS 查詢,可是一層層的往上級查找,直到全局作用域都找不到這個變量,此時引擎就會拋出 ReferenceError
異常
LHS 查詢實例
上面是有關于 RHS 查詢的,那么再看看關于 LHS 查詢的這段代碼
function fn () {
a = 1;
}
fn();
console.log(a); // 1
function fn () {
"use strict";
a = 1;
}
fn();
console.log(a); // Uncaught ReferenceError: a is not defined
可以看到,函數內的變量 a
沒有使用 var
來聲明,是直接進行使用的,當在執行 console.log
的時候,會對 a
進行一次 LHS 查詢。在非嚴格模式下,LHS 查詢會逐級向上查找,找到全局作用域時就會停止查找,如果沒有找到該變量,則會自動在全局作用域聲明一個這個變量(這也是為什么不使用 var,直接聲明變量會導致該變量污染全局作用域的原理);在嚴格模式下,LHS 查詢會逐級向上查找,找到全局作用域時就會停止查找,如果沒有找到該變量,則會拋出 ReferenceError
異常
總結:不成功的 RHS 引用會導致拋出
ReferenceError
異常。不成功的 LHS 引用會導致自動隱式地創建一個全局變量(非嚴格模式下),該變量使用 LHS 引用的目標作為標識符,或者拋出ReferenceError
異常(嚴格模式下)
TypeError異常
如果 RHS 查詢找到了一個變量,但是你嘗試對這個變量的值進行不合理的操作,比如試圖對一個非函數類型的值進行函數調用,或著引用 null 或 undefined 類型的值中的屬性,那么引擎會拋出另外一種類型的異常,叫作 TypeError
小結
在了解了這些機制以后,就可以知道異常的根本原因了:
ReferenceError
異常同作用域判別失敗相關TypeError
異常則代表作用域判別成功了,但是對結果的操作是非法或者不合理的
這樣了解了引擎對變量的查詢機制,以后在看到瀏覽器報錯信息時,就可以從根本出發,找到問題的根源了
本文大部分內容來自《you don't know JavaScript》,經過自己的理解和整理記錄成的筆記