讀書筆記 – 你不知道的 JavaScript(上)

牧云云發表於2019-02-28

讀書筆記 – 你不知道的 JavaScript(上)

本文首發在我的個人部落格:muyunyun.cn/

《你不知道的JavaScript》系列叢書給出了很多顛覆以往對JavaScript認知的點, 讀完上卷,受益匪淺,於是對其精華的知識點進行了梳理。

什麼是作用域

作用域是一套規則,用於確定在何處以及如何查詢變數。

編譯原理

JavaScript是一門編譯語言。在傳統編譯語言的流程中,程式中一段原始碼在執行之前會經歷三個步驟,統稱為“編譯”。

  • 分詞/詞法分析
    將字串分解成有意義的程式碼塊,程式碼塊又稱詞法單元。比如程式var a = 2;會被分解為var、a、=、2、;
  • 解析/語法分析
    將詞法單元流轉換成一個由元素逐級巢狀所組成的代表了程式語法介面的書,又稱“抽象語法樹”。
  • 程式碼生成
    將抽象語法樹轉換為機器能夠識別的指令。

理解作用域

作用域 分別與編譯器、引擎進行配合完成程式碼的解析

  • 引擎執行時會與作用域進行交流,確定RHS與LHS查詢具體變數,如果查詢不到會丟擲異常。
  • 編譯器負責語法分析以及生成程式碼。
  • 作用域負責收集並維護所有變數組成的一系列查詢,並確定當前執行的程式碼對這些變數的訪問許可權。

對於 var a = 2 這條語句,首先編譯器會將其分為兩部分,一部分是 var a,一部分是 a = 2。編譯器會在編譯期間執行 var a,然後到作用域中去查詢 a 變數,如果 a 變數在作用域中還沒有宣告,那麼就在作用域中宣告 a 變數,如果 a 變數已經存在,那就忽略 var a 語句。然後編譯器會為 a = 2 這條語句生成執行程式碼,以供引擎執行該賦值操作。所以我們平時所提到的變數提升,無非就是利用這個先宣告後賦值的原理而已!

異常

對於 var a = 10 這條賦值語句,實際上是為了查詢變數 a, 並且將 10 這個數值賦予它,這就是 LHS 查詢。 對於 console.log(a) 這條語句,實際上是為了查詢 a 的值並將其列印出來,這是 RHS 查詢。

為什麼區分 LHSRHS 是一件重要的事情?
在非嚴格模式下,LHS 呼叫查詢不到變數時會建立一個全域性變數,RHS 查詢不到變數時會丟擲 ReferenceError。 在嚴格模式下,LHS 和 RHS 查詢不到變數時都會丟擲 ReferenceError。

作用域的工作模式

作用域共有兩種主要的工作模型。第一種是最為普遍的,被大多數程式語言所採用的詞法作用域( JavaScript 中的作用域就是詞法作用域)。另外一種是動態作用域,仍有一些程式語言在使用(比如Bash指令碼、Perl中的一些模式等)。

詞法作用域

詞法作用域是一套關於引擎如何尋找變數以及會在何處找到變數的規則。詞法作用域最重要的特徵是它的定義過程發生在程式碼的書寫階段(假設沒有使用 eval() 或 with )。來看示例程式碼:

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

function bar() {
  var a = 3;
  foo();
}

var a = 2;

bar()複製程式碼

詞法作用域讓foo()中的a通過RHS引用到了全域性作用域中的a,因此會輸出2。

動態作用域

而動態作用域只關心它們從何處呼叫。換句話說,作用域鏈是基於呼叫棧的,而不是程式碼中的作用域巢狀。因此,如果 JavaScript 具有動態作用域,理論上,下面程式碼中的 foo() 在執行時將會輸出3。

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

function bar() {
  var a = 3;
  foo();
}

var a = 2;

bar()複製程式碼

函式作用域

匿名與具名

對於函式表示式一個最熟悉的場景可能就是回撥函式了,比如

setTimeout( function() {
  console.log("I waited 1 second!")
}, 1000 )複製程式碼

這叫作匿名函式表示式。函式表示式可以匿名,而函式宣告則不可以省略函式名。匿名函式表示式書寫起來簡單快捷,很多庫和工具也傾向鼓勵使用這種風格的程式碼。但它也有幾個缺點需要考慮。

  • 匿名函式在棧追蹤中不會顯示出有意義的函式名,使得除錯很困難。
  • 如果沒有函式名,當函式需要引用自身時只能使用已經過期的 arguments.callee 引用,比如在遞迴中。另一個函式需要引用自身的例子,是在事件觸發後事件監聽器需要解綁自身。
  • 匿名函式省略了對於程式碼可讀性 / 可理解性很重要的函式名。一個描述性的名稱可以讓程式碼不言自明。

始終給函式表示式命名是一個最佳實踐:

setTimeout( function timeoutHandler() { // 我有名字了
  console.log("I waited 1 second!")
}, 1000 )複製程式碼

提升

先有宣告還是先有賦值

考慮以下程式碼:

a = 2;

var a;

console.log(a); // 2複製程式碼

考慮另外一段程式碼

console.log(a); // undefined

var a = 2;複製程式碼

我們習慣將 var a = 2; 看作一個宣告,而實際上 JavaScript 引擎並不這麼認為。它將 var a 和 a = 2 當作兩個單獨的宣告,第一個是編譯階段的任務,而第二個是執行階段的任務。
這意味著無論作用域中的宣告出現在什麼地方,都將在程式碼本身被執行前首先進行處理。可以將這個過程形象地想象成所有的宣告(變數和函式)都會被“移動”到各自作用域的最頂端,這個過程稱為提升。

可以看出,先有宣告後有賦值。

再來看以下程式碼:

foo();  // TypeError
bar();  // ReferenceError

var foo = function bar() {
  // ...
};複製程式碼

這個程式碼片段經過提升後,實際上會被理解為以下形式:

var foo;

foo();  // TypeError
bar();  // ReferenceError

foo = function() {
  var bar = ...self...
  // ...
};複製程式碼

這段程式中的變數識別符號 foo() 被提升並分配給全域性作用域,因此 foo() 不會導致 ReferenceError。但是 foo 此時並沒有賦值(如果它是一個函式宣告而不是函式表示式就會賦值)。foo()由於對 undefined 值進行函式呼叫而導致非法操作,因此丟擲 TypeError 異常。另外即時是具名的函式表示式,名稱識別符號(這裡是 bar )在賦值之前也無法在所在作用域中使用。

閉包

之前寫過關於閉包的一篇文章深入淺出JavaScript之閉包(Closure)

迴圈和閉包

要說明閉包,for 迴圈是最常見的例子。

for (var i = 1; i <= 5; i++) {
  setTimeout( function timer() {
    console.log(i);
  }, i*1000 )
}複製程式碼

正常情況下,我們對這段程式碼行為的預期是分別輸出數字 1~5,每秒一次,每次一個。但實際上,這段程式碼在執行時會以每秒一次的頻率輸出五次6。

它的缺陷在於:根據作用域的工作原理,儘管迴圈中的五個函式是在各個迭代中分別定義的,但是它們都被封閉在一個共享的全域性作用域中,因此實際上只有一個i。因此我們需要更多的閉包作用域。我們知道IIFE會通過宣告並立即執行一個函式來建立作用域,我們來進行改進:

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);
}複製程式碼

在迭代內使用 IIFE 會為每個迭代都生成一個新的作用域,使得延遲函式的回撥可以將新的作用域封閉在每個迭代內部,每個迭代中都會含有一個具有正確值的變數供我們訪問。

重返塊作用域

我們使用 IIFE 在每次迭代時都建立一個新的作用域。換句話說,每次迭代我們都需要一個塊作用域。我們知道 let 宣告可以用來劫持塊作用域,那我們可以進行這樣改:

for (var i = 1; i <= 5; i++) {
  let j = i;
  setTimeout( function timer() {
    console.log(j);
  }, j*1000 )
}複製程式碼

本質上這是將一個塊轉換成一個可以被關閉的作用域。

此外,for迴圈頭部的 let 宣告還會有一個特殊行為。這個行為指出每個迭代都會使用上一個迭代結束時的值來初始化這個變數。

for (let i = 1; i <= 5; i++) {
  setTimeout( function timer() {
    console.log(i);
  }, i*1000 )
}複製程式碼

this全面解析

之前寫過一篇深入淺出JavaScript之this。我們知道this是在執行時進行繫結的,並不是在編寫時繫結,它的上下文取決於函式呼叫時的各種條件。this的繫結和函式宣告的位置沒有任何關係,只取決於函式的呼叫方式。

this詞法

來看下面這段程式碼的問題:

var obj = {
  id: "awesome",
  cool: function coolFn() {
    console.log(this.id);
  }
};

var id = "not awesome";

obj.cool();  // awesome

setTimeout( obj.cool, 100); // not awesome複製程式碼

obj.cool() 與 setTimeout( obj.cool, 100 ) 輸出結果不一樣的原因在於 cool() 函式丟失了同 this 之間的繫結。解決方法最常用的是 var self = this;

var obj = {
  count: 0,
  cool: function coolFn() {
    var self = this;

    if (self.count < 1) {
      setTimeout( function timer(){
        self.count++;
        console.log("awesome?");
      }, 100)
    }
  }
}

obj.cool(); // awesome?複製程式碼

這裡用到的知識點是我們非常熟悉的詞法作用域。self 只是一個可以通過詞法作用域和閉包進行引用的識別符號,不關心 this 繫結的過程中發生了什麼。

ES6 中的箭頭函式引人了一個叫作 this 詞法的行為:

var obj = {
  count: 0,
  cool: function coolFn() {
    if (this.count < 1) {
      setTimeout( () => {
        this.count++;
        console.log("awesome?");
      }, 100)
    }
  }
}

obj.cool(); // awesome?複製程式碼

箭頭函式棄用了所有普通 this 繫結規則,取而代之的是用當前的詞法作用域覆蓋了 this 本來的值。因此,這個程式碼片段中的箭頭函式只是”繼承”了 cool() 函式的 this 繫結。

但是箭頭函式的缺點就是因為其是匿名的,上文已介紹過具名函式比匿名函式更可取的原因。而且箭頭函式將程式設計師們經常犯的一個錯誤給標準化了:混淆了 this 繫結規則和詞法作用域規則。

箭頭函式不僅僅意味著可以少寫程式碼。本書的作者認為使用 bind() 是更靠得住的方式。

var obj = {
  count: 0,
  cool: function coolFn() {
    if (this.count < 1) {
      setTimeout( () => {
        this.count++;
        console.log("more awesome");
      }.bind( this ), 100)
    }
  }
}

obj.cool(); // more awesome複製程式碼

繫結規則

函式在執行的過程中,可以根據下面這4條繫結規則來判斷 this 繫結到哪。

  • 預設繫結
    • 獨立函式呼叫
  • 隱式繫結
    • 當函式引用有上下文物件時,隱式繫結規則會把函式呼叫中的 this 繫結到這個上下文物件
  • 顯示繫結
    • call/apply
    • bind(本質是對call/apply函式的封裝 fn.apply( obj, arguments )
    • 第三方庫的許多函式都提供了一個可選的引數(上下文),其作用和 bind() 一樣,確保回撥函式使用指定的 this
  • new 繫結
    • JavaScript 中的 new 機制實際上和麵向類的語言完全不同
    • 實際上並不存在所謂的“建構函式”,只有對於函式的“構造呼叫”

書中對4條繫結規則的優先順序進行了驗證,得出以下的順序優先順序:

  • 函式是否在 new 中呼叫(new 繫結)?如果是的話 this 繫結的是新建立的物件。
  • 函式是否通過 call、apply(顯式繫結)或者硬繫結(bind)呼叫?如果是的話,this 繫結的是指定物件。
  • 函式是否在某個上下文物件中呼叫(隱式繫結)?如果是的話,this 繫結的是那個上下文物件。
  • 如果都不是的話,使用預設繫結。在嚴格模式下,繫結到 undefined,否則繫結到全域性物件。

被忽略的 this

如果你把 null 或者 undefined 作為 this 的繫結物件傳入 call、apply 或者 bind,這些值在呼叫時會被忽略,實際應用的是預設規則。

什麼時候會傳入 null/undefined 呢?一種非常常見的做法是用 apply(..) 來“展開”一個陣列,並當作引數傳入一個函式。類似地,bind(..) 可以對引數進行柯里化(預先設定一些引數),如下程式碼:

function foo(a, b) {
  console.log( "a:" + a + ", b:" + b );
}

// 把陣列"展開"成引數
foo.apply(null, [2, 3]); // a:2, b:3

// 使用 bind(..) 進行柯里化
var bar = foo.bind( null, 2);
bar(3); // a:2, b:3複製程式碼

其中 ES6 中,可以用 … 操作符代替 apply(..) 來“展開”陣列,但是 ES6 中沒有柯里化的相關語法,因此還是需要使用 bind(..)。

使用 null 來忽略 this 繫結可能產生一些副作用。如果某個函式(比如第三庫中的某個函式)確實使用了 this ,預設繫結規則會把 this 繫結到全域性物件,這將導致不可預計的後果。更安全的做法是傳入一個特殊的物件,一個 “DMZ” 物件,一個空的非委託物件,即 Object.create(null)。

function foo(a, b) {
  console.log( "a:" + a + ", b:" + b );
}

var ø = Object.create(null);

// 把陣列"展開"成引數
foo.apply( ø, [2, 3]); // a:2, b:3

// 使用 bind(..) 進行柯里化
var bar = foo.bind( ø, 2);
bar(3); // a:2, b:3複製程式碼

物件

JavaScript中的物件有字面形式(比如var a = { .. })和構造形式(比如var a = new Array(..))。字面形式更常用,不過有時候構造形式可以提供更多選擇。

作者認為“JavaScript中萬物都是物件”的觀點是不對的。因為物件只是 6 個基礎型別( string、number、boolean、null、undefined、object )之一。物件有包括 function 在內的子物件,不同子型別具有不同的行為,比如內部標籤 [object Array] 表示這是物件的子型別陣列。

複製物件

思考一下這個物件:

function anotherFunction() { /*..*/ }

var anotherObject = {
  c: true
};

var anotherArray = [];

var myObject = {
  a: 2,
  b: anotherObject, // 引用,不是複本!
  c: anotherArray, // 另一個引用!
  d: anotherFunction
};

anotherArray.push( myObject )複製程式碼

如何準確地表示 myObject 的複製呢?
這裡有一個知識點。

  • 淺複製。複製出的新物件中 a 的值會複製舊物件中 a 的值,也就是 2,但是新物件中 b、c、d 三個屬性其實只是三個引用。
  • 深複製。除了複製 myObject 以外還會複製 anotherArray。這時問題就來了,anotherArray 引用了 myObject, 所以又需要複製 myObject,這樣就會由於迴圈引用導致死迴圈。

對於 JSON 安全的物件(就是能用 JSON.stringify 序列號的字串)來說,有一種巧妙的複製方法:

var newObj = JSON.parse( JSON.stringify(someObj) )複製程式碼

我認為這種方法就是深複製。相比於深複製,淺複製非常易懂並且問題要少得多,ES6 定義了 Object.assign(..) 方法來實現淺複製。 Object.assign(..) 方法的第一個引數是目標物件,之後還可以跟一個或多個源物件。它會遍歷一個或多個源物件的所有可列舉的自由鍵並把它們複製到目標物件,最後返回目標物件,就像這樣:

var newObj = Object.assign( {}, myObject );

newObj.a; // 2
newObj.b === anotherObject; // true
newObj.c === anotherArray; // true
newObj.d === anotherFunction; // true複製程式碼

JavaScript 有一些近似類的語法元素(比如 new 和 instanceof), 後來的 ES6 中新增了一些如 class 的關鍵字。但是 JavaScript 實際上並沒有類。類是一種設計模式,JavaScript 的機制其實和類完全不同。

  • 類的繼承(委託)其實就是複製,但和其他語言中類的表現不同(其他語言類表現出來的都是複製行為),JavaScript 中的多型(在繼承鏈中不同層次名稱相同,但是功能不同的函式)並不表示子類和父類有關聯,子類得到的只是父類的一份複本。
  • JavaScript 通過顯示混入和隱式混入 call() 來模擬其他語言類的表現。此外,顯示混入實際上無法完全模擬類的複製行為,因為物件(和函式!別忘了函式也是物件)只能複製引用,無法複製被引用的物件或者函式本身。

檢查“類”關係

思考下面的程式碼:

function Foo() {
  // ...
}

Foo.prototype.blah = ...;

var a = new Foo();複製程式碼

我們如何找出 a 的“祖先”(委託關係)呢?

  • 方法一:a instanceof Foo; // true (物件 instanceof 函式)
  • 方法二: Foo.prototype.isPrototypeOf(a); // true (物件 isPrototypeOf 物件)
  • 方法三: Object.getPrototypeOf(a) === Foo.prototype; // true (Object.getPrototypeOf() 可以獲取一個物件的 [[Prototype]]) 鏈;
  • 方法四: a.__proto__ == Foo.prototype; // true

建構函式

  • 函式不是建構函式,而是當且僅當使用 new 時,函式呼叫會變成“建構函式呼叫”。
  • 使用 new 會在 prototype 生成一個 constructor 屬性,指向構造呼叫的函式。
  • constructor 並不表示被構造,而且 constructor 屬性並不是一個不可變屬性,它是不可列舉的,但它是可以被修改的。

物件關聯

來看下面的程式碼:

var foo = {
  something: function() {
    console.log("Tell me something good...");
  }
};

var bar = Object.create(foo);

bar.something(); // Tell me something good...複製程式碼

Object.create(..)會建立一個新物件 (bar) 並把它關聯到我們指定的物件 (foo),這樣我們就可以充分發揮 [[Prototype]] 機制的為例(委託)並且避免不必要的麻煩 (比如使用 new 的建構函式呼叫會生成 .prototype 和 .constructor 引用)。

Object.create(null) 會建立一個擁有空連結的物件,這個物件無法進行委託。由於這個物件沒有原型鏈,所以 instanceof 操作符無法進行判斷,因此總是會返回 false 。這些特殊的空物件通常被稱作“字典”,它們完全不會受到原型鏈的干擾,因此非常適合用來儲存資料。

我們並不需要類來建立兩個物件之間的關係,只需要通過委託來關聯物件就足夠了。而Object.create(..)不包含任何“類的詭計”,所以它可以完美地建立我們想要的關聯關係。

此書的第二章第6部分就把面對類和繼承行為委託兩種設計模式進行了對比,我們可以看到行為委託是一種更加簡潔的設計模式,在這種設計模式中能感受到Object.create()的強大。

ES6中的Class

來看一段 ES6中Class 的例子

class Widget {
  constructor(width, height) {
    this.width = width || 50;
    this.height = height || 50;
    this.$elem = null;
  }
  render($where){
    if (this.$elem) {
      this.$elem.css({
        width: this.width + "px",
        height: this.height + "px"
      }).appendTo($where);
    }
  }
}

class Button extends Widget {
  constructor(width, height, label) {
    super(width, height);
    this.label = label || "Default";
    this.$elem = $("<button>").text(this.label)
  }
  render($where) {
    super($where);
    this.$elem.click(this.onClick.bind(this));
  }
  onClick(evt) {
    console.log("Button `" + this.label + "` clicked!")
  }
}複製程式碼

除了語法更好看之外,ES6還有以下優點

  • 基本上不再引用雜亂的 .prototype 了。
  • Button 宣告時直接 “繼承” 了 Widget。
  • 可以通過 super(..)來實現相對多型,這樣任何方法都可以引用原型鏈上層的同名方法。
  • class 字面語法不能宣告屬性(只能宣告方法)。這是一種限制,但是它會排除掉許多不好的情況。
  • 可以通過 extends 很自然地擴充套件物件(子)型別。

但是 class 就是完美的嗎?在傳統面向類的語言中,類定義之後就不會進行修改,所以類的設計模式就不支援修改。但JavaScript 最強大的特性之一就是它的動態性,在使用 class 的有些時候還是會用到 .prototype 以及碰到 super (期望動態繫結然而靜態繫結) 的問題,class 基本上都沒有提供解決方案。

這也是本書作者希望我們思考的問題。

相關文章