讀《JavaScript核心技術開發解密》筆記

tyrocoder發表於2019-06-21

【前言】《JavaScript核心技術開發解密》這本書真的寫的非常好,特別適合深入理解JavaScript的執行機制,個人推薦閱讀。以下是我在閱讀該書時根據書中章節做的筆記,目的是方便自己加深理解。如果有理解錯誤的地方,還請指出來。

【筆記內容】

一、資料結構

在JS語言核心中,我們必須瞭解三種資料結構:棧(stack)、堆(heap)、佇列(queue)。

棧是一種先進後出,後進先出(LIFO)的資料結構,類似於只有一個出入口的羽毛球盒,在JS中,棧可以用來規定程式碼的執行順序,例如函式呼叫棧(call stack)

JS的陣列是提供了兩個棧操作的方法,

push向陣列的末尾新增元素(進棧)

pop取出陣列最末尾的一個元素(出棧)

堆是一種樹形的結構,JS中的物件表示可以看成是一種堆的結構,如:

var root = {
    a: 10,
    b: 20,
    c: {
        d: 30,
        e: 40
    }
}
複製程式碼

可以畫出堆的圖示如:

image-20190520130628236

那麼對應的,JS的物件一般是儲存在堆記憶體中。

佇列

佇列是一種**先進先出(FIFO)**的資料結構,類似於排隊過安檢一樣,理解佇列對理解JS的事件迴圈很有幫助

二、資料型別和記憶體空間

原始資料型別

最新的ECMAScript標準號定義了7種資料型別,其中包括6種原始資料型別和一種引用型別

其中,原始型別是:number、string、boolean、null、undefined、symbol (在ES5中沒有Symbol型別),

一種引用資料型別是Object

我們在書寫函式的時候,宣告變數一般是這樣的:

function foo(){
    var num1 = 28;
    var num2 = 39;
    ...
}
複製程式碼

那,在執行函式foo的時候,它的變數儲存在哪裡?從JS的記憶體管理上來看,函式執行時,會建立一個執行環境,這個執行環境叫做執行上下文(EC),在執行上下文中,會建立一個變數物件(VO),即函式內宣告的基礎資料型別儲存在該執行上下文的變數物件中

變數物件是儲存在堆記憶體中的,但是由於變數物件具有特殊功能,所以在理解時,我們將變數物件與堆記憶體空間區分開來

引用資料型別

引用資料型別除了Object,陣列物件、正規表示式、函式等也屬於引用資料型別。其中,引用資料型別的值是儲存在堆記憶體空間中的物件。如:

var o = {
    a: 10,
   	b: { m: 20}
}
複製程式碼

對於如上程式碼,o屬於引用資料型別,等號右邊的內容屬於其值,那麼{a:10,b:{m:20}}存在堆記憶體空間,o存在對應的執行上下文的變數物件中,這裡的執行上下文為全域性。

我們根據一個例子和圖示理解下:

function foo(){
    var num = 28;
    var str = 'hello';
    var obj = null;
    var b = { m: 20 };
    var c = [1,2,3];
    ...
}
複製程式碼

如圖,當我們想要訪問物件b的內容時,實際上是通過一個引用(地址指標)來訪問的:

image-20190520140256862

我們再來思考兩個問題:

var a = 20;
var b = a;
b = 30;
console.log(a);  // 此時輸出多少?
var m = {x:10, y:20};
var n = m;
n.y = 30;
console.log(m.y); // 此時輸出多少?
複製程式碼

輸出的結果是20 30,如果能夠理解這兩個輸出,那麼相信你對於引用和JS的記憶體管理是理解了。

記憶體空間管理

JS有自動垃圾回收機制,當一塊記憶體空間的資料能夠被訪問時,垃圾回收器就認為該資料暫時未使用完不算垃圾,碰到不需要再使用的記憶體空間資料時,會將其標記為垃圾,並釋放該記憶體空間,也就是標記-清除演算法。這個演算法會從全域性物件開始查詢,類似從樹的根節點往下找,進行標記清除。

所以,一般當一個函式執行完之後,其內部的變數物件就會被回收。但是如果是全域性變數的話,變數什麼時候釋放對於回收器來說是比較難判斷的,我們為了效能,應該儘量避免過多的使用全域性變數或者在不使用該全域性變數時手動設定變數值為null這種方式釋放。

三、執行上下文

前面說到,JS在執行一個函式時會建立一個執行上下文。其實,在全域性環境下也會有執行上下文,可以籠統的將執行上下文分為區域性執行上下文和全域性執行上下文。

一個JS程式中只能有一個全域性環境,但是可以有很多區域性環境,所以可見,在一個JS程式中,必定出現多個執行上下文。JS引擎以函式呼叫棧的方式來處理執行上下文,其中棧底永遠都是全域性上下文,棧頂則是當前正在執行的上下文,棧頂的執行上下文執行完畢後,會自動出棧

我們通過一個例項來理解:

function a(){
    var hello = "Hello";
    var world = "world";
    function b(){
        console.log(hello);
    }
    function c(){
        console.log(world);
    }
    b();
    c();
}

a();
複製程式碼

第一步,全域性上下文入棧,並置於棧底:

image-20190520142921427

第二步,全域性上下文入棧後,開始執行全域性上下文內部程式碼,直到遇到a(),a()啟用了函式a,從而建立了a的執行上下文,於是a的執行上下文入棧,如圖:

image-20190520143147280

第三步,a的執行上下文執行內容,碰到了b()啟用了b函式,所以b的執行上下文入棧:

image-20190520143403615

第四步,在b的執行上下文裡面,沒有可以生成其他執行上下文的情況,所以這段程式碼可以順利執行完畢,b的執行上下文出棧。

image-20190520143147280

第五步,b的執行上下文出棧之後,急需執行a的後面內容,碰到了c()啟用了c函式,所以c的執行上下文入棧,如圖所示:

image-20190520143859026

第六步,在c的執行上下文中,沒有其他的生成執行上下文內容,所以當c裡面的執行程式碼結束後,c的執行上下文出棧:

image-20190520144111387

第七步,a接下來的程式碼也執行完畢,所以接著a的執行上下文出棧

image-20190520142921427

最後,全域性上下文在瀏覽器視窗關閉(或Node程式終止)的時候出棧。

注意:函式執行中,如果碰到return會直接終止可執行程式碼的執行,因此會直接將當前上下文彈出棧。

總的執行順序如圖:

image-20190520145341482

思考下面的程式從執行上下文來看分為幾步?

function a(){
    var hello = "hello";
    function b(){
        console.log(b);
    }
    return b;
}
var result = a();
result();
複製程式碼

圖示如下:

image-20190520145859647

四、變數物件

前面我們提到過變數物件,在JS程式碼中宣告的所有變數都儲存在變數物件中,其中變數物件包含如下內容:

  1. 函式的所有引數
  2. 當前上下文的所有函式宣告(通過function宣告的函式)
  3. 當前上下文的所有變數宣告(通過var宣告的變數)

建立過程

  1. 在Chrome瀏覽器(Node)中,變數物件會首先獲取函式的引數變數及值;在Firefox瀏覽器中,直接將引數物件arguments儲存到變數物件中;
  2. 先依次獲取當前上下文所有的函式宣告,也就是function關鍵字宣告的函式。函式名作為變數物件的屬性,其屬性值為指向該函式所在的記憶體地址引用。如果函式名已存在,那麼屬性值會被新的引用覆蓋
  3. 依次獲取當前上下文所有的變數宣告,也就是var關鍵字宣告的變數。每找到一個變數就在變數物件中建議一個屬性,屬性值為undefined。如果該變數名已存在,為防止同名函式被修改為undefined,則會直接跳過該變數,原屬性值不修改

我們根據上面的過程,思考下面這一句程式碼執行的過程:

var a = 30;
複製程式碼

過程如下:

第一步,上下文的建立階段會先確認變數物件,而變數物件的建立過程對於變數宣告來說是先獲取變數名並賦值為undefined,所以第一步拆解為:

var a = undefined;

複製程式碼

上下文建立階段結束後,進入執行階段,在執行階段完成變數賦值的工作,所以第二步是:

a = 30;

複製程式碼

需要注意的是,這兩步分別是在上下文的建立階段和執行階段完成的,因此var a=undefined是提前到比較早的地方去執行了,也即是變數提升(Hoisting)。所以,我們現在要有意識,就是JS程式的執行是分為上下文建立階段和執行階段的

思考如下程式碼的執行順序:

console.log(a);  // 輸出什麼?
var a = 30;

複製程式碼

在變數物件的建立過程中,函式宣告的優先順序高於變數宣告,而且同名的函式會覆蓋函式與變數,但是同名的變數並不會覆蓋函式。不過在上下文的執行階段,同名的函式會被變數重新賦值。

如下程式碼中:

var a = 20;
function fn(){ console.log('函式1') };
function fn(){ console.log('函式2') };
function a(){ console.log('函式a') };


fn();
var fn = '我是變數但是我要覆蓋函式';
console.log(fn);
console.log(a);

// 輸出:
// 函式2
// 我是變數但是我要覆蓋函式
// 20

複製程式碼

上面例子執行的順序可以看成:

/** 建立階段 **/
// 函式變數先提升
function fn(){ console.log('函式1') };
function fn(){ console.log('函式2') };
function a(){ console.log('函式a') };
// 普通變數接著提升
var a = undefined; 
var fn = undefined;  // 建立階段即使同名,但是變數的值不會覆蓋函式值

/** 執行階段 **/
a = 20;
fn();
fn = '我是變數但是我要覆蓋函式';
console.log(fn);
console.log(a);

複製程式碼

例項分析

function foo(){
    console.log(a);
    console.log(fn());
    
    var a = 1;
    function fn(){
        return 2;
    }
}
foo();

複製程式碼

執行foo函式時,對應的上下文建立,我們使用如下形式來表達這個過程:

/** 建立過程 **/
fooEC(foo的執行上下文) = {
    VO: {},		// 變數物件
    scopeChain: [],		// 作用域鏈
    this: {}	
}

// 這裡暫時不討論作用域與this物件

// 其中,VO含如下內容
VO = {
    arguments: {...},
    fn: <fn reference>,
    a: undefined
}

複製程式碼

建立過程中會建立變數物件,所以如上形式所示。在函式呼叫棧中,如果當前上下文在棧頂,那就開始執行,此時變數物件稱為活動物件(AO,Activation Object):

/** 執行階段 **/
VO -> AO
AO = {
    arguments: {},
    fn: <fn reference>,
    a: 1
}

複製程式碼

所以,這段程式碼的執行順序應該為:

function foo(){
    function fn(){
    	return 2;
    }
    var a = undefined;
    console.log(a);
    console.log(fn());
    a = 1;
}
foo();

複製程式碼

全域性上下文的變數物件

以瀏覽器為例,全域性變數物件為window物件。而在node中,全域性變數物件是global。

windowEC = {
    VO: window,
    this: window,
    scopeChain: []
}

複製程式碼

五、作用域與作用域鏈

在其他的語言中,我們肯定也聽說過作用域這個詞,作用域是用來規定變數與函式可訪問範圍的一套規則

種類

在JS中,作用域分為全域性作用域與函式作用域。

全域性作用域

全域性作用域中宣告的變數與函式可以在程式碼的任何地方被訪問。

如何建立全域性作用域下的變數:

  1. 全域性物件擁有的屬性與方法

    window.alert
    window.console
    
    複製程式碼
  2. 在最外層宣告的變數與方法

    var a = 1;
    function foo(){...}
    
    複製程式碼
  3. 非嚴格模式下,不使用關鍵字宣告的變數和方法【不要使用!!】

    function foo(){
        a = 1;    // a會成為全域性變數
    }
    
    複製程式碼

函式作用域

函式作用域中宣告的變數與方法,只能被下層子作用域訪問,而不能被其他不相干的作用域訪問。

例如:

function foo(){
    var a = 1;
    var b = 2;
}
foo();
function sum(){
    return a+b;
}
sum(); // 執行報錯,因為sum無法訪問到foo作用域下的a和b

複製程式碼

但是像下面這樣寫是對的:

function foo(){
    var a = 1;
    var b = 2;
    function sum(){
        return a+b;
    }
    sum();	// 可以訪問,因為sum的作用域是foo作用域的子作用域
}
foo();

複製程式碼

在ES6之前,ECMAScript沒有塊級作用域,因此使用時需要特別注意,一定是在函式環境中才可以生成新的作用域。而ES6之後,我們可以通過用let來宣告變數或方法,這樣它們就能在"{"和"}"之間形成塊級作用域

模擬塊級作用域

我們可以通過函式來模擬塊級作用域,如下:

var arr = [1,2,3,4];

(function(){
    for (var i=0; i< arr.length; i++ ){
        console.log(i);
    }
})();

console.log(i); // 輸出undefined,因為i在函式作用域裡

複製程式碼

這種函式叫做立即執行函式

寫法大致有如下幾種,建議第一種寫法:

(function(){
    ...
})();

!function(){
    ...
}();
    
// 把!改成+或-也可以

複製程式碼

在ECMAScript開發中,我們可能會經常使用立即執行函式方式來實現模組化。

作用域鏈

function a(){
    ...
    function b(){
        ...
    }
}
a();

複製程式碼

如上虛擬碼中,先後建立了全域性函式a和函式b的執行上下文,假設加上全域性上下文,它們的變數物件是VO(global),VO(a)和VO(b),那麼b的作用域鏈則同時包含了這三個變數物件,如下:

bEC = {
    VO: {...},
    scopeChain: [VO(b), VO(a), VO(global)],  //作用域
    this: {}
}

複製程式碼

作用域鏈是在程式碼執行階段建立的,理解作用域鏈是學習閉包的前提,閉包裡面會有更多的對作用域鏈的應用

六、閉包

什麼是閉包

簡單來講的話,閉包就是指有權訪問另一個函式作用域中變數的函式,其本質是在函式外部保持了內部變數的引用,。

建立一個閉包最常見的方式,就是在一個函式內部建立另一個函式,這個函式就是閉包,如下:

function foo(){
    var a = 1;
    var b = 2;
    
    function closure(){
        return a + b;
    }
    
    return closure;
}

複製程式碼

上面的程式碼中,closure函式就是一個閉包(在chrome的除錯裡面,用閉包的父作用域函式名錶示),閉包的作用域為[VO(closure), VO(foo), VO(global)]

根據上面的理解,我們來看一個例子,裡面有閉包嗎:

var name = 'window';
var obj = {
    name: 'my object',
    getName: function(){
        return function(){
            return this.name
        }
    }
}
console.log( obj.getName()() )  // 輸出: window

複製程式碼

在這個例子中,雖然在getName函式裡面,用了一個內部函式,但是我們發現最終返回的this.name輸出window,可以看出這不是一個閉包。因為其返回的是個匿名函式,而匿名函式的執行上下文是全域性上下文,因此其this物件通常指向全域性物件,所以this.name輸出了window。那我們怎麼修改可以讓其返回obj的name屬性呢?

如下:

var name = 'window';
var obj = {
    name: 'my object',
    getName: function(){
        var _this = this;
        return function(){
            return _this.name
        }
    }
}
console.log( obj.getName()() )  // 輸出: my object

複製程式碼

總結下,就是閉包的作用域鏈必須是包含了他的父級函式作用域,使用了父級作用域的變數物件下的變數肯定就包含了父級作用域

閉包和垃圾回收機制

我們來回顧下垃圾回收機制:當一個值不再被引用時會被標記然後清除,當一個函式的執行上下文執行完畢後,內部所有的內容都會失去引用而被清除。

閉包的本質是保持在外部對函式的引用,所以閉包會阻止垃圾回收機制進行回收。

例如一下程式碼:

function foo(){
    var n = 1;
    nAdd = function(){
        n += 1;
    }
    return function fn(){
        console.log(n);
    }
}
var result = foo();
result();      // 1
nAdd();
result();      // 2

複製程式碼

因為nAdd和fn函式都訪問了foo的n變數,所以它們都與foo形成了閉包。這個時候變數n的引用被儲存了下來。

所以,在使用閉包時應該警惕,濫用閉包,很可能會因為記憶體原因導致程式效能過差

閉包的應用場景

回顧下,使用閉包後,任何在函式中定義的變數,都可以認為是私有變數,因為不能在函式外部訪問這些變數。私有變數包括函式的引數、區域性變數和函式定義的其他函式。

迴圈、setTimeout與閉包

我們先來看一個面試常見的例子:

for( var i=0; i<5; i++ ){
    setTimeout(function timer(){
        console.log(i);
    }, i*1000);
}

複製程式碼

可能乍一看會覺得每隔1秒從0輸出到4,但是實際的執行是每隔1秒輸出一個5。

我們來分析一下:

  1. for迴圈不能形成自己的作用域,所以i是全域性變數,會隨著迴圈遞增,迴圈結束後為5
  2. 在每一次迴圈中,setTimeout的第二個引數訪問的都是當前的i,因此第二個引數中i分別為0,1,2,3,4
  3. 第一個引數timer訪問的是timer函式執行時的i,由於延遲原因,當timer開始執行時,此時i已經為5了

如果我們要隔秒輸出0,1,2,3,4,那就需要讓for迴圈形成自己的作用域,所以需要藉助閉包的特性,將每一個i值用一個閉包儲存起來。如下程式碼:

for( var i=0; i<5; i++ ){
    (function(i){
        setTimeout(function timer(){
            console.log(i);
        }, i*1000);
    })(i);
}

複製程式碼

當然,在ES6或更高版本中,可以直接使用let關鍵字形成for的塊級作用域,這樣也是OK的:

for( let i=0; i<5; i++ ){
    setTimeout(function timer(){
        console.log(i);
    }, i*1000);
}

複製程式碼

單例模式與閉包

JavaScript也有許多解決特定問題的編碼思維(設計模式),例如我們常聽到過的工廠模式、訂閱通知模式、裝飾模式、單例模式等。其中,單例模式是最常用也是最簡單的一種,我們嘗試用閉包來實現它。

其實在JS中,物件字面量就是一個單例物件。如下:

var student = {
    name: 'zeus',
    age: 18,
    getName: function(){
        return this.name;
    },
    getAge: function(){
        return this.age;
    }
}
student.getName();
student.name;

複製程式碼

但是,這種物件的變數很容易被外部修改,不符合我們的需求,我們期望建立自己的私有屬性和方法。如下:

var student = (function(){
    var name = 'zeus';
    var age = 18;
    
    return {  // 外部可訪問內容
        getName: function(){
            return name;
        },
        getAge: function(){
            return age;
        }
    }
})();
student.getName();
student.name;  // undefined

複製程式碼

如上,第二個例子中,在立即函式執行的時候就返回student物件了,下面我們寫一個例子,在呼叫時才初始化:

var student = (function(){
    var name = 'zeus';
    var age = 18;

    var instance = null; // 定義一個變數,用來儲存例項
    
    function init(){
        return {
            getName: function(){
                return name;
            },
            getAge: function(){
                return age;
            }
        }
    }

    return {
        getInstance: function(){
            if ( !instance ){
                instance = init();
            }
            return instance;
        }
    }

    
})();
var student1 = student.getInstance();
var student2 = student.getInstance();
console.log( student1 === student2 );  // true

複製程式碼

模組化與閉包

提出一個問題:如果想在所有的地方都能訪問同一個變數,應該怎麼做?例如全域性的動態管理。

解決方案:使用全域性變數(但是時間開發中,不建議輕易使用全域性變數)。

其實,模組化的思想可以幫助我們解決這個問題。

模組化開發是目前最流行,也是必須要掌握的一種開發思路。而模組化其實是建立在單例模式上的,因此模組化開發和閉包息息相關。目前比如Node裡的require,ES6的import和modules等,實現方式不同但是核心思路是一樣的

模組化架構一般需要實現下面三個內容:

1.每一個單例就是一個模組,在當前的一些模組化開發中,每一個檔案是一個模組

2.每個模組必須有獲取其他模組的能力

如在一些模組化開發中,使用require或者import來獲取其他模組內容

3.每一個模組都應該有對外的介面,以保證與其他模組互動的能力

在一些模組化開發中使用module.exports或者export default {}等將允許其他模組使用的介面暴露出來

我們今天使用單例模式,來實現簡單的模組化思想,案例實現的是每隔一秒,body的背景色就隨著一個數字的遞增在固定的三個顏色之間切換:

/**
*   管理全域性狀態模組,含有私有變數並暴露兩個方法來獲取和設定其內部私有變數
*/
var module_status = (function(){
    var status = {
        number: 0,
        color: null
    }

    var get = function(prop){
        return status[prop];
    }

    var set = function(prop, value){
        status[prop] = value;
    }

    return {
        get: get,
        set: set
    }
})();
/**
*   負責body背景顏色改變的模組
*/
var module_color = (function(){
    // 假裝用這種方式執行第二步引用模組
    var state = module_status;

    var colors = ['#c31a86', 'orange', '#ccc'];

    function render(){
        var color = colors[ state.get('number') % 3];
        document.body.style.backgroundColor = color;
    }

    return {
        render: render
    }

})();
/**
*   負責顯示當前number值模組,用於參考對比
*/
var module_context = (function(){
    var state = module_status;

    function render(){
        document.body.innerHTML = 'this Number is '+state.get('number');
    }

    return {
        render: render
    }

})();
/**
*   主模組,藉助上面的功能模組實現我們需要的功能
*/
var module_main = (function(){
    var state = module_status;
    var color = module_color;
    var context = module_context;

    setInterval(function(){
        var newNumber = state.get('number') + 1;
        state.set('number', newNumber);

        color.render();
        context.render();
    }, 1000);
})();

複製程式碼

自己分析整個完整的程式碼之後,真的很有幫助

七、this物件

上面六大節的內容,可以算是JavaScript的進階,但其實應該算是JavaScript的基礎,具備這些知識的時候再來看this物件這一節,收穫會很大。在JS中,最重要的部分就是理解閉包和this!

我們來回顧下執行上下文和變數物件那一節,我們知道在函式被呼叫執行時,變數物件VO會生成,這個時候,this的指向會確定。因此,必須牢記當前函式的this是在函式被呼叫執行的時候才確定的,也就是說this物件需要當前執行上下文在函式呼叫棧的棧頂時,VO變成AO,同時this的指向確定。

如下程式碼:

var a = 10;
var obj = {
    a: 20
}
function fn(){
    console.log(this.a);
}
fn();  // 10
fn.call(obj); // 20

複製程式碼

程式碼裡面,fn函式裡的this分別指向了window(全域性物件變數)與obj

全域性物件的this

全域性物件的變數物件是一個比較特殊的存在,在全域性物件中,this指向它本身,所以比較簡單

this.a1 = 10;
var a2 = 20;
a3 = 30;

console.log(a1); //10
console.log(a2); //20
console.log(a3); //30

複製程式碼

以上的用法都會構建全域性變數,且在非嚴格模式語法上沒有錯誤。

函式中的this

在本節第一個例子中,我們看到,同一個函式的this由於呼叫方式不同導致this的指向不同,因此,在函式中,this最終指向誰,與呼叫該函式的方式有關。

在一個函式的執行上下文中,this由該函式的呼叫者提供,由呼叫函式的方式來決定其指向

如下例子:

function fn(){
    console.log(this);
}
fn();	// fn為呼叫者,獨立呼叫,非函式的嚴格模式下指向全域性物件window

複製程式碼

如果呼叫者被某個物件擁有,那麼在呼叫該函式時,函式內部的this指向該物件。如果呼叫者函式獨立呼叫,那麼該函式內部this指向undefined,但是在非嚴格模式下,當this指向undefined時,它會指向全域性物件。

function fn(){
    'use strict';
    console.log(this);
}
fn();   // undefined
window.fn();  // window

複製程式碼

思考一下,如下這個例子返回什麼:

var a = 20;
var obj = {
    a: 30
}
function fn(){
    console.log('fn this:', this);
    function foo(){
        console.log(this.a);
    }
    foo();
}
fn.call(obj);
fn();

複製程式碼

另外,物件字面量不會產生作用域,所以如下

'use strict';
var obj = {
 a: 1,
 b: this.a+1
}

複製程式碼

嚴格模式下會報語法錯誤,非嚴格模式下this指向全域性物件

思考下面的例子:

var a = 10;
var foo = {
 a: 20,
 getA: function(){
     return this.a;
 }
}

console.log( foo.getA() );  // 20

var test = foo.getA();
console.log( test() );	// 10,這裡為什麼是10?

複製程式碼

因為test在執行時,test是呼叫者,它是獨立呼叫,在非嚴格模式下,其this指向全域性物件

思考如下程式碼輸出什麼:

function foo(){
    console.log(this.a);
}

function active(fn){
    fn();
}

var a = 20;
var obj = {
    a: 10,
    getA: foo,
    active: active
}

active(obj.getA);
obj.active(obj.getA);

複製程式碼

call/apply/bind顯式的指定this

JS內部提供了一種可以手動設定函式內部this指向的方式,就是call/apply/bind。所有的函式都可以呼叫這三個方法。

看如下例子:

var a = 20;
var obj = {
    a: 30
}
function foo(num1,num2){
    console.log(this.a+num1+num2);
}

複製程式碼

我們知道,直接呼叫foo(10,10)的話會列印40,如果我們想把obj裡的a列印出來,我們像下面這樣寫:

foo.call(obj,10,10);	// 50
// 或
foo.apply(obj, [10,10]);  // 50

複製程式碼

那其實call/apply表示將第一個引數作為該函式執行的this物件指向,然後立即執行函式。

call和apply有一點區別,就是傳參的區別:

在call中,第一個引數是函式內部this的指向,後續引數則是函式執行時所需引數;

在apply中,只有兩個引數,第一個引數是函式內部this的指向,第二個引數是一個陣列,陣列裡面是函式執行所需引數。

bind方法用法與call方法一樣,與call唯一不同的是,bind不會立即執行函式,而是直接返回一個新的函式,並且新的函式內部this指向bind方法的第一個引數

八、函式與函數語言程式設計

其實,我們仔細回顧下會發現,前面的一到七節的內容基本上都是在圍繞函式展開的,讓我們更加清晰的認識函式,這一節主要了解如何運用函式

函式

函式的形式有四種:函式宣告、函式表示式、匿名函式與立即執行函式。

1.函式宣告

關鍵字function,從前面的執行上下文建立過程我們知道function關鍵字宣告的變數比var關鍵字宣告的變數有更高的優先執行順序,所以變數提升中,先提升函式變數。

function fn(){ ... }

複製程式碼

2.函式表示式

指將一個函式體賦值給一個變數的過程

var fn = function(){ ... }

複製程式碼

可以理解為:

// 建立階段
var fn = undefined;
// 執行階段
fn = function(){ ... }

複製程式碼

所以使用函式表示式時,必須要考慮函式使用的先後順序:

fn();  // TypeError: fn is not a function

var fn = function(){ console.log('hello') }

複製程式碼

請問,如果在函式表示式裡面有this,那這個this指向什麼?

3.匿名函式

就是指沒有名字的函式,一般會作為引數或返回值來使用,通常不使用變數來儲存它的引用。

匿名函式不一定就是閉包,匿名函式可以作為普通函式來理解,而閉包的形成條件,僅僅是有的時候或者匿名函式有關而已

4.立即執行函式

立即執行函式是匿名函式一個非常重要的應用場景,因為函式可以產生作用域,所以我們經常使用立即執行函式來模擬塊級作用域,並進一步在此基礎上實現模組化的運用。

函數語言程式設計

函數語言程式設計其實就是將一些功能、邏輯等封裝起來以便使用,減少重複編碼量。函數語言程式設計的內涵就是函式封裝思想。怎麼去封裝,學習前輩優秀的封裝習慣。讓自己的程式碼看上去更加專業可靠是我們學習的目的。

1.函式是一等公民

一等公民也就是說函式跟其他的變數一樣,沒有什麼特殊的,我們可以像對待任何資料型別一樣對待函式。

  • 把函式賦值給一個變數

    var fn = function(){}
    
    複製程式碼
  • 把函式作為形參

    function foo(a, b, callback){
        callback(a+b);
    }
    function fn(res){
        console.log(res);
    }
    foo(2,3,fn);  // 5
    
    複製程式碼
  • 函式作為返回值

    function foo(x){
        return function(y){
            console.log(x+y);
        }
    }
    foo(2)(3);	// 5
    
    複製程式碼

2.純函式

相同的輸入總會得到相同的值,並且不會產生副作用的函式,叫做純函式。

例如我們想封裝一個獲取陣列最後一項的方法,有兩種選擇:

// 第一種
function getLast1(arr){
    return arr[arr.length];
}

// 第二種
function getLast2(arr){
    return arr.pop();
}

複製程式碼

getLast1和getLast2雖然都可以滿足需求,但是getLast2在使用之後會改變arr陣列內容,下一次再使用的話,由於arr最後一個值已經被取出,導致第二次使用取到的值是原來值的倒數第二個值。所以,像第二種這樣的封裝是非常糟糕的,會將原資料弄得特別混亂。在JavaScript的標準函式裡,也有許多不純的方法,我們在使用時要多注意。

3.高階函式

可以粗暴的理解,凡是接收一個函式作為引數的函式,就是高階函式。但是這樣就太廣義了,高階函式其實是一個高度封裝的過程,

我們來嘗試封裝一個方法mapArray(array, fn),其有兩個引數,第一個引數是一個陣列,第二個引數是一個函式,其中第二個引數引數有兩個引數fn(item, index)第一個item表示是陣列每次遍歷的值,第二個是每次遍歷的序號。

var a = [1,2,3,4,5];

function mapArray(array, fn){
    var temp = [];
    if ( typeof fn === "function" ){
        for ( var k=0; k<array.length; k++ ){
            temp.push( fn.call(array, array[k], k) );
        }
    } else {
        console.error('TypeError' + fn + ' is not a function.');
    }
    return temp;
}

var b = mapArray(a, function(item, index){
    console.log(this.length);  // 5
    return item + 3;
});

console.log(b);  // [4,5,6,7,8]
複製程式碼

mapArray函式實現了將陣列裡的每一項都進行了相同的操作,並且在第二個函式裡的this指向的是第一個陣列引數物件。

從這個封裝函式來看,其實是把陣列的迴圈給封裝了,那就是說,我們要封裝的就是程式公用的那一部分,而具體要做什麼事情,則以一個引數的形式,來讓使用者自定義。這個被當做引數傳入的函式就是基礎函式,而我們封裝的mapArray方法,就可以稱之為高階函式

高階函式其實是一個封裝公共邏輯的過程

4.柯里化函式

暫時不說,因為比較難,我需要仔細理清之後再寫

九、物件導向

雖然JS是物件導向的高階語言,但是它與Java等一類語言不同,在ES6之前是沒有class的概念的,基於原型的構建讓大家深入理解JavaScript的物件導向有點困難。難點就是重點,所以JS的物件導向肯定是需要我們去了解的

在EcmaScript-262中,JS物件被定義為**"無序屬性的集合,其屬性可以包含基本值、物件或者函式"**。

物件字面量

從上面的定義中,物件是由一系列的key-value對組成,其中value為基本資料型別或物件,陣列,函式等。像這種形式的物件定義格式,叫做物件字面量,如:

var Student = {
    name: 'ZEUS',
    age: 18,
    getName: function(){
        return this.name;
    }
    parent: {}
}
複製程式碼

建立物件

第一種,通過關鍵字new來建立一個物件:

var obj = new Object();		// new 後面接的是建構函式
複製程式碼

第二種,使用物件字面量:

var obj = {};
複製程式碼

我們要給物件建立屬性或方法時,可以像這樣:

// 第一種方式
var person = {};
person.name = 'zeus';
person.getName = function(){
    return this.name;
}

// 第二種方式
var person = {
    name: 'zeus',
    getName: function(){
        return this.name;
    }
}
複製程式碼

訪問物件的方法或屬性,可以使用.或者 ['']

建構函式與原型

在函數語言程式設計那一節,我們講到封裝函式就是封裝一些公共邏輯與功能。當面對具有同一類事物時,我們也可以藉助建構函式與原型的方式,將這類事物封裝成物件

例如:

var Student = function(name, age){
    this.name = name;
    this.age = age;
    console.log(this);
}
Student.prototype.getName = function(){
    return this.name;
}

// 例項化物件時
var zeus = new Student('zeus', 18);  // zeus例項
zeus.getName();
Student('zeus', 18);   // window
複製程式碼

可以看到,具體的某個學生的特定屬性,通常放在建構函式中;所有學生的方法和屬性,通常放在原型物件中。

上述程式碼輸出內容如下圖:

image-20190531113004351

這裡提個問,建構函式是高階函式嗎?在這裡,new Student()內部的this為什麼會指向例項物件呢,而Student()內部this指向window?

建構函式名約定首字母大寫,這裡必須要注意。建構函式的this與原型方法中的this指向的都是當前例項。像上面,使用了new關鍵字之後,Student()函式才是建構函式。那new關鍵字具體做了什麼呢?我們可以來用一個函式模擬new關鍵字的能力:

function New(func){
    var res = {};
    if ( func.prototype !== null ){
        res.__proto__ = func.prototype; 
    }
    var ret = func.apply(res, Array.prototype.slice.call(arguments, 1) );
    // 當我們在建構函式中明確指定了返回物件時,進行這一步
    if ( (typeof ret ==="object" || typeof ret==="function" ) && ret !== null ){
        return ret;
    }
    // 如果沒有明確指定返回物件,則預設返回res,這個res就是例項物件
    return res;
}
複製程式碼

通過對New方法的封裝,可以知道new關鍵字在建立例項時經歷瞭如下過程:

  1. 先建立一個新的、空的例項物件;
  2. 將例項物件的原型(__proto__),指向建構函式的原型(prototype);
  3. 將建構函式內部的this,修改為指向例項;
  4. 最後返回該例項物件

建構函式、原型、例項之間的關係

我們可不可以在建構函式裡面建立方法?當然是可以的,但是這樣比較消耗更多的記憶體空間,因為每一次建立例項物件,都會建立一次該方法。

所以可以看出,在建構函式裡宣告的變數與方法只屬於當前例項,因此我們可以將建構函式中宣告的屬性與方法看做該例項的私有屬性和方法,它們只能被當前例項訪問。而原型中的屬性與方法能夠被所有的例項訪問,因此可以將原型中宣告的屬性和方法稱為公有屬性與方法。如果建構函式裡的私有屬性/方法與原型裡的公有屬性/方法重名,那麼會優先訪問私有屬性/方法

怎麼判斷一個物件是否擁有某一個方法/屬性

  1. 通過in運算子來判斷,無論該方法/屬性是否公有,只要存在就返回true,否則返回false
  2. 通過hasOwnProperty方法來判斷,只有該方法/屬性存在且為私有時,才返回true,否則返回false
var Student = function(name, age){
this.name = name;
this.age = age;
this.speak = function(){
   console.log('我是'+this.name+'的私有方法')
}
}

Student.prototype.getName = function(){
console.log(this.name);
}

var Bob = new Student('Bob', 18);
Bob.speak();
Bob.getName();

console.log( 'speak' in Bob);  // true
console.log( 'getName' in Bob);  // false
console.log( Bob.hasOwnProperty('speak') );  // true
console.log( Bob.hasOwnProperty('getName') );  // false
複製程式碼

如果要在原型上新增多個方法,還可以這樣寫:

function Student(){};
Student.prototype = {
    constructor: Student,    // 必須宣告
    getName: function(){},
    getAge: function(){}
}
複製程式碼

原型物件

原型物件其實也是普通物件。在JS中,幾乎所有的物件都可以是原型物件,也可以是例項物件,還可以是建構函式,甚至身兼數職。當一個物件身兼多職時,它就可以被看作原型鏈中的一個節點。

當要判斷一個物件student是否是建構函式Student的例項時,可以使用instanceof關鍵字,其返回一個boolean值:

student instanceof Student;    // true or false
複製程式碼

我們回到最開始的時候,當建立一個物件時,除了使用物件字面量也可以使用new Object()來建立,因此Object其實是一個建構函式,而其對應的原型Object.prototype則是原型鏈的終點。

當建立函式時,除了使用function關鍵字外,還可以使用Function物件:

var add = new Function("a", "b", "return a+b");
// 等價於
var add = function(a, b){
    return a+b;
}
複製程式碼

在這裡,add方法是一個例項物件,它對應的建構函式是Function,它的原型是Function.prototype,也就是add.__proto__ === Function.prototype。這裡比較特殊的是,Function同時是Function.prototype的建構函式與例項(因為Function也是一個函式啦!);而與此同時,因為Function是繼承自Object的,所以Function.prototype還是Object.prototype的例項,它們的原型鏈可以用下圖表示:

add函式相關的原型鏈

對原型鏈上的方法與屬性的訪問,與作用域鏈相似,也是一個單向的查詢過程,雖然add與Object原型沒有直接關係,但是它們在同一條原型鏈上,因此add也可以使用Object的toString方法等(比如hasOwnProperty方法)。

例項方法,原型方法,靜態方法

看如下程式碼即可瞭解:

function Foo(){
    this.bar = function(){     // 例項(私有)方法
        return 'bar in Foo';    
    }
}

Foo.bar = function(){		// 靜態方法,不需要例項化,直接可以用函式名呼叫
    return 'bar in static';	
}

Foo.prototype.bar = function(){		// 原型方法
    return 'bar in prototype';
}
複製程式碼

繼承

因為封裝一個物件是由建構函式與原型共同組成的,所以繼承也被分為兩部分,一部分是建構函式繼承另一部分是原型繼承。

如下程式碼:

var Person = function(name, age){
    this.name = name;
    this.age = age;
}

Person.prototype.say = function(){
    console.log('您好');
}

var Student = function(name, age, grade){
    Person.call(this, name, age);  // 在這裡是構造繼承
    this.grade = grade;
}
// 下面這兩句是原型繼承
Student.prototype = new Person();
Student.prototype.constructor = Student;  // 這句一定不能少
Student.prototype.speak = function(){
    console.log(`我叫${this.name},我今年${this.age}歲了,我語文考了${this.grade}分`);
}

var kevin = new Student('kevin', 18, 90);
kevin.speak();
kevin.say();
複製程式碼

這段程式碼屬於組合繼承,是比較常用的一種繼承方式,不過他有個不足就是,無論什麼情況下都會呼叫兩次父級建構函式。

如下是優化之後的程式碼:

function inheritPrototype(child, parent){
    var obj = Object(parent.prototype);
    obj.prototype = child;
    child.prototype = obj;
}

var Person = function(name, age){
    this.name = name;
    this.age = age;
}
Person.prototype.say = function(){
    console.log('您好');
}

var Student = function(name, age, grade){
    Person.call(this, name, age);
    this.grade = grade;
}
inheritPrototype(Student, Person);
Student.prototype.speak = function(){
    console.log(`我叫${this.name},我今年${this.age}歲了,我語文考了${this.grade}分`);
}

var kevin = new Student('kevin', 18, 90);
kevin.speak();
kevin.say();
複製程式碼

這一段是寄生組合式繼承,是開發人員認為的引用型別最理想的繼承方式。

十、ES6基礎

ES6是ECMAScript6的簡稱,也被稱為ECMAScript2015。是目前相容性比較樂觀且比較新的ECMAScript標準,雖然增加了前端的學習成本,但是與ES5相比,它提供了很多新的特性,而且現在前端基本上都在轉ES6了,所以ES6也是學習前端的必備基礎。不過目前,並不是所有的瀏覽器都支援ES6新特性,但是在開發中,我們可以藉助babel提供的編譯工具,將ES6轉化為ES5,這也極大的推動了前端團隊對ES6的接受。對於大多數常用的ES6新特性,目前最新版的Chrome都已全部支援。不過對於部分知識,例如模組化modules,則需要通過構建工具才能夠使用,例如使用webpack和babel的VueJS。

新的變數宣告方式let/const

在ES6中,我們可以使用let來宣告變數,其中,let會產生變數的塊級作用域,並且let在變數提升的時候不會給變數賦值undefined,所以這樣使用會直接報錯:

console.log(a);  // 不會輸出undefined,會直接報ReferenceError
let a = 10;
複製程式碼

所以,如果你決定用ES6的變數宣告來寫了,就全部用let吧,不要let和var混用。

const是用來宣告一個常量的,該常量的引用地址不可改變。

這裡需要注意的是let和const變數的值,都是一個引用,如果對let的變數進行賦值操作,是新建了該值之後將其引用重新賦給變數。

例如:

const a = [];
a.push(1);    // 不會報錯
const b = 1;
b = 2;   // 報錯Uncaught TypeError: Assignment to constant variable.
複製程式碼

箭頭函式

ES6的箭頭函式是一個用起來比較舒適的方式,我們用例子來看一下:

// ES5中宣告函式
var fn = function(a, b){
    return a+b;
}
// ES6箭頭函式
var fn = (a, b) => a+b;  // 當函式直接return時,可以省略{}
複製程式碼

需要注意的是,箭頭函式只能替換函式表示式,使用function關鍵字宣告的函式不能使用箭頭函式替換,如下形式不能用箭頭函式替換:

function fn(a,b){
    return a+b;
}
// 不可以替換成下面形式
fn(a, b)=> a+b;
複製程式碼

我們一看到函式就應該去想以下它內部的this在呼叫時指向誰,從前面知識我們知道,函式內部的this指向,與它的呼叫者有關,或者使用call/apply/bind也可以修改函式內部的this指向。

我們來回顧一下,請思考下面的輸出內容:

var name = 'Tom';
var getName = function(){
    console.log(this.name);   
}
var person = {
    name: 'Alex',
    getName: function(){
        console.log(this.name);
    }
}
var other = {
    name: 'Jone'
}
getName();    // ?
person.getName();   // ?
getName.call(other);    // ?
複製程式碼

上面分別輸出了Tom,Alex,Jone,第一個getName()獨立呼叫,其this指向undefined並自動轉向window。那假如全部換成箭頭函式呢?我們看一下輸出結果:

var name = 'Tom';
var getName = () => {
    console.log(this.name);   
}
var person = {
    name: 'Alex',
    getName: () => {
        console.log(this.name);
    }
}
var other = {
    name: 'Jone'
}
getName();          //Tom
person.getName();   //Tom
getName.call(other);//Tom
複製程式碼

執行發現,三次都輸出了Tom,這也是需要大家注意的地方。箭頭函式中的this,就是宣告函式時所處的上下文中的this,他不會被其他方式所改變

所以有些場景可以用箭頭函式來解決:

document.name = 'doc';
var obj = {
    name: 'zeus',
    do: function(){
        document.onclick = function(){
            console.log(this.name);   // 因為是document呼叫了該函式,所以點選頁面輸出doc
        }
    }
}
obj.do();
複製程式碼

如果我們要在頁面被點選後輸出zeus,可能最常用的就是在document.onclick外面使用_this/that暫存this的值,如下:

document.name = 'doc';
var obj = {
    name: 'zeus',
    do: function(){
        var _this = this;
        document.onclick = function(){
            console.log(_this.name);   // 使用了_this中間變數,輸出zeus
        }
    }
}
obj.do();
複製程式碼

其實,可以用箭頭函式的特性來做:

document.name = 'doc';
var obj = {
    name: 'zeus',
    do: function(){
        document.onclick = () => {
            console.log(this.name);   // 箭頭函式的this指向當前上下文
        }
    }
}
obj.do();
複製程式碼

模板字串

模板字串是解決一般的字串拼接麻煩的問題產生的,它使用反引號`包裹字串,使用${}包裹變數名,如下程式碼:

// ES5
var a = 'hello';
var b = 'zeus';
var c = 10;
var s = a + ' '+ b + ' ' + (c+10);  // hello zeus 20
// ES6
var str = `${a} ${b} ${c+10}`;   // hello zeus 20
複製程式碼

解析結構

解析結構可以很方便的從陣列或物件獲取值,例如對於如下的物件字面量:

let zeus = {
    name: 'zeus',
    age: 20,
    job: 'Front-end Engineer'
}
複製程式碼

如果要取值,我們經常會使用點運算子進行取值,例如zeus.namezeus['age'],當使用解析結構時,可以這樣做:

const {name, age, job} = zeus;
console.log(name);
複製程式碼

當然const表示獲得到的值宣告為常量,也可以使用let或var。我們還可以給屬性變數指定預設值:

const {name = 'kevin', age = 20, job = 'student'} = zeus;
// 如果zeus物件對應屬性沒有值,則使用前面指定的預設值
複製程式碼

或者給屬性變數重新命名:

const {name: username, age, job} = zeus;
// 後面使用的話就必須使用username
複製程式碼

陣列也可以使用解析結構,如下:

let arr = [1,2,3,4];
const [a,b,c,d] = arr;
console.log(a);  // 1
console.log(c);  // 3
複製程式碼

陣列的解析結構的屬性變數名可以隨意命名,但是是按順序來一一對應的,而物件解析結構中的屬性變數必須跟變數屬性命名一致。物件屬性的解析結構也可以進行巢狀,例如:

let kevin = {
    name: 'kevin',
    age: 20,
    job: 'Student',
    school: {
    	name: 'smu',
    	addr: '成都'
	}
};
const {school: {name}} = kevin;
console.log(name);  // smu
複製程式碼

展開運算子

在ES6中,使用...作為展開運算子,它可以展開陣列/物件。例如:

const arr1 = [1,2,3];
const arr2 = [...arr1, 4,5,6];   // [1,2,3,4,5,6]
let person_kevin = {
    name: 'kevin',
    age: 20,
    job: 'Student'
};
let student_kevin = {
    ...person_kevin,
    school: {
        name: 'smu',
        addr: '成都'
    }
};
複製程式碼

展開運算子可以用在函式形參裡面,但是只能作為函式的最後一個引數

Promise

非同步與同步

同步是指傳送一個請求,需要等待直到請求結果返回之後,再繼續下一步操作。非同步在傳送請求後,不會等待而是直接繼續下一步操作。

我們來實現一個非同步方法:

function fn(){
    return new Promise((resolve, rejsct) =>{
        setTimeout(function(){
            resolve('執行fn內容');
        },1000);
    });
}
複製程式碼

可以使用async/await來模擬同步效果:

var foo1 = async function(){
    let t = await fn();
    console.log(t);
    console.log('接著執行的內容');
}
foo1();
// 等待1秒後,輸出:
// 執行fn內容
// 接著執行的內容
複製程式碼

如果採用非同步操作的話,如下:

var foo2 = function(){
    fn().then(res=>{
        console.log(res);
    });
    console.log('接著執行的內容');
}
foo2();
// 先輸出 接著執行的內容
// 等待1秒後
// 輸出 執行fn的內容
複製程式碼

簡單用法

我們應該有使用過jquery的$.ajax()方法,該方法獲取後端的值是在引數屬性success函式的引數中獲取的,假如我們在第一次ajax請求後,要進行第二次ajax請求並且這一次請求的引數是第一次success獲的值,如果還有第三次呢,那就必須這樣寫:

$.ajax({
    url: '',
    data: {...},
    success: function(res){
        $.ajax({
            data: {res.data},
            success: function(res){
                $.ajax(...)
            }
        })
    }
})
複製程式碼

這樣就形成了常說的“回撥地獄”,不過在ES6中,Promise語法可以解決這樣的問題。·Promise可以認為是一種任務分發器,將任務分配到Promise佇列,執行程式碼,然後等待程式碼執行完畢並處理執行結果。簡單的用法如下:

var post = function(url, data) {
    return new Promise(function(resolve, reject) {
        $.ajax({
            url: url,
            data: data,
            type: 'POST',
            success: function(res){
                resolve(res);
            },
            error: function(err) {
                reject(err);
            }
        });
    });
}
post('http://127.0.0.1:8080/order', {id:1}).then(function(res){
    // 這裡返回成功的內容
}, function(err){
    // 這裡是報錯資訊
});
複製程式碼

上面的程式碼封裝了jquery的ajax請求,將POST的請求進行了封裝,post(..)函式內部返回了一個Promise物件,Promise物件有一個then方法,then方法的第一個引數是resolve回撥函式表示成功的操作,第二個引數是reject回撥函式表示失敗或異常的操作。其實,Promise還有一個catch方法也可以獲取reject回撥函式,如post也可以這樣使用:

post('http://127.0.0.1:8080/order', {id:1}).then(function(res){
    // 這裡返回成功的內容
}).catch(function(err){
    // 這裡是報錯資訊
});
複製程式碼

事件迴圈機制

後面再單獨分享

物件與class

參考阮一峰class介紹

模組化

後面再單獨分享

相關文章