进阶篇:prototype 对象和继承
JavaScript 常被描述为一种基于原型的语言——每个对象拥有一个原型对象,对象以其原型为模板、从原型继承方法和属性。原型对象也可能拥有原型,并从中继承方法和属性,一层一层、以此类推。这种关系常被称为原型链,它解释了为何一个对象会拥有定义在其他对象中的属性和方法。
在传统的面向对象编程(Object Oriented Programming,OOP)语言中,首先定义类,实例按类定义持有自己的字段,方法通常仍挂在类上。JavaScript 并不把方法复制到每个实例上,而是在实例与构造函数的 prototype 之间建立一个链接(实例的内部槽 [[Prototype]],可通过 __proto__ 访问,指向构造函数的 prototype 对象),之后通过上溯原型链找到这些属性和方法。
function Person() { }
Person.prototype.hi = 'Hello,'
Person.prototype.greeting = function () {
console.log(`${this.hi} ${this.name}!`)
}
const person = new Person();
person.name = 'lizhao';
person.greeting() // Hello, lizhao!
console.log(Person.prototype) // {hi: 'Hello,', greeting: ƒ, constructor: ƒ}
console.log(Person.prototype === person.__proto__) // true
注意: ES6 引入的 class 关键字只是语法糖,JavaScript 仍然是基于原型的。
构造器(构造函数)
传统的面向对象编程语言(C++、Java...)都有类的概念。所谓类就是对象的模板,对象就是类的实例。但是,JavaScript 语言的对象体系,不是基于类的,而是基于构造函数和原型链。
在 JavaScript 中,构造函数是对象的模板,描述实例对象的基本结构,用于生成多个具有相同结构的实例对象。本质上说,构造函数就是一个普通的函数,当使用 new 操作符调用该函数时,就可以称为构造器(构造函数)。
构造函数有以下特征:
- 用 new 操作符来调用。
- 函数体的 this 指向生成的对象实例。
function Person(name) {
this.hi = 'Hello,'
this.name = name
this.greeting = function () {
console.log(`${this.hi} ${this.name}!`)
}
}
const person = new Person('lizhao');
person.greeting() // 'Hello, lizhao!'
构造函数的缺点: 同一个构造函数创建的多个实例之间,无法共享属性和方法。这样就会导致两个问题:一是系统资源的浪费,如上代码,每个实例都会创建 hi、name 属性和 greeting 方法,而 hi 属性和 greeting 方法在每个实例中都是一样的;二是构造函数与实例之间缺少联接,比如,想要统一更新所有已生成实例的 hi 属性。
new 操作符
new 操作符用于创建一个对象类型或者构造函数的实例。
new 操作符进行如下操作:
- 创建一个空的新对象(即
{}); - 把新对象的内部槽
[[Prototype]]指向构造函数的prototype(若prototype不是对象,则指向Object.prototype); - 将新对象作为
this的上下文; - 若构造函数返回对象(含函数、数组等),则返回该对象;否则返回
this。原始值返回值会被忽略。
函数内部可以使用 new.target。如果当前函数是用 new 调用的,new.target 指向当前函数(或 class 里实际被 new 的那个类),否则为 undefined。
function Person() {
console.log(new.target)
}
new Person(); // ƒ Person() { console.log(new.target) }
Person() // undefined
原型
JavaScript 继承机制的设计思想就是,原型对象的所有属性和方法,都能被实例对象共享。这样不仅节省了内存,还建立了实例和对象之间的联系。
function Person(name) {
this.name = name
}
Person.prototype.hi = 'Hello,'
Person.prototype.greeting = function () {
console.log(`${this.hi} ${this.name}!`)
}
const person1 = new Person('li');
person1.greeting() // Hello, li!
const person2 = new Person('zhao');
person2.greeting() // Hello, zhao!
原型对象的属性不是实例对象自身的属性:
person1.hasOwnProperty('name') // true
person1.hasOwnProperty('hi') // false
person1.hasOwnProperty('greeting') // false
修改原型对象会立刻体现到所有实例对象上:
Person.prototype.hi = '你好,'
person1.greeting() // 你好, li!
person2.greeting() // 你好, zhao!
实例对象可以重写原型对象中的属性和方法。当访问一个对象的属性和方法时,JavaScript 引擎先会查找自身是否有该属性或方法,如果没有,再去原型对象查找。
person2.hi = 'Goodbye,'
person2.hasOwnProperty('hi') // true
person2.greeting() // Goodbye, zhao!
person1.greeting() // 你好, li!
- 对象的原型
[[Prototype]](俗称隐式原型):每个普通 JS 对象内部都有一个内部插槽[[Prototype]],它的值就是这个实例所要继承的那个对象。通过obj.__proto__(访问器属性,非规范内置名)、Object.getPrototypeOf(obj)(标准 API)获取。Object.create(null)创建的对象,[[Prototype]] = null,没有继承原型。 - 函数的
.prototype属性(构造函数显式原型):prototype只是函数身上的一个普通属性,只有函数对象默认自带这个属性(普通对象、数组实例、普通字面量对象默认没有.prototype这个属性)。当这个函数被当作构造函数new Fn()调用时:new 出来的实例的[[Prototype]]= 构造函数的Fn.prototype。
简单理解:[[Prototype]]是实例身上继承指向谁(所有普通对象都具备这个内部插槽);.prototype是构造函数身上的属性,用来给未来 new 出来的实例当原型(只有函数默认拥有)。
用 new 创建的实例,其原型通常就是该构造函数的 prototype 对象。用 Object.create (proto) 或 Object.setPrototypeOf 时,原型可以是任意对象,不必经过构造函数。
constructor 属性
构造函数的 prototype 对象有一个 constructor 属性,默认指向该构造函数。
所有实例对象(Object.create(null) 创建的对象除外)都将从其构造函数的原型中继承了一个 constructor 属性,默认情况下,该属性指向实例对象的构造函数。
注意: 实例的 constructor 属性是继承而来的,不是自身属性。当获取实例的 constructor 时,其实是从实例的原型,也就是其构造函数的 prototype 对象中读取。
function Person() { }
const person = new Person();
Person.prototype.constructor === Person // true
person.constructor === Person.prototype.constructor // true
person.constructor === Person // true
之所以构造函数要在 prototype 对象有这样一个指向自身的属性,就是为了给实例对象继承,让实例对象知道自己是哪一个构造函数创建的。
constructor 属性应用: 一是获取实例对象的构造函数的名称,二是用于从一个实例对象创建另一个属于同一构造函数的实例对象。
person.constructor.name // 'Person'
const person2 = new person.constructor()
person2.constructor === person.constructor // true
可以为任何数据类型(null、undefined 除外)指定 constructor 属性,但基本类型(String、Number、Boolean 等)不会保留更改,也不会抛出异常(基本类型设置其他属性也是如此)。也就是,每次将基本类型当成对象使用时,都是根据原始值实时创建实例对象,其他属性被忽略了。
function Person() { }
const str = 'abc'
str.constructor = Person
str.prop = 123
console.log(str.constructor) // ƒ String() { [native code] }
console.log(str.prop) // undefined
基本上,除了基本类型外,任何对象都可以更改 constructor 属性的值。
const arr = []
arr.constructor = Person
console.log(arr.constructor) // ƒ Person() { }
改变构造函数 prototype 对象或者实例对象的 constructor 属性不会影响 instanceof 运算符。
arr instanceof Array // true
注意: 如果构造函数的 prototype 对象修改为其他构造函数的 prototype 对象,建议修改其 constructor 属性,也就是重新将 constructor 属性指向构造函数。
function Person() {
this.hi = 'Hello'
}
function Student() { }
Person.prototype = Student.prototype
const person = new Person()
console.log(person.constructor) // ƒ Student() { }
console.log(person.hi) // Hello
如上,person 实例本来是 Person 构造函数创建的,但是,由于 Person.prototype 对象修改为 Student.prototype 对象,导致 person 实例的 constructor 属性继承的是 Student.prototype.constructor,指向的是 Student 构造函数。
这样可能会引发一些问题,比如,当我们依赖实例对象的 constructor 属性创建新实例时:
const person2 = new person.constructor()
console.log(person2.hi) // undefined. 不是 Person 创建的实例
如上代码,本意是创建另一个 Person 实例,而实际创建的是一个 Student 实例。这是由于 person.constructor 指向的并不是真正创建 person 实例的构造函数。
因此,为了避免不必要的问题,不要让两个构造函数共用同一个 prototype 对象,推荐:
Person.prototype = Object.create(Student.prototype)
Person.prototype.constructor = Person
若写成 Person.prototype = Student.prototype,再改 constructor 会同时改掉 Student.prototype.constructor。
instanceof 运算符
instanceof 运算符返回一个布尔值,用于判断一个实例对象的原型链上是否包含某个构造函数的 prototype 属性。
[] instanceof Array // true
[] instanceof Object // true
[].__proto__ === Array.prototype // true
Array.prototype.__proto__ === Object.prototype // true
// 数组实例的原型是Array.prototype -> Array.prototype 的原型是 Object.prototype。所以,数组实例的原型链上也包含 Object.prototype。
需要注意的是: person instanceof Person 返回 true,并不意味着永远返回 true,因为 Person.prototype 属性的值是可变的,同样,person 的原型链也是可变的。
Person.prototype = { a: 1 }
person instanceof Person // false
Object.setPrototypeOf(person, { b: 1 })
person instanceof Person // false
注意: 在多个全局对象的情况下(如:多个 frame 或多个窗口),不同的全局环境拥有不同的全局对象,从而拥有不同的内置类型构造函数。这可能会引发一些问题。比如,[] instanceof window.frames[0].Array 会返回 false,因为 window.Array.prototype !== window.frames[0].Array.prototype。
注意: JavaScript 中绝大多数对象都是 Object 的实例,anyObject instanceof Object 通常为 true,但也有例外:一是原始值(不是对象),二是用 Object.create(null) 创建的对象。
const str1 = 'abc'
const str2 = new String('abc')
str1 instanceof Object // false
str2 instanceof Object // true
const obj = Object.create(null)
typeof obj // 'object'
obj.__proto__ // undefined
obj instanceof Object // false
其他相关的属性和方法
Object.create()
ECMAScript 5 中引入了 Object.create(),用来创建一个指定原型的新对象。
Object.create(null) // 创建一个原型为null的实例对象
function Constructor(){}
Object.create(Constructor.prototype, {
prop1: { // 数据属性
writable: true,
configurable: true,
value: "hello"
},
prop2: { // 访问器属性
configurable: false,
get: function () { return 10 },
set: function (value) {}
}
});
Object.create() 的主要用于从一个实例对象生成另一个实例对象,这是推荐的做法,比依赖实例对象的 constructor 属性更安全:
function Person() { }
const person = new Person()
Object.setPrototypeOf(person, null)
new person.constructor() // Uncaught TypeError: person.constructor is not a constructor
Object.create(Object.getPrototypeOf(person)) // {}
getPrototypeOf()
返回指定对象的原型(内部[[Prototype]]属性的值)。这是获取对象的原型的标准方法,与对象的 __proto__ 属性返回的是同一原型对象。
const arr = [];
Object.getPrototypeOf(arr) === arr.__proto__ // true
setPrototypeOf()
为对象设置一个指定的原型 (一个对象或 null),即修改对象 [[Prototype]] 属性的值。
const arr = [];
Object.setPrototypeOf(arr, String.prototype)
// arr 会失去 Array.prototype 上的属性和方法了,但会继承 String.prototype 上的属性和方法。
arr.push(1) // Uncaught TypeError: arr.push is not a function
arr.charAt(0) // 继承了 String.prototype.charAt,但 this 仍是数组
isPrototypeOf()
返回布尔值,判断对象是否在另一个对象的原型链上。作用与 instanceof 类似,但调用方式相反:A.prototype.isPrototypeOf(b) 对应 b instanceof A。instanceof 还会考虑 Symbol.hasInstance。
Object.prototype.isPrototypeOf([]) // true
[] instanceof Object // true
__proto__
返回实例对象内部 [[Prototype]] 属性,也就是构造函数的 prototype 对象。
注意: __proto__ 属性是一个访问器属性(一个 getter 和一个 setter 函数),是可读写的。
注意: __proto__ 长期由浏览器实现,ES6 才写入规范附录 B(遗留特性),不是首选 API。读取原型用 Object.getPrototypeOf()(ES5),设置用 Object.setPrototypeOf()(ES6)。__proto__ 在 Object.create(null) 的对象上不存在。
原型链
在 JavaScript 中,每个对象拥有一个原型对象,对象以其原型为模板、从原型继承方法和属性。原型对象也可能拥有原型,并从中继承方法和属性,一层一层、以此类推。这种关系常被称为原型链,它解释了为何一个对象会拥有定义在其他对象中的属性和方法。
所有对象(Object.create(null) 创建的对象除外)的原型最终都可以上溯到 Object.prototype,也就是说,所有对象都继承了 Object.prototype 的属性,如常见的 valueOf()、toString() 方法。
那么,Object.prototype 对象有没有原型呢?
有,Object.prototype 的原型是 null。
Object.getPrototypeOf(Object.prototype) // null
null 没有任何属性和方法,也没有自己的原型。因此,原型链的尽头就是 null。
读取对象的某个属性时,JavaScript 引擎先寻找对象本身的属性,如果找不到,就到它的原型去找,如果还是找不到,就到原型的原型去找。如果直到最顶层的 Object.prototype 还是找不到,则返回 undefined。
如果对象自身和它的原型,都定义了一个同名属性,那么优先读取对象自身的属性,这叫做 “覆盖”。
注意: 在原型链上查找属性会沿链逐级访问,层次越深越慢;找不到的属性会走到链的尽头。这与 for...in 枚举可枚举属性不是同一件事:普通属性读取不区分是否可枚举。
注意: 改变原型对象的属性,在每一个 JavaScript 引擎和浏览器中都是一个非常慢且影响性能的操作,原型对象的属性修改会影响到所有继承自该原型的实例对象。一般建议是,创建一个新的且可以继承该原型的对象(推荐使用 Object.create()),再重写对象的属性和方法。
继承
继承是面向对象编程中的一个概念。继承可以使得子类具有父类的各种属性和方法,而不需要再次编写相同的代码。在子类继承父类的同时,还可以重新定义某些属性,并重写某些方法,即覆盖父类的原有属性和方法,使其获得与父类不同的功能。另外,为子类追加新的属性和方法也是常见的做法。
一个简单示例
JavaScript 的继承基于原型链。常见写法是:子构造函数里 call 父构造函数初始化实例字段,再用 Object.create(Parent.prototype) 接上原型,避免两个构造函数共用同一个 prototype 对象。
function Person(name) {
this.hi = `Hello, I'm ${name}!`
}
Person.prototype.greet = function () {
console.log(this.hi)
}
function Student(name, grade) {
Person.call(this, name)
this.grade = grade
}
Student.prototype = Object.create(Person.prototype)
// Object.create(),是为防止 Student.prototype 的改动影响 Person.prototype
Student.prototype.constructor = Student
Student.prototype.read = function () { }
const student = new Student("Lizhao", 100)
console.log(student) // Student {hi: "Hello, I'm Lizhao!", grade: 100}
多重继承
JavaScript 不提供多重继承功能,但可以通过扩展构造函数的 prototype 对象来实现多重继承。
function Manager() { }
Manager.prototype.manage = function () { }
Object.assign(Student.prototype, Manager.prototype)
class 关键字
ES6 引入了一套新的关键字来定义类,包括 class、constructor、static、extends 和 super。
注意: ES6 中的类仍然是基于原型的,class 关键字只是语法糖。
class Person {
constructor(name) {
this.hi = `Hello, I'm ${name}!`
}
greet() {
console.log(this.hi)
}
}
class Student extends Person {
constructor(name, grade) {
super(name);
this.grade = grade
}
read() { }
}
const student = new Student("Lizhao", 100)
console.log(student) // Student {hi: "Hello, I'm Lizhao!", grade: 100}
constructor 方法是一个特殊的方法,用于创建和初始化一个由 class 创建的对象。
super 用来引用父类:在 constructor 里必须先调用 super() 才能使用 this;也可以用 super.method() 调用父类原型上的方法。父类赋在 this 上的实例属性,在 super() 之后通过 this 访问,而不是 super.属性名。
static 关键字用来定义一个类的静态属性和静态方法,静态属性和静态方法不会出现在类实例中。
extends 关键字用于创建一个类作为另一个类的一个子类。
常见问题
prototype、__proto__、getPrototypeOf?
prototype 对象大概是 JavaScript 中最容易混淆的名称之一。
prototype 的翻译是 “原型”。所以,你可能认为,prototype 对象指向当前对象的原型对象,其实不是。
一个对象的 prototype 对象的属性和方法,是用于被其他对象(子对象、实例)继承的,而一个对象的原型对象定义的是该对象可以从其他对象(父对象、构造函数)承继而来的属性和方法。
遵循 ECMAScript 标准,每个对象都有一个内部槽 [[Prototype]],指向它的原型。用 new 创建时,这个原型通常是构造函数的 prototype 对象;也可以是 Object.create 传入的对象。
读取原型的标准方法是 Object.getPrototypeOf()(ES5)。__proto__ 是引擎先实现、后来写入规范附录 B 的遗留访问器,与 getPrototypeOf 读到的是同一条链,但不推荐新代码使用。
为什么要修改 A.prototype.constructor = A ?
function Person() { }
function Student() { }
Student.prototype = Person.prototype
Student.prototype.constructor = Student
const student = new Student()
在原型链继承中,经常会看到类似 Student.prototype.constructor = Student 的代码。
为什么要这样修改呢?不修改会有什么后果呢?
事实上,就算不修改,new Student() 也不会有什么问题。
这一行代码的作用仅仅是,让实例对象能够找到它真正的构造函数。
比如,从一个实例对象生成另一个实例对象时,如果没有这行代码,会出现如下情况:
function Person() {
this.hi = 'Hello'
}
function Student() { }
Person.prototype = Student.prototype
const person = new Person()
console.log(person.hi) // 'Hello'。是Person的实例,有属性 hi。
const person2 = new person.constructor()
console.log(person2.hi) // undefined。不是Person的实例,所以没有属性 hi。