摘要:作用域分類作用域共有兩種主要的工作模型。換句話說,作用域鏈是基于調用棧的,而不是代碼中的作用域嵌套。詞法作用域詞法作用域中,又可分為全局作用域,函數作用域和塊級作用域。
一篇鞏固基礎的文章,也可能是一系列的文章,梳理知識的遺漏點,同時也探究很多理所當然的事情背后的原理。
為什么探究基礎?因為你不去面試你就不知道基礎有多重要,或者是說當你的工作經歷沒有亮點的時候,基礎就是檢驗你好壞的一項指標。
JS基礎都會有哪些考點:閉包,繼承,深拷貝,異步編程等一些常見考點,為什么無論是當我還是個學生的時候被面試還是到現在當面試官去考別人,都還是問這些?項目從jQuery都過渡到React全家桶了,js還是考這些?
因為這些知識點很典型,一個知識點弄懂需要先把很多前置的其他的知識點弄懂。比如閉包,閉包背后就有作用域,變量提升,函數提升,垃圾收集機制等知識點。所以這些知識點往往能以點概面,考察很多基礎的東西。
先來看看閉包(Closure)。
文章里提到了一些知識點:
JS編譯運行過程
詞法作用域與動態作用域
作用域鏈順序
變量與函數提升
閉包的應用
JS編譯原理 基本概念與JAVA,C++,C等靜態語言不同,JavaScript是不需要編譯的。在JAVA中,程序員寫的JAVA代碼要被編譯器編譯成機器語言,然后執行。
編譯
一般程序中的一段源代碼在執行之前會經歷三個步驟,統稱為“編譯”:
分詞/詞法分析(Tokenizing/Lexing)
這個過程會將由字符組成的字符串分解成(對編程語言來說)有意義的代碼塊,這些代碼塊被稱為詞法單元(token)。例如,考慮程序 var a = 2;。這段程序通常會被分解成為下面這些詞法單元:var、a、=、2 、;。空格是否會被當作詞法單元,取決于空格在這門語言中是否具有意義。
解析/語法分析(Parsing)
這個過程是將詞法單元流(數組)轉換成一個由元素逐級嵌套所組成的代表了程序語法結構的樹。這個樹被稱為“抽象語法樹”(Abstract Syntax Tree,AST)。var a = 2; 的抽象語法樹中可能會有一個叫作 VariableDeclaration 的頂級節點,接下來是一個叫作 Identifier(它的值是 a)的子節點,以及一個叫作 AssignmentExpression的子節點。AssignmentExpression 節點有一個叫作 NumericLiteral(它的值是 2)的子節點。
代碼生成
將 AST 轉換為可執行代碼的過程稱被稱為代碼生成。這個過程與語言、目標平臺等息息相關。拋開具體細節,簡單來說就是有某種方法可以將 var a = 2; 的 AST 轉化為一組機器指令,用來創建一個叫作 a 的變量(包括分配內存等),并將一個值儲存在 a 中。
解釋器
JavaScript則不同,JavaScript中對應編譯的部分叫做解釋器(Interpreter)。這兩者的區別用一句話來概括就是:編譯器是將源代碼編譯為另外一種代碼(比如機器碼,或者字節碼),而解釋器是直接解析并將代碼運行結果輸出。
編譯運行過程JavaScript編譯運行過程中有三個重要的角色:引擎,編譯器,作用域。三者互相配合這樣工作:
源代碼被編譯器處理,進行詞法和語法分析,將編譯出來的變量、方法、數據等存儲到作用域,然后將編譯出來的機器代碼交給引擎處理。
作用域負責收集并維護由所有聲明的標識符(變量)組成的一系列查詢,并實施一套非常嚴格的規則,確定當前執行的代碼對這些標識符的訪問權限。
引擎運行來處理這些機器代碼,遇到變量、方法、數據等去作用域中尋找,并執行。
舉個例子:
var a = 1;
這段代碼交給解釋器之后:
編譯器運行源代碼,識別出聲明變量var a,編譯器詢問作用域是否已經有一個該名稱的變量存在于同一個作用域的集合中。如果是,編譯器會忽略該聲明,繼續進行編譯;否則它會要求作用域在當前作用域的集合中分配內存聲明一個新的變量,并命名為 a。
編譯器將上述代碼編譯成機器代碼并交給引擎執行。
引擎運行時從作用域獲取a,如果沒有a則拋出異常,有的話則將a賦值1。
上述代碼在執行過程中開起來就好像:
var a; a = 1;
這不就是很熟悉的變量提升嗎,但是為什么會有變量提升呢,可以理解為代碼的聲明和賦值是分別在編譯和運行時執行,兩者之間的數據銜接全靠作用域(事實上并不是這樣,后面會提到)。異常
這里我們很熟悉,有兩種異常:編譯異常,運行異常。
編譯異常
編譯器在編譯的時候發生錯誤,編譯停止比如:
很明顯編譯器無法知道將1賦值給誰,沒法寫出對應的機器語言,編譯停止。
運行異常
引擎在運行時候發生錯誤,例如:
引擎向作用域獲取a,但是編譯器未在作用域中聲明a,運行報錯。
聲明了a,并將a賦值為1,但是a無法運行,運行報錯。
LHS查詢 RHS查詢垃圾收集RHS 查詢與簡單地查找某個變量的值別無二致,而 LHS 查詢則是試圖找到變量的容器本身,從而可以對其賦值。
ES5 中引入了“嚴格模式”。同正常模式,或者說寬松 / 懶惰模式相比,嚴格模式在行為上
有很多不同。其中一個不同的行為是嚴格模式禁止自動或隱式地創建全局變量。因此,在
嚴格模式中 LHS 查詢失敗時,并不會創建并返回一個全局變量,引擎會拋出同 RHS 查詢
失敗時類似的 ReferenceError 異常。接下來,如果 RHS 查詢找到了一個變量,但是你嘗試對這個變量的值進行不合理的操作,
比如試圖對一個非函數類型的值進行函數調用,或著引用 null 或 undefined 類型的值中的
屬性,那么引擎會拋出另外一種類型的異常,叫作 TypeError。
和C#、Java一樣JavaScript有自動垃圾回收機制,也就是說執行環境會負責管理代碼執行過程中使用的內存,在開發過程中就無需考慮內存分配及無用內存的回收問題了。
JavaScript垃圾回收的機制很簡單:找出不再使用的變量,然后釋放掉其占用的內存,但是這個過程不是時時的,因為其開銷比較大,所以垃圾回收器會按照固定的時間間隔周期性的執行。
變量生命周期
什么叫不再使用的變量?不再使用的變量也就是生命周期結束的變量,當然只可能是局部變量,全局變量的生命周期直至瀏覽器卸載頁面才會結束。局部變量只在函數的執行過程中存在,而在這個過程中會為局部變量在棧或堆上分配相應的空間,以存儲它們的值,然后再函數中使用這些變量,直至函數結束(閉包特殊)。
一旦函數結束,局部變量就沒有存在必要了,可以釋放它們占用的內存。貌似很簡單的工作,為什么會有很大開銷呢?這僅僅是垃圾回收的冰山一角,就像剛剛提到的閉包,貌似函數結束了,其實還沒有,垃圾回收器必須知道哪個變量有用,哪個變量沒用,對于不再有用的變量打上標記,以備將來回收。用于標記無用的策略有很多,常見的有兩種方式:標記清除和 引用計數,這里介紹一下標記清除:
標記清除(mark and sweep)
這是JavaScript最常見的垃圾回收方式,當變量進入執行環境的時候,比如函數中聲明一個變量,垃圾回收器將其標記為“進入環境”,當變量離開環境的時候(函數執行結束)將其標記為“離開環境”。至于怎么標記有很多種方式,比如特殊位的反轉、維護一個列表等,這些并不重要,重要的是使用什么策略,原則上講不能夠釋放進入環境的變量所占的內存,它們隨時可能會被調用的到。
垃圾回收器會在運行的時候給存儲在內存中的所有變量加上標記,然后去掉環境中的變量以及被環境中變量所引用的變量(閉包),在這些完成之后仍存在標記的就是要刪除的變量了,因為環境中的變量已經無法訪問到這些變量了,然后垃圾回收器相會這些帶有標記的變量機器所占空間。
大部分瀏覽器都是使用這種方式進行垃圾回收,只是垃圾收集的時間間隔不同。
作用域(scope)作用域負責收集并維護由所有聲明的標識符(變量)組成的一系列查詢,并實施一套非常嚴格的規則,確定當前執行的代碼對這些標識符的訪問權限。作用域分類
作用域共有兩種主要的工作模型。第一種是最為普遍的,被大多數編程語言所采用的詞法作用域,我們會對這種作用域進行深入討論。另外一種叫作動態作用域,仍有一些編程語言在使用(比如 Bash 腳本、Perl 中的一些模式等)。
詞法作用域詞法作用域是由你在寫代碼時將變量和塊作用域寫在哪里來決定的,因此當詞法分析器處理代碼時會保持作用域不變(大部分情況下是這樣的)。
動態作用域動態作用域并不關心函數和作用域是如何聲明以及在何處聲明的,只關心它們從何處調用。換句話說,作用域鏈是基于調用棧的,而不是代碼中的作用域嵌套。
JavaScript中大部分場景都是詞法作用域,函數中的this則是動態作用域,我們先仔細討論詞法作用域。
詞法作用域詞法作用域中,又可分為全局作用域,函數作用域和塊級作用域。
全局作用域默認進入的就是全局作用域,在瀏覽器上全局作用域通常都被掛載到windows上。
函數作用域函數作用域的含義是指,屬于這個函數的全部變量都可以在整個函數的范圍內使用及復用(事實上在嵌套的作用域中也可以使用)。
var a = 1;
function fn () { // 函數作用域起點
var a = 2;
console.log(a);
} // 函數作用域終點
fn(); // 函數作用域這行業是,因為涉及到參數傳值
console.log(a);
塊級作用域
很常見,簡單來說用{}來包裹起來的,通常可以復用的代碼就是,比如for循環,switch case,while等等。
for(var i=0; i<10; i++){ // 塊作用域
console.log(i);
} // 塊作用域
var a = 1;
switch (a) { // 塊作用域
case 1: { // 塊作用域
// ....
}
case 2: { // 塊作用域
// ....
}
default: { // 塊作用域
// ....
}
}
while (a) { // 塊作用域
// ....
}
{ // 硬寫了一個塊作用域
let a = 2;
console.log(a);
}
看一個例子:
function func (a) {
var b = a * 2;
function foo (c) {
console.log(a, b, c);
}
foo(b*3)
{
let a = 2;
console.log(a); // 2
}
}
func(1); // 1,2,3
通過上面這個例子我們來分析:
func被定義在了默認的全局作用域,全局作用域只有 func;
func函數創造了一個函數作用域,在函數體內定義的變量被定義在了函數作用域內:a,b,foo;
foo函數又創造了一個函數作用域,里面有:c
{}創造了一個塊級作用域,里面使用let定義了一個a,這里的變量有:a
動態作用域在詞法作用域中,函數運行時遇到變量,回去在其詞法作用域中尋找對應變量,而在動態作用域中,則是根據當前運行情況來確定,最常見的就是this關鍵字。
var b = 1;
var c = 123;
function fn (a) {
console.log(a);
console.log(b);
console.log(this.c);
}
fn("hello");
var obj = {
b: 2,
c: 12,
fn: fn
}
var o = {
obj: obj
}
obj.fn("world");
o.obj.fn("!");
fn分別在全局作用域中執行,和obj的屬性執行。
變量a是fn的函數作用域中定義的,屬于詞法作用域范疇;
變量b沒有在函數作用域中定義,向上尋找,在全局作用域中找到,也是詞法作用域范疇;
this.c屬于動態作用域,函數執行的時候順著調用棧動態尋找,this總是指向調用函數者。
作用域鏈(scope chain)不同作用域之間是如何協作的,這就涉及到了作用域鏈。
作用域查找會在找到第一個匹配的標識符時停止。在多層的嵌套作用域中可以定義同名的標識符,這叫作“遮蔽效應”(內部的標識符“遮蔽”了外部的標識符)。拋開遮蔽效應,作用域查找始終從運行時所處的最內部作用域開始,逐級向外或者說向上進行,直到遇見第一個匹配的標識符為止。
不同作用域之間是可以嵌套的,所有的局部作用域都在全局作用域這個大容器之中,作用域之間的嵌套關系就好比堆棧和出棧。
還是上面的例子:
func函數被定義在了全局作用域上,所以func函數內的變量作用域鏈為:[func函數作用域,全局作用域];
foo函數被定義在了foo函數內,兩個作用域互相嵌套,foo函數的作用域就是:[foo函數作用域,func函數作用域,全局作用域];
{}在func函數內定義了一個塊級作用域:[塊級作用域,func函數作用域,全局作用域]。
在每個作用域內查找變量,如果對于的作用域內無法找到變量,則去其作用域鏈的上一級查找,直到找到第一個結果返回,否則返回undefined。
如果多個作用域內有相同名稱的變量,則會找到距離當前作用域最近的變量。
提升(hoisting)一開始編譯運行過程的時候我們就知道了JS中存在變量提升,實際上分成兩種情況:變量聲明提升和函數聲明提升。
變量聲明提升通常JS引擎會在正式執行之前先進行一次預編譯,在這個過程中,首先將變量聲明及函數聲明提升至當前作用域的頂端,然后進行接下來的處理。
這個我們應該很熟悉了,舉個例子:
console.log(a); // undefined var a = 1; console.log(a); // 1
按照閱讀邏輯,在a聲明之前調用a,會發生RHS異常,從而觸發ReferenceError。
但是實際運行的時候,并沒有報錯,因為上面的代碼看起來被編譯成了:
var a; console.log(a); a = 1; console.log(a);
這樣理解看起來是不是就很合理了。
但是值得注意的是,變量提升只會提升至本作用域最頂端,而不會夸作用域:
var foo = 3;
function func () {
var foo = foo || 5;
console.log(foo); // 5
}
func();
在func里面的是函數作用域,全局作用域的一個子集,所以在函數作用域中調用變量foo應該就近尋找當前作用域內有無變量,找到一個即停止尋找。上述代碼看起來:
var foo = 3;
function func () {
var foo;
foo = foo || 5;
console.log(foo); // 5
}
func();
函數聲明提升
與變量聲明類似的,函數在聲明的時候也會發生提升的情況:
func(); // "hello world"
function func () {
console.log("hello world");
}
相似的,如果在同一個作用域中存在多個同名函數聲明,后面出現的將會覆蓋前面的函數聲明;
對于函數,除了使用上面的函數聲明,更多時候,我們會使用函數表達式,下面是函數聲明和函數表達式的對比:
console.log(foo1);
//函數聲明
function foo1() {
console.log("function declaration");
}
console.log(foo2);
//匿名函數表達式
var foo2 = function() {
console.log("anonymous function expression");
};
console.log(bar);
console.log(foo3);
//具名函數表達式
var foo3 = function bar() {
console.log("named function expression");
};
console.log(bar);
JavaScript中的函數是一等公民,函數聲明的優先級最高,會被提升至當前作用域最頂端。上述的例子可以發現:只有函數聲明的時候,才會發生變量提升,函數無論是匿名函數/具名函數表達式,均不會發生函數聲明提升。
兩者優先級兩者同時存在提升,那個優先級更高:
console.log(a);
var a = 1;
function a () {
console.log("hello");
}
console.log(b);
function b () {
console.log("hello");
}
var b = 1;
上面例子可以看到,當變量和函數同名的時候,無論誰聲明在后,都是函數的優先級最高,變量為函數讓路。
為什么提升至于變量提升的原因:Note 4. Two words about “hoisting”
閉包經過前面知識點鋪墊之后,終于來到了閉包。
function closure () {
var a = 1;
function result () {
return a;
}
return result;
}
closure()();
上面這個例子是個很常見的閉包,變量a在函數closure內,不應該在其作用域外被訪問,但是通過返回result函數實現了在外部訪問到了a,這就是一個簡單的閉包。
事實上閉包的定義:(wiki pedia)
閉包,又稱詞法閉包(Lexical Closure)或函數閉包(function closures),是引用了自由變量的函數。這個被引用的自由變量將和這個函數一同存在,即使已經離開了創造它的環境也不例外。
簡單說就是,函數內定義了一個引用了其作用域內變量的函數,然后將該函數當做一個值傳遞到其他地方,該函數在運行的時候,雖然運行環境已經不是其詞法作用域,但是還可以訪問到其詞法作用域中的變量。
或者說我們可以這樣理解:
本質上無論何時何地,如果將函數(訪問它們各自的詞法作用域)當作第一級的值類型并到處傳遞,你就會看到閉包在這些函數中的應用。在定時器、事件監聽器、Ajax 請求、跨窗口通信、Web Workers 或者任何其他的異步(或者同步)任務中,只要使用了回調函數,實際上就是在使用閉包!
這里關鍵點:函數,函數引用了其作用域內的變量,在其詞法作用域外被調用。來看一些常見的例子:
function closure () {
var a = 1;
function result () {
return a;
}
window.result = result;
}
closure();
result();
上面例子的變形,closure不在return result,而是掛載到window對象上。很顯然result的詞法作用域在不是全局作用域,滿足閉包的條件,也是一個閉包。
function wait(message) {
setTimeout( function timer() {
console.log( message );
}, 1000 );
}
wait( "Hello, closure!" );
這個有點意思,延遲很常見。timer的詞法作用域是[wait函數作用域,全局作用域],wait里面起了一個延遲隊列任務,timer被當做參數傳遞到了延遲里,而timer里面還調用了message。這樣的話,wait執行結束之后并不會被內存回收,1s之后,timer執行,其詞法作用域都還在,滿足閉包條件,是一個閉包。
練習一道經典題目:輸出結果
for(var i = 0; i<5; i++){
setTimeout(function(){
console.log(i);
}, 100);
}
代碼運行之后,打印5個5。這里setTimeout定義之后不會被立即執行,而是加入到隊列中延遲執行,執行的時候運行匿名函數,匿名函數打印i,i不在匿名函數作用域中,順著作用域鏈向上尋找,在全局作用域中找到i,這時候的i已經是5了,所以均打印5。
這里變形一下:還保留for循環,以及setTimeout形式,要求結果輸出0,1,2,3,4,怎么改?
很多種方法,我們分成不同方向去考慮:
1. 使用塊級作用域
變量i實際上是個全局作用域變量,for循環,每次都重復聲明i,可以使用塊級作用域,聲明不同的塊級作用域中的變量:
for(let i = 0; i<5; i++){
setTimeout(function(){
console.log(i);
}, 100);
}
或者,賦值轉換:
for(var i = 0; i<5; i++){
let a = i;
setTimeout(function(){
console.log(a);
}, 100);
}
這樣的話,匿名函數執行的時候,函數作用域內沒有i,去塊級作用域尋找i,找到并返回結果,并不會直接尋找到全局作用域。
2. 閉包
閉包應該是最容易想到的,因為他的場景滿足在其詞法作用域外被調用,怎么使用閉包:立即執行函數(IIFE)
for(var i = 0; i<5; i++){
(function(i){
setTimeout(function(){
console.log(i);
}, 100);
})(i);
}
立即執行函數創造了一個新的匿名函數作用域,這個作用域內的i是定義的時候傳進來的,settimeout函數執行時候線上尋找到該作用域,并打印變量。
3.bind函數
或者使用bind函數可以直接更改匿名函數的作用域:
for(var i = 0; i<5; i++){
setTimeout(function(i){
console.log(i);
}.bind(this, i), 100);
}
4.奇技淫巧
只針對這個題目,可以使用進棧出棧保持順序:
var arr = [];
for(var i = 0; i<5; i++){
arr.unshift(i);
setTimeout(function(){
console.log(arr.pop());
}, 100);
}
參考
《你所不知道的JavaScript》
《JavaScript高級程序設計》
JavaScript系列文章:變量提升和函數提升
JavaScript深入之閉包
閉包
文章版權歸作者所有,未經允許請勿轉載,若此文章存在違規行為,您可以聯系管理員刪除。
轉載請注明本文地址:http://m.hztianpu.com/yun/94301.html
摘要:是詞法作用域工作模式。使用可以將變量綁定在所在的任意作用域中通常是內部,也就是說為其聲明的變量隱式的劫持了所在的塊級作用域。 作用域與閉包 如何用js創建10個button標簽,點擊每個按鈕時打印按鈕對應的序號? 看到上述問題,如果你能看出來這個問題實質上是考對作用域的理解,那么恭喜你,這篇文章你可以不用看了,說明你對作用域已經理解的很透徹了,但是如果你看不出來這是一道考作用域的題目,...
摘要:是詞法作用域工作模式。使用可以將變量綁定在所在的任意作用域中通常是內部,也就是說為其聲明的變量隱式的劫持了所在的塊級作用域。 作用域與閉包 如何用js創建10個button標簽,點擊每個按鈕時打印按鈕對應的序號? 看到上述問題,如果你能看出來這個問題實質上是考對作用域的理解,那么恭喜你,這篇文章你可以不用看了,說明你對作用域已經理解的很透徹了,但是如果你看不出來這是一道考作用域的題目,...
摘要:是詞法作用域工作模式。使用可以將變量綁定在所在的任意作用域中通常是內部,也就是說為其聲明的變量隱式的劫持了所在的塊級作用域。 作用域與閉包 如何用js創建10個button標簽,點擊每個按鈕時打印按鈕對應的序號? 看到上述問題,如果你能看出來這個問題實質上是考對作用域的理解,那么恭喜你,這篇文章你可以不用看了,說明你對作用域已經理解的很透徹了,但是如果你看不出來這是一道考作用域的題目,...
摘要:是詞法作用域工作模式。使用可以將變量綁定在所在的任意作用域中通常是內部,也就是說為其聲明的變量隱式的劫持了所在的塊級作用域。 作用域與閉包 如何用js創建10個button標簽,點擊每個按鈕時打印按鈕對應的序號? 看到上述問題,如果你能看出來這個問題實質上是考對作用域的理解,那么恭喜你,這篇文章你可以不用看了,說明你對作用域已經理解的很透徹了,但是如果你看不出來這是一道考作用域的題目,...
閱讀 1305·2021-10-14 09:43
閱讀 1394·2021-10-11 11:07
閱讀 3331·2021-08-18 10:23
閱讀 1736·2019-08-29 16:18
閱讀 1141·2019-08-28 18:21
閱讀 1687·2019-08-26 12:12
閱讀 4013·2019-08-26 10:11
閱讀 2651·2019-08-23 18:04