上文《詳解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的整個繼承思想,以後編寫面向對象的代碼,閱讀別人寫的組件化代碼這兩方面的能力都一定能提升一個層次。最後希望本文確如我所說,能給你帶來一些收穫。
感謝閱讀:)