關於js中原生建構函式的繼承
前言
最近參加了螞蟻金服的面試,一個關於js繼承的問題答的不是特別好。在如今快節奏的工作當中,很多基礎的東西會漸漸被丟掉。就如繼承這個話題,寫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高階程式設計引發的一些思考。因此,百忙之中,我們也需要經常去溫習基礎知識,所謂溫故而知新,正是如此。