JS红皮书读书笔记-06-面向对象

TOC
  1. 1. 理解对象
    1. 1.1. 属性类型
    2. 1.2. 定义多个属性
    3. 1.3. 读取属性的特性
  2. 2. 创建对象
    1. 2.1. 工厂模式
    2. 2.2. 构造函数模式
    3. 2.3. 原型模式
    4. 2.4. 构造函数+原型模式
    5. 2.5. 动态原型模式
    6. 2.6. 寄生构造函数
    7. 2.7. 稳妥构造函数
  3. 3. 继承
    1. 3.1. 原型链
    2. 3.2. 借用构造函数
    3. 3.3. 组合继承
    4. 3.4. 原型式继承
    5. 3.5. 寄生式继承
    6. 3.6. 寄生组合继承

理解对象

第五讲里面讲了对象声明的两种方式:

  • 使用Object构造函数
  • 对象字面量

属性类型

ES5描述对象属性(property)的特征, 称为特性(attribute), 定义特性是为了实现js引擎用的, 所以在JS中不能直接访问它们(特性). 为了表示特性是内部值, 规范把它们放在两个方括号之中.

ES5有两种属性:

1. 数据属性:

数据属性包含一个数据值的位置。在这个位置可以读取和写入值。数据属性有 4 个描述其行为的特性。

  • [[Configurable]]: 表示能否通过 delete 删除属性从而重新定义属性,能否修改属性的特性,或者能否把属性修改为访问器属性。默认值: true
  • [[Enumerable]]: 可枚举, 即能否for-in循环出这个属性, 默认值: true
  • [[Writable]]: 是否能改属性的值, 默认值:true
  • [[Value]]: 默认值: undefined

为了帮助理解, 我们来看一个例子:

va person = {}; 
Object.defineProperty(person, "name", {
writable: false,
value: "Nicholas",
configurable: false, //禁止删除
});

// 注意这个配置只能用一次,否则会报错
Object.defineProperty(person, "name", {
writable: true, // Cannot redefine property:
})

alert(person.name); //"Nicholas"
person.name = "Greg"; //非严格模式下赋值被忽略, 严格模式下报错
alert(person.name); //"Nicholas"

delete person.name; //严格模式下报错
alert(person.name); //"Nicholas"

如果运行Object.defineProperty方法时, 不明确指定的话, configurable, enumerable, writable的值都会变成false(要记住原本他们都是true).

注意: 笔者写本文的时候(2019年), 亲自写示例测试, 书上的这部分话已经不可信.

2. 访问器属性

访问器属性不包含数据值;它们包含一对儿 getter 和 setter 函数(不过,这两个函数都不是必需的)。在读取访问器属性时,会调用 getter 函数,这个函数负责返回有效的值;在写入访问器属性时,会调用
setter 函数并传入新值,这个函数负责决定如何处理数据。访问器属性有如下 4 个特性:

  • [[Configurable]]:同数据属性
  • [[Enumerable]]: 同数据属性
  • [[Get]]: 读取属性时候调用的函数, 默认undefined
  • [[Set]]: 写入属性时候调用的函数, 默认undefined

要修改访问器属性的特性, 同样是用Object.defineProperty方法

我们看这个例子:

var demo = {};
Object.defineProperty(demo, 'name', {
configurable:true,
enumerable:true,
get:function(){
console.log('我读取了属性')
},
set:function(){
console.log('我设置了属性')
}
})

定义多个属性

定义多个属性可以用Object.defineProperties方法, 用法和Object.defineProperty类似, 只不过第二个参数为复合对象

读取属性的特性

用Object.getOwnPropertyDescriptor读取属性的特性

var book = {};
Object.defineProperties(book, {
_year: {
value: 2004
},
edition: {
value: 1
},
year: {
get: function(){ return this._year;
},
set: function(newValue){
if (newValue > 2004) {
this._year = newValue;
this.edition += newValue - 2004;
}
}
}
});

var descriptor = Object.getOwnPropertyDescriptor(book, "_year");
alert(descriptor.value); //2004
alert(descriptor.configurable); //false
alert(typeof descriptor.get); //"undefined"

var descriptor = Object.getOwnPropertyDescriptor(book, "year");
alert(descriptor.value); //undefined
alert(descriptor.enumerable); //false
alert(typeof descriptor.get); //"function"

创建对象

为了解决多次生成对象的问题, 形成了以下常见的接种封装模式

工厂模式

function createPerson(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 person1 = createPerson("Nicholas", 29, "Software Engineer");
var person2 = createPerson("Greg", 27, "Doctor");

工厂模式解决了生成多个类似对象的问题, 但是还没有解决对象属于什么”类”的问题

构造函数模式

function Person(name, age, job){ 
this.name = name;
this.age = age;
this.job = job;
this.sayName = function(){
alert(this.name);
};
}

var person1 = new Person("Nicholas", 29, "Software Engineer");
var person2 = new Person("Greg", 27, "Doctor");

我们通常把构造函数的函数名首字母大写, 这样它看起来想一个”类”, 区别于工厂模式, 构造函数模式有三个不同点:

  • 没有显示地创建对象
  • 直接将数学和方法赋给this
  • 没有return

创建一个实例的方式变成了 new Person, 背后产生了以下几个步骤:

  • 创建一个新对象
  • 将构造函数的作用域赋给新对象(因此 this 就指向了这个新对象);
  • 执行构造函数中的代码(为这个新对象添加属性);
  • 返回新对象

构造函数的优点:

构造函数的好处在于, 它可以作为一种自定义的”类”, 也容易识别实例是否为某一种类型:

alert(person1.constructor == Person); //true
alert(person2.constructor == Person); //true

注意, 创建Person的实例的时候必须用new关键字, 否则创建的示例会挂载到window上.


构造函数的缺点:

还是用上一个例子, 但是我们稍作修改:

function Person(name, age, job){ 
this.name = name;
this.age = age;
this.job = job;
this.sayName = new Function('alert(this.name)'); //为了方便理解我们使用new Function
//这样每次创建Person实例的时候, sayName都是重复创建具有一样功能的方法(即资源浪费)
}

var p1 = new Person('k',18,'fe');
var p2 = new Person('x',19,'bd');
p1.sayName == p2.sayName; //false

前面我知道了函数名其实就是一个指针, 那么我们可以稍微改造一下:

function Person(name, age, job){ 
this.name = name;
this.age = age;
this.job = job;
this.sayName = sayName;
}

function sayName(){
alert(this.name)
}
var p1 = new Person('k',18,'fe');
var p2 = new Person('x',19,'bd');
p1.sayName == p2.sayName; //true

这样, 看起来也没什么问题了. 不过sayName作为一个全局函数, 只能给Person的示例调用, 好像又对不起它作为全局函数的称号, 如果要定义很多个方法, 那就需要很多个全局函数, 这样看起来又不像”封装好”的样子.

原型模式

为了解决构造函数模式的问题, 诞生了原型模式, 第五章我们提到了Function的每个实例都有两个属性, 一个是length, 另一个是prototype, 我们现在来着重讲这个prototype. prototype其实是一个指针, 指向一个对象, 这个对象用于存储所有实例共享的属性和方法.

我们先看一张图:

demo.prototype

可以看到我随便创造的一个空函数demo, 它的prototype指向了一个对象, 这个对象包含了constructor__proto__两个属性, 而
constructor又指回了demo本身, __proto__指向的是Object, 实际上这个Object, 就是我们常常看到的 new Object里面的那个Object构造函数, 这侧面反映了几个事实:

  • 所有对象都是Object的实例(这个讲原型链的时候会继续深入讲解)
  • 一个对象可以通过__proto__访问生成这个对象的构造函数原型
  • 原型(prototype)的constructor属性,指向的是构造函数本身

我们再看这个例子:

function Person(){ }
Person.prototype.name = "Nicholas";
Person.prototype.age = 29;
Person.prototype.job = "Software Engineer";
Person.prototype.sayName = function(){
alert(this.name);
};

var person1 = new Person();
person1.sayName(); //"Nicholas"

var person2 = new Person();
person2.sayName(); //"Nicholas"
alert(person1.sayName == person2.sayName); //true

实际上它的原理如下:

原理图

图中实例的[[prototype]], 其实就是__proto__;

除了用这个属性确定对象实例和原型的关系, 还可以通过下列方式查看:

Person.prototype.isPrototypeOf(person1) //true
Object.getPrototypeOf(person1) == Person.prototype //true

前面给出的例子, 构造函数都是一个空函数, 不存在任何的属性和方法. 基于上一个代码示例, 我们尝试通过实例重写原型的属性.

alert(person1.name);  // "Nicholas"
person1.name = "kkk"; //注意我这里只是改了实例的属性, 并没有改构造函数
alert(person1.name); //"kkk"
alert(person2.name); // "Nicholas"

可以看出, 通过实例去改变原型, 是没办法在原型中改变对应的属性或者方法.

从上面的代码能反映出, 对象实例查找某一个属性(或者方法), 是先查找构造函数中的同名属性 , 如果找到则停止, 否则继续在原型中查找同名属性.

解释: 上面的代码是显式地person1.name = “kkk”. 假如构造函数中存在this.name=”kkk”, 那么person1.name的值毫无疑问就是”kkk”;

由于构造函数和原型的这种特性, 我们要查找一个对象实例的属性究竟来自自身还是来自原型, 需要用hasOwnProperty方法:

alert(person1.hasOwnProperty('name'));  //true

in:
通常我们会在for-in(当然ES5可以用Object.keys)中看到in操作符, 用于遍历一个对象的所有课枚举属性, 然而单独使用in操作符的时候, 是用于检测某个对象的某个属性(或方法)是否存在于原型链中. 只要原型中存在需要查找的属性, 假设这个属性为name, 那么不管构造函数是否存在name, in操作符依然能查找出来.

为了查找只存在原型上的属性(或方法), 我们可以将in和hasOwnProperty写成一个函数用于检测:

function hasPrototypeProperty(object, name){
return !object.hasOwnProperty(name) && (name in object);
}

简写原型:

前面的代码可以看到, 每添加一个属性, 就要多书写Person.prototype一次, 实际上们可以这样:

function Person(){}
Person.prototype = {
constructor:Person, //注意这里要手动绑定构造函数,因为此时的原型相当于一个新领养的小孩, 要重新让他认爹. 虽然我们大多数情况下用不到constructor属性, 但是建议养成这个习惯
}

原型的动态:

由于在原型中查找值的过程是一次搜索,因此我们对原型对象所做的任何修改都能够立即从实例上反映出来 即使是先创建了实例后修改原型也照样如此。

请看例子:

var friend = new Person(); 
Person.prototype.sayHi = function(){
alert("hi");
};
friend.sayHi(); //"hi"(没有问题!)

如果生成实例之后用字面量的形式修改了原型对象, 那么就会报错:

function Person(){
}

var friend = new Person();

Person.prototype = {
constructor: Person,
name : "Nicholas",
age : 29,
job : "Software Engineer",
sayName : function () {
alert(this.name);
}
};

friend.sayName(); //error. 因为friend是原本那个原型衍生而来的

原生对象的原型: Array, Object, Function同样可以通过上述方式修改原型对象, 不过除非必要, 不建议修改.


原型模式的缺点:
所有属性和方法都写在原型, 看上去实现了共享. 但是如果原型对象中的属性是引用类型的话, 实例对改属性的修改, 也会立刻反映到所有实例上.

看例子:

function Person(){ }

Person.prototype = {
constructor: Person,
name : "Nicholas",
age : 29,
job : "Software Engineer",
friends : ["Shelby","Court"],
sayName : function () {
alert(this.name);
}
};

var person1 = new Person();
var person2 = new Person();

person1.friends.push("Van");
alert(person1.friends); //"Shelby,Court,Van"
alert(person2.friends); //"Shelby,Court,Van"
alert(person1.friends === person2.friends); //true

构造函数+原型模式

仅仅只用构造函数, 那么在生成对象方法的时候会造成资源浪费. 如果只是用原型模式的话, 那么会产生属性为引用类型时候的弊端. 所以我们可以把这两张方式结合起来:

function Person(name,age, job){
this.name = name;
this.age = age;
this.job = job;
this.friends = ["Shelby","Court"],
}

Person.prototype = {
constructor: Person,
sayName : function () {
alert(this.name);
}
};
var person1 = new Person("Nicholas", 29, "Software Engineer"); var person2 = new Person("Greg", 27, "Doctor");
person1.friends.push("Van");

alert(person1.friends); //"Shelby,Count,Van"
alert(person2.friends); //"Shelby,Count"

这样, 组合模式基本解决所有的需求.

动态原型模式

组合模式的优化:

function Person(name,age, job){
this.name = name;
this.age = age;
this.job = job;
this.friends = ["Shelby","Court"],
// 看起来更像是一个'类'
if (typeof this.sayName != "function"){
Person.prototype.sayName = function(){
alert(this.name);
};
}
}

寄生构造函数

假如你有这么一个这样的需求: 批量生产某一种数据类型, 比如数组实例, 但是这个数组实例又有一个toPipedString方法, 那你可以考虑用寄生构造函数.

它和工厂模式长得很像(实际上就是一样的, 该有的缺点都有):

function _Array(){
var values = new Array();
values.push.apply(values, arguments);
values.toPipedString = function(){
return this.join("|");
}
return values;
}
var a = new _Array(2,6,8,9,4);
a.toPipedString();

var b = _Array(2,6,8,9,4);// 这里没有用new , 返回的结果依旧一样(就是工厂模式)
b.toPipedString();

在上面的_Array构造函数, 并没有使用到this, 而且它还有显式的return, 也就是说new了也是白new. 虽然书上给出了这个寄生构造函数模式, 但笔者认为实在没有必要使用这种方式.

稳妥构造函数

就是寄生构造函数模式下, 只暴露方法, 不允许通过对象实例直接访问对象的属性值

继承

JS只有实现继承, 并且主要依靠原型链实现的.

原型链

前面我们讲了原型, 至于原型链. 实际就是用父类的实例, 作为子类的原型对象(即用父类实例重写子类原型对象), 这样父类拥有的属性和方法, 子类实例自然也能访问到.

原型链需要注意:

function SuperType(){ 
this.property = true;
}

SuperType.prototype.getSuperValue = function(){
return this.property;
};

function SubType(){
this.subproperty = false;
}

//继承了 SuperType
SubType.prototype = new SuperType();

//注意以下操作一点要在继承完之后才能执行, 并且不能用字面量添加方法, 否则会导致继承失效.

//添加新方法
SubType.prototype.getSubValue = function (){
return this.subproperty;
};
//重写超类型中的方法
SubType.prototype.getSuperValue = function (){
return false;
};

var instance = new SubType();
alert(instance.getSuperValue()); //false

除了以上要注意的地方, 原型链继承还有两个问题:

  • 第一个问题就是, 当父类的实例包含了引用类型属性时, 子类的原型对象就同样包含了这个引用类型的属性.
    我们来看示例:
function SuperType(){
this.colors = ["red", "blue", "green"];
}
function SubType(){ }
SubType.prototype = new SuperType();

var instance1 = new SubType();
instance1.colors.push("black");
alert(instance1.colors); //"red,blue,green,black"

var instance2 = new SubType();
alert(instance2.colors); //"red,blue,green,black", 已经发生修改
  • 第二个问题: 不能向父类传参.

借用构造函数

为了解决原型链继承问题, 我们可以借用父类构造函数, 而不是将父类实力重写子类原型对象 , 通常这种方式叫做经典继承或者伪造继承.

function SuperType(name){
this.name = name;
this.colors = ["red", "blue", "green"];
}

function SubType(name){
var name = name || 'testdog'
SuperType.call(this,name); //想起call和apply的作用吗?
}

var instance1 = new SubType();
instance1.colors.push("black");
alert(instance1.colors); //"red,blue,green,black"

var instance2 = new SubType();
alert(instance2.colors); //"red,blue,green"

var instance = new subType('kkk')
instance.name; //'kkk'

不过这样的话, 就子类实例无法用instanceof来检查是否是也是父类的实例了.

当然, 不可能把所有属性和方法都放在父类构造函数中, 肯定还有写在父类原型对象的情况. 所以只用call/apply借用父类构造函数实现继承, 也是不够的.

组合继承

顾名思义, 原型链+构造函数组合而成.

function SuperType(name){ 
this.name = name;
this.colors = ["red", "blue", "green"];
}

SuperType.prototype.sayName = function(){
alert(this.name);
};

function SubType(name, age){
//继承属性
SuperType.call(this, name);
this.age = age;
}
//继承方法
SubType.prototype = new SuperType();
SubType.prototype.constructor = SubType;
SubType.prototype.sayAge = function(){
alert(this.age);
};

var instance1 = new SubType("Nicholas", 29);
instance1.colors.push("black");
alert(instance1.colors); //"red,blue,green,black"
instance1.sayName(); //"Nicholas";
instance1.sayAge(); //29

var instance2 = new SubType("Greg", 27);
alert(instance2.colors); //"red,blue,green"
instance2.sayName(); //"Greg";
instance2.sayAge(); //27

注意: 创建一个子类实例, SuperType其实是运行了两次的 , 一次在于原型链继承, 另一次在于借用以覆盖引用类型属性共享的问题.

原型式继承

如果只是基于现有的对象实现继承, 那可不比兴师动众写那么多函数:

var k = {
name:'k',
age:18,
friends:['a','b','c'];
};
var object = function(o){
function F(){};
F.prototype = o; //浅复制,
reutrn o;
};
var obj = object(k);
obj.friends.push('d');

var obj2 = object(k);
obj2.friends; //['a','b','c','d'];

ES5规范化了这种继承方式, 于是有了Object.create方法, 用法同上, 但是它可以多一个参数.这个参数的格式和Object.defineProperties的第二个参数格式一致.

var person = {
name: "Nicholas",
friends: ["Shelby", "Court", "Van"]
};

var anotherPerson = Object.create(person, {
name: {
value: "Greg"
}
});

alert(anotherPerson.name); //"Greg"

寄生式继承

其实是原型式继承的基础上再包装一层, 用于添加需要的方法, 类似于前面讲的寄生构造函数模式或者工厂模式.

寄生组合继承

前面讲了组合继承, 它还存在一个两次调用父类的问题. 既然组合类型是通过借用父类函数实现属性继承, 通过原型链实现方法的继承. 那么我们可以在原型链这里动一次手脚.

function inheritPrototype(subType, superType){
var prototype = Object.create(superType.prototype);
prototype.constructor = subType;
subType.prototype = prototype;
}

function SuperType(name){
this.name = name;
this.colors = ["red", "blue", "green"];
}

SuperType.prototype.sayName = function(){
alert(this.name);
};

function SubType(name, age){
SuperType.call(this, name);
this.age = age;
}

inheritPrototype(SubType, SuperType);
SubType.prototype.sayAge = function(){
alert(this.age);
};

本章完

访客评论