乐趣区

JavaScript高级程序设计第3版读书笔记-第6章-面向对象的程序设计

  • 面向对象(Object-Oriented, OO)的语言有一个标志,它们都有类的概念,而通过类可以创建任意多个具有相同属性和方法的对象。
  • ECMAScript 中没有类的概念,因此它的对象也与基类的语言中的对象有所不同
  • ECMA-262 把对象定义为:“无序属性的集合,其属性可以包含基本值、对象或者函数。”我们可以把 ECMAScript 的对象想象成散列表:无非就是一组名值对,其中值可以是数据或者函数
  • 每个对象都是基于一个引用类型创建的,这个引用类型可以是第 5 章讨论的原生类型,也可以是开发者定义的类型

理解对象

// 创建对象,赋给属性
var person = new Object();
person.name = "Nicholas";
person.age = 29;person.job = "Sofware Engineer";

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

// 字面量方式创建对象
var person = {
  name: "Nicholas",
  age: 29,
  job: "Sofware Engineer",
  sayName: function() {alert(this.name);
  }
}

属性类型

  • ECMAScript 中有两种属性:数据属性和访问器属性

数据属性

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

    • [[Configurable]] 表示能否通过 delete 删除属性从而重新定义属性,能否修改属性的特性,或者能否把属性修改为访问器属性。像前述例子中那样直接在对象定义的属性,它们这个特性默认值为 true
    • [[Enumerable]] 表示能否通过 for-in 循环返回属性。前述例子这个特性默认为 true
    • [[Writable]] 表示能否修改属性的值。前述例子这个特性默认为 true
    • [[Value]] 包含这个属性的数据值。读取属性值得实惠,从这个位置读;写入属性值得实惠,把新值保存在这个位置。这个特性的默认值为 undefined
  • 要修改属性默认的特性,必须使用 ECMAScript5 的 Object.defineProperty()方法。

    • 主要接收三个参数:属性所在的对象,属性的名字和一个描述符对象。描述符(descriptor)对象的属性必须是:configurable,enumerable,writalbe 和 value。
    • 设置其中的一或多个值,可以修改对应的特性值
    var person = {};
    // 创建了一个 name 属性,它的值 "Nicholas" 是只读的。Object.defineProperty(person, "name", {
      writable: false,
      value: "Nicholas"
    });
    
    console.log(person.name);                // "Nicholas"
    // 在非严格模式下,赋值操作会被忽略
    // 而在严格模式下,会导致错误
    person.name = "Greg";
    console.log(person.name);                // "Nicholas"
    • 把 configurable 设置为 false,表示不能从对象中删除属性。如果对这个属性调用 delete,则在非严格模式下什么也不会发生,而在严格模式下会导致错误
    • 一旦把属性定义为不可配置的,就不能再把它变回可配置了。此时再调用 Object.defineProperty()方法修改除 writable 之外的特性,都会导致错误
    var person = {};
    Object.defineProperty(person, "name", {
      configurable: false,
      value: "Nicholas"
    });
    
    // 抛出错误
    Object.defineProperty(person, "name", {
      configurable: true,
      value: "Nicholas"
    })
    • 可以多次调用 Object.defineProperty()方法修改同一个属性,但在把 configurable 特性设置为 false 之后就会有限制了。
    • 调用 Object.defineProperty()如果不指定,configurable,enumerable,writalbe 特性的默认值都是 false。多数情况下,没有必要利用 Object.defineProperty()方法提供的高级功能。

访问器属性

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

    • [[Configurable]] 表示能否通过 delete 删除属性从而重新定义属性,能否修改属性的特性,或者能否把属性修改为数据属性。对于直接在对象上定义的属性,这个特性的默认值为 true
    • [[Enumerable]] 表示能否通过 for-in 循环返回属性。对于直接在对象上定义的属性,这个特性的默认值为 true
    • [[Get]] 在读取属性时调用的函数。默认值为 undefined
    • [[Set]] 在写入属性时调用的函数。默认值为 undefined
  • 访问器属性不能直接定义,必须使用 Object.defineProperty()来定义
var book = {
  _year: 2004,
  edition: 1
};

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

// 这是使用访问器属性的常见方式,即设置一个属性的值会导致其他属性发生变化。book.year = 2005;
console.log(book.edition);   // 2
  • 不一定非要同时制定 getter 和 setter。只指定 getter 意味着属性时不能写,尝试写入属性会被忽略。在严格模式下尝试写入只指定了 getter 函数的属性会抛出错误。
  • 只指定 setter 意味着属性时也不能读,否则在非严格模式下回返回 undefined,而在严格模式下会抛出错误
  • 支持 ECMAScript 5 这个方法的浏览器有 IE9+(IE8 只是部分实现),Firefox 4+, Safari 5+, Opera 12+, Chrome
  • 在不支持 Object.defineProperty()方法的浏览器中不能修改[[Configurable]] 和 [[Enumerable]]

定义多个属性

  • Object.defineProperties() 定义多个属性的方法
var book = {}
Object.defineProperties(book, {
  _year: {
    writable: true,
    value: 2004
  },

  edition: {
    writable: true,
    value: 1
  },

  year: {get: function() {return this._year;},

    set: function(newValue) {if (newValue > 2004) {
        this._year = newValue;
        this.editio += newValue - 2004;
      }
    }
  }
})

读取属性的特性

  • Ojbect.getOwnPropertyDescriptor() 方法,可以取得给定属性的描述符,这个方法接收两个参数:属性所在的对象和要读取属性描述符的属性名称。返回值时一个对象,如果是访问器属性,这个对象的属性有 configurable, enumerable, get, set;如果是数据属性,这个对象的属性有 configurable, enumerable, writable, value
var book = {};

Object.defineProperties(book, {
  _year: {
    writable: true,
    value: 2004
  },

  edition: {
    writable: true,
    value: 1
  },

  year: {get: function() {return this._year;},

    set: function(newValue) {if (newValue > 2004) {
        this._year = newValue;
        this.editio += newValue - 2004;
      }
    }
  }
});

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

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

创建对象

  • 虽然 Object 构造函数或对象字面量都可以用来创建单个对象,但这些方式有个明显的缺点:使用同一个接口创建很多对象,会产生大量的重复代码。为解决这个问题,人们开始使用工厂模式的一种变体

工厂模式

  • 考虑到 ECMAScript 中无法创建类,开发者就发明了一种函数,用函数来封装以特定接口创建对象的细节
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 语句
    • 函数名首字母大写,按照惯例,构造函数都应该以一个大写字母开头,而非构造函数小写
  • person1 和 person2 分别保存着 Person 的一个不同的实例。两者都有一个 constructor(构造函数)属性,改属性指向 Person
console.log(person1.constructor == Person); // true
console.log(person2.constructor == Person); // true
  • 对象的 constructor 属性最初是用来标识对象类型的。但是,检测对象类型,还是 instanceof 操作符要更可靠。
  • 这个例子中创建的所有对象既是 Object 的实例,同时也是 Person 的实例
console.log(person1 instanceof Object);   // true
console.log(person2 instanceof Object);   // true
console.log(person1 instanceof Person);   // true
console.log(person2 instanceof Person);   // true
  • 创建自定义的构造函数意味着将来可以将它的实例标识为一种特定的类型,这正是构造韩式模式胜过工厂模式的地方。这个例子中,person1, person2 之所以同时是 Object 的实例,是因为所有对象均继承自 Object
  • 以这种方式定义的构造是定义在 Global 对象(浏览器中是 window)中的。第 8 章将详细讨论浏览器对象模型(BOM)

将构造函数当作函数

  • 构造函数与其他函数的唯一区别就在于调用方式不同。
  • 构造函数也是函数,不存在定义构造函数的特殊语法
  • 任何函数只要通过 new 操作符来调用,那它就可以作为构造函数
  • 而任何函数不通过 new 操作符来调用,那它跟普通函数没有区别
// 作为构造函数使用
var person = new Person("Nicholas", 29, "Software Engineer");
person.sayName();   // "Nicholas"

// 作为普通函数调用,此时 this 指向 window 对象
Person("Greg", 27, "Doctor");  // 添加到 window 对象上
window.sayName();       // "Greg"

// 在另一个对象的作用域中调用
var o = new Object();
Person.call(o, "Kristen", 25, "Nurse");
o.sayName(); // "Kristen"

构造函数的问题

  • 构造函数模式的并非没有缺点。主要问题就是每个方法都要在每个实例上重新创建一遍。在前述例子中 person1 和 person2 都有一个名为 sayName()的方法,但那两个方法不是同一个 Function 的实例。不要忘了 ECMAScript 中函数是对象,因此每定义一个函数,也就是实例化了一个对象。从逻辑角度讲,此时的构造函数也可以这样定义
function Person(name, age, job) {
  this.name = name;
  this.age = age;
  this.job = job;
  // 与声明函数再路基上是等价的
  this.sayName = new Function("alert(this.name)");
}

console.log(person1.sayName == person2.sayName);  // false
  • 有 this 对象在,没有必要在执行代码前就把函数绑定到特定对象上面,通过把函数定义转移到函数外部来简化
function Person(name, age, job) {
  this.name = name;
  this.age = age;
  this.job = job;
  this.sayName = sayName
}

function sayName () {alert(this.name);
}

var person1 = new Person("Nicholas", 29, "Software Engineer");
var person2 = new Person("Greg", 27, "Doctor");
  • 我们把 sayName 函数的定义转移到了函数外部。而在构造函数内部,我们将 sayName 属性设置成等于全局的 sayName 函数。如此一来,由于 sayName 包含的是一个指向函数的指针,因此 person1 和 person2 对象就共享了全局作用域中定义的同一个 sayName() 函数。
  • 新的问题出现了,在全局作用域中定义的函数实际上只能被某个对象调用,这让全局作用域有点名不副实。而更让人无法接受的是:如果对象需要定义很多方法,那么就要定义很多个全局函数,于是我们这个自定义的引用类型就丝毫没有封装性可言了。
  • 好在可以通过原型模式来解决

原型模式

  • 我们创建的每个函数都有一个 prototype(原型),这个属性是一个指针,指向一个对象,而这个对象的用途是包含可以由特定类型的所有实例共享的属性和方法。
  • prototype 就是通过调用构造函数而创建的那个对象实例的原型对象,使用原型对象的好处是可以让所有对象实例共享它所包含的属性和方法。换言之,不必再构造函数中定义对象实例的信息,而是可以将这些信息直接添加到原型对象中
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"

console.log(person1.sayName == person2.sayName);    // true
  • 在此,我们将 sayName()方法和所有属性直接添加到了 Person 的 prototype 属性中,构造函数变成了空函数。即使如此,也仍然可以通过调用构造函数来创建新对象,而且新对象还会具有相同的属性和方法。
  • 但与构造函数模式不同的是,新对象的这些属性和方法时由所有实例共享的。换言之,person1 和 person2 访问的都是同一组属性和方法
  • 要理解原型模式的工作原理,必须先理解 ECMAScript 中原型对象的性质

理解原型对象

  • 只要创建了一个函数,就会根据一组特定的规则为该函数创建一个 prototype 属性,这个属性指向函数的原型对象。
  • 默认情况下,所有原型对象都会自动获得一个 constructor(构造函数)属性,这个属性是一个指向 prototype 属性所在函数的指针。例如上述例子,Person.prototype.constructor 指向 Person。
  • 我们可以继续为原型对象添加其他属性和方法
  • 创建了自定义构造函数后,其原型对象默认只会得到 constructor 属性,至于其他方法,则都是从 Object 继承而来。当调用构造函数创建一个新的实例后,该实例的内部将包含一个指针(内部属性),指向构造函数的原型对象。ECMAScript 5 中管这个叫[[Prototype]]。
  • 虽然脚本中没有标准的方式访问[[Prototype]], 但 Firefox, Safari, Chrome 在每个对象都支持一个属性__proto__; 这个属性对脚本则是完全不可见的。
  • 这个链接存在于实例与构造函数的原型对象之间,而不是存在于实例与构造函数之间

  • 图 6 -1(148 页)展示了 Person 构造函数、Person 的原型属性以及 Person 现有的两个实例之间的关系。在此,Person.prototype 指向了原型对象,而 Person.prototype.constructor 又指回了 Person。原型对象中除了包含 constructor 属性之外,还包括后来添加的其他属性。Person 的每个实例——person1 和 person2 都包含一个内部属性,该属性仅仅指向了 Person.prototype;换言之,person1 和 person2 与构造函数没有直接的关系。此外,要格外注意的是,虽然这两个实例都不包含属性和方法,但我们却可以调用 person1.sayName()。这是通过查找对象属性的过程来实现的。
  • 虽然在所有实现中都无法访问到 [[Prototype]],但可以通过 isPrototypeOf() 方法来确定对象之间是否存在这种关系。
console.log(Person.prototype.isPrototypeOf(person1));     // true
console.log(Person.prototype.isPrototypeOf(person2));     // true

console.log(Person.isPrototypeOf(person1));     // false
console.log(Person.isPrototypeOf(person2));     // false
  • ECMAScript 5 新增了一个方法,Object.getPrototypeOf(),在所有支持的实现中,这个方法返回 [[Prototype]] 的值。支持的浏览器 IE9+, Safari 5+, Opera 12+, Chrome
console.log(Object.getPrototypeOf(person1) == Person.prototype); // true
console.log(Object.getPrototypeOf(person1).name);                // "Nicholas"
  • 每当代码读取某个对象的某个属性时,都会执行一次搜索,目标是具有给定名字的属性。搜索从对象实例本身开始,如果没有找到,则继续搜索指针指向的的原型对象,在原型对象中查找具有给定名字的属性。也就是说,在我们调用 person1.sayName()的时候,会先执行两次搜索,在 person1 中没有找到 sayName 属性,继续在 person1 的原型中搜索,在 Person.prototype 中找到了 sayName 属性,然后读取那个保存在原型对象中的函数。
  • 原型最初只包含 constructor 属性,而该属性也是共享的,因此可以通过对象实例访问
  • 虽然可以通过对象实例访问保存在原型中的值,但却不能通过对象实例重写原型中的值。如果我们在实例中添加了一个属性,而该属性与实例原型中的一个属性同名,那我们就在实例中创建该属性,该属性将会屏蔽原型中的那个属性
  • hasOwnProperty()方法(继承于 Object)可以检测一个属性是存在于实例中,还是存在于原型中。
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();
var person2 = new Person();

console.log(person1.hasOwnProperty("name"));       // false

person1.name = "Greg";
console.log(person1.name);                         // "Greg" ——来自实例
console.log(person1.hasOwnProperty("name"));       // true

console.log(person2.name);                         // "Nicholas" ——来自原型
console.log(person2.hasOwnProperty("name"));       // false

// 使用 delete 操作符删除实例中的属性
delete person1.name;
console.log(person1.name);                         // "Nicholas" ——来自实例
console.log(person1.hasOwnProperty("name"));       // false
  • ECMAScript 5 的 Object.getOwnPropertyDescriptor() 方法只能用于实例属性,要取得原型属性的描述符,必须直接在原型对象上调用 Object.getOwnPropertyDescriptor() 方法

原型与 in 操作符

  • 两种方式使用 in 操作符,单独使用和在 for-in 循环中使用。
  • 单独使用 in 操作符,会在通过对象能够访问给定属性时返回 true,无论该属性存在于实例中还是原型中。
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();
var person2 = new Person();

console.log(person1.hasOwnProperty("name"));       // false
console.log("name" in person1);                    // true

person1.name = "Greg";
console.log("name" in person1);                    // true

console.log(person2.name);                         // "Nicholas" ——来自原型
console.log("name" in person2);                    // true

// 使用 delete 操作符删除实例中的属性
delete person1.name;
console.log("name" in person1);                    // true
  • 同时使用 hasOwnProperty() 方法和 in 操作符,就可以确定该属性存在于实例中还是存在运行中
function hasPrototypeProperty(object, name) {return !object.hasOwnProperty(name) && (name in object);
}
  • 由于 in 操作符只要通过能够访问到属性就返回 true,hasOwnProperty() 只在属性存在于实例中才返回 true,因此只要 in 操作符返回 true,而 hasOwnproperty()返回 false,就可以确定属性是原型中的属性。
function Person() {}
Person.prototype.name = "Nicholas";
Person.prototype.age = 29;
Person.prototype.job = "Software Engineer";
Person.prototype.sayName = function () {alert(this.name);
};

var person = new Person();
console.log(hasPrototypeProperty(person, "name"));      // true

person.name = "Greg";
console.log(hasPrototypeProperty(person, "name"));      // false
  • 使用 for-in 循环,返回的是所有能够通过对象访问的、可枚举的(enumerad)属性,其中既包括存在于实例中的属性,也包括存在于原型中的属性。屏蔽了原型中不可枚举的属性(即将 [[Enumerable]] 标记为 false 的属性)实例属性也会在 for-in 循环返回,因为根据规定,所有开发人员定的属性,都是可枚举的——只有在 IE8 及更早版本中例外。
  • IE 早期版本的实现中存在一个 bug,即屏蔽不可枚举属性的实例属性不会出现在 for-in 循环中。
var o = {toString: function() {return  "My Object";}
};

for (var prop in o) {if (prop ==  "toString") {console.log("Found toString");    // 在 IE 中不会显示
  }
}
  • 在 IE 中,由于其实现认为原型的 toString()方法被打上了值为 false 的 [[Enumerable]] 标记,因此应该跳过该属性,结果就不会打印。该 bug 会影响默认不可枚举的所有属性和方法,包括:hasOwnProperty(), propertyIsEnumerable(), toLocaleString(), valueOf()
  • ECMAScript 5 也将 constructor 和 prototype 属性的 [Enumerable]] 特性设置为 false,但并不是所有浏览器都照此实现。
  • 要取得对象上所有可枚举的实例属性,可以使用 ECMAScript5 的 Object.keys()方法。这个方法接收一个对象作为参数,返回一个包含所有可枚举属性的字符串数组
function Person() {}
Person.prototype.name = "Nicholas";
Person.prototype.age = 29;
Person.prototype.job = "SoftWare Engineer";
Person.prototype.sayName = function() {console.log(this.name);
};

var keys = object.keys(Person.prototype);
console.log(keys);                            // "name,age,job,sayName"

var p1 = new Person();
p1.name = "Rob";
p1.age = 31;
var p1kyes = Object.keys(p1);
console.log(p1kyes);                         // "name,age"
  • 如果你想要得到所有实例属性,无论它是否可枚举,都可以使用 Object.getOwnPropertyNames()方法。
var keys = Object.getOwnPropertyNames(Person.prototype);     // "constructor,name,age,job,sayName"
  • 注意结果中包含了不可枚举的 constructor 属性。Object.keys() 和 Object.getOwnPropertyNames()方法都可以用来替代 for-in 循环。支持这两个方法的浏览器有 IE9+, Firefox4+, Safari5+, Opera12+, Chrome

更简单的原型语法

  • 前面例子中每添加一个属性和方法就要敲一遍 Person.prototype。为减少不必要的输入,也为了从视觉上更好的封装原型的功能,更常见的做法是用一个包含所有属性和方法的对象字面量来重写整个原型对象
function Person() {}

Person.prototype = {
  name: "Nicholas",
  age: 29,
  job: "Software Engineer",
  sayName: function () {console.log(this.name);
  }
};
  • 我们将 Person.prototype 设置为等于一个以对象字面量形式创建的新对象。最终结果相同,但有一个例外:constructor 属性不再指向 Person 了。因为每创建一个函数,就会同时创建它的 prototype 对象,整个对象也会自动获得 constructor 属性。而我们在这里使用的语法,本质上完全重写了默认的 prototype 对象,因此 constructor 属性也就变成了新对象的 constructor 属性(指向 Object 构造函数),不再指向 Person 函数。此时尽管 instanceof 操作符还能返回正确的结果,但通过 constructor 已经无法确定对象的类型
var friend = new Person();

console.log(friend instanceof Object);                     // true
console.log(friend instanceof Person);                     // true
console.log(friend.constructor == Person);                 // false
console.log(friend.constructor == Object);                 // Object
  • 如果 constructor 的值真的很重要,可以像下面这样特意将它设置回适当的值。
function Person() {}

Person.prototype = {
  constructor: Person,                          // 让 prototype 的 constructor 重新指向 Person
  name: "Nicholas",
  age: 29,
  job: "Software Engineer",
  sayName: function () {console.log(this.name);
  }
};
  • 这种方式重设 constructor 会导致它的 [[Enumerable]] 特性被设置为 true。默认情况下,原生的 constructor 属性是不可枚举的,因此如果你使用兼容 ECMAScript 5 的 JavaScript 引擎,可以试一试 Object.defineProperty()
function Person() {}

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

// 重设构造函数,只适用于 ECMASCript 5 兼容的浏览器
Object.defineProperty(Person.prototype, "constructor", {
  enumerable: false,
  value: Person
});

原型的动态性

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

Person.prototype.sayHi = function() {console.log("Hi");
};

friend.sayHi();               // "Hi"
  • 尽管可以随时为原型添加属性和方法,并且修改能够立即在所有对象实例中反映出来,但如果是重写整个原型对象,那么情况就不一样了。调用构造函数时会为实例添加一个指向最初原型的 [[Protoype]] 指针,而吧原型修改为另外一个对象,就等于切断了构造函数与最初原型之间的联系。请记住:实例中的指针仅指向原型,而不是指向构造函数。
function Person() {}

var friend = new Person();

// 重写整个原型对象,就等于切断了构造函数与最初原型之间的联系
Person.prototype = {
  constructor: Person,
  age: 29,
  job: "Software Engineer",
  sayName: function () {console.log(this.name);
  }
};

friend.sayName();  // error

原生对象的原型

  • 原型模式的重要性不仅体现在创建自定义类型方面,就连所有原生的引用类型,都是采用这种模式创建的。所有原生引用类型(Object, Array, String, 等)都在其构造函数的原型上定义了方法。例如,在 Array.prototype 中可以找到 sort()方法,而在 String.prototype 中可以找到 substring()方法修改同一个属性
console.log(typeof Array.prototype.sort);          // "function"
console.log(typeof String.prototype.substring);    // "function"
  • 通过原生对象的原型,不仅可以取得所有默认方法的引用,而且也可以定义新方法。可以像修改自定义对象的原型一样修改原生对象的原型,因此可以随时添加方法。下面的代码就给基本包装类型 String 添加了一个名为 startsWith()的方法
String.prototype.startsWith = function (text) {return this.indexOf(text) == 0;
};

var msg = "Hello world!";
console.log(msg.startsWith("Hello"));             // true
  • 尽管看起来很方便,但不推荐在产品化的程序修改原生对象的原型。如果某个实现中缺少某个方法,就在原生对象的原型中添加这个方法,那么当另一个支持该方法的实现中运行代码时,就可能会导致命名冲突。而且这样做也可能会意外的重写原生方法。

原型对象的问题

  • 原型模式也不是没有缺点。

    • 首先,它省略了为构造函数传递初始化参数这一环节,结果所有实例在默认情况下都将取得相同的属性值。虽然这回在某种程度上带来一些不方便,但还不是原型的最大问题
    • 原型模式最大的问题是由其共享的本性锁导致的。原型中所有属性是被很多实例共享的,这种共享对于函数非常适合。对于那些包含基本值的属性倒也说得过去,毕竟通过在实例上添加一个同名属性,可以隐藏原型中的对应属性。
    • 然而,对于包含引用类型值得属性来说,问题就比较突出了
    function Person() {}
    
    Person.prototype = {
      constructor: Person,                          // 让 prototype 的 constructor 重新指向 Person
      name: "Nicholas",
      age: 29,
      job: "Software Engineer",
      friend: ["Shelby", "Court"],
      sayName: function () {console.log(this.name);
      }
    };
    
    var person1 = new Person();
    var person2 = new Person();
    
    // 这里修改的实际上是 Person.prototype.friends
    person1.friends.push("Van");
    
    // 不但 person1 的 friends 属性被修改,person2 也做了同样的改动
    console.log(person1.friends);                               // "Shelby,Court,Van"
    console.log(person1.friends);                               // "Shelby,Court,Van"
    // 因为两个实例的 friends 属性指向的都是 Person.prototype.friends
    console.log(person1.friends === person2.friends);           // true
    
    • 上述问题正是很少有人单独使用原型模式的原因的所在

组合使用构造函数模式和原型模式

  • 创建自定义类型的最常见方式,就是组合使用构造函数模式与原型模式。构造函数模式用于定义实例属性,而原型模式用于定义方法和共享的属性。这样每个实例都会有自己的一份实例属性的副本,但又同时共享着对方法的引用,最大限度的节省了内存。
  • 另外这种混成模式还支持向构造函数传递参数
  • 这种构造函数与原型混成的模式,是目前在 ECMAScript 中使用最广泛,认同度最高的一种创建自定义类型的方法。可以说是用来定义引用类型的一种默认模式。
function Person(name, age, job) {
  this.name = name;
  this.age = age;
  this.job = job;
  this.friends = ["Shelby", "Court"];
}

Person.prototype = {
  constructor: Person,
  sayName: function() {console.log(this.name);
  }
};

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

person1.friends.push("Van");
console.log(person1.friends);                               // "Shelby,Court,Van"
console.log(person1.friends);                               // "Shelby,Court"
console.log(person1.friends === person2.friends);           // false
console.log(person1.sayName === person2.sayName);           // true

动态原型模式

  • 动态原型模型把所有信息都封装在了构造函数中,而通过在构造函数中初始化原型(仅在必要的情况下),又保持了同时使用构造函数和原型的优点。换言之,可以通过检查某个应该存在的方法是否有效,来决定是否需要初始化原型
function Person(name, age, job) {
  // 属性
  this.name = name;
  this.age = age;
  this.job = job;
  // 方法
  if (typeof this.sayName != "function") {Person.perototype.sayName = function() {console.log(this.name);
    };
  }
}

var friend = new Person("Nicholas", 29, "Software Engineer");
friend.sayName();                 // "Nicholas"
  • 使用动态原型模型时,不能使用对象字面量重写原型。如果在已经创建了实例的情况下重写原型,那么就会切断现有实例与新原型之间的联系。

寄生构造函数模式

  • 在前述几种模式都不适用的情况下,可以使用寄生(parasitic)构造函数模式。创建一个函数,该函数的作用仅仅是封装创建的对象代码,然后再返回新创建的对象;但从表面上看,这个函数又很像是典型的构造函数
function Person(name, age, job) {var o = new Object();
  o.name = name;
  o.age = age;
  o.job = job;
  o.sayName = function() {console.log(this.name);
  };
  return o
}

var friend = new Person("Nicholas", 29, "Software Engineer");
friend.sayName();                 // "Nicholas"
  • 除了使用 new 操作符并把使用的包装函数叫做构造函数之外,这个模式跟工厂模式其实是一模一样的。构造函数再不返回值得情况下,默认会返回新对象实例。而通过一个 return 语句,可以重写调用构造函数时返回的值。
  • 这个模式可以在特殊情况下用来为对象创建构造函数。假设我们想创建一个具有额外方法的特殊数组。由于不能直接修改 Array 构造函数,因此可以使用这个模式。
function SpecialArray() {

  // 创建数组
  var values = new Array();

  // 添加值
  values.push.apply(values, arguments);

  // 添加方法
  values.toPipedString = function() {return this.join("|");
  };

  // 返回数组
  return values;
}

var colors = new SpecialArray("red", "blue", "green");
console.log(colors.toPipedString());                      // "red|blue|green"
  • 关于寄生构造函数模式,有一点需要说明:首先,返回的对象与构造函数或者与构造函数的原型属性之间没有关系;也就是说,构造函数返回的对象与构造函数外部创建的对象没有什么不同。为此不能依赖 instanceof 操作符来确定对象类型。由于上述问题,我们建议在可以使用其他模式的情况下,不要使用寄生模式

稳妥构造函数模式

  • 道格拉斯·克罗克福德(Douglas Crockford)发明了 JavaScript 中的稳妥对象(durable objects)这个概念。稳妥对象,指的是没有公共属性而且其方法不引用 this 的对象。稳妥对象最适合在一些安全的环境中(这些环境中会禁止使用 this 和 new),或者在防止数据被其他应用程序(如 Mashup 程序)改动时使用。稳妥构造函数遵循与寄生构造函数类似的模式,但有两点不同:

    • 一是创建对象的实例方法不引用this
    • 二是不适用 new 操作符调用构造函数
function Person(name, age, job) {

  // 创建要返回的对象
  var o = new Object();

  // 这里定义私有变量和函数
  ...

  // 添加方法
  o.sayName = function() {console.log(name);
  };

  // 返回对象
  return o;
}

var friend = Person("Nicholas", 29, "Software Engineer");
friend.sayName();   // "Nicholas"
  • 在这种模式创建的对象中,除了使用 sayName()方法之外,没有其他办法访问 name 的值。
  • 与寄生构造函数模式类似,使用稳妥构造函数模式创建的对象与构造函数之间也没有什么关系,因此 instanceof 操作符对这种对象也没有意义

继承

  • 许多 OO 语言都支持两种继承方式

    • 接口继承,只继承方法签名
    • 实现继承,继承实际方法
  • 如前所述,在 ECMAScript 中无法实现接口继承,只支持实现继承,而且其实现继承主要是依靠原型链来实现的。

原型链

  • 原型链 实现继承的主要方法。基本思想是利用原型让一个引用类型继承另一个引用类型的属性和方法。简单回顾一下构造函数、原型和实例的关系:

    • 每个构造函数都有一个原型对象
    • 原型对象都包含一个指向构造函数的指针
    • 而实例都包含一个指向原型对象的内部指针。
  • 假如我们让原型对象等于另一个类型的实例,显然,此时的原型对象将包含一个指向另一个原型的指针,相应地,另一个原型中也包含着指向另一个构造函数的指针。假如另一个原型又是另一个类型的实例,那么上述关系依然成立,如此层层递进,就构成了实例与原型的链条。这就是所谓原型链的基本概念
  • 实现原型链有一种基本模式
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;};

var instance = new SubType();
console.log(instance.getSuperValue());        //true
  • 上述代码中,我们没有使用 SubType 默认使用的原型,而是给它换了个新原型,SuperType 的实例。于是新原型不仅具有作为一个 SuperType 的实例所拥有的全部属性和方法,而且其内部还有一个指针,指向了 SuperType 的原型。
  • 最终:

    • instance 指向 SubType 的原型
    • SubType 的原型又指向 SuperType 的原型
    • getSuperValue() 方法仍然还在 SuperType.prototype 中,但 property 则位于 SubType.prototype 中。这是因为 property 是一个实例属性,而 getSuperValue()则是一个原型方法。 既然 SubType.prototype 现在是 SuperType 的实例,那么 property 当然就位于该实例中了。
    • 此外,要注意 instance.constructor 现在指向的是 SuperType,这是因为原来 SubType.prototype 中的 constructor 被重写了的缘故
  • 通过实现原型链,本质上拓展了原型搜索机制。当读取模式访问一个实例属性时,首先会在实例中搜索该属性。如果没有找到该属性,则会继续搜索实例的原型。在通过原型链实现继承的情况下,搜索过程就得以沿着原型链继续向上。调用 instance.getSuperValue()会经历三个搜索步骤

    1. 搜索实例
    2. 搜索 SubType.prototype
    3. 搜索 SuperType.prototype 最终找到方法

别忘记默认的原型

  • 事实上,前述例子的原型链少了一环。所有引用类型默认都继承了 Object,而这个继承也是通过原型链实现的。所有函数的默认原型都是 Object 的实例,因为默认原型都会包含一个内部指针,指向 Object.prototype。这也正是所有自定义类型都会继承 toString(), valueOf()等默认方法的根本原因。
  • SubType 继承了 SuperType,而 SuperType 继承了 Object。当调用 instance.toString()方法,实际上调用的是保存在 Object.prototype 中的那个方法

确定原型和实例的关系

  • 第一种方式,使用 instanceof 操作符,只要用这个操作符来测试实例与原型链中出现过的构造函数,结果就会返回 true。
// 由于原型链,我们可以是 instance 是 Object,SuperType, SubType 中任何一个类型的实例
console.log(instance instanceof Object);         // true
console.log(instance instanceof SuperType);      // true
console.log(instance instanceof SubType);        // true
  • 第二种方式,使用 isPrototypeOf()方法。同样,只要是原型链中出现过的原型,都可以说是该原型链所派生的实例的原型。
console.log(Object.prototype.isPrototypeOf(instance));         // true
console.log(SuperType.prototype.isPrototypeOf(instance));      // true
console.log(SubType.prototype.isPrototypeOf(instance));        // true

谨慎的定义方法

  • 子类型有时候需要覆盖超类型中的一个方法,或者需要添加超类型中不存在的某个方法。但不管怎么样,给原型链添加方法的代码一定要放在替换原型的语句之后
function SuperType() {this.property = true;}

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

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

// 继承了 SuperType, 原来默认的原型被替换
SubType.protoype = new SuperType();

// 添加新方法
SubType.prototype.getSubValue = function () {return this.subproperty;};

// 重写超类型中的方法
SubType.protoype.getSuperValue = function () {return false;};

var instance = new SubType();
console.log(instance.getSuperValue());       // false
  • 还有一点需要提醒读者,即在通过原型链实现继承时,不能使用对象字面量创建原型方法。因为这样就会重写原型链
function SuperType() {this.property = true;}

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

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

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

// 使用字面量添加新方法,会导致上一行代码无效
// 原型链被切断——SubType 和 SuperType 之间已经没有关系
SubType.prototype = {getSubValue: function () {return this.subproperty;},

  someOtherMethod: function () {return false;}
};

var instance = new SubType();
console.log(instance.getSuperValue()));    // error

原型链的问题

  • 最主要的问题,来自包含引用类型值的原型。包含引用类型值的原型属性,会被所有实例共享;所以要在构造函数中定义值而不是原型对象。在通过原型来实现继承时,原型实际上会变成另一个类型的实例。于是,原先的 实例属性也就顺理成章的变成了 现在的原型属性了。
function SuperType() {this.colors = ["red", "blue", "green"];
}

function SubType() {}

// SubType 继承了 SuperType 之后
// SubType.prototype 就变成了 SuperType 的一个实例
// 因此 SubType.prototype 也拥有了自己的 colors 属性 等价于创建了一个 SubType.prototype.colors
SubType.protype = new SuperType();

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

// 结果就是所有 SubType 实例都会共享这个 colors 属性
var instance2 = new SubType();
console.log(instance2.colors);                // "red,blue,green,black"
  • 原型链的第二个问题:在创建子类型的实力时,不能向超类型的构造函数中传递参数。准确的说是没有办法在不影响所有对象实例的情况下,给超类型的构造函数传递参数。
  • 有鉴于此,实践中很少会单独使用原型链

借用构造函数

  • 在解决原型中包含引用类型值所带来的问题过程中,开发人员开始使用一种叫做 借用构造函数(constructor stealing) 的技术(有时候也叫伪造对象或经典继承)。
  • 思想相当简单,即在子类型构造函数的内部调用超类型构造函数。函数只不过是在特定环境中执行代码的对象,因此通过使用 apply() call()方法也可以在(将来)新创建对象上执行构造函数。实际上是在(未来将要)新创建的 SubType 实例环境下,调用了 SuperType 构造函数,就会在新 SubType 对象上执行 SuperType() 函数中定义的所有对象初始化代码。结果每个 SubType 的实例都会具有自己的 colors 属性的副本了。
function SuperType() {this.colors = ["red", "blue", "green"];
}

function SubType() {
  // 继承了 SuperType
  // " 借调“了超类型的构造函数
  SuperType.call(this);
}

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

// SubType 实例都不会共享这个 colors 属性
var instance2 = new SubType();
console.log(instance2.colors);                // "red,blue,green"

传递参数

  • 相对于原型链而言,借用构造函数有一个很大的优势,即可以在子类型构造函数中向超类型构造函数传递参数。
  • 为了确保 SuperType 构造函数不会重写子类型的属性,可以在调用超类型构造函数后,再添加应该在子类型中定义的属性。
function SuperType(name) {this.name = name;}

function SubType() {
  // 继承了 SuperType 同时传递了参数
  SuperType.call(this. "Nicholas");

  // 实例属性
  this.age = 29;
}

var instance = new SubType();
console.log(instance.anme);             // "Nicholas"
console.log(instance.age);              // 29

借用构造函数的问题

  • 仅仅是借用构造函数,也将无法避免构造函数模式存在的问题——方法都在构造函数中定义,因此函数复用就无从谈起了。
  • 在超类型的原型中定义方法,对子类型而言也是不可见的,结果所有类型都只能使用构造函数模式。
  • 有鉴于此,借用构造函数也是很少单独使用的

组合继承

  • 组合继承(combination inheritance),有时候也叫作伪经典继承,是将原型链和借用构造函数的技术组合到一起,从而发挥二者之长。其背后的思路是使用原型链实现对原型属性和方法的继承,而通过借用构造函数来实现对实例属性的继承。既通过在原型上定义方法实现了函数复用,又能保证每个实例都有它自己的属性
function SuperType(name) {
  this.name = name;
  this.colors = ["red", "blue", "green"];
}

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

function SubType(name, age) {
  // 继承属性
  SuperType.call(this, name);

  // 子类型自己的属性
  this.age = age
}

// 继承方法
SubType.prototype = new SuperType();
// 如果不指定 constructor,SubType.prototype.constructor 为 SuperType
SubType.prototype.constructor = SubType;
SubType.prototype.sayAge = function() {console.log(this.age);
};

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

var instance2 = new SubType("Greg", 27);
console.log(instance2.colors);                 // "red,blue,green"
instance2.sayName();                           // "Greg"
instance2.sayAge();                            // 27
  • 组合继承避免了原型链和借用构造函数的缺陷,融合了它们的优点,成为 JavaScript 中最常用的继承模式。而且 instanceof 和 isPrototypeOf() 也能够用于识别基于组合继承创建的对象。

原型式继承

  • 道格拉斯·克罗克福德 2006 年在文章中介绍了一种实现继承的方法,这种方法并没有使用严格意义上的构造函数。他的想法是借助原型可以基于已有对象创建新对象,同时还不必因此创建自定义类型。为达到这个目的,给出了如下函数
function object(o) {function F() {}
  F.prototype = o;
  return new F();}
  • 在 object() 函数内部,先创建了一个临时性的构造函数,然后将传入的对象作为这个构造函数的原型,最后返回了这个临时类型的新实例。从本质上讲,object() 对传入其中的对象执行了一次浅复制
var person = {
  name: "Nicholas",
  friends: ["Shelby", "Court", "Van"],
};

var anotherPerson = object(person);
anotherPerson.name = "Greg";
anotherPerson.friends.push("Rob");
console.log(anotherPerson.friends);             // "Shelby,Court,Van,Rob"

var yetAnotherPerson = object(person);
yetAnotherPerson.name = "Linda";
yetAnotherPerson.friends.push("Barbie");
console.log(yetAnotherPerson.friends);          // "Shelby,Court,Van,Rob,Barbie"

console.log(person.friends);                    // "Shelby,Court,Van,Rob,Barbie"
  • 这种原型式继承,要求你必须有一个对象可以作为另一个对象的基础。把它传递给 object()函数,然后再根据具体需求对得到的对象加以修改即可。这意味着,person.friends 不仅属于 person,而且也会被 anotherPerson, yetAnotherPerson 共享。实际上就相当于又创建了 person 对象的两个副本(浅拷贝)。
  • ECMAScript 5 通过新增 Object.create() 方法规范化了原型式继承。这个方法接受两个参数:一个用作新对象原型的对象和(可选)一个新对象定义额外的属性的对象。在传入一个参数的情况下,Object.create() 与 Object()方法的行为相同(原著如此表述,但实际两者并不相同,参照第五章 Object 类型 的相关补充说明
var person = {
  name: "Nicholoas",
  friends: ["Shelby", "Court", "Van"]
};

var anotherPerson = Object.create(person);
anotherPerson.name = "Greg";
anotherPerson.friends.push("Rob");
console.log(anotherPerson.friends);             // "Shelby,Court,Van,Rob"

var yetAnotherPerson = Object.object(person);
yetAnotherPerson.name = "Linda";
yetAnotherPerson.friends.push("Barbie");
console.log(yetAnotherPerson.friends);          // "Shelby,Court,Van,Rob,Barbie"

console.log(person.friends);                    // "Shelby,Court,Van,Rob,Barbie"
  • Object.create() 方法的第二个参数与 Object.defineProperties() 方法的第二个参数格式相同:每个属性都是通过自己的描述符定义的。以这种方式制定的任何属性都会覆盖对象上同名的属性。
var person = {
  name: "Nicholoas",
  friends: ["Shelby", "Court", "Van"]
};

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

console.log(anotherPerson.name);           // "Greg"
  • 支持 Object.create()方法的浏览器:IE9+, Firefox4+, Opera12+, Chrome
  • 在没有必要兴师动众的创建构造函数,而只想让一个对象与另一个对象保持类似的情况下,原型式继承是完全可以胜任的。但别忘了,包含引用类型值(如上面的 friends 属性是一个数组)始终都会共享相应的值,就像使用原型模式一样。

寄生式继承

  • 寄生式继承(parasitic)是与原型式继承紧密相关的一种思路,并且同样也是由克罗克福德推而广之的。
  • 思路与构造函数和工厂模式类似,既创建了一个仅用于封装继承过程的函数,该函数在内部以某种方式来增强对象,最后再像真的是它做了所有工作一样返回对象。
function createAnother(original) {var clone = object(original);         // 通过调用函数创建一个新对象
  clone.sayHi = function() {            // 以某种方式增强这个对象
    console.log("hi");
  };
  return clone;                         // 返回这个对象
}

寄生组合式继承

  • 组合继承是最常用的继承模式;不过最大的问题就是无论什么情况下,都会调用两次超类型构造函数:

    • 一次是在创建子类型原型的时候
    • 另一次是在子类型构造函数内部
  • 也就是说,子类型最终会包含超类型对象的全部实例属性,但我们不得不在调用子类型构造函数时,重写这些属性。
function SuperType(name) {
  this.name = name;
  this.colors = ["red", "blue", "green"];
}

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

function SubType(name, age) {SuperType.call(this, name);               // 第二次调用 SuperType()

  this.age = age;
}

// 实例化 SuperType 作为 SubType 的原型
// 立即触发
SubType.prototype = new SuperType();        // 第一次调用 SuperType()
SubType.prototype.constructor = SubType;
SubType.prototype.sayAge = function() {console.log(this.age);
};

// 此时触发第二次调用
var instance = new SubType("Nicholas", 29);

console.log(instance.name);                // "Nicholas"
console.log(SubType.prototype.name);       // undefined
  • 在第一次调用 SuperType 构造函数时,SubType.prototype 会得到两个属性:name 和 colors;它们都是 SuperType 的实例属性,只不过现在位于 SubType 的原型中。当调用 SubType 构造函数时,又会调用一次 SuperType 的构造函数,这一次又在新对象上创建了实例属性 name 和 colors 属性。于是这两个属性就屏蔽了原型中的两个同名属性。(图 6 -6)

  • 有两组 name 和 colors 属性,一组在实例 instance 上,一组在 SubType 的原型中。这就是调用两次 SuperType 构造函数的结果
  • 所谓寄生组合式继承,即通过借用构造函数来继承属性,通过原型链的混成形式来继承方法。其背后的思路是:不必为了指定子类型的原型而调用超类型的构造函数,我们所需要 的无非就是超类型原型的一个副本。本质上,就是使用寄生式继承来继承超类型的原型,然后再将结果指定给子类型的原型
function inheritPrototype(subType, superType) {

  // 创建对象 - 超类型的对象原型的副本
  // 这里没有使用 new 操作符,所以没有生成 SuperType 的实例
  // 这里没有调用 SuperType 构造函数
  var prototype = Object(superType.prototype)
  console.log(prototype == superType.prototype)

  // 增强对象 - 弥补因重写原型而失去的默认 constructor 属性
  // 而这也将导致 supert.prototype.constructor 指向了 subType
  prototype.constructor = subType;

  // 指定对象 - 将新创建的对象(即副本)赋值给子类型的原型。subType.prototype = prototype;
}

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

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

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

  this.age = age;
}

inheritPrototype(SubType, SuperType);

SubType.prototype.sayAge = function() {console.log(this.age);
};
  • inheritPrototype() 函数实现了寄生组合式继承的最简单形式。这个函数接受两个参数:子类型构造函数和超类型构造函数。
  • 这样就只调用了一次 SuperType 构造函数,并且因此便了 SubType.prototype 上创建不必要的、多余的属性。与此同时,原型链还能保持不变;因此,还能够正常使用 instanceof 和 isPrototypeOf().
  • 开发人员普遍认为寄生组合式继承是引用类型最理想的继承范式。
退出移动版