【讀】JavaScript之物件導向

_高洋_發表於2019-04-16

本篇是JavaScript高階程式設計第三版第六章《物件導向的程式設計》閱讀記錄。如有疑問可以聯絡我

理解物件

根據ECMA-262,物件為無序屬性的集合,其屬性可以包含基本值、物件、或函式。下面是個例子:

var person = new Object();
person.name = 'GY';
person.age = 18;
person.sayHi = function() {
    alert('Hi!')
};

// 或者可以這樣

var person = {
    name: 'GY',
    age: 18,
    sayHi: function() {
        alert('Hi!');
    }
};
複製程式碼

這裡完美的展現了JavaScript物件無序屬性的集合的定義。

屬性型別

ECMAScript中有兩種屬性:資料屬性 和 訪問器屬性。

用來描述屬性(property)各種特徵的,稱之為特性(attribute)。對比屬性描述物件。這些特性不能直接訪問,常用[[name]]來描述,比如[[configurable]]

  • 資料屬性

    可以寫入和讀取值。該種屬性有4個特性:

    • [[value]],寫入和讀取時,都操作的是這裡。預設值undefined
    • [[configurable]],表示是否能通過delete從物件中刪除該屬性、能否修改該屬性的特性、能否把屬性修改為訪問器屬性。預設值true
    • [[enumerable]],表示是否能通過for-in遍歷該屬性。預設值true
    • [[writable]],表示是否可以修改該屬性的值。預設值true

    比如上面定義的person,其中的屬性值,都儲存在其[[value]]特性中。

    那麼如何操作這些特徵值呢?先來看看如何修改,使用Object.defineProperty方法。

    /// 引數依次為:
    /// 需要操作的物件(這裡為person),
    /// 需要操作的屬性(這裡為name),
    /// 特徵值(型別為物件)
    Object.defineProperty(object, 'property', attributes);
    複製程式碼

    比如對對於上面的person物件,我們寫出如下程式碼

    Object.defineProperty(person, 'name', {
        writable: false,
        value: 'OtherName'
    });
    
    console.log(person.name); // OtherName
    person.name = '任意值';
    console.log(person.name); // OtherName
    複製程式碼

    這裡通過Object.definePropertyname屬性的writable特性定義為false,那麼name屬性將為只讀屬性。無法再次賦值。對writable特性為false的屬性賦值,非嚴格模式下會忽略,嚴格模式下會丟擲錯誤Cannot assign to read only property 'name' of object

    相應的,可以通過該方法修改configurableenumerable特性。值得注意的是,對configurable設定為false後,將導致configurableenumerable不能再次更改。

    Object.defineProperty(person, 'name', {
        configurable: false
    });
    
    console.log(person.name); // GY
    // delete person.name; // 嚴格模式會報錯
    
    // 在configurable: false這裡將不能再次修改
    // Cannot redefine property: name at Function.defineProperty
    Object.defineProperty(person, 'name', {
        configurable: true,
        enumerable: true,
        
    });
    複製程式碼
  • 訪問器屬性

    該型別屬性不儲存值,沒有[[value]]。包含getset函式(這兩者都是非必須的)。在讀取和寫入時將呼叫對應函式。訪問器屬性有4個特性:[[configurable]][[enumerable]][[get]][[set]].

    訪問器屬性不能直接定義,需要使用Object.defineProperty,比如:

    var person = {
        // 下劃線通常表示需要通過物件方法訪問。規範!
        _age: 0
    };
    
    Object.defineProperty(person, 'age', {
        get: function() {
            return this._age;
        },
        set: function(v) {
            this._age = v >= 0 ? v : 0;
        }
    });
    複製程式碼

    如只提供get,意味著該屬性只讀;只提供set,讀取會返回undefined

    若你想一次定義多個屬性及其特性,可以使用Object.defineProperties,向下面這樣:

    var person = {
        // 下劃線通常表示需要通過物件方法訪問。規範!
        _age: 10
    };
    
    Object.defineProperties(person, {
        age: {
            get: function() {
                return this._age;
            },
            set: function(v) {
                this._age = v >= 0 ? v : 0;
            }
        },
        name: {
            value: 'unnamed',
            writable: true,
            enumerable: true
        }
    });
    複製程式碼
  • 如何獲取屬性特徵

    使用Object.getOwnPropertyDescriptor方法

    var descriptor = Object.getOwnPropertyDescriptor(person, 'name');
    console.log(descriptor.value + ' ' + descriptor.writable + ' ' + descriptor.enumerable);
    複製程式碼

建立物件

這一節,將會介紹多種建立物件的方法。

工廠模式

/// 工廠模式
/// 這種模式減少了建立多個相似物件的重複程式碼,
/// 但無法解決物件識別問題(即怎樣知道物件的型別)
function createPerson(name, age) {
    var o = new Object();
    o.name = name;
    o.age = age;
    o.sayHi = function() {
        alert('Hi! I\'m ' + this.name);
    }
    return o;
}

var instance = createPerson('GY', 18);
person.sayHi();
複製程式碼

建構函式模式

/// 建構函式模式
/// 這種方式需要顯示的使用 new 關鍵字
function Person(name, age) {
    this.name = name;
    this.age = age;
    this.sayHi = function() {
        alert('Hi! I\'m ' + this.name);
    };
}

var instance = new Person('GY1', 18);
person.sayHi();
複製程式碼

這裡不像工廠模式那樣直接建立物件,但最終結果相同。主要因為使用new關鍵字會經歷下面幾個步驟:

  • 建立物件
  • 將建構函式的作用域賦值給新物件(因此this就指向了該新物件)
  • 執行建構函式中的程式碼
  • 返回新物件

這裡可以通過person.constructor === Person明確知道其型別。使用instanceof檢測也是通過的。

alert(instance.constructor === Person); // true
alert(instance instanceof Person); // true
複製程式碼
  • 建構函式也是函式,可以不通過new直接使用

    任何函式通過new來呼叫,都可以作為建構函式;任何函式,不通過new呼叫,那它跟普通函式也沒什麼兩樣。

    由於建構函式中使用了this,不通過new來使用,this將指向global物件(瀏覽器中就是window)。

    // 這樣直接呼叫會在window物件上定義name和age屬性
    Person('Temp', 10);
    複製程式碼
  • 建構函式也存在問題

    使用建構函式的主要問題是,每一個方法都會在每個物件上重新定義一遍。每個物件上的方法都是不相等的。這樣會造成記憶體浪費以及不同的作用域鏈和識別符號解析。很明顯,這樣是沒必要的。

    我們可以使用下面的方法來避免:

    function sayHi() {
        alert(this.name);
    }
    
    function Person(name, age) {
        this.name = name;
        this.age = age;
        this.sayHi = sayHi;
    }
    
    var instance1 = new Person('instance1', 18);
    var instance2 = new Person('instance2', 20);
    複製程式碼

    就是把每個方法都單獨定義,在建構函式內部引用。這樣又引發了新的問題:全域性域上定義的函式實際為某些物件而服務,這樣全域性域有點名不副實。其次,如果物件上需要有很多方法,那麼這些方法都需要在全域性域上定義。

    再來看看下面生成物件的方法。

原型模式

每個函式都有一個prototype(原型)屬性,這是一個指標,指向一個物件,該物件是用來包含特定型別所有例項共享的屬性和方法的。 這樣,之前在建構函式中定義的例項資訊就可以寫在原型物件中了。如下:

function Person() {
}

Person.prototype.name = 'unnamed';
Person.prototype.age = 18;
Person.prototype.sayHi = function() {
    alert(this.name);
}

var instance1 = new Person();
alert(instance1.name);
var instance2 = new Person();
alert(instance2.name);
複製程式碼

繼續往下之前,先來了解下原型物件:

無論什麼時候,只要建立了一個新函式,就會根據特定規則為該函式建立prototype屬性,這個屬性指向函式的原型物件。預設情況下,該原型物件還會擁有constructor(建構函式)屬性,指向該函式。當然,也包含從Object物件繼承來的屬性(這個我們後面再講)。

在通過建構函式建立新例項物件後,每個例項物件可以通過__proto__來訪問建構函式的原型物件。

下面是他們的關係圖:

【讀】JavaScript之物件導向

我們可以使用Person.prototype.isPrototypeOf(instance1)來檢測一個物件(這裡為Person的prototype物件)是否為指定物件(這裡為instance1)的原型。

推薦使用Object.getPrototypeOf(instance1)獲取指定物件的原型物件,而不是使用__proto__

上面的例子中,例項物件中均沒有name屬性,卻能夠訪問到。也正是因為原型物件的原因:當程式碼獲取某個物件屬性時,都會執行一次搜尋,目標是具有給定名字的屬性。搜尋首先從物件例項本身開始,如果找到對應屬性,返回該值;如果沒有找到,繼續搜尋原型物件。

從搜尋過程可以發現,例項物件和原型物件都有的屬性,例項中的會覆蓋原型中的。使用delete刪除時,只是刪除了例項中的屬性。

使用hasOwnProperty方法可以檢測一個屬性是否在例項中。只有給定屬性存在於例項中,才會返回true。

使用in操作符時(property in object),不管是在例項中,還是原型中都會返回true。

使用for-in操作時,返回的是所有能夠通過物件訪問的、可列舉的屬性,不管是在例項中,還是原型中;

既然原型物件也是物件,那我們可以手動賦值原型物件,從而減少不必要的輸入。向下面這樣:

function Person() {
}

Person.prototype = {
    constructor: Person, // constructor記得要宣告
    name: 'unnamed',
    age: 0,
    sayHi: function() {
        alert('Hi! this is ' + this.name);
    }
};
複製程式碼

注意:原生的constructor是不可列舉的,這樣定義後,導致constructor也可以列舉。

由於在原型中查詢值是一次搜尋過程,這就導致了原型的動態性。也就是說我們對原型物件的操作都會立刻反應在例項物件上。但是,如果我們重新賦值了建構函式的原型物件,那在賦值之前建立的物件將不受影響,原因是之前的例項物件指向的原型和現在的原型是完全不同的兩個物件了。

到這裡,你也許就能理解原生物件的方法都儲存在其原型物件上了吧。

那麼使用原型建立物件的方法是不是沒有問題了呢?答案是否定的。首先,其省略了建構函式傳參的環節,結果就是所有例項預設會有相同的屬性值;其次,由於共享屬性,在屬性值為引用型別時,一個例項修改屬性會影響另一個例項。

function Person() {
}

Person.prototype = {
    constructor: Person, // constructor記得要宣告
    name: 'unnamed',
    age: 0,
    sayHi: function() {
        alert('Hi! this is ' + this.name);
    },
    friends: ['A', 'B'] // 增加了friends屬性
};

var instance1 = new Person();
var instance2 = new Person();

instance1.friends.push('C'); // 修改instance1的friends屬性
alert(instance2.friends); // 但instance2的friends屬性同樣也改變成了A, B, C
複製程式碼

組合使用建構函式模式和原型模式

該模式使用建構函式定義例項屬性,使用原型定義共享的方法和屬性。

/// 定義例項屬性
function Person(name, age) {
    this.name = name;
    this.age = age;
    this.friends = [];
}

/// 定義公共方法
Person.prototype = {
    constructor: Person, // constructor記得要宣告
    sayHi: function() {
        alert('Hi! this is ' + this.name);
    }
};

var instance1 = new Person('instance1', 18);
var instance2 = new Person('instance2', 20);
複製程式碼

這種使用建構函式與原型混合的模式,是目前ECMAScript使用最廣泛、認同度最高的建立自定義型別的方法。

動態原型

也許你對上面的組合模式將屬性和方法分開來寫的形式感到彆扭。那麼動態原型模式可以來拯救你。

動態原型模式,將組合模式中分開的程式碼合併在一起,通過判斷動態的新增共享的方法或屬性。

function Person(name, age) {
    this.name = name;
    this.age = age;
    this.friends = [];

    // 這裡就是動態原型模式和混合模式的區別
    // 對於共享的屬性或方法,你不必每一個都去判斷
    // 找到一個標準就行
    if (typeof this.sayHi != 'function') {
        Person.prototype.sayHi = function() {
            alert('Hi! I\'m ' + this.name);
        };
    }
}

var instance1 = new Person('instance1', 18);
instance1.sayHi();
複製程式碼

注意:這裡不能使用字面量語法重寫原型屬性,這樣會切斷之前建立的例項和現有原型的聯絡。

寄生建構函式模式

寄生建構函式模式提供一個函式用於封裝建立物件的程式碼.這和工廠模式極為相似。

function Person(name, age) {
    var o = new Object();
    o.name = name;
    o.age = age;
    o.sayHi = function() {
        alert('Hi! I\'m ' + this.name);
    };
    return o; 
}

var instance = new Person('instance', 18);
instance.sayHi();
複製程式碼

既然函式內部並沒有使用new操作生成的例項物件,為啥還要生成?這個點暫時沒搞懂。

可以看到,和工廠模式相比,除了使用new操作符以為,其他的沒啥兩樣。但是在一些特定場合,還是有它的用武之地的。比如ES6之前原生型別是無法繼承的,可以使用這種方法生成繼承原生型別的例項

看下這個例子:

function SpecialArray() {
    var values = new Array();
    values.push.apply(values, arguments);
    // 提供新方法
    values.toPipedString = function() {
        return this.join('|');
    };
    
    return values;
}

var colors = new SpecialArray('red', 'blue', 'green');
alert(colors.toPipedString());
複製程式碼

這裡提供了建構函式,內部使用Array物件,並新增了特有方法。

之所以叫做寄生,原因大概因為新的功能依託於原有物件吧。

穩妥建構函式模式

所謂穩妥,指沒有公共屬性,而且其方法不引用this。在該模式中不適用new來呼叫建構函式。下面是個例子:

function Person(name, age) {
    var o = new Object();
    o.sayHi = function() {
        alert('Hi! I\'m ' + name);
    }
    return o;
}

var instance = Person('instance', 18);
instance.sayHi();
複製程式碼

也許你會納悶,這裡的name沒有顯示的儲存,到底如何能訪問到?請看下面的斷點截圖。說明了其儲存在閉包中,或者說被閉包捕獲了。(若理解有誤請告知。謝謝!)

【讀】JavaScript之物件導向

繼承

ECMAScript中依靠原型鏈來實現繼承。

原型鏈

簡單回顧下建構函式、原型、和例項之間的關係:建構函式也是物件,該物件擁有prototype屬性,指向了其原型物件,原型物件存在一個constructor屬性,指向了該建構函式;例項物件擁有__proto__屬性,也指向了建構函式的原型物件(再次說明下,__proto__不推薦使用哈,)。

現在,如果讓建構函式的prototype屬性指向另一個型別的例項物件呢?上面的情況會層層遞進。讓我們看下面的例子:

// 父型別
function SuperType() {
    this.property = true;
}

SuperType.prototype.getSuperValue = function() {
    return this.property;
}

// 子型別
function SubType() {
    this.subproperty = false;
}

// 子型別的prototype指向父型別的例項物件
SubType.prototype = new SuperType();

SubType.prototype.getSubValue = function() {
    return this.subproperty;
}

var instance = new SubType();
alert(instance.getSuperValue()); // true
複製程式碼

對應關係圖:

【讀】JavaScript之物件導向

值得注意的是,instance.constructor將得到的是SuperType。

  • 預設的原型

    所有引用型別都繼承了Object,也就是說所有函式的prototype指向了Object例項。讓我們更新下上面的關係圖:

    【讀】JavaScript之物件導向

  • 確定原型和例項的關係 可以使用instanceofisPrototypeOf方法

    alert(instance instanceof Object);
    alert(instance instanceof SuperType);
    alert(instance instanceof SubType);
    alert(Object.prototype.isPrototypeOf(instance));
    alert(SuperType.prototype.isPrototypeOf(instance));
    alert(SubType.prototype.isPrototypeOf(instance));
    複製程式碼

    這裡都會返回true。判斷規則為:例項的原型在原型鏈中。我們可以使用下面的方法進行模擬。

    /// 物件是否是指定型別的例項,會查詢原型鏈
    Object.prototype.isKindsOf = function(func) {
        for (
            let proto = Object.getPrototypeOf(this); 
            proto !== null; 
            proto = Object.getPrototypeOf(proto)
        ) {
            if (proto === func.prototype) {
                return true
            }
        }
        return false
    }
    
    /// 物件是否是指定型別的例項,不進行原型鏈判斷
    Object.prototype.isMemberOf = function(func) {
        return Object.getPrototypeOf(this) === func.prototype
    }
    複製程式碼
  • 原型鏈存在的問題

    通過原型實現繼承,是將子類的原型物件賦值為父類(這裡暫時使用子類和父類來表述)的例項,這樣,原先父類的例項屬性成了子類原型屬性,會被子類的所有例項共享,這也包含引用型別的屬性。

    再者,建立子類型別例項時,無法向父類的建構函式中傳遞引數。

    下面我們一起看看如何解決這些問題。

借用建構函式

// 父型別
function SuperType() {
    this.colors = ['red', 'blue', 'green'];
}

// 子型別
function SubType() {
    // 使父類的建構函式在子類例項物件上初始化
    SuperType.call(this);
}

var instance1 = new SubType();
instance1.colors.push('gray');
var instance2 = new SubType();
alert(instance2.colors); // red, blue, green
複製程式碼

這裡在構造子類例項時,呼叫父類建構函式,完成父類特定的初始化。 像下面這樣,還可以完成引數的傳遞。

// 父型別
function SuperType(name) {
    this.name = name;
}

// 子型別
function SubType() {
    // 使父類的建構函式在子類例項物件上初始化
    // 這樣子類的屬性就會覆蓋其原型屬性
    SuperType.call(this, 'unnamed');
}

var instance = new SubType();
alert(instance.name); // unnamed
複製程式碼

借用建構函式也存在一些問題。比如,方法無法實現複用;父類原型中定義的方法對子類不可見(因為這種情形子類和父類並沒有原型鏈上的關係,只是子類在構造過程中借用了父類的構造過程)。

組合繼承

將原型鏈和借用建構函式組合一起,使用原型鏈實現對原型屬性和方法的繼承,借用建構函式實現例項屬性的繼承。這成為最常用的繼承模式。下面是一個例子:

// 父型別
function SuperType(name) {
    this.name = name;
    this.colors = ['red', 'blue', 'green'];
}

SuperType.prototype.sayHi = function() {
    alert('Hi! I\'m ' + this.name);
};

// 子型別
function SubType(name, age) {
    // 使父類的建構函式在子類例項物件上初始化, 完成例項屬性的繼承
    SuperType.call(this, name);
    // 子類特有的屬性
    this.age = age;
}

// 子型別的prototype指向父型別的例項物件,完成繼承
SubType.prototype = new SuperType();
SubType.prototype.sayAge = function(){ 
    alert(this.name + ' is ' + this.age + ' years old!');
}

var instance1 = new SubType('instance1', 18);
instance1.colors.push('black');
alert(instance1.colors); // red,blue,green,black
instance1.sayHi(); // Hi! I'm instance1
instance1.sayAge(); // instance1 is 18 years old!

var instance2 = new SubType('instance2', 20);
alert(instance2.colors); // red,blue,green
instance2.sayHi(); // Hi! I'm instance2
instance2.sayAge(); // instance2 is 20 years old!
複製程式碼

原型式繼承

這是一種藉助已有物件建立新物件,同時不必建立自定義型別的方法。先看下面的例子:

function object(o) {
    /// 建立臨時建構函式
    function F(){}
    /// 將建構函式的原型賦值為傳入的物件
    F.prototype = o;

    /// 返回例項
    return new F();
}

var person = {
    name: 'instance1',
    friends: ['gouzi', 'maozi']
};

var anotherPerson = object(person);
anotherPerson.name = 'cuihua';
anotherPerson.friends.push('xiaofeng');

var yetAnotherPerson = object(person);
yetAnotherPerson.name = 'daha';
yetAnotherPerson.friends.push('bob');

alert(person.friends);
複製程式碼

可以看到,這裡相當於複製了person的兩個副本。在ECMAScript5中,新增了Object.create方法來規範了原型繼承模式。

/// 可以只傳入一個引數
var anotherPerson = Object.create(person);

// 也可多傳入屬性及其特性
var yetAnotherPerson = Object.create(person, {
    name: {
        value: 'dab'
    }
});
複製程式碼

寄生式繼承

相比原型繼承,寄生式繼承僅是提供一個函式,用來封裝物件的繼承過程。如下:

function createAnother(original) {
    /// 向原型繼承一樣,建立新物件
    var clone = Object.create(original)
    /// 自定義的增強過程
    clone.sayHi = function() {
        alert('Hi!');
    }
    return clone;
}
複製程式碼

可以發現,該模式,無法對函式進行復用。

寄生組合模式

回顧下之前的組合繼承方式,這會導致兩次父類建構函式呼叫:一次在建立子類原型,另一次在建立子類例項。這將導致,子類的例項和原型中都存在父類的屬性。如下:

// 父型別
function SuperType(name) {
    this.name = name;
    this.colors = ['red', 'blue', 'green'];
}

SuperType.prototype.sayHi = function() {
    alert('Hi! I\'m ' + this.name);
};

// 子型別
function SubType(name, age) {
    // 使父類的建構函式在子類例項物件上初始化, 完成例項屬性的繼承
    SuperType.call(this, name); // 又一次父類建構函式呼叫
    // 子類特有的屬性
    this.age = age;
}

// 子型別的prototype指向父型別的例項物件,完成繼承
SubType.prototype = new SuperType(); // 一次父類建構函式呼叫
SubType.prototype.sayAge = function(){ 
    alert(this.name + ' is ' + this.age + ' years old!');
}

var instance = new SubType();
instance.colors.push('gray');
alert(instance.colors); // red,blue,green,gray
alert(instance.__proto__.colors); // red,blue,green
複製程式碼

為了解決這個問題,可以考慮使用建構函式繼承屬性,使用原型鏈來繼承方法;不必為了指定子類的原型而呼叫父類的建構函式(這樣就避免了生成父類的例項,因為父類的原型物件已經存在了),我們需要的就是原型物件的一個副本而已。看看下面的例子:

// 父型別
function SuperType(name) {
    this.name = name;
    this.colors = ['red', 'blue', 'green'];
}

SuperType.prototype.sayHi = function() {
    alert('Hi! I\'m ' + this.name);
};

// 子型別
function SubType(name, age) {
    // 使父類的建構函式在子類例項物件上初始化, 完成例項屬性的繼承
    SuperType.call(this, name); // 又一次父類建構函式呼叫
    // 子類特有的屬性
    this.age = age;
}

/// 使用寄生的方式完成繼承
inheritPrototype(SubType, SuperType);

SubType.prototype.sayAge = function(){ 
    alert(this.name + ' is ' + this.age + ' years old!');
}

function inheritPrototype(subType, superType) {
    // 獲得父類原型副本,作為子類的原型
    var prototype = Object.create(superType.prototype);
    // 配置子類原型
    prototype.constructor = subType;
    // 指定子類原型
    subType.prototype = prototype;
}

var instance = new SubType();
instance.colors.push('gray');
alert(instance.colors); // red,blue,green,gray
alert(instance.__proto__.colors); // undefined
複製程式碼

至此,我們找到了一種最理想的繼承模式。

總結

該篇從物件的含義,到物件的建立方式,再到繼承的多種實現。由淺入深的介紹了ECMAScript中物件導向相關知識。也許你在閱讀過程中感到疑惑,那麼就動手實現一遍...那時,就不必多說什麼了!

參考

相關文章