《你不知道的javascript上卷》摘要(上)

翻回看過的書,整理筆記,方便溫故而知新。這是一本很不錯的書,分為兩部分,第一部分主要講解了作用域、閉包,第二部分主要講解this、對象原型等知識點。

第一部分 作用域和閉包

第1章 作用域是什么

1.1編譯原理

傳統編譯語言的流程中,程序中的一段源代碼在執行之前會經歷三個步驟,統稱為“編譯”:

  • 分詞/詞法分析:這個過程會將由字符組成的字符串分解成(對編程語言來說)有意義的代碼塊,這些代碼塊被稱為詞法單元(token)。
  • 解析/語法分析:這個過程是將詞法單元流(數組)轉換成一個由元素逐級嵌套所組成的代表了程序語法結構的樹。這個樹被稱為“抽象語法樹”(Abstract Syntax Tree,AST)。
  • 代碼生成:將 AST 轉換為可執行代碼的過程稱被稱為代碼生成。這個過程與語言、目標平臺等息息相關。

JavaScript 引擎要復雜得多,不會有大量的(像其他語言編譯器那么多的)時間用來進行優化,編譯過程不是發生在構建之前的。對于Javascript來說,大部分情況下編譯發生在代碼執行前的幾微妙的時間內。

1.2理解作用域

變量的賦值操作會執行兩個動作,首先編譯器會在當前作用域中聲明一個變量(如果之前沒有聲明過),然后在運行時引擎會在作用域中查找該變量,如果能夠找到就會對它賦值。
當變量出現在賦值操作左側時進行LHS查詢,出現在右側時進行RHS查詢。
LHS:賦值操作的目標是誰(試圖找到變量的容器本身);(獲取地址)
RHS:誰是賦值操作的源頭(retrieve his source value);(獲取值)

function foo(a) {
  console.log( a ); // 2
}
foo( 2 );

(1)foo(..) 函數的調用需要對 foo 進行 RHS 引用
(2)隱式的 a=2 操作,為了給參數 a(隱式地)分配值,需要進行一次LHS 查詢
(3)console 對象進行 RHS 查詢,并且檢查得到的值中是否有一個叫作 log 的方法
(4)進 log(..)(通過變量 a 的 RHS 查詢)。

function foo(a) {
  var b = a;
  return a + b;
}
var c = foo( 2 );
  1. 找到其中所有的 LHS 查詢。(這里有 3 處!)
  2. 找到其中所有的 RHS 查詢。(這里有 4 處!)

1.3 作用域嵌套

當一個塊或函數嵌套在另一個塊或函數中時,就發生了作用域的嵌套。因此,在當前作用域中無法找到某個變量時,引擎就會在外層嵌套的作用域中繼續查找,直到找到該變量,或抵達最外層的作用域(也就是全局作用域)為止。

1.4 異常

為什么區分 LHS 和 RHS 是一件重要的事情?
因為在變量還沒有聲明(在任何作用域中都無法找到該變量)的情況下,這兩種查詢的行為是不一樣的。

  • 如果 RHS 查詢在所有嵌套的作用域中遍尋不到所需的變量,引擎就會拋出ReferenceError異常。
  • 當引擎執行 LHS 查詢時,如果在頂層(全局作用域)中也無法找到目標變量,全局作用域中就會創建一個具有該名稱的變量,并將其返還給引擎(前提是程序運行在非“嚴格模式”下)。

ReferenceError 同作用域判別失敗相關,而TypeError 則代表作用域判別成功了,但是對結果的操作是非法或不合理的(比如試圖對一個非函數類型的值進行函數調用,或著引用 null 或 undefined 類型的值中的屬性)。

1.5 小結

作用域是一套規則,用于確定在何處以及如何查找變量(標識符)。如果查找的目的是對變量進行賦值,那么就會使用 LHS 查詢;如果目的是獲取變量的值,就會使用 RHS 查詢。
var a = 2 這樣的聲明會被分解成兩個獨立的步驟:
(1)var a 在其作用域中聲明新變量。(是代碼執行前進行)
(2)a = 2 會查詢(LHS 查詢)變量 a 并對其進行賦值。


第2章 詞法作用域

作用域共有兩種主要的工作模型:詞法作用域(大多數編程語言所采用)、動態作用域(Bash 腳本、Perl 中的一些模式等)

詞法階段

  • 詞法作用域是由你在寫代碼時將變量和塊作用域寫在哪里來決定的(詞法作用域就是定義在詞法階段的作用域)。
  • 沒有任何函數可以部分地同時出現在兩個父級函數中。
  • 作用域查找會在找到第一個匹配的標識符時停止。在多層的嵌套作用域中可以定義同名的標識符,這叫作“遮蔽效應”(內部的標識符“遮蔽”了外部的標識符)。

欺騙詞法

JavaScript 中有兩個機制可以“欺騙”詞法作用域(不要使用它們):
eval(..):對一段包含一個或多個聲明的“代碼”字符串進行演算,并借此來修改已經存在的詞法作用域(在運行時)
with:通過將一個對象的引用當作作用域來處理,將對象的屬性當作作用域中的標識符來處理,從而創建了一個新的詞法作用域(同樣是在運行時)。


第3章 函數作用域和塊作用域

3.1 函數中的作用域

函數作用域的含義是指,屬于這個函數的全部變量都可以在整個函數的范圍內使用及復用(事實上在嵌套的作用域中也可以使用)。

3.2 隱藏內部實現

最小授權或最小暴露原則:在軟件設計中,應該最小限度地暴露必要內容,而將其他內容都“隱藏”起來,比如某個模塊或對象的 API 設計。

規避沖突:

(1)全局命名空間
在全局作用域中聲明一個名字足夠獨特的變量,通常是一個對象。這個對象被用作庫的命名空間,所有需要暴露給外界的功能都會成為這個對象(命名空間)的屬性,而不是將自己的標識符暴漏在頂級的詞法作用域中。
(2)模塊管理
從眾多模塊管理器中挑選一個來使用,通過依賴管理器的機制將庫的標識符顯式地導入到另外一個特定的作用域中。

3.3 函數作用域

var a = 2;
function foo() { // <-- 添加這一行
  var a = 3;
  console.log( a ); // 3
} // <-- 以及這一行
foo(); // <-- 以及這一行
console.log( a ); // 2

在任意代碼片段外部添加包裝函數,可以將內部的變量和函數定義“隱藏”起來,外部作用域無法訪問包裝函數內部的任何內容。它并不理想,因為會導致一些額外的問題:
(1)必須聲明一個具名函數,意味著這個名稱本身“污染”了所在作用域。
(2)必須顯式地通過函數名調用這個函數才能運行其中的代碼。
解決方案:

var a = 2;
(function foo(){ // <-- 添加這一行
  var a = 3;
  console.log( a ); // 3
})(); // <-- 以及這一行
console.log( a ); // 2

包裝函數的聲明以 (function... 而不僅是以 function... 開始。函數會被當作函數表達式而不是一個標準的函數聲明來處理。

第一個片段中 foo 被綁定在所在作用域中,可以直接通過foo() 來調用它。
第二個片段,(function foo(){ .. }) 作為函數表達式意味著 foo 只能在 .. 所代表的位置中被訪問,外部作用域則不行。foo 變量名被隱藏在自身中意味著不會非必要地污染外部作用域。


匿名和具名

匿名函數表達式書寫起來簡單快捷,但是它也有幾個缺點需要考慮:
(1)匿名函數在棧追蹤中不會顯示出有意義的函數名,使得調試很困難。
(2) 如果沒有函數名,當函數需要引用自身時只能使用已經過期的arguments.callee 引用,比如在遞歸中。另一個函數需要引用自身的例子,是在事件觸發后事件監聽器需要解綁自身。
(3) 匿名函數省略了對于代碼可讀性 / 可理解性很重要的函數名。一個描述性的名稱可以讓代碼不言自明。


立即執行函數表達式
  • IIFE,代表立即執行函數表達式(Immediately Invoked Function Expression),由于函數被包含在一對 ( ) 括號內部,因此成為了一個表達式,通過在末尾加上另外一個( ) 可以立即執行這個函數,比如 (function foo(){ .. })()。
  • 相較于傳統的 IIFE 形式,很多人都更喜歡另一個改進的形式:(function(){ .. }())。這兩種形式在功能上是一致的。選擇哪個全憑個人喜好。
  • IIFE 的另一個非常普遍的進階用法是把它們當作函數調用并傳遞參數進去。
var a = 2;
(function IIFE( global ) {
  var a = 3;
  console.log( a ); // 3
  console.log( global.a ); // 2
})( window );
console.log( a ); // 2

3.4 塊作用域

變量的聲明應該距離使用的地方越近越好,并最大限度地本地化。

var foo = true;
if (foo) {
  var bar = foo * 2;
  bar = something( bar );
  console.log( bar );
}

bar 變量僅在 if 聲明的上下文中使用,因此如果能將它聲明在 if 塊內部中會是一個很有意義的事情。但是,當使用 var 聲明變量時,它寫在哪里都是一樣的,因為它們最終都會屬于外部作用域。

  • with
    用 with 從對象中創建出的作用域僅在 with 聲明中而非外部作用域中有效
  • try/catch
    catch 分句會創建一個塊作用域,其中聲明的變量僅在 catch 內部有效。
  • let
    let 關鍵字可以將變量綁定到所在的任意作用域中(通常是 { .. } 內部)。換句話說,let為其聲明的變量隱式地了所在的塊作用域。
  • const
    同樣可以用來創建塊作用域變量,但其值是固定的(常量)。

3.5 小結

  • 函數是 JavaScript 中最常見的作用域單元。本質上,聲明在一個函數內部的變量或函數會在所處的作用域中“隱藏”起來,這是有意為之的良好軟件的設計原則。
  • 但函數不是唯一的作用域單元。塊作用域指的是變量和函數不僅可以屬于所處的作用域,也可以屬于某個代碼塊(通常指 { .. } 內部)。

第4章 提升

函數作用域和塊作用域的行為是一樣的,可以總結為:任何聲明在某個作用域內的變量,都將附屬于這個作用域。
到底是聲明在前,還是賦值在前?

  • 引擎會在解釋 JavaScript 代碼之前首先對其進行編譯。編譯階段中的一部分工作就是找到所有的聲明,并用合適的作用域將它們關聯起來。
  • 因此,包括變量和函數在內的所有聲明都會在任何代碼被執行前首先被處理

var a = 2; 實際上會將其看成兩個聲明:var a; 和 a = 2;。第一個定義聲明是在編譯階段進行的。第二個賦值聲明會被留在原地等待執行階段。

這個過程就好像變量和函數聲明從它們在代碼中出現的位置被“移動”到了最上面。這個過程就叫作提升

  • 只有聲明本身會被提升,而賦值或其他運行邏輯會留在原地。
  • 每個作用域都會進行提升操作。
  • 函數聲明會被提升,但是函數表達式卻不會被提升。
  • 函數會首先被提升,然后才是變量。
foo(); // 1
var foo;
function foo() {
  console.log( 1 );
}
foo = function() {
  console.log( 2 );
};

會輸出 1 而不是 2 !這個代碼片段會被引擎理解為如下形式:

function foo() {
  console.log( 1 );
}
foo(); // 1
foo = function() {
  console.log( 2 );
};

var foo 盡管出現在 function foo()... 的聲明之前,但它是重復的聲明(因此被忽略了),因為函數聲明會被提升到普通變量之前。

盡管重復的 var 聲明會被忽略掉,但出現在后面的函數聲明還是可以覆蓋前面的。(在同一個作用域中進行重復定義是非常糟糕的,而且經常會導致各種奇怪的問題)

foo(); // 3
function foo() {
  console.log( 1 );
}
var foo = function() {
  console.log( 2 );
};
function foo() {
  console.log( 3 );
}

一個普通塊內部的函數聲明通常會被提升到所在作用域的頂部:

foo(); // "b"
var a = true;
if (a) {
  function foo() { console.log("a"); }
}
else {
  function foo() { console.log("b"); }
}

第5章 作用域閉包

當函數可以記住并訪問所在的詞法作用域時,就產生了閉包,即使函數是在當前詞法作用域之外執行

function foo() {
  var a = 2;
  function bar() {
    console.log( a );
  }
  return bar;
}
var baz = foo();
baz(); // 2 —— 朋友,這就是閉包的效果。
  • bar() 在自己定義的詞法作用域以外的地方執行。
  • 在 foo() 執行后,通常會期待 foo() 的整個內部作用域都被銷毀,而閉包的“神奇”之處正是可以阻止這件事情的發生。事實上內部作用域依然存在,因此沒有被回收。(bar() 本身在使用這個內部作用域)
  • bar() 所聲明的位置擁有涵蓋 foo() 內部作用域的閉包,使得該作用域能夠一直存活,以供 bar() 在之后任何時間進行引用。
  • bar() 依然持有對該作用域的引用,而這個引用就叫作閉包。

無論通過何種手段將內部函數傳遞到所在的詞法作用域以外,它都會持有對原始定義作用域的引用,無論在何處執行這個函數都會使用閉包。
在定時器、事件監聽器、 Ajax 請求、跨窗口通信、Web Workers 或者任何其他的異步(或者同步)任務中,只要使用了回調函數,實際上就是在使用閉包!


var a = 2;
(function IIFE() {
  console.log( a );
})();

嚴格來講它并不是閉包。
因為函數(示例代碼中的 IIFE)并不是在它本身的詞法作用域以外執行的。它在定義時所在的作用域中執行(而外部作用域,也就是全局作用域也持有 a)。a 是通過普通的詞法作用域查找而非閉包被發現的。


循環和閉包

// 預期是分別輸出數字 1~5,每秒一次,每次一個
// 實際運行時會以每秒一次的頻率輸出五次 6
// 它們都被封閉在一個共享的全局作用域中,因此實際上只有一個 i。
for (var i=1; i<=5; i++) {
  setTimeout( function timer() {
    console.log( i );
  }, i*1000 );
}

當定時器運行時即使每個迭代中執行的是 setTimeout(.., 0),所有的回調函數依然是在循環結束后才會被執行。

// 依舊不能(IIFE 只是一個什么都沒有的空作用域。)
for (var i=1; i<=5; i++) {
  (function() {
    setTimeout( function timer() {
      console.log( i );
    }, i*1000 );
  })();
}

// 行了!它能正常工作了!
for (var i=1; i<=5; i++) {
  (function() {
    var j = i;
    setTimeout( function timer() {
      console.log( j );
    }, j*1000 );
  })();
}

// 代碼進行一些改進:
for (var i=1; i<=5; i++) {
  (function(j) {
    setTimeout( function timer() {
      console.log( j );
    }, j*1000 );
  })( i );
}

模塊

function CoolModule() {
  var something = "cool";
  var another = [1, 2, 3];
  function doSomething() {
    console.log( something );
  }
  function doAnother() {
    console.log( another.join( " ! " ) );
  }
  return {
    doSomething: doSomething,
    doAnother: doAnother
  };
}
var foo = CoolModule();
foo.doSomething(); // cool
foo.doAnother(); // 1 ! 2 ! 3
  • 這個模式在 JavaScript 中被稱為模塊。最常見的實現模塊模式的方法通常被稱為模塊暴露,這里展示的是其變體。
  • 這個返回的對象中含有對內部函數而不是內部數據變量的引用。我們保持內部數據變量是隱藏且私有的狀態。可以將這個對象類型的返回值看作本質上是模塊的公共 API。

模塊模式需要具備兩個必要條件
(1)必須有外部的封閉函數,該函數必須至少被調用一次(每次調用都會創建一個新的模塊實例)。
(2)封閉函數必須返回至少一個內部函數,這樣內部函數才能在私有作用域中形成閉包,并且可以訪問或者修改私有的狀態。
模塊有兩個主要特征:
(1)為創建內部作用域而調用了一個包裝函數;
(2)包裝函數的返回值必須至少包括一個對內部函數的引用,這樣就會創建涵蓋整個包裝函數內部作用域的閉包

一個具有函數屬性的對象本身并不是真正的模塊。從方便觀察的角度看,一個從函數調用所返回的,只有數據屬性而沒有閉包函數的對象并不是真正的模塊。


現代的模塊機制

多數模塊依賴加載器 / 管理器本質上都是將這種模塊定義封裝進一個友好的 API。

未來的模塊機制

ES6 中為模塊增加了一級語法支持。但通過模塊系統進行加載時,ES6 會將文件當作獨立的模塊來處理。每個模塊都可以導入其他模塊或特定的API 成員,同樣也可以導出自己的API 成員。

基于函數的模塊并不是一個能被穩定識別的模式(編譯器無法識別),它們的 API 語義只有在運行時才會被考慮進來。因此可以在運行時修改一個模塊的 API。

相比之下,ES6 模塊 API 更加穩定(API 不會在運行時改變)。由于編輯器知道這一點,因此在(的確也這樣做了)編譯期檢查對導入模塊的 API 成員的引用是否真實存在。

// bar.js
function hello(who) {
  return "Let me introduce: " + who;
}
export hello;
//foo.js
// 僅從 "bar" 模塊導入 hello()
import hello from "bar";
var hungry = "hippo";
function awesome() {
  console.log(hello( hungry ).toUpperCase());
}
export awesome;
// baz.js
// 導入完整的 "foo" 和 "bar" 模塊
module foo from "foo";
module bar from "bar";
console.log(bar.hello( "rhino" )); // Let me introduce: rhino
foo.awesome(); // LET ME INTRODUCE: HIPPO
  • import 可以將一個模塊中的一個或多個 API 導入到當前作用域中,并分別綁定在一個變量上(在我們的例子里是 hello)。
  • module 會將整個模塊的 API 導入并綁定到一個變量上(在我們的例子里是 foo 和 bar)。
  • export 會將當前模塊的一個標識符(變量、函數)導出為公共 API。

這些操作可以在模塊定義中根據需要使用任意多次。


附錄A 動態作用域

JavaScript 中的作用域就是詞法作用域(大部分語言都是基于詞法作用域的)。
詞法作用域是一套關于引擎如何尋找變量以及會在何處找到變量的規則。詞法作用域最重要的特征是它的定義過程發生在代碼的書寫階段。

動態作用域并不關心函數和作用域是如何聲明以及在何處聲明的,只關心它們從何處調用。換句話說,作用域鏈是基于調用棧的,而不是代碼中的作用域嵌套。

function foo() {
  console.log( a ); 
}
function bar() {
  var a = 3;
  foo();
}
var a = 2;
bar();
  • 詞法作用域讓 foo() 中的 a 通過 RHS 引用到了全局作用域中的 a,因此會輸出 2。
  • 如果 JavaScript 具有動態作用域,理論上, foo() 在執行時將會輸出 3。因為當 foo() 無法找到 a 的變量引用時,會順著調用棧在調用 foo() 的地方查找 a,而不是在嵌套的詞法作用域鏈中向上查找。
  • 事實上 JavaScript 并不具有動態作用域。它只有詞法作用域,簡單明了。但是 this 機制某種程度上很像動態作用域。

主要區別:詞法作用域是在寫代碼或者說定義時確定的,而動態作用域是在運行時確定的。(this 也是!)詞法作用域關注函數在何處聲明,而動態作用域關注函數從何處調用。


附錄 B 塊作用域的替代方案

  • catch 分句具有塊作用域,因此它可以在 ES6 之前的環境中作為塊作用域的替代方案。
  • 工具可以將 ES6 的代碼轉換成能在 ES6 之前環境中運行的形式。
let (a = 2) {
   console.log( a ); // 2
}
console.log( a ); // ReferenceError

這種 let 的使用方法,它被稱作 let 作用域或 let 聲明(對比前面的 let 定義)。let 聲明并不包含在 ES6 中。官方的 Traceur 編譯器也不接受這種形式的代碼。
兩種解決方案:
(1)使用合法的 ES6 語法并且在代碼規范性上做一些妥協。

/*let*/ { let a = 2;
console.log( a );
}
console.log( a ); // ReferenceError

(2)編寫顯式 let 聲明,然后通過工具將其轉換成合法的、可以工作的代碼。

為什么不直接使用 IIFE 來創建作用域?

(1)try/catch 的性能的確很糟糕,但技術層面上沒有合理的理由來說明 try/catch 必須這么慢,或者會一直慢下去。自從 TC39 支持在 ES6 的轉換器中使用 try/catch 后,Traceur 團隊已經要求 Chrome 對 try/catch 的性能進行改進。
(2)IIFE 和 try/catch 并不是完全等價的,因為如果將一段代碼中的任意一部分拿出來用函數進行包裹,會改變這段代碼的含義,其中的 this、return、break 和 contine 都會發生變化。IIFE 并不是一個普適的解決方案,它只適合在某些情況下進行手動操作。


附錄 C this詞法

箭頭函數在涉及 this 綁定時的行為和普通函數的行為完全不一致。它放棄了所有普通 this 綁定的規則,取而代之的是用當前的詞法作用域覆蓋了 this 本來的值。

原文:
《你不知道的javascript上卷》摘要(上)
《你不知道的javascript上卷》摘要(下)

最后編輯于
?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。