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

本文首發在個人我的博客:muyunyun.cn/css

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

什麼是做用域

做用域是一套規則,用於肯定在何處以及如何查找變量。程序員

編譯原理

JavaScript是一門編譯語言。在傳統編譯語言的流程中,程序中一段源代碼在執行以前會經歷三個步驟,統稱爲「編譯」。es6

  • 分詞/詞法分析
    將字符串分解成有意義的代碼塊,代碼塊又稱詞法單元。好比程序var a = 2;會被分解爲var、a、=、二、;
  • 解析/語法分析
    將詞法單元流轉換成一個由元素逐級嵌套所組成的表明了程序語法接口的書,又稱「抽象語法樹」。
  • 代碼生成
    將抽象語法樹轉換爲機器可以識別的指令。

理解做用域

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

  • 引擎執行時會與做用域進行交流,肯定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 )。來看示例代碼:app

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 基本上都沒有提供解決方案。

這也是本書做者但願咱們思考的問題。

相關文章
相關標籤/搜索