前言
在如今快節奏的工作當中,很多基礎的東西會漸漸地被丟掉。就如繼承這個話題,寫React的同學應該都是class xxx extends React.Component,然而這可以理解為es5的一個語法糖,所以問題又回到了js如何實現繼承。面試結束后,趕緊翻了翻積滿灰塵的js高級程序設計,重新學習了一遍面向對象這一章,有一個創建對象的模式吸引到了我。
寄生構造函數模式
在oo中我們是通過類去創建自定義類型的對象,然而js中沒有類的概念,在es5的時代,如果我們要去模擬類,學過的同學應該知道最好采用一種構造函數與原型混成的模式。而書中作者提到了一種有意思的模式,叫做寄生構造函數模式,代碼如下:
function Person(name, age, job) {
var o = new Object();
o.name = name;
o.age = age;
o.job = job;
o.sayName = function() {
alert(this.name);
};
return o;
}
var friend = new Person("Nicholas", 29, "Software Engineer");
friend.sayName(); // "Nicholas"
對於這種模式有諸多不解:
- 仔細一看,這特么不就是所謂的工廠函數模式嗎?工廠模式的幾個缺點它都存在,一種是創建的所有對象均為Object類型,無法進行類型識別;其次每次創建對象都會重新生成一個function用來創建sayName屬性,浪費內存。
- 這里的new有什么意義嗎?new的作用是生成一個對象,將當前上下文即this指向該對象,然后return該對象。但是此處return了一個o,new就完全沒用了。
帶着諸多的不解,又看到了作者提到了該模式的一個使用場景,看代碼:
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()); // "red|blue|green"
從代碼我們得知,該構造函數是希望創建一個具有額外方法的特殊數組,仔細想想,這不就是繼承嘛。繼承在書中提到的最棒的方式是通過寄生組合式繼承,那為什么還要通過這種方式來實現Array繼承,況且該方式有個很大的問題就是上面提到的類型無法通過instanceof來確定。
寄生組合式繼承
我們先來看看最常用的繼承范式:寄生組合式繼承,寫法如下:
function SpecialArray() {
// 調用Array函數,綁定給當前上下文
Array.apply(this, arguments);
};
// 創建一個以Array.prototype為原型的對象作為SpecialArray的原型
SpecialArray.prototype = Object.create(Array.prototype);
// constructor指向SpecialArray,默認情況[[enumerable]]為false
Object.defineProperty(SpecialArray.prototype, "constructor", {
enumerable: false,
value: SpecialArray
});
SpecialArray.prototype.toPipedString = function() {
return this.join("|");
};
var arr = new SpecialArray(1, 2, 3);
console.log(arr); // arr為SpecialArray {}
console.log(new Array(1, 2, 3).hasOwnProperty('length')) // true 證明length是Array的實例屬性
console.log(arr.hasOwnProperty('length')) // false 證明Array無視apply方法的this綁定
上面是典型的寄生組合式繼承的寫法,其存在幾個問題:
- new的行為上面介紹過,它會返回對象類型,而我們的SpecialArray希望像Array一樣,new的時候返回數組。
- 我們先通過hasOwnProperty證明了length是Array的一個實例屬性,既然如此通過執行Array.apply(this, arguments)會將length綁定給SpecialArray的實例arr,但是實際arr上沒有length屬性,因此可以證明Array無視apply方法的this綁定。
既然this無法綁定,那我們只能通過new一個Array來幫我們構造一個數組實例並返回,此時我們的構造函數應該像這樣:
function SpecialArray() {
var values = new Array()
// 添加初始值
values.push.apply(values, arguments);
return values
};
這其實就是我們上面提到的寄生構造函數模式,但是此時返回的values是Array的實例,其原型對象是Array.prototype。這樣會造成兩個問題:
- 無法通過instanceof確定實例的類型,它始終為Array的實例
- 我們希望將構造函數的方法放入prototype實現共享,而不是放入構造函數中,在每次生成實例都重新生成一個function
因此我們要做的事情就是將生成的values實例的原型指向SpecialArray.prototype。我們知道實例對象有一個__proto__屬性,它指向其構造函數的原型,我們可以通過修改該屬性達到我們的目的:
function SpecialArray() {
var values = new Array()
// 添加初始值
values.push.apply(values, arguments);
// 將values的原型指向SpecialArray.prototype
values.__proto__ = SpecialArray.prototype
return values
};
// 創建一個以Array.prototype為原型的對象作為SpecialArray的原型
SpecialArray.prototype = Object.create(Array.prototype);
// constructor指向SpecialArray,默認情況[[enumerable]]為false
Object.defineProperty(SpecialArray.prototype, "constructor", {
enumerable: false,
value: SpecialArray
});
SpecialArray.prototype.toPipedString = function() {
return this.join("|");
};
var arr = SpecialArray(1, 2, 3); // 不需要new
console.log(arr.toPipedString()); // 1|2|3
console.log(arr instanceof SpecialArray) // true
我們看到arr.toPipedString()可以返回正確的值了,且arr instanceof SpecialArray為true,即完成了繼承。這種做法恰好和原型鏈繼承相反,原型鏈繼承是將父類實例作為子類的原型,而該方法是將父類實例的原型指針指向了子類的原型。但是,這種方法有一個很大的問題:__proto__屬性是一個非標准屬性,其在部分安卓機上未被實現,因此就有一種說法:ES5及以下的JS無法完美繼承數組。
es6 extends
es6的extends其實能夠很方便的幫我們完成Array繼承:
class SpecialArray extends Array {
constructor(...args) {
super(...args)
}
toPipedString() {
return this.join("|");
}
}
var arr = new SpecialArray(1, 2, 3)
console.log(arr.toPipedString()) // 1|2|3
console.log(arr instanceof SpecialArray) // true
因為我們調用super的時候是先新建父類的實例this,然后再用子類的構造函數SpecialArray來修飾this,這是es5當中做不到的一點。
vue中的數組
我們知道在vue中,push、pop、splice等方法可以觸發響應式更新,而arr[0] = 1這種寫法無法觸發,原因是defineProperty無法劫持數組類型的屬性,那么vue是如何讓常用的方法觸發更新的呢,我們看:
var arrayProto = Array.prototype;
var arrayMethods = Object.create(arrayProto);
var methodsToPatch = [
'push',
'pop',
'shift',
'unshift',
'splice',
'sort',
'reverse'
];
/**
* Intercept mutating methods and emit events
*/
methodsToPatch.forEach(function (method) {
// cache original method
var original = arrayProto[method];
def(arrayMethods, method, function mutator () {
var args = [], len = arguments.length;
while ( len-- ) args[ len ] = arguments[ len ];
var result = original.apply(this, args);
var ob = this.__ob__;
var inserted;
switch (method) {
case 'push':
case 'unshift':
inserted = args;
break
case 'splice':
inserted = args.slice(2);
break
}
if (inserted) { ob.observeArray(inserted); }
// notify change
ob.dep.notify();
return result
});
});
這是vue的部分源碼,我們不用細看,看重點即可。我們可以看到vue創建了一個對象arrayMethods,它是以Array.prototype作為原型的。然后改寫了arrayMethods中的push、pop、shift等方法,即在原有功能的基礎上觸發ob.dep.notify()完成更新。那它是如何將我們聲明的數組指向arrayMethods的呢,我們繼續看:
var Observer = function Observer (value) {
this.value = value;
this.dep = new Dep();
this.vmCount = 0;
def(value, '__ob__', this);
if (Array.isArray(value)) {
var augment = hasProto
? protoAugment
: copyAugment;
augment(value, arrayMethods, arrayKeys);
this.observeArray(value);
} else {
this.walk(value);
}
};
/**
* Augment an target Object or Array by intercepting
* the prototype chain using __proto__
*/
function protoAugment (target, src, keys) {
/* eslint-disable no-proto */
target.__proto__ = src;
/* eslint-enable no-proto */
}
/**
* Augment an target Object or Array by defining
* hidden properties.
*/
/* istanbul ignore next */
function copyAugment (target, src, keys) {
for (var i = 0, l = keys.length; i < l; i++) {
var key = keys[i];
def(target, key, src[key]);
}
}
我們看到vue先是做了個判斷,即當前運行環境是否支持__proto__屬性。若支持,執行protoAugment(),將target的__proto__指向arrayMethods,這其實就是我們上面實現的es5的繼承方式。若不支持,就將arrayMethods里的方法注入到target中完成mixin的操作。
總結
寄生組合式繼承雖然很完美,但是它沒辦法做到繼承原生類型的構造函數,此時可以借用我們實現的進化版的寄生構造函數模式完成繼承。每個階段回頭去看一些基礎總會發現有不同的收獲,這次的分享內容也是看了js高級程序設計引發的一些思考。因此,百忙之中,我們也需要經常去溫習基礎知識,所謂溫故而知新,正是如此。