前言
这是我看完《JavaScript 高级程序设计》第六章——面向对象的编程设计后写下的读书笔记。其他相关笔记:
- JavaScript 基础(超详细)
- js 中的引用类型(内置对象)
- js 中的对象属性——configurable、writable 等(数据属性和访问器属性)
- js 中原型、原型链和继承概念(详细全面)
- 函数使用进阶——递归——闭包
目录
1. 对象的创建方式
这部分先从对象的各种创建方式的介绍开始,并指出它们的弊端。从而引出新的创建方式,引出原型和原型链的概念和作用。
1.1 字面量和 Object 构造函数方式
使用字面量与 Object 构造函数可以用来创建单个对象,并为它们添加属性和方法。但是有一个明显的缺点:使用同一个接口创建很多对象,会产生大量的重复代码。例如我需要创建两个表示名星的对象,他们有一些共同的属性和方法。
// 字面量对象
var ldh = {
name: '刘德华',
age: 46,
nationality: '中国',
say: function() {
console.log('我叫' + this.name);
}
};
// 构造函数
var bjqs = new Object({
name: '板井泉水',
age: 20,
nationality: '日本',
say: function() {
console.log('我叫' + this.name);
}
});
1.2 工厂模式
上面创建的某类对象(名星)具有一些共同的属性和方法,创建时代码大量重复。因此可以将这些代码封装在一个函数中,调用函数并传入特定参数创建并返回一个需要的对象。它抽象了对象的创建过程。
function createStarObject(name, age, nationality) {
let obj = {};
obj.name = name;
obj.age = age;
obj.nationality = nationality;
obj.say = function() {
console.log('我叫' + this.name);
};
// 返回创建地新对象
return obj;
}
var ldh = createStarObject('刘德华', 46, '中国');
ldh.say(); // '我叫刘德华'
var bjqs = createStarObject('板井泉水', 20, '日本');
bjqs.say(); // '我叫板井泉水'
优点:解决了创建一类对象时代码重复的问题。
缺点:存在对象识别的问题(无法判断创建的对象是那类对象),通俗地讲是对象创建后与该函数断了联系。
1.3 构造函数模式
构造函数与其他函数的唯一区别就在于调用他们的方式不同。他们的本质也是函数,不存在定义构造函数的特殊语法。任何函数,只要通过 new 操作符来调用,那它就可以作为构造函数,如果不通过 new 操作符来调用,那它跟普通函数也不会有什么区别。用首字母是否大写来区分普通函数和构造函数(表明函数的用途)。示例如下:
function Star(name, age, nationality) {
// 根据参数初始化this
this.name = name;
this.age = age;
this.nationality = nationality;
// 定义方法
this.say = function() {
console.log('我叫' + this.name);
};
}
// 使用 new 操作符进行调用
var ldh = new Star('刘德华', 46, '中国');
ldh.say(); // '我叫刘德华'
// 没有使用new调用构造函数,直接使用普通函数方式调用
var zxy = Star('张学友', 48, '中国');
window.say(); // '我叫张学友'
// 或者在另一个对象的作用域内调用
var mby = {};
Star.call(mby, '毛不易', 28, '中国');
mby.say(); // '我叫毛不易'
上面的示例也验证了 new 关键字调用函数实际上会经历以下四个步骤:
- 创建一个新的对象。
- 将构造函数的作用域赋值给新对象(this 指向这个新对象)。
- 执行构造函数中的代码(此时对 this 的操作即对新对象的操作)。
- 返回新对象。
构造函数模式没有显式创建对象和返回对象是和工厂模式的区别。但最重要的是构造函数可以将它的实例标识为一种特定的类型,即实例对象创建后并没有就此和创建它的构造函数断了联系。那它是怎么实现的呢?
每个对象都有一个 constructor 属性,constructor 属性最初是用来标识对象类型的,它指向的正是创建该对象的构造函数。(检测对象类型可以使用 instanceof 操作符)
console.log(ldh.constructor === Star); // true
console.log(ldh instanceof Star); // true
console.log(ldh.constructor == Object); // false
console.log(ldh instanceof Object); // true
为什么使用 instanceof 操作符检测 ldh 是否是 Object 类型,结果也是 true ?这就是后面类的继承的知识,涉及到了原型对象、对象原型和原型链的概念。
好了,构造函数同时解决了代码复用和实例对象特定类型的标识问题。但是这种方式还是存在一个内存浪费问题。每实例化一个对象时,里面的某些属性和方法无论是否和其他同一类型的对象的相同,都会重新开辟空间去存储这些属性和方法。如下图所示:
如果就这样利用构造函数去实例化一个对象,肯定是不可行的。那像 Array 引用类型一样每个实例对象都有 slice、splice、forEach 等大量共同方法,那创建几个实例对内存来说浪费就相当严重了。所以肯定不会也不能这么做。那我们可以怎么做呢?将共同的方法定义在构造函数函数外面,在内部只将其引用赋值给实例对象的方法。如下示例:
function Star(name, age, nationality) {
this.name = name;
this.age = age;
this.nationality = nationality;
this.say = say;
}
// 将方法定义在构造函数外面,内部只传递方法的引用
function say() {
console.log('我叫' + this.name);
};
var ldh = new Star();
ldh.say(); // '我叫刘德华'
这样确实解决了大量浪费内存的问题,但是又会出现以下几个问题:
- 这些函数实际上只想能被某类对象调用,但是现在定义在了全局作用域中,使全局作用域受到污染。
- 将构造函数里面大量的方法定义在全局作用域,自定义的引用类型毫无封装性可言。
还有一个问题是共同属性还是没法共享内存空间。例如我现在定义的是中国名星这类对象(有点子类继承的那味了),他们的国籍都是中国。那怎么实现内存共享呢?
1.4 原型模式
原型:JavaScript 中,每个构造函数(函数)都有一个 prototype 属性。这个属性指向一个对象,这个对象可称为构造函数的原型对象。使用该构造函数创建的对象可以共享这个构造函数的原型对象里面的所有属性和方法。因此我们可以把那些不变的方法和属性直接定义在 prototype 对象上,这样所有对象实例就可以共享这些属性和方法了。这就是原型的概念和作用。
function ChinaStar() {};
// 在ChinaStar的原型上定义共享的方法和属性
ChinaStar.prototype.say = function() {
console.log('我是' + this.nationality + '人,我会唱歌');
};
ChinaStar.prototype.nationality = '中国';
var ldh = new ChinaStar();
var zxy = new ChinaStar();
ldh.say(); // '我是中国人,我会唱歌'
zxy.say(); // '我是中国人,我会唱歌'
console.log(zxy.nationality); // '中国'
上面实例化的两个对象访问的都是同一组属性和同一个 say() 函数。即没有重新开辟内存空间单独保存各自的这些共同信息和方法。
对象原型:每个对象都会有一个属性 __proto__ (可以称为对象原型)指向构造函数的原型对象(prototype)。实例对象可以使用构造函数 prototype 原型对象的属性和方法,就是因为对象有 __proto__ 原型的存在
- __proto__ 对象原型和原型对象 prototype 是等价的。
- __proto__ 对象原型的意义在于为对象属性方法的查找机制提供一个方向或者说一条路线,但它是一个非标准属性,因此实际开发中不可以使用这个属性,它只是内部指向原型对象 prototype 。
每个对象还都有一个 constructor 属性,它指向的是对象引用于那个构造函数。其实上面第一次使用构造函数创建对象时就已经学过实例对象的这个属性的作用(保持实例对象和构造函数的联系)。无论是实例对象、对象原型还是构造函数的原型(当然这两者是同一个对象)他们的 constructor 属性都指向构造函数。也因此我们称 constructor 为构造函数。他们三者的关系如下图所示:图中有误,实际上访问实例的 constructor 返回的是原型对象上的 constructor 属性,使用 hasOwnProperty() 方法验证。应该去掉ldh.constructor 的指向箭头,懒得改图了。
创建自定义构造函数后,其原型对象默认只会取得 constructor 属性,其他的方法和属性则都是自动从 Object(父类为其他则需要我们自己实现继承,这是继承部分知识)继承而来。
这部分涉及的内容还有点多,为了不显得乱,我将他们总结为几个内容。
- 实例对象和原型对象关系的确定问题。
- 通过实例对象可以访问原型对象上的属性和方法,但是是不能通过对象实例重写原型中的值。
- 如何检测某个属性或者方法是实例上的还是原型上的属性和方法。
- in 操作符的使用。
- 原型对象具有动态性。
- 重写整个原型对象需要注意的问题。
- 原型对象在原生对象中的运用。
- 单独使用原型对象模式的缺点。
1、实例对象和原型对象关系的确定
构造函数的原型对象(prototype) 是其创建的实例对象的对象原型(__proto__)。实际开发中不可以使用 __proto__ 这个属性,但是可以通过对象的 isPrototypeOf() 方法或者 Object 的静态方法 getPrototypeOf() 来确定对象之间是否存在这种关系。用法示例如下:
console.log(ChinaStar.prototype.isPrototypeOf(ldh)); // true
console.log(Object.getPrototypeOf(ldh) === ChinaStar.prototype); // true
console.log(Object.getPrototypeOf(ldh).nationality); // '中国'
2、通过实例对象可以访问原型对象上的属性和方法,但是是不能通过对象实例重写原型中的值。如果我们在实例中添加了一个属性,而该属性与原型中的一个属性同名,那我们在实例中创建的属性会屏蔽原型中的那个属性,而不是重写了原型里面该属性的值。
var ldh = new ChinaStar();
var zjl = new ChinaStar();
zjl.nationality = '中国台湾';
ldh.say(); // '我是中国人,我会唱歌'
zjl.say(); // '我是中国台湾人,我会唱歌'
一旦设置了这个同名属性或者方法,如果不使用 delete 删除,那么无论是否将该方法设置为 null 或者将该属性设置为 undefined ,我们都无法再访问到原型里面的同名属性。
zjl.nationality = null;
zjl.say(); // '我是null人,我会唱歌'
zjl.nationality = undefined;
zjl.say(); // '我是undefined人,我会唱歌'
delete zjl.nationality;
zjl.say(); // '我是中国人,我会唱歌'
3、 如何检测某个属性或者方法是实例上的还是原型上的属性和方法。
使用 hasOwnProperty() 方法可以检测一个属性是存在于实例中还是存在于原型中。这个方法只有在给定属性存在于对象实例中时才会返回 true 。示例如下:
var ldh = new ChinaStar();
var zjl = new ChinaStar();
zjl.nationality = '中国台湾';
console.log(ldh.hasOwnProperty('nationality')); // false
console.log(zjl.hasOwnProperty('nationality')); // true
4、 in 操作符的使用。
in 操作符可以用来判断一个对象是否可以访问某个属性或者方法,也包括原型对象上可枚举的属性和方法。可以单独使用,也可以配合 for 语句使用。示例如下:
console.log('nationality' in ldh); // true
console.log('nationality' in zjl); // true
for (let k in zjl) {
console.log(k);
}; // 'nationality' 'say'
还有有个方法与对象的属性有关。Object.keys() 方法可以返回包含对象所有可枚举的实例属性,Object.getOwnPropertyNames() 方法可以得到所有的实例属性,无论它是否可枚举。实例如下:
var ldh = new ChinaStar();
var zjl = new ChinaStar();
Object.defineProperty(zjl, 'id', {
value: 1,
enumerable: false
});
ldh.id = 0;
console.log(Object.keys(zjl)); // -> []
console.log(Object.getOwnPropertyNames(zjl)); // ->['id']
console.log(Object.keys(ldh)); // ->['id']
5、 原型对象具有动态性。
原型对象具有动态性主要是与对象访问某个属性或者方法时的实际动作有关。它会首先判断对象有没有该属性,如果没有会到原型对象上查找该属性。因此我们对原型所做的修改都能立即从实例上反映出来,即使是先创建对象后再修改原型也一样。
function ChinaStar() {};
ChinaStar.prototype.say = function() {
console.log('我是' + this.nationality + '人,我会唱歌');
};
ChinaStar.prototype.nationality = '中国';
var ldh = new ChinaStar();
ldh.say(); // '我是中国人,我会唱歌'
ChinaStar.prototype.say = function() {
console.log('我不但会唱歌,我还会演戏');
};
ldh.say(); // '我不但会唱歌,我还会演戏'
6、 重写整个原型对象需要注意的问题。
如果自定义的引用类型中有大量的共享属性方法,将他们一个个添加到 prototype 上比较麻烦。我们可以重写 prototype 属性,即重写整原型对象。示例如下:
function ChinaStar() {};
var ldh = new ChinaStar();
ChinaStar.prototype = {
nationality: '中国',
say: function() {
console.log('我是' + this.nationality + '人');
},
sing: function() {
console.log('我会唱歌');
}
};
var zxy = new ChinaStar();
console.log(ldh instanceof ChinaStar); // false
console.log(zxy instanceof ChinaStar); // true
问题1:原型重写后,构造函数的 prototype 属性保存的指针发生改变,指向一个创建的新对象。因此原型里面的 constructor 不再指向构造函数,而是指向 Object 。尽管使用 instanceof 操作符还能返回正确的结果(但是在之前定义的对象就是 false )。因此需要将新原型的 constructor 属性重新指向构造函数并且是设置成不可遍历的。
Object.defineProperty(ChinaStar.prototype, 'constructor', {
value: ChinaStar,
enumerable: false
});
问题2:虽然原型具有动态性,当是将整个原型重写就是另一个概念了。重写原型后相当于改变了构造函数的 prototype 属性的指针,它会重新指向新对象。而在此之前使用该构造函数创建的对象的对象原型(__proto__)仍然指向最初的原型。这会造成同一引用类型的实例具有不同原型(__proto__)的问题。
// 例如上面重写原型后,新原型里面的属性和方法在之前创建的对象里面是没有的
zxy.sing(); // '我会唱歌'
console.log(ldh.nationality); // undefined
ldh.sing(); // ldh.sing is not a function
重写原型对象切断了现有原型与任何之前已经存在的对象实例之间的联系,他们引用的仍然是最初的原型。这也是后面动态原型模式,原型不能重写的原因。
原理如图所示:
针对使用 instanceof 这个操作符的返回问题。instanceof 操作符用于测试构造函数的 prototype 属性是否出现在对象的原型链中的任何位置。再看上面示例可以看到,重写原型后使用 instanceof 操作符检测重写前创建的对象返回的是 false 。究其原因是构造函数的原型指向了一个新对象,而不再是 ldh 的对象原型指向的那个对象。
7、原型对象在原生对象中的运用。
原型模式不仅运用于创建自定义引用类型方面。其实所有原生的引用类型也都是采用这种模式创建的。因此通过原生对象的原型,不仅可以取得所有默认方法的引用,而且可以修改原有的方法或者添加新的方法。示例如下:
var arr = [1, 2, 3];
arr.push(4, 5);
console.log(arr.length); // 5
Array.prototype.push = Array.prototype.pop;
arr.push(); // 4
console.log(arr.length);
上面示例中将数组类型的 push() 方法直接改成了 pop() 方法。虽然可以修改原生对象的原型,但是不推荐(因为这是捣乱)。如果确实需要一些基于原生类型但又有些不同的对象,可以使用寄生构造函数模式或者使用继承解决。
8、 单独使用原型对象模式的缺点。
单独使用原型模式创建对象没有个性,即都没有自己的所独有的属性和方法。它们所有的属性和方法都是共享的,原因是它省略了为构造函数传递初始化参数这一环节。但是但每个对象都具有 age、name、friends 等属性,但是他们的值又不一样时,单独使用原型模式是没法实现的。而且将这些不该共享的属性共享后会出现很多问题。示例如下:
function Person() {};
// 省略 constructor 属性的重新定义
Person.prototype = {
friends: ['小陈', '小刘'],
say: function() {
console.log('我的朋友有' + this.friends.join('、'));
}
};
var me = new Person();
me.say(); // '我的朋友有小陈、小刘'
var you = new Person();
// 某天你和小刘闹翻了
you.friends.pop();
// 我们没有任何关系为什么小刘也跟我闹翻了😂,离谱
me.say(); // '我的朋友有小陈'
因此对于不能共享的属性绝对不能单独使用原型模式。这就需要结合构造函数模式一起使用,也就是下面的组合使用构造函数和原型模式。
1.5 组合使用构造函数模式和原型模式
组合使用构造函数模式和原型模式是创建自定义类型最常见的方式。构造函数用于定义实例属性,而原型模式用于定义方法和共享的属性。这样每个实例都会有自己的一份实例属性的副本,但同时又共享着原型里面的方法的引用和属性。这种混成模式实例属性的值可以通过构造函数传参的方式获得。
function Star(name, age, nationality) {
// 定义实例属性
this.name = name;
this.age = age;
this.nationality = nationality;
}
// 定义共享的属性和方法
Star.prototype = {
constructor: Star,
job: '歌手',
say: function() {
console.log('我叫' + this.name + ',我的职业是' + this.job);
}
};
var iu = new Star('李知恩', 28, '韩国');
var ldh = new Star('刘德华', 60, '中国');
console.log(iu.age, ldh.age); // 28 60
iu.say(); // '我叫李知恩,我的职业是歌手'
ldh.say(); // '我叫刘德华,我的职业是歌手'
1.6 动态原型模式
动态原型模式主要就是改变了上述模式中构造函数和原型独立存在的代码风格。它把所有信息都封装在构造函数中,包括在构造函数中初始化原型。但是为了防止重复初始化原型的操作,需要添加判断条件。原型在第一次调用构造函数初始化后,在以后的调用过程中不再会进行初始化操作。
function Chinese(name) {
this.name = name;
// 某原型属性不存在说明原型还未初始化,使用的是默认的原型,则在此进行初始化
if (!Chinese.prototype.nationality) {
Chinese.prototype.nationality = 'chinese';
Chinese.prototype.kongfu = function() {
console.log('你过来呀');
}
}
}
var me = new Chinese();
me.kongfu(); // ->'你过来呀'
Chinese.prototype.kongfu = function() {
console.log('我不敢');
};
// 这里不会再进行原型初始化操作,即不会将kongfu再改回去
var you = new Chinese();
you.kongfu(); // '我不敢'
me.kongfu(); // '我不敢'
最重要的一点是初始化时不能重写原型。原理跟原型模式重写原型那个点一样。在已经创建了实例的情况下重写原型,那么就会切断现有实例与新原型之间的联系。
也因此如果初始化原型采用重写原型的方式,那么问题会出现在原型初始化阶段,即创建第一个实例时。第一次使用 new 调用构造函数创建实例时,首先创建一个新对象,并将 this 指向新对象。但是此时原型是使用的默认的原型(还未进行原型初始化操作),所以第一次创建的对象的使用的并不是这个原型。之后不会再进行原型初始化操作(判断为 false),所以才都使用同一个原型。
function Chinese(name) {
this.name = name;
if (!Chinese.prototype.nationality) {
// 采用重写原型的方式
Chinese.prototype = {
nationality: 'chinese',
kongfu: function() {
console.log('你过来呀');
}
};
}
}
var me = new Chinese('TKOP_');
var you = new Chinese('看客');
var others = new Chinese('错过的人');
// 只有第一个实例没法访问原型上的方法
console.log(me.kongfu); // ->undefined
you.kongfu(); // ->'你过来呀'
others.kongfu(); // ->'你过来呀'
1.7 寄生构造函数模式
寄生构造函数模式本质就是工厂模式(个人理解)。因为其基本思想也仅仅是函数封装创建对象的代码并返回新创建的对象。不同的是封装的函数名首字母大写,也使用 new 操作符调用。这让其看起来像典型的构造函数,但是其本质还是工厂模式。代码示例如下:
function Person(name, age, job) {
// 调用Object构造函数创建新对象并加强该对象
let o = new Object();
o.name = name;
o.age = age;
o.job = job;
o.say = function() {
console.log('我叫' + this.name);
};
// 返回新的对象
return o;
};
var me = new Person('TKOP_', 18, 'Software Engineer');
me.say(); // ->'我叫TKOP_'
寄生构造函数模式和工厂模式本质相同,那他们有一样特性:
- 对象的方法属性不存在共享。
- 返回的对象与构造函数或者与构造函数的原型属性之间没有关系。
所以它的用途局限性比较大,例如用来创建一个(多了浪费内存)具有额外方法的特殊数组。由于不能修改 Array 构造函数,又不能就为了一个对象去重新创建一个子类(太麻烦)。先创建一个普通数组对象再添加该方法,这样在创建时凸显不出该对象和其他数组对象之间的差异。此时可以使用寄生构造函数模式。
function EspecialArr() {
// 宿主是构造函数Array
let arr = new Array();
// 添加传入的元素
arr.push.apply(arr, arguments);
// 添加新的方法
arr.newJoin = function() {
return this.join(' ');
};
return arr;
};
// 创建时看不到使用 Array 对象,凸显了它和普通数组的差异
var test = new EspecialArr('I', 'Love', 'You');
console.log(test.newJoin()); // 'I Love You'
console.log(test instanceof EspecialArr); // false
console.log(test instanceof Array); // true
为什么叫寄生构造函数模式呢?个人认为可以将Array构造函数看成宿主,而新定义的构造函数就像一个寄生者。它利用宿主创建某个新对象后加强返回。
1.8 稳妥构造函数模式
稳妥构造函数模式用于创建稳妥对象。稳妥对象是指没有公共属性方法,而且其方法也不引用 this 的对象。稳妥对象适合在一些安全的环境中(这些环境中会禁用 this 和 new),或者在防止数据被其他应用程序修改时使用。稳妥构造函数类似于寄生构造函数,不过它有两个重要的不同点:
- 新创建的实例方法不引用 this 。
- 不使用 new 操作符调用构造函数。
稳妥构造函数示例如下:
function Person(name, job, age) {
var o = new Object();
// 这里定义私有变量和函数
// 添加方法
o.say = function() {
console.log(name);
console.log(job);
console.log(age);
};
return o;
}
// 不能使用 new 去调用
var me = Person('tkop', '黑涩会小弟', 18);
me.say(); // 'tkop' '黑涩会小弟' '18'
var you = Person('大佬', '黑涩会大佬', 88);
you.say(); // '大佬' '黑涩会大佬' '88'
注意:在以这种模式创建的对象中,除了使用各自独有的 say() 方法之外,没有其他方法可以访问到该对象的数据成员。即使有其他代码会给这个对象添加新的方法或数据成员,但也不可能再有别的方法访问得到传入构造函数中的原始数据。
出现以上特点的本质原理:创建对象时并没有将传入构造函数的原始数据作为新创建的对象属性进行初始化对象,实例方法也就没有必要引用 this 来访问这些数据。它能访问到主要是使用了闭包的原理。不同的对象各自独有的 say() 方法产生了不同的闭包保存着不同的原始数据。如下图,在调用 say() 方法时产生的闭包(Closure)。
当然使用稳妥构造函数创建的对象与构造函数之间也没有什么关系,所以 instanceof 操作符检测时返回的也是 false 。
2. 继承
继承就是子类中的实例能够自动拥有父类中的某些属性和方法。继承的概念也非常重要。接下来学习继承的示例代码为显得有条理,都会使用两个类去示范。以下是它们的关系和结构。
Star(名星,父类):
- name(名字,实例属性)
- age(年龄,实例属性)
- nationality(国籍,实例属性)
- attributes(共同品质,原型属性,值为 “struggling”)
- sing(唱歌,原型方法)
LocalStar(本土名星,子类):
- name(名字,实例属性)
- age(年龄,实例属性)
- height(身高,实例属性)
- nationality(国籍,父类实例属性,子类原型属性,值为 “中国”)
- attributes(共同品质,原型属性,值为 “struggling”,继承自父类)
- sing(唱歌,原型方法,继承自父类)
- kongfu(中国功夫,原型方法)
继承方式主要有原型链、借用构造函数、组合继承、原型式继承、寄生式继承和寄生组合式继承。还有一个混入方式继承(这个了解一下就好)。
2.1 原型链
2.1.1 原型链的概念
首先看一个示例然后引出原型链的概念,然后总结原型链继承方式。
function Star(name, age, nationality) {
this.name = name;
this.age = age;
this.nationality = nationality;
if (!Star.prototype.attributes) {
Star.prototype.attributes = 'struggling';
Star.prototype.sing = function() {
console.log(this.name + '会唱歌');
}
}
};
// 使用function关键字声明(构造)函数时后台默认执行操作
/* Star.prototype = new Object();
Object.defineProperty(Star.prototype, 'constructor', {
value: Star,
enumerable: false
}) */
var iu = new Star('李知恩', 28, '韩国');
Object.prototype.say = function() {
console.log(this.str);
}
Object.prototype.str = '给Object原型添加属性和方法';
iu.sing(); // '李知恩会唱歌'
iu.say(); // '给Object原型添加属性和方法'
iu.str = '我自己有的str属性,不用向上找了';
iu.say(); // '我自己有的str属性,不用向上找了'
console.log(iu.height); // undefined
在 Object.prototype 属性上添加方法纯粹只是为了演示原型链的形成过程。这既可以说明为什么所有对象都是 Object 的实例,也可以形象地描述原型链的形成过程。但是实际不推荐修改内置引用类型的 prototype 属性。
以上示例代码中在自定义引用类型,即使用 function 关键字声明函数对象的过程中,后台执行了一系列默认操作。使用 Object 构造函数实例化一个对象并将其赋值给 Star 的 prototype 属性,同时设置 prototype 属性里面的 constructor 属性。这也是每个构造函数默认都有一个自带 constructor 属性的 prototype 属性的原因。
每个对象又都有一个对象原型属性 __proto__ 指向构造函数的原型对象。对象原型属性 __proto__为对象属性方法的查找机制提供了方向或者线路。当试图访问一个对象的属性时,它不仅仅在该对象上搜寻,还会搜寻该对象的原型,以及该对象的原型的原型。依次层层向上搜索,知道找到一个名字匹配的属性或者达到 Object 原型的原型 null ,这就形成了一个链式的查找规则。例如上面三次调用方法时。
- 调用 sing(),查找 iu 对象上没有,通过 __proto__ 属性确定其原型(Star.prototype),查找原型上有这个方法,调用该方法结束查找。调用过程中查找 name 属性,在实例上有该属性返回属性值并结束查找。
- 调用 say(),同理一直查找到 Star 的原型对象(Star.prototype)上依旧没有该方法。继续通过Star.prototype 的 __proto__ 属性确定Star 的原型对象的原型(即 Object.prototype),在 Object.prototype 上找到了该方法进行调用。调用过程中查找 str 属性,从实例开始查找——没有——根据 __proto__ 到 Star.prototype 上查找——还是没有——根据 Star.prototype 的 __proto__ 到 Object.prototype 上查找——找到该属性并返回——结束查找。
- 再次调用 say(),此时不同的是实例 iu 上已经有了 str 属性,所以不会查找到 Object.prototype.str 。
- 第四次输出 undefined 也有一个查找过程。查找 iu.height 属性同样从实例开始一直查找到 Object.prototype 还是没有。又通过 Object.prototype 的 __proto__ 属性找到上一级,但是此时这个对象是 null 什么都没有。从这条链的开始到结束都找不到这个属性,所以返回 undefined 。
以上就是原型链的实现过程和表现形式,上示例形成的原型链如图所示:
一句话总结什么是原型链:原型链就是实例根据原型属性(__proto__)提供的对象属性方法的查找机制进行属性访问时形成的一个路线(个人概括)。
2.1.2 利用原型链实现继承
其实上面就已经使用原型链实现了继承,不过是后台默认操作,我们并没有手动去实现继承(Object 引用类型)。在自定义类型需要继承自其他引用类型而不是默认的 Object 类型时,我们就需要利用同样的原理去实现继承。示例代码如下:
// 子类构造函数
function LocalStar(name, age, height) {
// 子类实例属性
this.name = name;
this.age = age;
this.height = height;
};
// 利用父类实例替换子类原型
LocalStar.prototype = new Star();
// 将原型的constructor属性重新指回子构造函数
Object.defineProperty(LocalStar.prototype, 'constructor', {
value: LocalStar,
enumerable: false
});
// 子构造函数原型添加其他属性和方法
LocalStar.prototype.nationality = '中国';
LocalStar.prototype.kongfu = function() {
console.log(this.nationality + '名星会功夫');
};
var ldh = new LocalStar('刘德华', 60, 188);
ldh.sing(); // '刘德华会唱歌'
ldh.kongfu(); // '中国名星会功夫'
console.log(ldh.attributes); // 'struggling'
可以看到该继承的方法和属性确实都继承,继承之后在原型上又添加了自己独有的原型属性和方法。在添加独有的或者覆盖父类原型上的属性和方法时注意以下两点:
- 必须在替换原型之后进行这些操作,道理跟重写原型一样。
- 替换原型后进行这些操作时也不能使用重写原型的方式。
在此说一段个人认为没必要说的话,为什么不直接 LocalStar.prototype = Star.prototype(看视频时出现的一条弹幕)?这。。。不叫继承了吧,这叫弑父夺产呀。直接将父类原型引用赋值给子类原型,只要其中一个变化另一个也发生改变,多个类共用一个原型,也就没有原型链和继承可言。然后有人又说那深拷贝一份呢?额。。。这也不算继承,因为这样子类和父类没有什么联系呀。就是具有一样的原型而已,可以当作两个实例属性不同,原型一样的引用类型吧。本质上主要是因为他们无法形成原型链,只有默认的都继承于 Object 。
2.1.3 单独使用原型继承的缺点
总结书上的观点:
- 包含引用类型值的属性的原型,继承后每个实例访问修改该属性都会引起原型改变。需要通过构造函数传参的方式定义成实例属性。
- 在创建子类型的实例时,不能向父类的构造函数传递参数。即没有办法在不影响所有对象实例的情况下,给父类构造函数传递参数。
首先,我在这里问了一下自己。所有上面的示例代码中,无论是 Star 自动继承 Object 还是 LocalStar 继承 Star ,子类在继承父类中的属性时原型上的属性确实算是一种继承。但是在父类构造函数里面定义的实例属性要不要继承?例如父类上有 name、age 这些实例属性,子类中也有这些实例属性,这难道不算继承?但是如果也要继承的话,那只通过调用子类构造函数传递参数初始化子类实例属性貌似跟父类没有(继承)关系,也无法明确地看出子类到底继承了父类地哪些实例属性(例如 height 属性是不是也是继承的实例属性)。
然后再回头理解从书中总结出来地两句话。如果将引用类型值而各个实例又不共享值的属性作为实例属性继承过来,而不是使用原型进行继承过来就解决了第一个问题。第二个问题,不能创建子类实例时给父类构造函数传递参数,是因为此时子类构造函数和父类构造函数没有联系,即实例属性没有实现正真意义上的继承。
对不起这里只有苍白无力的个人理解。好吧也许不需要讲道理讲得那么清,因为借用构造函数解决了这些问题。
2.2 借用构造函数
借用构造函数的思想很简单,即在子类构造函数的内部调用父类构造函数。在调用父类构造函数时传递相应的参数对需要从父类中继承的实例属性进行初始化操作,然后再对子类独有的实例属性进行初始化。
function LocalStar(name, age, height) {
Star.call(this, name, age);
this.height = height;
// this.sing = Star.sing;
// this.kongfu = function() { ... };
// this.nationality = '中国';
};
var ldh = new LocalStar('刘德华', 60, 188);
console.log(ldh.height); // ->188
console.log(ldh.name); // ->'刘德华'
console.log(ldh.nationality); // ->undefined
以上便是实例属性的继承方法,但是从上面也可以看出单独使用借用构造函数实现继承的话(类比单独使用构造函数创建函数),没有实现方法和共同属性的继承(nationality)。虽然我注释掉的代码一样可以使用,但是和继承没有关系,完全就是在重新实现子类的方法和初始化实例属性。所以要使用下面的组合继承。
2.3 组合继承
组合继承就是同时使用原型链和借用构造函数的方式实现继承。使用原型链实现方法和共同属性的继承,使用借用构造函数的方式实现实例属性的继承。示例如下(为了思路完整贴出了整个代码):
function Star(name, age, nationality) {
this.name = name;
this.age = age;
this.nationality = nationality;
if (!Star.prototype.attributes) {
Star.prototype.attributes = 'struggling';
Star.prototype.sing = function() {
console.log(this.name + '会唱歌');
}
}
};
function LocalStar(name, age, height) {
// 实例属性继承,第 2 次调用父类构造函数
Star.call(this, name, age);
this.height = height;
};
/*
* LocalStar.prototype = new Star(undefined, undefined, '中国');
* 如果父类某实例属性是子类上的原型属性可以像上面这么实现
* 后面也就没有必要在子类原型上单独去定义该属性
*/
// 创建子类构造函数原型,第 1 次调用父类构造函数
LocalStar.prototype = new Star();
Object.defineProperty(LocalStar.prototype, 'constructor', {
value: LocalStar,
enumerable: false
});
LocalStar.prototype.nationality = '中国';
LocalStar.prototype.kongfu = function() {
console.log(this.nationality + '名星会功夫');
};
var ldh = new LocalStar('刘德华', 60, 188);
console.log(ldh);
console.log(ldh.nationality); // ->undefined
当然这种方式也不是全无缺点,其中组合继承方式无论在什么情况下都调用两次父类构造函数。
如图,第一次调用父类构造函数创建子类原型时,子类原型会得到父类所有的属性(包括实例属性)。子类原型上具有父类实例属性 nationality 是合理的,但是它还包括了父类实例属性 age 和 name 。虽然他们的值为 undefined ,但后面继承比较复杂的话可能出现更上层相应属性被屏蔽的问题(不仅仅是浪费一点点内存内存的问题而是严不严谨的问题)。第二次在子类构造函数内调用父类构造函数,虽然实现了实例属性的继承,但是继承的是所有的实例属性。例如已在原型上的 nationality 属性也会作为实例属性再次继承。也会造成实例属性屏蔽原型属性的问题。故在输出时结果是 undefined 。
我们可以使用 delete 将不需要的属性删掉。但是要是有很多这种属性呢?而且程序先是定义一个属性觉得没用再删掉,于第二个问题而言每实例化一个子类实例就进行一次这种不严谨的操作就显得有点呆。因此我们继续学习其他的方式。
2.4 原型式继承
原型式继承并没有使用严格意义上的构造函数(没有定义新的引用类型)。它的思想是借助原型可以基于已有的对象创建新的对象,同时还不必因此创建自定义类型。基础原理如下:
实例代码 1:
function newObject(obj) {
function F() {};
F.prototype = obj;
return new F();
};
var initObject = {
name: '某某',
friends: ['小李'],
say: function() {}
}
var localStar1 = newObject(initObject);
var localStar2 = newObject(initObject);
localStar2.friends.push('小陈');
console.log(localStar1.friends[1]); // ->'小陈'
console.log(localStar2); // 输出结果如下图
示例代码封装了一个用于创建对象的函数。在函数内部创建了一个临时性的构造函数,然后将传入的对象作为这个构造函数的原型,最后返回这个临时类型的一个实例。利用该函数创建了两个对象,这两个对象将共享作为实参的对象的的属性和方法。 “从本质上讲对传入其中的对象进行了一次浅复制” 。浅复制的话他们之间的改动是相互影响的,下列代码之所以没有影响,只是实例对象的属性覆盖了原型对象上的属性而已。如下列代码:
localStar1.name = '我自己';
console.log(initObject.name); // ->'某某'
这种原型式继承方式的一个应用是内置对象 Object 静态方法 Object.create() 方法的封装。Object.create() 方法接收两个参数,第一个参数实际就是作为原型的那个对象;而第二个可选参数也是一个对象,它可以为新对象定义额外的属性(格式和 Object.defineProperties() 方法的第二个参数一样)。使用的具体方式如下:
示例代码 2:
var initObject = {
name: '某某',
friends: ['小李'],
say: function() {}
}
// 只传递一个参数
var localStar3 = Object.create(initObject);
// 传递两个参数
var localStar4 = Object.create(initObject, {
name: {
value: 'TKOP_'
}
});
localStar4.friends.push('小陈');
console.log(localStar3.friends[1]); // ->'小陈'
console.log(localStar4.name); // ->'TKOP_'
console.log(localStar3); // 输出结果如图
如下两张图,第一张是示例 1 输出的 localStar1 ,第二张是示例 2 输出的 localStar3 。两者都是利用原型式继承创建了一个没有实例属性的新对象,都只有一个对象原型(__proto__)属性。
唯一比较碍眼的可能就是该方式虽没有使用严格意义上的构造函数,但是示例一的代码确实使用了 F 构造函数,所以输出时对象的引用类型标识还是会出现一个 F(图中的红色框)。但是并没有去创建自定义类型,主要原因是使用的构造函数是在函数内部声明的,在外部执行环境中不存在。如果使用 localStar1 instanceof F 检测两者间的关系肯定会报错(F is not defined) 。我这个人有点强迫症,所以想出了以下方法使他们输出完全一样。看代码:
function newObject(obj) {
return new(function() {
// 不能使用this.constructor.prototype = obj;
this.__proto__ = obj
})(obj);
};
看到以上代码你可能有两个问题:
- 为什么不可以使用注释部分的代码?这又是原型重写的问题,建议再仔细看看原型模式创建对象那部分的内容。
- 不是对象原型属性(__proto__)开发者不能使用吗?当然开发者不能使用,但是 Object.create() 方法是 ECMAScript 替我们封装的呀。
然后对于第二个参数是怎么实现的,请看下节寄生式继承。
总结:在没有必要创建构造函数,而是只想让一个对象与另一个对象保持类似的情况下,原型式继承是完全可以胜任的。但是需要注意的是引用类型值的属性始终都会共享相应的值。
2.5 寄生式继承
寄生式继承的思路与寄生构造函数模式和工厂模式类似。即创建一个仅用于封装继承过程的函数,该函数在内部以某种方式来增强新对象并返回这个新对象。因为可以使用原型式继承的方式返回的新对象或者其他能够返回新对象的函数作为获得新对象的方式,所以创建的新对象就像依赖(寄生)于其他对象一样。类比寄生构造函数宿主是构造函数,它的宿主则是一个对象(个人理解)。
function newObject(obj) {
return new(function() {
// 不能使用this.constructor.prototyep = obj;
this.__proto__ = obj
})(obj);
};
var initObject = {
say: function() {
console.log('我是宿主(原型上的方法)');
}
}
function newCreate(arg1, arg2) {
// 创建新对象,宿主为 arg1 对象
let clone = newObject(arg1);
// 利用 arg2 参数增强新对象(当然可以任何方式)
Object.defineProperties(clone, arg2);
// 例如我也可以直接在这里添加方法
clone.kongfu = function() {
console.log('嚯嚯嚯');
};
return clone;
};
// 创建一个寄生虫 tkop
var tkop = newCreate(initObject, {
name: {
value: 'TKOP_',
},
age: {
value: 18
},
eat: {
value: function() {
console.log('吃饭特勤快');
}
}
});
tkop.eat();
tkop.kongfu();
tkop.say();
console.log(tkop);
个人亲自测试了一下,确实 Object.create() 方法和上面封装的 newCreate() 方法是一样的。
寄生式继承同样适合主要关注对象,而不在乎类型和构造函数的场景。上面用于创建新对象时的函数 newObject() 不是必需的,任何能返回新对象的函数都适用于这个模式(获取宿主方式不同而已)。
使用寄生式继承来为对象添加函数,会由于不能做到函数复用而降低效率,这一点与构造函数模式类似。
2.6 寄生组合式继承
有关组合继承方式两次调用父类构造函数的弊端前面已经做了详细的探究。寄生组合式继承帮我们解决了第一次调用遗留的问题。第一次调用会将父类所有(包括实例属性)属性作为子类原型的属性。问题在于我们只需要父类原型上的属性,其他的如果需要再单独添加。上面的寄生式继承刚好可以满足这种要求。将父类原型视为宿主对象,产生的寄生对象可以作为子类原型,在寄生继承中再对子类原型进行加强。真的太完美了。为了让思维完整,完整的示例代码都贴出来,如下:
// 父类构造函数 Star
function Star(name, age, nationality) {
this.name = name;
this.age = age;
this.nationality = nationality;
// 动态原型模式
if (!Star.prototype.attributes) {
Star.prototype.attributes = 'struggling';
Star.prototype.sing = function() {
console.log(this.name + '会唱歌');
}
}
};
// 子类构造函数
function LocalStar(name, age, height) {
// 实例属性继承,第 2 次调用父类构造函数
Star.call(this, name, age);
this.height = height;
};
// 创建子类构造函数原型,第 1 次调用父类构造函数
// LocalStar.prototype = new Star();
// 原型式继承
function newObject(obj) {
function F() {};
F.prototype = obj;
return new F();
};
// 寄生式继承
function initProto(SuperType, SubType, agr3) {
// 创建新对象,宿主为SuperType原型,寄生对象为agr2原型
SubType.prototype = newObject(SuperType.prototype);
// 利用 agr3 参数增强新对象(当然可以任何方式)
Object.defineProperties(SubType.prototype, agr3);
SubType.prototype.constructor = SubType;
Object.defineProperty(SubType.prototype, 'constructor', {
value: SubType,
enumerable: false
});
};
// 调用函数实现原型继承
initProto(Star, LocalStar, {
nationality: {
value: '中国'
}
});
// 子类实例化
var ldh = new LocalStar('刘德华', 60, 188);
console.log(ldh);
console.log(ldh.nationality); // ->'中国'
然后我看了以下输出,不仅解决了组合继承第一次调用父类构造函数遗留的问题,连带第二次调用父类构造函数遗留的问题也莫名奇妙地没有了?这点让我有点疑惑。这个问题是怎么解决掉的???不是第二次调用父类构造函数还是会执行 this.nationality = undefined(没有传递参数)吗???如果有看到且知道的请品论区指教。。。。
2.7 混入方式继承(了解)
。。。。。。
3. ES6 中的类的概念
在典型的 OOP 语言中,都存在类的概念,类就是对象的模板,对象就是类的实例。但是在 ES6 之前,JS 并没有引入类的概念。在 ES6 之前,对象不是基于类创建的,而是使用构造函数模拟实现类的功能并创建实例对象。ES6 引入了类的概念(语法糖),这里主要是记录有关类的概念的笔记。涉及的关键字有 class、extends、super 。
类是指抽象了对象的公共部分,泛指某个一大类(class)的对象,例如汽车,人等是类的概念。对象特指某一个具体的对象,例如某辆具体的车,某个人等。
3.1 创建类
创建类使用 class 关键字,使用类实例化对象时必须使用 new 关键字。更具体地语法如下示例代码:
// 1. 使用class创建一个Person类
class Person {
// 类中默认方法constructor()
constructor(uname, age) {
// 类的共有属性放到 constructor 里面
this.uname = uname;
this.age = age;
};
// 类中共享属性的定义
say() {
console.log('我是' + this.uname + ',我现在很痛苦');
}
}
// 2. 使用new实例化一个对象
var tkop = new Person('TKOP_', 18);
console.log(tkop);
tkop.say();
- 通过class 关键字创建类, 类名我们还是习惯性定义首字母大写。
- constructor() 方法是类的构造函数(默认方法),用于传递参数,返回实例对象。
- 通过 new 命令生成实例对象时,自动调用 constructor() 方法。如果没有显式定义,类的内部会自动帮我们创建一个 constructor() 。
- 生成实例 new 关键字不能省略。
- 注意语法规范,创建类时类名后面不添加小括号,实例化时后面需要跟小括号并传递参数。
- 类中方法的定义都不使用 function 关键字。
3.2 类的继承
类的继承使用到的关键字有 extends 和 super 。extends 用于指明类之间的继承关系,即该类继承于那个类。super 关键字用于访问和调用父类上的函数。可以调用父类的构造函数,也可以调用父类的普通函数。示例代码如下:
// 创建一个父类Person
class Person {
constructor(uname, age) {
// 类的共有属性放到 constructor 里面
this.uname = uname;
this.age = age;
};
say() {
console.log('我是父类中的 say 方法');
}
}
// 子类Student继承自父类Person
class Student extends Person {
constructor(uname, age, id) {
super(uname, age);
this.id = id;
}
study() {
console.log('子类中的方法');
}
}
var tkop = new Student('tkop', 18, '007');
console.log(tkop); // ->输出tkop对象含有uname age等实例属性
tkop.say(); // ->'我是父类中的 say 方法'
tkop.study(); // ->'子类中的方法'
注意:子类构造函数内使用 super 关键字调用父类构造函数必须在使用 this 关键字之前。
在子类方法中也可以使用 super 关键字调用父类原型中的方法。
// 子类Student继承自父类Person
class Student extends Person {
constructor(uname, age, id) {
super(uname, age);
this.id = id;
}
study() {
console.log('子类中的方法');
super.say();
}
say() {
super.say();
console.log('这是在子类say方法调用父类的say后的输出');
}
}
var tkop = new Student('tkop', 18, '007');
console.log(tkop);
tkop.say();
tkop.study();
输出结果:
3.3 类的本质
对于使用 class 声明的类,其本质就是一个函数。使用 typeof 类型检测结果如下:
console.log(typeof Person); // ->function
不过是 ES6 为我们封装的一个创建自定义引用类型的语法糖。实现上使用的是寄生组合式继承。但是有一点与寄生组合不同就是在调用父类构造函数时需要全部继承父类实例属性。否则子类中没有继承过来的父类属性依旧会在实例中出现,且值为 undefined 。
// 创建一个父类Person
class Person {
constructor(uname, age, job) {
// 类的共有属性放到 constructor 里面
this.uname = uname;
this.age = age;
this.job = job;
};
say() {
console.log('我是父类中的 say 方法');
}
}
Person.prototype.id = '000';
// 子类Student继承自父类Person
class Student extends Person {
constructor(uname, age, id) {
// 没有传递所有父类实例属性,即没有job
super(uname, age);
this.id = id;
}
study() {
console.log('子类中的方法');
}
}
var tkop = new Student('tkop', 18, '007');
console.log(tkop); // ->输出tkop实例上有job属性,值为undefined