详解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
以上就是基于实例工作模式的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的整个继承思想,以后编写面向对象的代码,阅读别人写的组件化代码这两方面的能力都一定能提升一个层次。最后希望本文确如我所说,能给你带来一些收获。
感谢阅读:)