本文是對《詳解Javascript的繼承實作》的補充,針對上文繼承庫實作的注意事項和閱讀《JavaScript面向對象程式設計指南》一書關于繼承部分的内容進行說明。
上文《詳解Javascript的繼承實作》介紹了一個通用的繼承庫,基于該庫,可以快速建構帶繼承關系和靜态成員的javascript類,好使用也好了解,額外的好處是,如果所有類都用這種庫來建構,還能使代碼在整體上保持一緻的風格,便于其它同僚閱讀和了解。在寫完該文之後,這兩天時不時都在思考這個庫可能存在的問題,加上這兩天又在溫習《JavaScript面向對象程式設計指南》這本書繼承這一章的内容,發現對繼承的内容有了一些新的發現和了解,有必要再把這兩天的收獲再分享出來。
1. 繼承庫的注意事項
為了友善閱讀本部分的内容,隻好先把上文繼承庫的實作代碼和示範代碼再搬出來,省的還得回到那篇文章去找相關内容,好在代碼我加了折疊的功能,即使不想展開看,也不會太影響閱讀:
//繼承庫實作部分
var Class = (function () {
var hasOwn = Object.prototype.hasOwnProperty;
//用來判斷是否為Object的執行個體
function isObject(o) {
return typeof (o) === 'object';
}
//用來判斷是否為Function的執行個體
function isFunction(f) {
return typeof (f) === 'function';
}
//簡單複制
function copy(source) {
var target = {};
for (var i in source) {
if (hasOwn.call(source, i)) {
target[i] = source[i];
}
}
return target;
}
function ClassBuilder(options) {
if (!isObject(options)) {
throw new Error('Class options must be an valid object instance!');
}
var instanceMembers = isObject(options) && options.instanceMembers || {},
staticMembers = isObject(options) && options.staticMembers || {},
extend = isObject(options) && isFunction(options.extend) && options.extend,
prop;
//表示要建構的類的構造函數
function TargetClass() {
if (extend) {
//如果有要繼承的父類
//就在每個執行個體中添加baseProto屬性,以便執行個體内部可以通過這個屬性通路到父類的原型
//因為copy函數導緻原型鍊斷裂,無法通過原型鍊通路到父類的原型
this.baseProto = extend.prototype;
}
if (isFunction(this.init)) {
this.init.apply(this, arguments);
}
}
//添加靜态成員,這段代碼需在原型設定的前面執行,避免staticMembers中包含prototype屬性,覆寫類的原型
for (prop in staticMembers) {
if (hasOwn.call(staticMembers, prop)) {
TargetClass[prop] = staticMembers[prop];
}
}
//如果有要繼承的父類,先把父類的執行個體方法都複制過來
extend && (TargetClass.prototype = copy(extend.prototype));
//添加執行個體方法
for (prop in instanceMembers) {
if (hasOwn.call(instanceMembers, prop)) {
//如果有要繼承的父類,且在父類的原型上存在目前執行個體方法同名的方法
if (extend && isFunction(instanceMembers[prop]) && isFunction(extend.prototype[prop])) {
TargetClass.prototype[prop] = (function (name, func) {
return function () {
//記錄執行個體原有的this.base的值
var old = this.base;
//将執行個體的this.base指向父類的原型的同名方法
this.base = extend.prototype[name];
//調用子類自身定義的執行個體方法,也就是func參數傳遞進來的函數
var ret = func.apply(this, arguments);
//還原執行個體原有的this.base的值
this.base = old;
return ret;
}
})(prop, instanceMembers[prop]);
} else {
TargetClass.prototype[prop] = instanceMembers[prop];
}
}
}
TargetClass.prototype.constructor = TargetClass;
return TargetClass;
}
return ClassBuilder
})();
//繼承庫示範部分
var Employee = Class({
instanceMembers: {
init: function (name, salary) {
this.name = name;
this.salary = salary;
//調用靜态方法
this.id = Employee.getId();
},
getName: function () {
return this.name;
},
getSalary: function () {
return this.salary;
},
toString: function () {
return this.name + '\'s salary is ' + this.getSalary() + '.';
}
},
staticMembers: {
idCounter: 1,
getId: function () {
return this.idCounter++;
}
}
});
var Manager = Class({
instanceMembers: {
init: function (name, salary, percentage) {
//通過this.base調用父類的構造方法
this.base(name, salary);
this.percentage = percentage;
},
getSalary: function () {
return this.base() + this.salary * this.percentage;
}
},
extend: Employee
});
var e = new Employee('jason', 5000);
var m = new Manager('tom', 8000, 0.15);
console.log(e.toString()); //jason's salary is 5000.
console.log(m.toString()); //tom's salary is 9200.
console.log(e.constructor === Employee); //true
console.log(m.constructor === Manager); //true
console.log(e.id); //1
console.log(m.id); //2
從上文的實作和調用舉例中,有以下事項在使用的時候值得注意;
1)一定要在instanceMembers選項裡提供init方法,并在該方法内完成類的構造邏輯,這個方法名是固定的,隻有init方法在類執行個體化(new)的時候會自動調用,是以不能把構造邏輯寫到其它方法裡面;
2)如果有繼承,子類的執行個體需要通路父類的原型的直接通過子類執行個體的baseProto屬性即可通路,是以在設計一個類的時候,盡量不要把某些業務屬性的名字設定成baseProto,否則就有可能導緻該執行個體沒有途徑通路到父類的原型,如果非要把某些屬性設定成baseProto,在init方法裡面,建議做conflict的處理:
var Manager = Class({
instanceMembers: {
init: function (name, salary, percentage, baseProto) {
//通過this.base調用父類的構造方法
this.base(name, salary);
this.percentage = percentage;
//保留原始的baseProto連結,其它位置可通過this.oldBaseProto通路到父級的原型
this.oldBaseProto = this.baseProto;
this.baseProto = baseProto;
},
getSalary: function () {
return this.base() + this.salary * this.percentage;
}
},
extend: Employee
});
3)instanceMembers隻能用來提供執行個體方法,不能用來提供執行個體屬性,如果想把某個執行個體屬性加在類的原型上,以便被所有執行個體共享,那麼這個屬性就不是執行個體屬性,應該作為靜态屬性放置到staicMembers裡面;
4)執行個體屬性建議在init方法裡面全部聲明清楚,即使某些屬性并不在init方法裡面用到,也可以把它們聲明出來并賦一個預設值,增強代碼的閱讀性;私有的屬性建議在前面加下劃線的辨別:
var Manager = Class({
instanceMembers: {
init: function (name, salary, percentage) {
//通過this.base調用父類的構造方法
this.base(name, salary);
this.percentage = percentage;
this.worksStatus = 'working';
this.clothes = undefined;
this._serialId = undefined;
},
setClothes(clothes) {
this.clothes = clothes;
},
getSalary: function () {
return this.base() + this.salary * this.percentage;
}
},
extend: Employee
});
5)在建構類的靜态成員時,采用的是淺拷貝的方式,如果靜态屬性是引用類型的資料,要注意引用的問題,尤其是當外部調用這個庫之前,已經把要建構的類的staticMembers緩存過的時候。
6)私有方法不要放置在instanceMember上,盡管調用友善,但是不符合語義,而且容易被誤調用,在實際使用這個庫的時候,可以把調用的代碼再包裝在一個匿名子產品内部:
var Employee = (function () {
//給對象添加一些預設的事件
function bindEvents() {
console.log('events binded!');
}
var F = Class({
instanceMembers: {
init: function (name, salary) {
this.name = name;
this.salary = salary;
//調用靜态方法
this.id = Employee.getId();
bindEvents.apply(this);
},
getName: function () {
return this.name;
},
getSalary: function () {
return this.salary;
},
toString: function () {
return this.name + '\'s salary is ' + this.getSalary() + '.';
}
},
staticMembers: {
idCounter: 1,
getId: function () {
return this.idCounter++;
}
}
});
return F;
})();
這樣外部就無法調用類的私有方法:
以上這幾點注意事項可以看作是在使用前面的繼承庫的時候應該遵守的規範。隻有這樣,團隊所有人寫的代碼才能保持一緻,整體健壯性才會更好。
2. 《JavaScript面向對象程式設計指南》的相關内容
在該書第6章-繼承部分,介紹了10多種繼承方法,這些方法都很簡單,但是都不能作為完整的繼承實作,每個方法對應的實際隻是繼承的單個知識點,比如原型鍊方法僅僅是在說明父類執行個體作為子類原型的這個知識點:
構造器借用法僅僅是在說明在子類構造函數内通過借用父類構造函數來繼承父類執行個體屬性的知識點:
是以本文不會逐個去介紹這每個方法的細節,因為在上文《詳解Javascript的繼承實作》的内容中已經把繼承的大部分要點說的很詳細了,而且這書中有些方法不具備廣泛适用性,從我的角度來說,了解下就夠了,反正我工作不會用。
本文這個部分要說明的是該書對繼承方法的分類,它把javascript的繼承分為了基于構造函數工作模式的繼承和基于執行個體工作模式的繼承,前者是指繼承關系發生在類與類之間的方式,後者是指繼承關系發生在執行個體與執行個體之間的方式,這個分類為我們帶來了除了前面的繼承庫提供的方式之外的另外一種繼承思路,而且這個思路早已經被我們在js中廣泛的使用。
前文的繼承庫是一種基于構造函數模式的繼承方式,我們在使用的時候,都是預先建構好類以及類與類的繼承關系,通過類之間的擴充,來給子類執行個體增加父類執行個體不曾擁有的能力,這種方式用起來更符合程式設計語言對于現實世界的抽象,是以很容易了解和使用。但是有很多時候這種傳統的建構和繼承方式也會給我們的工作帶來不少麻煩。
首先來看基于前文的繼承庫,我們如何實作一個單例的元件:
var Util = Class({
instanceMembers: {
trim: function(s){
s += '';
return s.replace(/\s*|\s*/gi, '');
}
}
});
var UtilProxy = (function() {
var ins = null;
return {
getIns: function() {
!ins && (ins = new Util());
return ins;
}
}
})();
console.log(UtilProxy.getIns() === UtilProxy.getIns());//true
按照繼承庫的思路,為了實作這個單例,我們一定得先定義一個元件類Util,然後為了保證這個類對外提供的始終是一個執行個體,還得考慮使用代理來實作一個單例模式,最後給外部調用的時候,還得通過代理去擷取元件的唯一執行個體才行,這種做法有4個不好的地方:
一是複雜,上面看起來還比較簡單,那是因為這個例子簡單,要真是複雜的單例子產品,要寫的代碼多多了;
二是麻煩,無論什麼單例元件都得按這個結構寫和用,要寫的代碼多;
三是不安全,萬一不小心直接通過元件的構造函數去執行個體化了,單例就不無法保證了;
四是不好擴充,想想如果要擴充一個單例元件,是不是得先實作一個繼承Util的類,再給這個新類實作一個代理才行:
var LOG_LEVEL_CONFIG = 'DEBUG';
var UtilSub = Class({
instanceMembers: {
log: function(info) {
LOG_LEVEL_CONFIG === 'DEBUG' && console.dir(info);
}
},
extend: Util
});
var UtilSubProxy = (function() {
var ins = null;
return {
getIns: function() {
!ins && (ins = new UtilSub());
return ins;
}
}
})();
console.log(UtilSubProxy.getIns() === UtilSubProxy.getIns());//true
是以你看,這種完全面向對象的做法在這種單例的元件需求下,就不是那麼好用。所幸的是,從我們自己的工作經驗來看,假如我們需要單例元件的時候,我們一般首先想到的方法都不是這種基于類的建構方法,因為javascript是一門基于對象的語言,我們在建構元件的時候,完全可以抛棄掉元件類,直接建構元件的對象,我們隻關注對象的行為和特性,但是它屬于哪個類别,對我們的需求而言不重要。以經驗來說,通常我們有2種方式來實作這種基于對象的建構思路。第一種是直接通過對象字面量來建立執行個體:
var util = {
trim: function(s){
s += '';
return s.replace(/\s*|\s*/gi, '');
}
}
第二種,是通過立即調用的匿名函數來傳回執行個體,執行個體的建立邏輯被包裝在匿名函數内部,對外隻提供調用接口,這種對于想要實作一些私有邏輯和邏輯封裝的需求就特别友善:
var util = (function () {
var LOG_LEVEL_CONFIG = 'DEBUG';
return {
LOG_LEVEL_CONFIG: LOG_LEVEL_CONFIG,
trim: function (s) {
s += '';
return s.replace(/\s*|\s*/gi, '');
},
log: function (info) {
LOG_LEVEL_CONFIG === 'DEBUG' && console.dir(info);
}
}
})();
對比前面的基于類的建構方法,這兩種方法完全沒有前面方法提出的麻煩,複雜和不安全問題,唯一值得讨論的是第四點:這種基于對象的建構方法,好不好繼承。到目前為止,還沒有讨論過這種基于對象的建構,在需要擴充元件對象的功能時,該如何來實作繼承或者說擴充,有沒有類似繼承庫這種的通用機制,以便我們能夠快速地基于對象進行繼承。這個問題的解決方法,正是我們前面提到的《JavaScript面向對象程式設計指南》這本書為我們帶來的另外一種思路,也就是基于執行個體工作模式的繼承方式,它為我們提供了2種比較實用的基于對象執行個體的元件在繼承時可以采用的方法:
1)淺拷貝模式
當我們隻想為原來的元件對象添加一些新的行為的時候,我們首先想到的肯定就是下面這種方法:
//util.js
var util = {
trim: function (s) {
s += '';
return s.replace(/\s*|\s*/gi, '');
}
};
//other.js
util.getQueryObject = function getQueryObject(url) {
url = url == null ? window.location.href : url;
var search = url.substring(url.lastIndexOf("?") + 1);
var obj = {};
var reg = /([^?&=]+)=([^?&=]*)/g;
search.replace(reg, function (rs, $1, $2) {
var name = decodeURIComponent($1);
var val = decodeURIComponent($2);
val = String(val);
obj[name] = val;
return rs;
});
return obj;
};
直接基于原來的對象添加新的方法即可。不好的是,當我們要一次性添加多個方法的時候,這些指派的邏輯都是重複的,而且會使我們的代碼看起來很不整潔,是以可以把這個指派的邏輯封裝成一個函數,新的行為都通過newProps傳遞進來,由該函數完成各個屬性指派給sourceObj(原來的對象)的操作,比如以下示例中的copy函數就是用來完成這個功能的:
//util.js
var util = {
trim: function (s) {
s += '';
return s.replace(/\s*|\s*/gi, '');
}
};
var copy = function (sourceObj, newProps) {
if (typeof sourceObj !== 'object') return;
newProps = typeof newProps === 'object' && newProps || {};
for (var i in newProps) {
sourceObj[i] = newProps[i];
}
};
//other.js
copy(util, {
getQueryObject: function getQueryObject(url) {
url = url == null ? window.location.href : url;
var search = url.substring(url.lastIndexOf("?") + 1);
var obj = {};
var reg = /([^?&=]+)=([^?&=]*)/g;
search.replace(reg, function (rs, $1, $2) {
var name = decodeURIComponent($1);
var val = decodeURIComponent($2);
val = String(val);
obj[name] = val;
return rs;
});
return obj;
}
});
這個copy函數也就是那本書中所介紹的淺拷貝模式。有了這個copy函數,我們在工作中就能很友善地基于已有的對象進行新功能的擴充,不用再寫前面提到重複指派邏輯。不過它有一個小問題,在開發的時候值得十分注意,由于這個模式直接把newProps裡面的屬性值賦給sourceObj,是以當newProps裡面的某個屬性是一個引用類型的值時,尤其是指向數組和其它非函數型的object對象時,很容易出現引用的問題,也就是改變了newProps,同樣會影響到sourceObj的問題,如果這種意外地修改并不是你所期望的,那麼就不能考慮使用這種模式來擴充。不過很多時候,淺拷貝的模式也足夠用了,隻要你确定當你使用淺拷貝方法的時候,不會發生引用問題即可。
2)深拷貝模式
上面的淺拷貝模式存在的問題,可以用深拷貝模式來解決,與淺拷貝模式不同的是,深拷貝模式在擴充對象的時候,如果發現newProps裡面的屬性是一個數組或者非函數類型的object對象,就會建立一個新數組或新的object對象來存放要擴充的屬性的内容,并且會遞歸做這樣的處理,保證sourceObj不會再與newProps有相同的指向數組或者非函數類型object對象的引用。隻要對前面的copy函數稍加改造,就能得到我們所需要的深拷貝模式的繼承實作,也就是下面的deepCopy函數:
var deepCopy = function (sourceObj, newProps) {
if (typeof sourceObj !== 'object') return;
newProps = typeof newProps === 'object' && newProps || {};
for (var i in newProps) {
if (typeof newProps[i] === 'object') {
sourceObj[i] = Object.prototype.toString.apply(newProps[i]) === '[object Array]' ? [] : {};
copy(sourceObj[i], newProps[i]);
} else {
sourceObj[i] = newProps[i];
}
}
};
var util = {};
var newProps = {
cache: [{name: 'jason'}]
};
deepCopy(util, newProps);
console.log(util.cache === newProps.cache);//false
console.log(util.cache[0] === newProps.cache[0]);//false
有了這個deepCopy函數,淺拷貝模式的引用問題也就迎刃而解了。不過還有一點值得一說的是,由于函數在js裡面也是對象,是以函數類型的資料也會存在引用問題,但是不管是深拷貝還是淺拷貝,都沒有考慮這一點,畢竟函數在絕大部分場景來說,本身就屬于共享型對象,就是應該重用的,是以沒有必要做拷貝。
以上就是基于執行個體工作模式的2種繼承方法實作:淺拷貝和深拷貝。關于這兩種實作還有兩點需要說明:
1)在實作過程中,周遊newProps的時候,始終沒有用到hasOwnProperty去判斷,因為hasOwnProperty是用來判斷某個屬性是否從該對象的原型鍊繼承而來,如果加了這個判斷,那麼就會把newProps對象上的那些從原型鍊繼承而來的屬性或者方法都過濾掉,這不一定符合我們的期望,因為這兩種拷貝的模式,都是基于對象來工作的,大部分場景中,在擴充一個對象的時候,我們往往是考慮把要擴充的對象也就是newProps上的所有屬性和行為都添加給原來的對象,是以就不能用hasOwnProperty去判斷。
2)淺拷貝的實作還算比較完整,因為它适用的範圍簡單。但是深拷貝的實作還不夠完美,第一是可能考慮的情況不全,第二是欠缺優化。另外這兩個實作的代碼要是能夠整合到一塊,形成一個類似繼承庫一樣的子產品的話,在實際工作中才會更大的應用價值。好在jquery中已經有一個extend方法把我提到的這些問題都解決了,這也就是為啥我前面說我們已經在代碼中廣泛引用基于對象進行擴充這種繼承思路的原因。是以在實際工作過程中,我們完全可以拿jquery.extend來實作我們基于對象的擴充,即使是不想使用jquery的環境,也可以完全拿它的extend源碼實作一個能獨立運作的extend子產品出來,比如這樣子,用法還與jQuery.extend一緻:
var extend = function () {
var options, name, src, copy, copyIsArray, clone,
target = arguments[0] || {},
i = 1,
length = arguments.length,
deep = false;
// Handle a deep copy situation
if (typeof target === "boolean") {
deep = target;
// Skip the boolean and the target
target = arguments[i] || {};
i++;
}
// Handle case when target is a string or something (possible in deep copy)
if (typeof target !== "object" && !jQuery.isFunction(target)) {
target = {};
}
// return if only one argument is passed
if (i === length) {
return;
}
for (; i < length; i++) {
// Only deal with non-null/undefined values
if ((options = arguments[i]) != null) {
// Extend the base object
for (name in options) {
src = target[name];
copy = options[name];
// Prevent never-ending loop
if (target === copy) {
continue;
}
// Recurse if we're merging plain objects or arrays
if (deep && copy && ( Object.prototype.toString.apply(copy) === '[object Object]' ||
(copyIsArray = Object.prototype.toString.apply(copy) === '[object Array]') )) {
if (copyIsArray) {
copyIsArray = false;
clone = src && Object.prototype.toString.apply(src) === '[object Array]' ? src : [];
} else {
clone = src && Object.prototype.toString.apply(src) === '[object Object]' ? src : {};
}
// Never move original objects, clone them
target[name] = extend(deep, clone, copy);
// Don't bring in undefined values
} else if (copy !== undefined) {
target[name] = copy;
}
}
}
}
// Return the modified object
return target;
};
3. 總結
本文在上文《詳解Javascript的繼承實作》的基礎上補充了很多了内容,首先把上文的繼承庫實作在實際使用的一些注意事項說明了一下,然後對《javascript面向對象程式設計指南》這部分繼承的相關的内容做了簡單介紹,本文最重要的是說明基于對象擴充的繼承思路,這種思路應用最廣的是淺拷貝和深拷貝的模式,在javascript這種語言中都有很多實際的應用場景。比較繼承庫跟基于對象擴充這兩種思路,發現他們的思想和實際應用完全是不沖突的,繼承庫更适合可重複建構的元件,而基于對象擴充更适合不需要重複建構的元件,每種模式都有不同的價值,在實際工作中要用什麼機制來開發元件,完全取決于這個元件有沒有必要重複建構這樣的需求,甚至有時候我們會把這兩種方式結合起來使用,比如首先通過繼承庫來建構元件類A,然後再執行個體化A的對象,最後直接基于A的執行個體進行擴充。我覺得這兩種思路結合起來,包含了javascript繼承部分相關的所有核心内容,這篇文章還有上篇文章,從要點跟實作細節說明了在繼承開發當中的各方面問題,是以對于那些跟我水準差不多的web開發人員來說,應該還是有不少價值,隻要把這兩篇文章裡面的關鍵點都掌握了,就相當于掌握了javascript的整個繼承思想,以後編寫面向對象的代碼,閱讀别人寫的元件化代碼這兩方面的能力都一定能提升一個層次。最後希望本文确如我所說,能給你帶來一些收獲。
感謝閱讀:)
如果您覺得本文對你有用,不妨幫忙點個贊,或者在評論裡給我一句贊美,小小成就都是今後繼續為大家編寫優質文章的動力,流雲拜謝!
歡迎您持續關注我的部落格:)
作者:流雲諸葛
出處:http://www.cnblogs.com/lyzg/
版權所有,歡迎保留原文連結進行轉載:)