JS原生之繼承、閉包、原型、原型鏈

概覽

最近在從新看js基礎,索性就將繼承、閉包、原型鏈這三個原生js中比較重要的點寫篇文章總結一下。本身明白理解是一回事,寫了文章讓別人看明白是另一回事,經過講述,本身也能進步。

原型

背景

JS 的做者 Brendan Eich 在設計這門編程語言時,只是爲了讓這門語言做爲瀏覽器與網頁互動的工具。他以爲這門語言只須要能完成一些簡單操做就夠了,好比判斷用戶是否填寫了表單。
基於簡易語言的設計初衷,做者以爲 JS 不須要有相似 java 等面嚮對象語言所擁有的「繼承」機制。可是考慮到 JS 中一切皆對象(全部的數據類型均可以用對象來表示),必須有一種機制,把全部的對象聯繫起來,實現相似的「繼承」機制。
不一樣於大部分面嚮對象語言, ES6 以前並無引入類( class)的概念, JS 並不是經過類而是經過構造函數來建立實例,使用 prototype 原型模型來實現「繼承」。

構造函數

JavaScript 裏,構造函數一般是用來實現實例的,JavaScript 沒有類的概念,可是有特殊的構造函數。構造函數本質上是個普通函數,充當類的角色,主要用來建立實例,並初始化實例,即爲實例成員變量賦初始值。java

構造函數和普通函數的區別在於,構造函數應該遵循如下幾點規範:golang

  1. 在命名上,構造函數首字母須要大寫;
  2. 調用方式不一樣,普通函數是直接調用,而構造函數須要使用 new 關鍵字來進行調用;
  3. 在構造函數內部,this 指向的是新建立的實例;
  4. 構造函數中沒有顯示的 return 表達式,通常狀況下,會隱式地返回 this,也就是新建立的對象,若是想要使用顯式的返回值,則顯式的返回值必須是對象,不然依然返回實例。

原型的規則

構造函數是用來建立實例的
// 步驟1:新建構造函數
function Person(name) {
    this.name = name;
    this.sayName = function() {
        console.log(this.name);
    }
}

// 步驟2:建立實例
var person = new Person('yang');

此時,以下圖所示,針對步驟1,當構造函數被建立時,會在內存空間新建一個對象,構造函數內有一個屬性 prototype 會指向這個對象的存儲空間,這個對象稱爲構造函數的原型對象。
image.png
針對步驟2,以下圖所示,person 是經過 Person 構造函數建立的實例,在 person 內部將包含一個指針(內部屬性),指向構造函數的原型對象,這個指針稱爲 [[prototype]]
目前,大部分瀏覽器都支持 __proto__ 這個屬性來訪問構造函數的原型對象,就像這裏,person.__proto__ 指向 Person.prototype 的對象存儲空間。
image.png
由上面示例圖知道,實例 person 若是訪問原型對象,須要使用 __proto__ 這個屬性。
事實上,__proto__ 是一個訪問器屬性(由一個 getter 函數和一個 setter 函數構成),但做爲訪問 [[prototype]] 的屬性,它是一個不被推薦的屬性, JavaScript 規範中規定,這個屬性僅在瀏覽器環境下才能使用。
[[prototype]] 是內部的並且是隱藏的,當須要訪問內部 [[prototype]] 時,可使用如下現代方法:編程

// 返回對象 `obj` 的 `[[prototype]]`。
Object.getPrototypeOf(obj);

// 將對象 `obj` 的 `[[prototype]]` 設置爲 `proto`。
Object.setPrototypeOf(obj, proto) 

// 利用給定的 `proto` 做爲 `[[prototype]]` 和屬性描述符(可選)來建立一個空對象。
Object.create(proto[, descriptors])

在默認狀況下,全部的原型對象都會自動得到一個 constructor 的屬性,這個屬性包含一個指向 prototype 所在函數的指針,即 constructor 屬性會指向構造函數自己。
此外,Person.prototype 指向的位置是一個對象,也包含有內部 [[prototype]] 指針,這個指針指向的是 Object.prototype,是一個對象。這個關係表示,Person.prototype 是由 Object 做爲構造函數建立的。
須要注意的是,原型是能夠被改寫的。可是 JavaScript 中對其作了規定,只能夠被改寫成對象,若是改寫成其餘值(空值 null 也不行),會自動被忽略,會讓原型鏈下一級來替換這個被改寫的原型。瀏覽器

原型的做用

  1. 屬性公用化:原型能夠存儲一些默認屬性和方法,而且在各個不一樣的實例中能夠共享使用;
  2. 繼承:在子類構造函數中借用父類構造函數,再經過原型來繼承父類的原型屬性和方法,模擬繼承的效果;
  3. 節省存儲空間:結合第1點,公用的屬性和方法多了,對應須要的存儲空間也減小了。
// 第一步 新建構造函數
function Person(name) {
    this.name = name;
    this.age = 18;
    this.sayName = function() {
        console.log(this.name);
    }
}
// 第二步 建立實例 1
var person1 = new Person('1號');

// 第三步 建立實例2
var person2 = new Person('2號');

// 結果均爲 true
person1.__proto__ === Person.prototype;
person2.__proto__ === Person.prototype; 

// 1號 2號
console.log(person1.name, person2.name);

// 18 18
console.log(person1.age, person2.age);

原型鏈

JavaScript 中,萬物皆對象(全部的數據類型均可以用對象來表示),對象與對象之間存在關係,並非孤立存在的,對象之間的繼承關係,在 JavaScript 中實例對象經過內部屬性 [[prototype]] 指向父類對象的原型空間,直到指向瀏覽器實現的內部對象 Object 爲止, Object 的內部屬性 [[prototype]]null,這樣就造成了一個原型指向的鏈條,這個鏈條稱爲原型鏈。
當訪問對象的屬性時,會先在對象自身屬性中查找,若是有則直接返回使用,若是沒有則會順着原型鏈指向繼續尋找(不斷查找內部屬性 [[prototype]]),直到尋找瀏覽器內置對象的原型,若是依然沒有找到,則返回 undefined。

須要注意的是,原型鏈中訪問器屬性和數據屬性在讀寫上是有區別的(點擊瞭解訪問器屬性和數據屬性)。若是在原型鏈上某一級設置了訪問器屬性(假設爲 age),則讀取 age 時,直接按訪問器屬性設置的值返回;寫入時也是以訪問器屬性爲最優先級。在數據屬性的讀寫上,讀取時,會按照原型鏈屬性查找進行查找;寫入時,直接寫入當前對象,若原型鏈中有相同屬性,會被覆蓋。
能夠結合如下代碼來對原型鏈進行分析:網絡

// 第一步 新建構造函數
function Person(name) {
    this.name = name;
    this.age = 18;
    this.sayName = function() {
        console.log(this.name);
    }
}
// 第二步 建立實例
var person = new Person('person');
複製代碼

根據以上代碼,能夠獲得下面的圖示:閉包

第一步中,新建 Person 的構造函數,此時原型空間被建立;第二步中,經過 new 構造函數生成實例 personperson [[prototype]] 會指向原型空間。app

不少人容易忽視的是瀏覽器對於下面的處理,這裏 Person.prototype.__proto__ 指向內置對象,由於 Person.prototype 是個對象,默認是由 Object 函數做爲類建立的,而 Object.prototype 爲內置對象。異步

Person.__proto__ 指向內置匿名函數 anonymous,由於 Person 是個函數對象,默認由 Function 做爲類建立,而 Function.prototype 爲內置匿名函數 anonymous編程語言

這裏還須要注意一個點,Function.prototypeFunction.__proto__ 同時指向內置匿名函數 anonymous,這樣原型鏈的終點就是 null,而不用擔憂原型鏈查找會陷入死循環中。函數

繼承

  • 概念:經過某種方式,可讓某個對象訪問到其餘對象中的屬性、方法,這種方式稱之爲繼承。
  • 背景:有些對象會有方法,而這些方法都是函數(函數也是對象),若是把這些方法都放在構造函數中聲明,則會產生內存浪費
  • 注意:js的繼承都是創建在:方法在原型上建立、屬性在實例上建立的前提下

實現繼承的方式

一、 藉助call
function Parents(age, live) {
    this.name = '藉助call方式實現繼承'
    this.age = age
    this.live = live
}
function Child() {
    Parents.call(this, ...arguments)
}
let child = new Child(18, true)

console.log('child: ', child)

缺點:這樣寫的時候子類雖然可以拿到父類的屬性值, 可是問題是父類原型對象中一旦存在方法那麼子類沒法繼承。

二、藉助原型鏈
function Parents1(age) {
    this.name = "藉助原型鏈實現繼承"
    this.age = age
}
function Child1() {
    this.type = 'Child1'
}
Child1.prototype = new Parents1()


let child1 = new Child1()

console.log("child1: ", child1.name)

缺點:改變實例的屬性會影響到父類的屬性,由於共用一個原型對象(引用類型)

三、 將前兩中組合(組合式繼承)
function Parents2(age) {
    this.name = '藉助組合式實現繼承'
    this.age = age
    this.arr = [1, 2, 3]
}
function Child2() {
    this.type = 'Child2'
    Parents2.call(this, ...arguments)
}
Child2.prototype = new Parents2()

let child2 = new Child2(12)
let anthorChild2 = new Child2(13)
child2.arr.push(4)
console.log('child2: ', child2)
console.log('anthorChild2: ', anthorChild2)

缺點:這種繼承的問題 那就是Parent2的構造函數會多執行了一次(Child2.prototype = new Parent2();)

四、組合繼承的優化
function Parents3(age) {
    this.age = age
    this.name = '組合繼承的優化1'
}
function Child3() {
    Parents.call(this, ...arguments)
    this.type = 'Child3'
}
// 這裏讓將父類原型對象直接給到子類,父類構造函數只執行一次,
// 並且父類屬性和方法均能訪問
Child2.prototype = Parents3.prototype

缺點:子類實例的構造函數是Parent3,顯然這是不對的,應該是Child3。

五、寄生組合式繼承
function Parents4(age) {
    this.age = age
    this.name = '寄生組合式繼承'
}
function Child4() {
    Parents.apply(this, [...arguments])
    this.type = 'Child4'
}
Child4.prototype = Object.create(Parents4.prototype)
Child4.prototype.constructor = Child4

這是最推薦的一種方式, 接近完美的繼承, 它的名字也叫作寄生組合繼承。

六、ES6的extends

它用的就是寄生組合式繼承,可是加了一個Object.setPrototypeOf(subClass, superClass)
是用來繼承父類的靜態方法。這也是原來的繼承方式疏忽掉的地方。

擴展:面向對象繼承的問題,沒法決定繼承哪些屬性, 全部屬性都得繼承。

  • 一方面父類是沒法描述全部子類的細節狀況的,爲了避免同的子類特性去增長不一樣的父類,代碼勢必會大量重複。
  • 另外一方面一旦子類有所變更,父類也要進行相應的更新,代碼的耦合性過高,維護性很差。
  • 用組合, 這也是當今編程語法發展的趨勢, 好比golang徹底採用的是面向組合的設計方式。
  • 面向組合就是先設計一系列零件, 而後將這些零件進行拼裝, 來造成不一樣的實例或者類。

例如:不一樣的車有不一樣的功能

function drive(){
    console.log("發動");
}

function music() {
    console.log("音樂")
}

function addOil() {
    console.log("加油")
}
// compose是一個組合各類方法的方法
// 普通汽車
let car = compose(drive, music, addOil);
// 新能源
let newEnergyCar = compose(drive, music);

閉包

閉包是指有權訪問另一個函數做用域中的變量的函數(紅寶書)
閉包是指那些可以訪問自由變量的函數。(MDN)其中自由變量, 指在函數中使用的, 但既不是函數參數arguments也不是函數的局部變量的變量,其實就是另一個函數做用域中的變量。)

做用域

提及閉包,就必需要說說做用域,ES5種只存在兩種做用域:一、函數做用域。二、全局做用域
當訪問一個變量時, 解釋器會首先在當前做用域查找標示符,若是沒有找到, 就去父做用域找, 直到找到該變量的標示符或者不在父做用域中, 這就是做用域鏈,每個子函數都會拷貝上級的做用域, 造成一個做用域的鏈條。
let a = 1;

function f1() {
    var a = 2

    function f2() {
        var a = 3;
        console.log(a); //3
    }
}

在這段代碼中, f1的做用域指向有全局做用域(window) 和它自己,而f2的做用域指向全局做用域(window)、 f1和它自己。並且做用域是從最底層向上找, 直到找到全局做用域window爲止,若是全局尚未的話就會報錯。閉包產生的本質就是, 當前環境中存在指向父級做用域的引用。

function f2() {
    var a = 2

    function f3() {
        console.log(a); //2
    }
    return f3;
}
var x = f2();
x();

這裏x會拿到父級做用域中的變量, 輸出2。 由於在當前環境中,含有對f3的引用, f3偏偏引用了window、 f3和f3的做用域。 所以f3能夠訪問到f2的做用域的變量。那是否是隻有返回函數纔算是產生了閉包呢?回到閉包的本質,只須要讓父級做用域的引用存在便可。

var f4;

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

讓f5執行,給f4賦值後,等於說如今f4擁有了window、f5和f4自己這幾個做用域的訪問權,仍是自底向上查找,最近是在f5中找到了a,所以輸出2。在這裏是外面的變量f4存在着父級做用域的引用, 所以產生了閉包,形式變了,本質沒有改變。

場景

  1. 返回一個函數。
  2. 做爲函數參數傳遞。
  3. 在定時器、 事件監聽、 Ajax請求、 跨窗口通訊、 Web Workers或者任何異步中,只要使用了回調函數, 實際上就是在使用閉包。
  4. IIFE(當即執行函數表達式) 建立閉包, 保存了全局做用域window和當前函數的做用域。
var b = 1;

function foo() {
    var b = 2;

    function baz() {
        console.log(b);
    }
    bar(baz);
}

function bar(fn) {
    // 這就是閉包
    fn();
}
// 輸出2,而不是1
foo();
// 如下的閉包保存的僅僅是window和當前做用域。
// 定時器
setTimeout(function timeHandler() {
   console.log('111');
}, 100)

// 事件監聽
// document.body.click(function () {
//     console.log('DOM Listener');
// })

// 當即執行函數
var c = 2;
(function IIFE() {
    // 輸出2
    console.log(c);
})();

經典的一道題

for (var i = 1; i <= 5; i++) {
    setTimeout(function timer() {
        console.log(i)
    }, 0)
}  // 6 6 6 6 6 6
// 爲何會所有輸出6? 如何改進, 讓它輸出1, 2, 3, 4, 5?

解析:

  • 由於setTimeout爲宏任務, 因爲JS中單線程eventLoop機制, 在主線程同步任務執行完後纔去執行宏任務。
  • 所以循環結束後setTimeout中的回調才依次執行, 但輸出i的時候當前做用域沒有。
  • 往上一級再找,發現了i,此時循環已經結束,i變成了6,所以會所有輸出6。
// 一、利用IIFE(當即執行函數表達式)當每次for循環時,把此時的i變量傳遞到定時器中
for (var i = 0; i < 5; i++) {
    (function (j) {
        setTimeout(() => {
            console.log(j)
        }, 1000);
    })(i)
}
// 二、給定時器傳入第三個參數, 做爲timer函數的第一個函數參數
for (var i = 0; i < 5; i++) {
    setTimeout(function (j) {
        console.log(j)
    }, 1000, i);
}
// 三、使用ES6中的let
// let使JS發生革命性的變化, 讓JS有函數做用域變爲了塊級做用域,
// 用let後做用域鏈不復存在。 代碼的做用域以塊級爲單位,
for (let i = 1; i <= 5; i++) {
    setTimeout(function timer() {
        console.log(i)
    }, 2000)
}

說明

以上部份內容來源與本身複習時的網絡查找,也主要用於我的學習,至關於記事本的存在,暫不列舉連接文章。若是有做者看到,能夠聯繫我將原文連接貼出。

相關文章
相關標籤/搜索