講清楚之 javascript 物件繼承

kooky發表於2019-02-16

這一節梳理物件的繼承。

我們主要使用繼承來實現程式碼的抽象和程式碼的複用,在應用層實現功能的封裝。

javascript 的物件繼承方式真的是百花齊放,屬性繼承、原型繼承、call/aplly繼承、原型鏈繼承、物件繼承、建構函式繼承、組合繼承、類繼承… 十幾種,每一種都細講需要花很多時間,這裡大致梳理常用的幾種。 javascript 中的繼承並不是明確規定的,而是通過模仿實現的。下面我們簡單梳理幾種有代表性的繼承。

原型繼承

ECMAScript 5 中引入了一個新方法: Object.create。可以呼叫這個方法來建立一個新物件。新物件的原型就是呼叫 create 方法時傳入的引數:

let too = {
    a: 1
}
let foo = Object.create(too)
console.log(foo.a) // 1

通過使用Object.create方法, 物件 too 會被自動加入到 foo 的原型上。我們可以手動模擬實現一個Object.create相同功能的函式:

let too = {
    a: 1
}
function create (prot) {
    let o = function () {}
    o.prototype = prot
    return new o()
}
let foo = create(too)
console.log(foo.a) // 1

或者用更簡單直白的方式來寫:

function Foo() {}
Foo.prototype = {
    a: 1
}

let too = new Foo()
console.log(too.a) // 1

原型繼承是基於函式的prototype屬性

原型鏈的繼承

function Foo (id) {
    this.a = 1234
    this.b = id || 0
}
Foo.prototype.showData = function () {
    console.log(`${this.a}, id: ${this.b}`)
}
function Too (id) {
    Foo.apply(this, arguments)
}
Too.prototype = new Foo()
let bar = new Too(999)
bar.showData() // 1234, id: 999

上面建構函式TOO 通過重新指定prototype屬性,指向了建構函式Foo的一個例項,然後在Too建構函式中呼叫Foo的建構函式,從而完成對建構函式Foo功能的繼承。例項bar 通過屬性__proto__來訪問原型鏈上的共享屬性和方法。

class繼承

javascript 中的 class繼承又稱模擬類繼承。ES6中正式引入了 class 關鍵字來實現類語言方式建立物件。從此我們也可以使用抽象類的方式來實現繼承。

// 父類
class Polygon {
  constructor(height, width) {
    this.height = height;
    this.width = width;
  }
}
// 子類
class Square extends Polygon {
  constructor(sideLength) {
    super(sideLength, sideLength); // 呼叫父物件的搞糟函式
  }
  get area() {
    return this.height * this.width;
  }
  set sideLength(newLength) {
    this.height = newLength;
    this.width = newLength;
  }
}

var square = new Square(2);

在JavaScript中沒有類的概念,只有物件。雖然我們使用class關鍵字,這讓 JavaScript 看起來似乎是擁有了”類”,可表面看到的不一定是本質,class只是語法糖,實質還是原型鏈那一套。因此,JavaScript中的繼承只是物件與物件之間的繼承。反觀繼承的本質,繼承便是讓子類擁有父類的一些屬性和方法,在JavaScript中便是讓一個物件擁有另一個物件的屬性和方法。

繼承的實現是有很多種,這裡不一一列舉。需要注意的是 javascript 引擎在原型鏈上查詢屬性是比較耗時的,對效能有副作用。與此同時我們遍歷物件時,原型上的屬性也會被列舉出來。要識別屬性是在物件上還是從原型上繼承的,我們可以使用物件上的hasOwnProperty方法:

let foo = {
    a: 1
}
foo.hasOwnProperty(`a`) // true
foo.hasOwnProperty(`toString`) // false

使用hasOwnProperty方法檢測屬性是否直接存在於該物件上並不會遍歷原型鏈。

javascript 支援的是實現繼承,不支援介面繼承,實現繼承主要依賴的是原型鏈。

思考

前面我們講到的基本是 javascript 怎麼實現物件導向程式設計的一些知識點。

不從概念來講,簡單來說當我們有屬性和方法需要被重複使用,或者屬性需要被多個物件共享時就需要去考慮繼承的問題。在函式層面,大家通常的做法是使用作用域鏈來實現內層作用域對外層作用域屬性或函式的共享訪問。舉個例子吧~~

function car (userName) {
    let color = `red`
    let wheelNumber = 4
    let user = userName
    let driving = function () {
        console.log(`${user} 的汽車,${wheelNumber}個輪子滾啊滾...`)
    }
    let stop = function () {
        console.log(`${user} 的汽車,${wheelNumber}個輪子滾不動了,嘎。。。`)
    }
    return {
        driving: driving,
        stop: stop
    }
}
var maruko = car(`小丸子`)
maruko.driving() // 小丸子 的汽車,4個輪子滾啊滾...
maruko.stop() // 小丸子 的汽車,4個輪子滾不動了,嘎。。。

var nobita = car(`大雄`)
nobita.driving() // 大雄 的汽車,4個輪子滾啊滾...
nobita.stop() // 大雄 的汽車,4個輪子滾不動了,嘎。。。

這。。。什麼鬼。是不是有種似曾相識的感覺,這其實就是經典的閉包 ,jquery 年代很多外掛 js 庫都採用這種方式去封裝獨立的功能。說閉包也是繼承是不是有點勉強,但是 javascript 裡函式也是物件,閉包利用函式的作用域鏈來訪問上層作用域的屬性和函式。當然像閉包這樣不使用this去實現私有屬性比較麻煩, 閉包只適合單例項的場景。再舉一個栗子:

function GoToBed (name) {
    console.log(`${name}, 睡覺了...`)
}
function maruko () {
    let name = `小丸子`
    function dinner () {
        console.log(`${name}, 吃完晚餐`)
        GoToBed(name)
    }
    dinner()
}

function nobita () {
    let name = `大雄`
    function homework () {
        console.log(`${name}, 做完作業`)
        GoToBed(name)
    }
    homework()
}

maruko()
nobita()

// 小丸子, 吃完晚餐
// 小丸子, 睡覺了...
// 大雄, 做完作業
// 大雄, 睡覺了...

像上面栗子中這樣,以程式導向的方式將公共方法抽離到上層作用域的用法比較常見, 至少我很長時間都是這麼幹的。將GoToBed函式抽離到全域性物件中,函式marukonobita 內部直接通過作用域鏈查詢GoToBed函式。這種鬆散結構的程式碼塊組織其實跟上面閉包含義是差不多的。

所以依據作用域鏈來進行公共屬性、方法的管理嚴格意義上不能算是繼承, 只能算是 javascript 程式導向的一種程式碼抽象分解的方式,一種程式設計正規化。這種正規化程式設計是基於作用域鏈,與前面講的繼承是基於原型鏈的本質區別是屬性查詢方式的不同。

全域性物件 window 形成一個閉合上下文,如果我們將整個 window 物件假設為一個全域性函式,所有建立的區域性函式都是該函式的內部函式。當我們使用這個假設時很多問題就要清晰多了,全域性函式在頁面被關閉前是一直存在的,且在存活期間為內嵌函式提供執行環境,所有內嵌函式都共享對全域性環境的讀寫許可權。

這種函式呼叫時命令式的,函式組織是巢狀的,使用閉包(函式巢狀)的方式來組織程式碼流是無模式的一種常態。

相關文章