5. 继承
5.1 原型对象链和Object.prototype
Js内建的继承方法被称为原型对象链,又称为原型对象继承。原型对象的属性可以由对象实例访问。实例对象集成了原型对象的属性,因为原型对象也是一个对象,它也有自己的原型对象并继承其属性。这就是原型继承链:对象继承其原型对象,而原型对象继承它的原型对象,以此类推。所有的对象,包括自定义的对象都继承自Object
,除非另有指定。更确切的说,所有对象都继承自Object.prototype
,任何以字面量形式定义的对象,其[[Prototype]]
的值都被设为Object.prototype
,这意味着它继承Object.prototype的属性。
var book = {title: 'a book'} console.log(Object.getPrototypeOf(book) === Object.prototype) // true 复制代码
5.1.1 继承自Object.prototype的方法
前几张用到的几个方法都是定义在Object.prototype上的,因此可以被其他对象继承:
Methods | Usage |
hasOwnProperty() | 检查是否存在一个给定名字的自有属性 |
propertyIsEnumerable() | 检查一个自有属性是否为可枚举 |
isPrototypeOf() | 检查一个对象是否是另一个对象的原型对象 |
valueOf() | 返回一个对象的值表达 |
toString() | 返回一个对象的字符串表达 |
这几种方法由继承出现在所有的对象中,当你需要对象在Js中以一致的方式工作时,最后两个尤为重要。
- valueOf()每当一个操作符被用于一个对象时就会调用
valueOf()
方法,其默认返回对象实例本身。原始封装类型重写了valueOf()
以使得它对String返回一个字符串,对Boolean返回一个布尔,对Number返回一个数字;类似的,对Date对象的valueOf()返回一个epoch时间,单位是毫秒(正如Data.prototype.getTime())。
var now = new Date // now.valueOf() === 1505108676169 var earlier = new Date(2010,1,1) // earlier.valueOf() === 1264953600000 console.log(now>earlier) // true console.log(now-earlier) // 240155076169 复制代码
- now是一个代表当前时间的Date,而earlier是过去的时间,当使用操作符
>
时,两个对象上都调用了valueOf()
方法,你甚至可以用两个Date相减来获得它们在epoch时间上的差值。如果你的对象也要这样使用操作符,你可以定义自己的valueOf()
方法,定义的时候你并没有改变操作符的行为,仅仅应了操作符默认行为所使用的值。 - toString()一旦
valueOf()
返回的是一个引用而不是原始值的时候,就会回退调用toString()
方法。另外,当Js期望一个字符串时也会对原始值隐式调用toString()
。例如当加号操作符的一边是一个字符串时,另一边就会被自动转换成字符串,如果另一边是一个原始值,会自动转换成一个字符串表达(true => "true"),如果另一边是一个引用值,则会调用valueOf()
,如果其返回一个引用值,则调用toString()
。
var book = {title: 'a book'} console.log("book = " + book) // "book = [object Object]" 复制代码
- 因为book是一个对象,因此调用它的
toString()
方法,该方法继承自Object.prototype,大部分Js引擎返回默认值[object Object],如果对这个值不满意可以复写,为此类字符串提供包含跟多信息。
var book = {title: 'a book', toString(){return `[Book = ${this.title} ]`}} console.log("book = " + book) // book = [Book = a book ] 复制代码
5.1.2 修改Object.prototype
所有的对象都默认继承自Object.prototype,所以改变它会影响到所有的对象,这是非常危险的。 如果给Obejct.prototype添加一个方法,它是可枚举的,可以粗现在for-in循环中,一个空对象依然会输出一个之前添加的属性。尽量不要修改Object.prototype。
5.2 对象继承
对象字面量形式会隐式指定Object.prototype为其[[Prototype]]
,也可以用Object.create()方式显示指定。Object.create()方法接受两个参数:需要被设置为新对象[[Prototype]]
的对象、属性描述对象,格式如在Object.defineProperties()中使用的一样(第三章)。
var book = {title: 'a book'} // ↑ 等价于 ↓ var book = Object.create(Object.prototype, { title: { configurable: true, enumerable: true, value: 'a book', writable: true } }) 复制代码
第一种写法中字面量形式定义的对象自动继承Object.prototype且其属性默认设置为可配置、可写、可枚举。第二种写法显示使用Object.create()做了相同的操作,两个book对象的行为完全一致。
var person = { name: "Jack", sayName: function(){ console.log(this.name); } } var student = Object.create(person, { name:{value: "Ljc"}, grade: { value: "fourth year of university", enumerable: true, configurable: true, writable: true } }); person.sayName(); // "Jack" student.sayName(); // "Ljc" console.log(person.hasOwnProperty("sayName")); // true console.log(person.isPrototypeOf(student)); // true console.log(student.hasOwnProperty("sayName")); // false console.log("sayName" in student); // true console.log(student.__proto__===person) // true console.log(student.__proto__.__proto__===Object.prototype) // true 复制代码
对象person2继承自person1,也就集成了person1的name和sayName(),然而又通过重写name属性定义了一个自有属性,隐藏并替代了原型对象中的同名属性。所以person1.sayName()输出Nicholas而person2.sayName()输出Greg。 在访问一个对象的时候,Js引擎会执行一个搜索过程,如果在对象实例上发现该属性,该属性值就会被使用,如果没有发现则搜索[[Prototype]]
,如果仍然没有发现,则继续搜索该原型对象的[[Prototype]]
,知道继承链末端,末端通常是一个Object.prototype,其[[prototype]]
为null。这就是原型链。 当然也可以通过Object.create()创建[[Prototype]]
为null的对象:var obj=Object.create(null)
。该对象obj是一个没有原型链的对象,这意味着toString()
和valueOf
等存在于Object原型上的方法都不存在于该对象上。
5.3 构造函数继承
Js中的对象继承也是构造函数继承的基础,第四章提到:几乎所有的函数都有prototype
属性(通过Function.prototype.bind
方法构造出来的函数是个例外),它可以被替换和修改。该prototype
属性被自动设置为一个继承自Object.prototype的泛用对象,该对象有个自有属性constructor
。
// 构造函数 function YourConstructor() {} // Js引擎在背后做的: YourConstructor.prototype = Object.create(Object.prototype, { constructor: { configurable: true, enumerable: true, value: YourConstructor, writable: true } }) console.log(YourConstructor.prototype.__proto__===Object.prototype) // true 复制代码
你不需要做额外工作,Js引擎帮你把构造函数的prototype
属性设置为一个继承自Object.prototype的对象,这意味着YourConstructor创建出来的任何对象都继承自Object.prototype,YouConstructor是Object的子类。 由于prototype可写,可以通过改写它来改变原型链:
function Rectangle(length, width) { this.length = length this.width = width } Rectangle.prototype.getArea = function() {return this.length * this.width}; Rectangle.prototype.toString = function() {return `[ Rectangle ${this.length}x${this.width} ]`}; function Square(size) { this.length = size this.width = size } Square.prototype = new Rectangle() Square.prototype.constructor = Square Square.prototype.toString = function() {return `[ Square ${this.length}x${this.width} ]`} var rect = new Rectangle(5, 10) var squa = new Square(6) console.log(rect instanceof Rectangle) // true console.log(rect instanceof Square) // false console.log(rect instanceof Object) // true console.log(squa instanceof Rectangle) // true console.log(squa instanceof Square) // true console.log(squa instanceof Object) // true 复制代码
MDN:instanceof 运算符可以用来判断某个构造函数的 prototype 属性是否存在另外一个要检测对象的原型链上。
Square构造函数的prototype属性被改写为Rectagle的一个实例,此时不需要给Rectangle的调用提供参数,因为它们不需要被使用,而且如果提供了,那么所有的Square对象实例都会共享这样的维度。如果用这种方式改写原型链,需要确保构造函数不会再参数缺失时抛出错误(很多构造函数包含的初始化逻辑)且构造函数不会改变任何全局状态。
// inherits from Rectangle function Square(size){ this.length = size; this.width = size; } Square.prototype = new Rectangle(); // 尽管是 Square.prototype 是指向了 Rectangle 的对象实例,即Square的实例对象也能访问该实例的属性(如果你提前声明了该对象,且给该对象新增属性)。 // Square.prototype = Rectangle.prototype; // 这种实现没有上面这种好,因为Square.prototype 指向了 Rectangle.prototype,导致修改Square.prototype时,实际就是修改Rectangle.prototype。 console.log(Square.prototype.constructor); // 输出 Rectangle 构造函数 Square.prototype.constructor = Square; // 重置回 Square 构造函数 console.log(Square.prototype.constructor); // 输出 Square 构造函数 Square.prototype.toString = function(){ return "[Square " + this.length + "x" + this.width + "]"; } var rect = new Rectangle(5, 10); var square = new Square(6); console.log(rect.getArea()); // 50 console.log(square.getArea()); // 36 console.log(rect.toString()); // "[Rectangle 5 * 10]", 但如果是Square.prototype = Rectangle.prototype,则这里会"[Square 5 * 10]" console.log(square.toString()); // "[Square 6 * 6]" console.log(square instanceof Square); // true console.log(square instanceof Rectangle); // true console.log(square instanceof Object); // true 复制代码
Square.prototype
并不真的需要被改成为一个 Rectangle
对象。事实上,是 Square.prototype
需要指向 Rectangle.prototype
使得继承得以实现。这意味着可以用 Object.create()
简化例子。
// inherits from Rectangle function Square(size){ this.length = size; this.width = size; } Square.prototype= Object.create(Rectangle.prototype, { constructor: { configurable: true, enumerable: true, value: Square, writable: true } }) 复制代码
在对原型对象添加属性前要确保你已经改写了原型对象,否则在改写时会丢失之前添加的方法(因为继承是将被继承对象赋值给需要继承的原型对象,相当于重写了需要继承的原型对象)。
5.4 构造函数窃取
由于JavaScript中的继承是通过原型对象链来实现的,因此不需要调用对象的父类的构造函数。如果确实需要在子类构造函数中调用父类构造函数,那就可以在子类的构造函数中利用 call、apply方法调用父类的构造函数。
function Rectangle(length, width) { this.length = length this.width = width } Rectangle.prototype.getArea = function() {return this.length * this.width}; Rectangle.prototype.toString = function() {return `[ Rectangle ${this.length}x${this.width} ]`}; function Square(size) {Rectangle.call(this, size, size)} Square.prototype = Object.create(Rectangle.prototype, { constructor: { value: Square, enumerable: true, configurable: true, writable: true } }) Square.prototype.toString = function() {return `[ Square ${this.length}x${this.width} ]`} var rect = new Rectangle(5, 10) var squa = new Square(6) console.log(rect.getArea()) console.log(rect.toString()) console.log(squa.getArea()) console.log(squa.toString()) 复制代码
一般来说,需要修改 prototype
来继承方法并用构造函数窃取来设置属性,由于这种做法模仿了那些基于类的语言的类继承,所以这通常被称为伪类继承。
5.5 访问父类方法
其实也是通过指定 call
或 apply
的子对象调用父类方法。
6. 对象模式
可以使用继承或者混入等其他技术令对象间行为共享,也可以利用Js高级技巧阻止对象结构被改变。
6.1 私有成员和特权成员
6.1.1 模块模式
模块模式是一种用于创建拥有私有数据的单件对象的模式。 基本做法是使用立即调用函数表达式(IIFE)来返回一个对象。原理是利用闭包。
var yourObj = (function(){ // private data variables return { // public methods and properties } }()); 复制代码
模块模式还有一个变种叫暴露模块模式,它将所有的变量和方法都放在 IIFE 的头部,然后将它们设置到需要被返回的对象上。
// 一般写法 var yourObj = (function(){ var age = 25; return { name: "Ljc", getAge: function(){ return age } } }()); // 暴露模块模式,保证所有变量和函数声明都在同一个地方 var yourObj = (function(){ var age = 25; // 私有变量,外部无法访问 function getAge(){ return age }; return { name: "Ljc", // 公共变量外部可以访问 getAge: getAge // 外部可以访问的对象 } }()); 复制代码
6.1.2 构造函数的私有成员
模块模式在定义单个对象的私有属性十分有效,但对于那些同样需要私有属性的自定义类型呢?你可以在构造函数中使用类似的模式来创建每个实例的私有数据。
function Person(name){ // define a variable only accessible inside of the Person constructor var age = 22; this.name = name; this.getAge = function(){return age;}; this.growOlder = function(){age++;} } var person = new Person("Ljc"); console.log(person.age); // undefined person.age = 100; console.log(person.getAge()); // 22 person.growOlder(); console.log(person.getAge()); // 23 复制代码
构造函数在被new的时候创建了一个本地作用于并返回this对象。这里有个问题:如果你需要对象实例拥有私有数据,就不能将相应方法放在 prototype
上。 如果你需要所有实例共享私有数据(就好像它被定义在原型对象里那样),则可结合模块模式和构造函数,如下:
var Person = (function(){ var age = 22; function InnerPerson(name){this.name = name;} InnerPerson.prototype.getAge = function(){return age;} InnerPerson.prototype.growOlder = function(){age++;}; return InnerPerson; }()); var person1 = new Person("Nicholash"); var person2 = new Person("Greg"); console.log(person1.name); // "Nicholash" console.log(person1.getAge()); // 22 console.log(person2.name); // "Greg" console.log(person2.getAge()); // 22 person1.growOlder(); console.log(person1.getAge()); // 23 console.log(person2.getAge()); // 23 复制代码
6.2 混入
这是一种伪继承。一个对象在不改变原型对象链的情况下得到了另外一个对象的属性被称为“混入”。因此,和继承不同,混入让你在创建对象后无法检查属性来源。
function mixin(receiver, supplier){ for(var property in supplier){ if(supplier.hasOwnProperty(property)){ receiver[property] = supplier[property]; } } } 复制代码
这是浅拷贝,如果属性的值是一个引用,那么两者将指向同一个对象。 要注意一件事,使用这种方式,supplier
的访问器属性会被复制为receiver
的数据属性。
function mixin(reciver, supplier) { if (Object.getOwnPropertyDescriptor) { // 检查是否支持es5 Object.keys(supplier).forEach(property => { var descriptor = Object.getOwnPropertyDescriptor(supplier, property) Object.defineProperty(reciver, property, descriptor) }) } else { for (var property in supplier) { // 否则使用浅复制 if (supplier.hasOwnProperty(property)) { reciver[property] = supplier[property] } } } } 复制代码
6.3 作用域安全的构造函数
构造函数也是函数,所以不用 new 也能调用它们来改变 this
的值。在非严格模式下, this
被强制指向全局对象。而在严格模式下,构造函数会抛出一个错误(因为严格模式下没有为全局对象设置 this,this 保持为 undefined)。 而很多内建构造函数,例如 Array、RegExp 不需要 new 也能正常工作,这是因为它们被设计为作用域安全的构造函数。 当用 new 调用一个函数时,this 指向的新创建的对象已经属于该构造函数所代表的自定义类型。因此,可在函数内用 instanceof 检查自己是否被 new 调用。
function Person(name){ if(this instanceof Person){ // called with "new" }else{ // called without "new" } } 复制代码
具体案例:
function Person(name){ if(this instanceof Person){ this.name = name; }else{ return new Person(name); } }