1. ES5 建立物件的方式
在 JavaScript 中,建立物件的方式有很多種,最常用的一般是通過字面量的方式,而要建立例項物件則一般通過建立一個建構函式,通過 new 關鍵字來構造。
雖然 Object 函式和字面量都可以建立物件,但同時也會有一個問題:使用一個介面建立多個物件時,會出現大量重複程式碼。下面來介紹一些建立物件的變體。
1.1 工廠模式
function createPerson(name, age) {
var obj = new Object();
obj.name = name;
obj.age = age;
obj.sayName = function () {
console.log(this.name);
};
return obj;
}
var person = createPerson('mike', 18);
複製程式碼
工廠模式解決了建立多個相似物件的問題,但缺點是無法識別物件原型。
這裡列印 person 物件,可以看到有 2 個屬性和 1 個方法,原型物件是 Obejct,constructor 屬性(指向建構函式的指標)指向 Object 物件。
1.2 建構函式
function Person(name, age) {
this.name = name;
this.age = age;
this.sayName = function () {
console.log(this.name);
}
}
var person = new Person('mike', 18);
var person2 = new Person('alice', 20);
// 相當於以下操作
var obj = new Object();
obj.__proto__ = Person.prototype;
Person.call(obj, 'mike', 18);
複製程式碼
建構函式模式是比較常見的一種方式,通過大寫函式名的第一個字母來用以區分普通函式。
建構函式與工廠模式還有以下的不同:
- 沒有顯示建立物件
- 直接將屬性賦值給了 this
- 沒有 return
此時建立 person 例項需要通過 new 關鍵字,通過 new 關鍵字呼叫建構函式的過程其實經歷了以下四個步驟:
- 建立一個新物件: var obj = new Object();
- 將建構函式的原型物件賦值給新的物件 obj: obj.__proto__ = Person.prototype;
- 執行建構函式中的程式碼,給新物件 obj 新增屬性和方法: Person.call(obj, 'mike', 18);
- 返回 obj 物件
建構函式解決了工廠模式不能識別例項型別的問題,但是也有一個缺點:在這個例子裡它會多次建立了相同函式 sayName。
1.3 原型模式
我們建立每一個函式都有一個 prototype(原型)屬性,指向一個物件。這個物件的用途是包含所有特定型別(例子是 Person)的所有例項共享的屬性(name age)和方法(sayName)。
function Person() { }
Person.prototype = {
constructor: Person, // 不指定 constructor 會使 constructor 指向斷裂,導致物件型別無法正確識別。
name: 'mike',
age: 19,
hobby: ['football', 'singing'],
sayName: function () {
console.log(this.name);
}
}
var person1 = new Person();
var person2 = new Person();
person1.hobby.push('dancing'); // person2.hobby: ['football', 'singing','dancing']
複製程式碼
constructor 指向未斷裂的情況:指向了 Person
constructor 指向斷裂的情況:失去了 constructor,預設指向了 Object
原型鏈示意圖:
下圖可見通過原型模式解決了建構函式模式多次建立了 sayName 方法的問題,但聰明的電視機前的你肯定發現了定義的原型屬性會被所有的例項共享。
當我們操作了 person1 的 hobby 物件的時候,person2 的也同時被修改了,這是我們不願看到的。
1.4 組合模式
function Person(name, age) {
this.name = name;
this.age = age;
this.hobby = ['football', 'singing']
}
Person.prototype = {
constructor: Person, // 不指定 constructor 會使 constructor 指向斷裂,導致物件型別無法正確識別。
sayName: function () {
console.log(this.name);
}
}
var person1 = new Person('mike', 18);
person1.hobby.push('dancing');
var person2 = new Person('alice', 19);
複製程式碼
通過以上的幾種方式的分析,我們差不多也能得到比較好的一種模式了,那就是組合模式。
在建構函式中新增例項屬性,在建構函式的原型鏈上新增例項方法,這樣既解決了例項共享,又解決了多次建立相同函式的問題,是目前使用比較廣泛的模式。
2. ES6 建立物件的方式
ES6 裡我們可以通過 class 關鍵字來定義一個類,class 實際上是一個語法糖,雖然絕大部分的功能可以通過 ES5 實現,但是 class 的寫法讓物件變的更加清晰,更接近物件導向的語法。 通過 class 來改寫組合模式:
class Person {
constructor(name, age) {
this.name = name;
this.age = age;
this.hobby = ['football', 'singing'];
}
sayName() {
console.log(this.name);
}
}
var person1 = new Person('mike', 18);
person1.hobby.push('dancing');
var person2 = new Person('alice', 19);
複製程式碼
由此對比可見,和 ES5 的結果只有在 __proto__ 物件裡的 constructor 顯示的是 class,其餘的部分都是一致。 通過 babel 編譯成 ES5,我們進行一下對比。
'use strict';
var _createClass = function () {
// 定義屬性的配置項
function defineProperties(target, props) {
for (var i = 0; i < props.length; i++) {
var descriptor = props[i];
descriptor.enumerable = descriptor.enumerable || false;
descriptor.configurable = true;
if ("value" in descriptor) descriptor.writable = true;
Object.defineProperty(target, descriptor.key, descriptor);
}
}
return function (Constructor, protoProps, staticProps) {
if (protoProps) {
defineProperties(Constructor.prototype, protoProps);
}
if (staticProps) {
defineProperties(Constructor, staticProps);
}
return Constructor;
};
}();
// 檢查例項是否是後者的例項
function _classCallCheck(instance, Constructor) {
if (!(instance instanceof Constructor)) {
throw new TypeError("Cannot call a class as a function");
}
}
var Person = function () {
function Person(name, age) {
_classCallCheck(this, Person);
this.name = name;
this.age = age;
this.hobby = ['football', 'singing'];
}
// 掛載 sayName 方法
_createClass(Person, [{
key: 'sayName',
value: function sayName() {
console.log(this.name);
}
}]);
return Person;
}();
var person1 = new Person('mike', 18);
person1.hobby.push('dancing');
var person2 = new Person('alice', 19);
複製程式碼
拋開對屬性的一些配置上的操作,與 ES5 我們所用的組合模式並無不同。
3. ES5 實現繼承
首先我們通過組合模式建立一個 Animal 父類物件
// 定義一個動物類
function Animal(name) {
// 屬性
this.name = name || 'Animal';
// 例項方法
this.sleep = function () {
return this.name + ' 正在睡覺!';
}
}
// 原型方法
Animal.prototype.eat = function (food) {
return this.name + ' 正在吃: ' + food;
};
複製程式碼
3.1 原型鏈繼承
核心: 將父類的例項作為子類的原型(注意不能使用字面量方式定義原型方法,會重寫原型鏈)
function Cat() {}
Cat.prototype = new Animal();
Cat.prototype.name = 'cat';
// Test Code
var cat = new Cat();
console.log(cat.name); // cat
console.log(cat.eat('fish')); // cat 正在吃:fish
console.log(cat.sleep()); // cat 正在睡覺!
console.log(cat instanceof Animal); // true
console.log(cat instanceof Cat); // true
複製程式碼
特點:
- 非常純粹的繼承關係,例項是子類的例項,也是父類的例項
- 父類新增原型方法/原型屬性,子類都能訪問到
- 簡單,易於實現
缺點:
- 可以在Cat建構函式中,為Cat例項增加例項屬性。如果要新增原型屬性和方法,則必須放在new Animal()這樣的語句之後執行。
- 無法實現多繼承
- 來自原型物件的引用屬性是所有例項共享的
- 建立子類例項時,無法向父類建構函式傳參
推薦指數:★★(3、4兩大致命缺陷)
3.2 構造繼承
核心:使用父類的建構函式來增強子類例項,等於是複製父類的例項屬性給子類(沒用到原型)
function Cat(name){
Animal.call(this);
this.name = name || 'Tom';
}
// Test Code
var cat = new Cat();
console.log(cat.name); // Tom
console.log(cat.sleep()); // Tom 正在睡覺
// console.log(cat.eat('fish')); // 會報錯,原型在這裡不可用
console.log(cat instanceof Animal); // false
console.log(cat instanceof Cat); // true
複製程式碼
特點:
- 解決了原型鏈繼承中,子類例項共享父類引用屬性的問題
- 建立子類例項時,可以向父類傳遞引數
- 可以實現多繼承(call 多個父類物件)
缺點:
- 例項並不是父類的例項,只是子類的例項
- 只能繼承父類的例項屬性和方法,不能繼承原型屬性/方法
- 無法實現函式複用,每個子類都有父類例項函式的副本,影響效能
推薦指數:★★(缺點3)
3.3 例項繼承(原型式繼承)
核心:為父類例項新增新特性,作為子類例項返回
function Cat(name){
var instance = new Animal();
instance.name = name || 'Tom';
return instance;
}
// Test Code
var cat = new Cat();
console.log(cat.name);
console.log(cat.sleep());
console.log(cat instanceof Animal); // true
console.log(cat instanceof Cat); // false
複製程式碼
特點:不限制呼叫方式,不管是new 子類()還是子類(),返回的物件具有相同的效果
缺點:
- 例項是父類的例項,不是子類的例項
- 不支援多繼承
推薦指數:★★
3.4 拷貝繼承
function Cat(name){
var animal = new Animal();
for(var p in animal){
Cat.prototype[p] = animal[p];
}
Cat.prototype.name = name || 'Tom';
}
// Test Code
var cat = new Cat();
console.log(cat.name);
console.log(cat.sleep());
console.log(cat instanceof Animal); // false
console.log(cat instanceof Cat); // true
複製程式碼
特點:支援多繼承
缺點:
- 效率較低,記憶體佔用高(因為要拷貝父類的屬性)
- 無法獲取父類不可列舉的方法(不可列舉方法,不能使用for in 訪問到)
推薦指數:★(缺點1)
3.5 組合繼承
核心:通過呼叫父類構造,繼承父類的屬性並保留傳參的優點,然後通過將父類例項作為子類原型,實現函式複用
function Cat(name){
Animal.call(this);
this.name = name || 'Tom';
}
Cat.prototype = new Animal();
// Test Code
var cat = new Cat();
console.log(cat.name);
console.log(cat.sleep());
console.log(cat instanceof Animal); // true
console.log(cat instanceof Cat); // true
複製程式碼
特點:
- 彌補了方式2的缺陷,可以繼承例項屬性/方法,也可以繼承原型屬性/方法
- 既是子類的例項,也是父類的例項
- 不存在引用屬性共享問題
- 可傳參
- 函式可複用
缺點: 呼叫了兩次父類建構函式,生成了兩份例項(子類例項將子類原型上的那份遮蔽了)
推薦指數:★★★★(僅僅多消耗了一點記憶體)
3.6 寄生組合繼承
核心:通過寄生方式,砍掉父類的例項屬性,這樣,在呼叫兩次父類的構造的時候,就不會初始化兩次例項方法/屬性,避免的組合繼承的缺點
function Cat(name){
Animal.call(this);
this.name = name || 'Tom';
}
(function(){
// 建立一個沒有例項方法的類
var Super = function(){};
Super.prototype = Animal.prototype;
//將例項作為子類的原型
Cat.prototype = new Super();
})();
// 等價於下面這種情況
// function inheritPrototype(sub, sup) {
// var Fn= function() {}
// Fn.prototype = sup.prototype;
// sub.prototype = new Fn();
// }
// inheritPrototype(Cat, Animal);
// Test Code
var cat = new Cat();
console.log(cat.name);
console.log(cat.sleep());
console.log(cat instanceof Animal); // true
console.log(cat instanceof Cat); //true
複製程式碼
特點:堪稱完美
缺點:實現較為複雜
推薦指數:★★★★
4. ES6 實現繼承
首先還是建立一個 Animal 類
class Animal {
constructor(name) {
this.name = name || 'Animal';
this.sleep = function () {
return this.name + ' 正在睡覺!';
}
}
eat(food) {
return this.name + ' 正在吃: ' + food;
};
}
複製程式碼
然後通過 extends 關鍵字來繼承 Animal
class Cat extends Animal {
constructor(name, age) {
super(name);
this.age = age; // 新增的子類屬性
}
eat(food) {
const result = super.eat(food); // 通過 super 呼叫父類方法
return this.age + ' 歲的 ' + result;
}
}
const cat = new Cat('miao', 3);
複製程式碼
總結
總的來說,ES6 的 class 語法糖更清晰和優雅地實現了建立物件和物件繼承。 但是我們要想更好的理解 class,那麼關於 ES5 的物件、物件繼承以及原型鏈等知識也是要掌握的很牢固。