JavaScript面向对象-ES5实现继承

137 阅读12分钟

前言

面试官:“你怎么处理继承?”

我:“我不造啊......-_-!!!”

一、认识原型

1.对象的原型

1.1认识[[prototype]]

JavaScript中每个对象都有一个特殊的内置属性[[prototype]],这个特殊的对象可以指向另一个对象,指向的对象就是对象的原型。通过字面量创建的对象也有这个属性,只要是对象都有这个属性。

image.png

1.2获取原型

  1. 并不存在真实的属性名[[prototype]],它只是一个标准。所以无法通过常规获取对象属性的方式获取。
image.png
  1. 可以通过以下两种方式获取原型:
  • 非标准方法:obj.__proto__(早期浏览器自己添加的,有一定兼容性问题)。
  • 标准方法:Object.getPrototypeOf(obj)
obj.__proto__ === Object.getPrototypeOf(obj) // true

如果浏览器不实现__proto__,则无法使用obj.__proto__。所以一般把__proto__叫做隐式原型

1.3原型的作用

  • 当通过引用对象的属性key来获取value时(如obj.nameobj['name']),会触发[[Get]]的操作。
  • 这个操作会先检查该对象是否有对应的属性,如果有就使用它;如果没有就会访问对象[[prototype]]内置属性指向的对象上的属性。
let obj = { name: 'why' }
console.log(obj.message) // undefined
// 只是举个栗子,实际开发最好别这么做!!!
obj.__proto__.message = 'hello'
console.log(obj.message) // hello

一句话总结:用来查找属性,如果在自己的对象找不到,就去原型上查找。

2.函数的原型

2.1认识prototype

所有的函数都有一个prototype属性。函数原型对象原型的区别:

  • 函数原型prototype显式原型,对象原型__proto__隐式原型

  • 只有函数有prototype属性,对象没有。

    显示原型:是可以直接获取的。
    隐式原型:一般不会去获取。如果浏览器不实现,则没有这个属性。
    

2.2获取原型

  • 将函数看成普通对象时,它具备__proto__
  • 将函数看成函数时,它具备prototype

prototype显式原型,所以可以直接通过常规方法获取:foo.prototype

2.3原型的作用

2.3.1 new操作符

new的过程:

  1. 创建空对象
var obj = {}
  1. 将这个空对象赋值给this
this = obj
  1. 函数的显式原型赋值给这个对象作为它的隐式原型
obj.__proto__ = Foo.prototype
  1. 执行函数体中的代码
  2. 将这个对象默认返回
// 举个栗子
function Foo () {

}
var foo = new Foo()

// 上面的操作相当于:
// f = {}
// f.__proto__ = Foo.prototype

// 此时
console.log(foo.__proto === Foo.prototype) // true

2.3.2 将函数定义放到原型上

当多个对象拥有共同的值(一般是函数)时,可以将这些值放到构造函数对象的显式原型上,这样由构造函数创建出来的所有对象,都会共享这些属性。

function Student (name, age, sno) {
  this.name = name
  this.age = age
  this.sno = sno

  // 方式1:编写函数,会创建很多函数对象
  this.running = function () {
    console.log(this.name + 'running');
  }
  this.eating = function () {
    console.log(this.name + 'eating');
  }
}

// 方式2:
Student.prototype.running = function () {
  console.log(this.name + 'running');
}
Student.prototype.eating = function () {
  console.log(this.name + 'eating');
}

var stu1 = new Student('why1', 18, 111)
var stu2 = new Student('why2', 19, 112)
var stu3 = new Student('why3', 20, 113)

// 隐式原型的作用:
// 1.stu1的隐式原型是Student.prototype对象
// 2.stu1.running查找:
//  先在自己身上查找,没有找到
//  去原型上查找
stu1.running()

2.3.3 原型的作用

在通过new操作创建对象时,将这个显式原型赋值给创建出来的对象的隐式原型

一句话总结:用来构建对象时,给对象设置隐式原型。

小声比比:“其实就是要记住这个构造函数,然后把这个构造函数的信息传给构造函数构造出来的对象。最后的最后,还是为了让对象通过原型找属性。”

3.显示原型中的属性

默认情况下,显示原型上都会添加一个属性:constructor,这个属性指向当前的函数对象

function Person () {

}
var prototype = Person.prototype
console.log(prototype.constructor) // [Function: Person]
console.log(prototype.constructor === Person) // true

4.创建对象过程(内存图预警)

  1. 定义构造函数
function Person (name, age) {
  this.name = name
  this.age = age
}

image.png

  • 首先,在堆内存中创建一个函数对象,这个函数对象有name、length等属性,还有一个prototype属性指向显示原型对象。显示原型对象上有一个constructor属性,指向函数本身,构成循环引用。
  1. 使用new创建对象
var p1 = new Person('why', 18)
var p2 = new Person('why1', 19)

image.png

  • new操作符的过程:先创建一个空的对象。
  • Person显式原型赋值给新对象的隐式原型
  • 执行代码体里的代码。
  • 将这个对象默认返回
  1. 给函数原型添加方法
Person.prototype.running = function () {
  console.log('running');
}

image.png

  • 创建一个running函数对象,这个函数对象有name、length等属性。
  • Person原型对象上新增一个running属性,并指向running函数对象。
  1. 获取属性
console.log(p1.name);
console.log(p2.name);
p1.running()
p2.running()
  • p1实例对象上有name属性,可以直接获取,值为'why'
  • p2实例对象上有name属性,可以直接获取,值为'why1'
  • p1对象上没有running属性,顺着隐式原型找到Person显式原型对象,找到running属性,则执行这个running方法。
  • p2对象上没有running属性,顺着隐式原型找到Person显式原型对象,找到running属性,则执行这个running方法。
  1. 新增属性
Person.prototype.address = '中国'
p1.__ptoto__.info = '卷王'

p1.height = 1.88
p2.isAdmin = true

image.png

  • Person显式原型对象上添加address属性并赋值。
  • p1__ptoto__指向Person显式原型对象,所以info属性被添加到Person显式原型对象上,并赋值。
  • p1实例对象添加height属性并赋值。
  • p2实例对象添加isAdmin属性并赋值。
  1. 新增属性后获取属性
console.log(p1.address);
console.log(p2.isAdmin);
console.log(p1.isAdmin);
console.log(p2.info);
  • p1实例对象上没有address属性,顺着隐式原型找到Person显式原型对象,找到address属性,获取值为中国
  • p2实例对象上有isAdmin属性,可以直接获取,值为true
  • p1实例对象上没有isAdmin属性,顺着隐式原型找到Person显式原型对象Person显式原型对象上也没有isAdmin属性,则值为undefined
  • p2实例对象上没有info属性,顺着隐式原型找到Person显式原型对象,找到info属性,获取值为卷王
  1. 修改属性的值并获取
p1.address = '地球'
console.log(p2.address);

image.png

  • p1实例对象添加height属性并赋值。
  • p2实例对象上没有address属性,顺着隐式原型找到Person显式原型对象,找到address属性,获取值为中国
// 完整代码
// 1.定义构造函数
function Person (name, age) {
  this.name = name
  this.age = age
}
// 3.给函数原型添加方法
Person.prototype.running = function () {
  console.log('running');
}
// 2.使用new创建对象
var p1 = new Person('why', 18)
var p2 = new Person('why1', 19)
// 4.获取属性
console.log(p1.name);
console.log(p2.name);
p1.running()
p2.running()
// 5.新增属性
Person.prototype.address = '中国'
p1.__ptoto__.info = '卷王'
p1.height = 1.88
p2.isAdmin = true
// 6.新增属性后获取属性
console.log(p1.address);
console.log(p2.isAdmin);
console.log(p1.isAdmin);
console.log(p2.info);
// 7.修改属性的值并获取
p1.address = '地球'
console.log(p2.address);

5.重写原型对象

function Person () {}
  1. 在原有的原型对象上添加新的属性
Person.prototype.message = 'message'
Person.prototype.info = '卷王'
Person.prototype.running = function () {}
Person.prototype.eating = function () {}
  • 在原型对象上不断的添加新属性
  1. 直接赋值一个新的原型对象
Person.prototype = {
  message: 'message',
  info: '卷王',
  running: function () {},
  eating: function () {}
}

Object.defineProperty(Person.prototype, 'constructor', {
  configurable: false,
  writable: false,
  enumerable: false,
  value: Person
})
  • 在内存中创建一个新的原型对象,将函数对象的原型指向这个新的原型对象。

缺点:

  • 需要手动添加constructor属性,并指向构造函数。
  • 同时constructor属性需要不可枚举。

注:一般不会重写原型对象。如果要重写,细节也要符合标准。

二、ES5中的继承

1.认识面向对象

面向对象是一种编程范式,正如函数式编程。函数式编程就是就将代码抽象成函数,让函数作为一等公民通过传递调用的方式使用。面向对象编程就是把代码抽象成对象使用

面向对象的三大特性

  1. 封装:将属性方法封装到一个中。
  2. 继承:是面向对象非常重要的特性。子类继承父类的代码,不仅可以减少代码重复的数量,也是多态的前提(纯面向对象中)。
  3. 多态不同的对象在执行时表现出不同的形态

2.认识继承

继承可以将重复的代码和逻辑抽取到父类中,子类继承过来使用即可。在很多编程语言中,继承也是多态的前提。

function Student (name, age, sno, score) {
  this.name = name
  this.age = age
  this.sno = sno
  this.score = score
}
Student.prototype.running = function () {}
Student.prototype.eating = function () {}
Student.prototype.studying = function () {}
function Teacher (name, age, title) {
  this.name = name
  this.age = age
  this.title = title
}
Teacher.prototype.running = function () {}
Teacher.prototype.eating = function () {}
Teacher.prototype.teaching = function () {}
  • Student类和Teacher类有共同的name、age属性和running、eating方法,此时可以将这些共同的内容放到一个Person类中,然后再继承Person类。

一句话总结:继承的目的就是重复利用另一个对象的属性和方法。

3.认识原型链

从一个对象上获取属性,如果在当前对象中没有获取到,就会去它的原型上面获取,如果原型对象上没有,就去原型对象的原型继续查找。原型对象原型对象之间形成的链条就是原型链

var obj = {
  name: 'why',
  age: 18
}
// 等价于
// var obj = new Object()
console.log(obj.__proto__);
console.log(obj.__proto__.__proto__); // null
  • 原型链不会永无止境的查找,obj.__proto__找到Object的原型对象Object原型对象上也有__proto__,此时obj.__proto__.__proto__指向null,就是顶层原型。当查找到顶层原型就不再继续查找。

注:Object类是所有类的父类。构造函数原型对象也是一个对象,它的隐式原型([[ptotoytpe]])对象是null,因为Object函数原型已经是顶层原型。

console.log(Object.prototype.__proto__) // null

image.png

4.原型链实现方法的继承

继承内容包含:属性和方法。

function Person (name, age) {
  this.name = name
  this.age = age
}
Person.prototype.running = function () {
  console.log('running');
}
Person.prototype.eating = function () {
  console.log('eating');
}
function Student (name, age, sno, score) {
  this.name = name
  this.age = age
  this.sno = sno
  this.score = score
}
Student.prototype.running = function () {
  console.log('running');
}
Student.prototype.eating = function () {
  console.log('eating');
}
Student.prototype.studying = function () {
  console.log('studying');
}

4.1方案1(不可用)

父类的原型直接赋值给子类的原型。

function Person (name, age) {
  this.name = name
  this.age = age
}
Person.prototype.running = function () {
  console.log('running');
}
Person.prototype.eating = function () {
  console.log('eating');
}
function Student (name, age, sno, score) {
  this.name = name
  this.age = age
  this.sno = sno
  this.score = score
}
// 方案1:Student原型直接指向Person原型
Student.prototype = Person.prototype
// Student.prototype.running = function () {
//   console.log('running');
// }
// Student.prototype.eating = function () {
//   console.log('eating');
// }
Student.prototype.studying = function () {
  console.log('studying');
}
// 创建学生
var stu1 = new Student('why', 18, 111, 100)
stu1.running()

image.png

  • Student原型直接指向Person原型。

缺点:只继承了方法,没实现属性继承。而且父类和子类共享一个原型对象,修改了一个,另一个也会被修改。

4.2方案2(可用但不完美)

创建一个父类的实例对象(new Person()),用这个实例对象作为子类的原型对象。

function Person (name, age) {
  this.name = name
  this.age = age
}
Person.prototype.running = function () {
  console.log('running');
}
Person.prototype.eating = function () {
  console.log('eating');
}
function Student (name, age, sno, score) {
//  this.name = name
//  this.age = age
  this.sno = sno
  this.score = score
}
var p = new Person('whyp', 20)
Student.prototype = p
// Student.prototype.running = function () {
//   console.log('running');
// }
// Student.prototype.eating = function () {
//   console.log('eating');
// }
Student.prototype.studying = function () {
  console.log('studying');
}
// 创建学生
var stu1 = new Student('why', 18, 111, 100)
stu1.running()

image.png

基本实现继承:

  • 定义构造函数。
  • 父类原型添加内容。
  • 定义子类构造函数。
  • 创建父类对象,并且作为子类原型对象
  • 在子类原型上添加内容。

缺点:属性继承了又没继承,某些属性是保存在p对象上的。

  • 直接打印对象看不到这个属性。
  • 这个属性会被多个对象共享,如果这个对象是一个引用类型,会造成一些问题。
  • 不能给Person传递参数,让每个stu都有自己的属性,无法定制化。

5.借用构造函数实现属性的继承

为了解决原型链继承中存在的问题,出现新的技术:constructor stealing(借用构造函数/经典继承/伪造对象)。

原理:在子类构造函数的内部调用父类构造函数

  • 因为函数可以在任意时刻被调用。
  • 可以通过apply()call()可以在新创建的对象上执行构造函数。
function Person (name, age) {
  this.name = name
  this.age = age
}
Person.prototype.running = function () {
  console.log('running');
}
Person.prototype.eating = function () {
  console.log('eating');
}
function Student (name, age, sno, score) {
  // this.name = name
  // this.age = age
  // 在`子类构造函数`的内部调用`父类构造函数`
  Person.call(this, name, age)
  this.sno = sno
  this.score = score
}
Student.prototype.running = function () {
  console.log('running');
}
Student.prototype.eating = function () {
  console.log('eating');
}
Student.prototype.studying = function () {
  console.log('studying');
}
// 创建学生
var stu1 = new Student('why', 18, 111, 100)
stu1.running()

image.png

至此,原型链方法继承+借用属性继承基本上实现了ES5的继承。组合借用继承是ES5常用的继承模式之一。

缺点:

  • 无论在什么情况下,都会调用两次父类构造函数
    • 创建子类原型(function Student () {})。
    • 创建子类实例(new Student())。
  • 所有子类实例属性都会拥有两份父类属性

6.寄生组合式继承-最终继承方案

思路:就是优化p对象。

观察发现需要满足的条件:

  • 必须创建一个对象。
  • 这个对象的隐式原型必须指向父类的显示原型
  • 将这个对象赋值给子类的显示原型
  1. 思路
function Person () {}
function Student () {}
var obj = {}
obj.__proto__ = Person.prototype
Student.prototype = obj
  1. 实现(重点)
// 封装成工具函数,最终目的都是使stu对象的原型指向Person对象
// 1.创建对象
// 方案1
function createObject (obj) {
  function F() {}
  F.prototype = obj
  return new F()
}
// 方案2
// function createObject (obj) {
//   var newObj = {}
//   Object.setPrototypeOf(newObj, obj)
//   return newObj
// }
// 方案3:使用Object.create创建子类对象
// var stu = Object.create(Person, {
//   info: {
//     value: '卷王',
//     enumerable: true
//   }
// })

// 2.将subtype和supertype联系在一起
function inherit (Subtype, Supertype) {
   // 方案1
  Subtype.prototype = createObject(Supertype.prototype)
  Object.defineProperty(Subtype.prototype, 'construtor', {
    enumerable: false,
    configurable: true,
    writable: true,
    value: Subtype
  })
  // 方案2:有兼容性问题
  // Subtype.prototype.__proto__ = Supertype.prototype
  // 方案3:有兼容性问题
  // Object.setPrototypeOf(Subtype.prototype, Supertype.prototype)
}
  1. 使用
function Person () {}
function Student () {}
inherit(Student, Person)

以上方案使用到了原型链、借用、原型式(对象之间)、寄生式函数等知识点,所以称之为寄生组合式继承

三、总结

遇事不决,先画个内存图,会发现所有的方案就是对象间的组合和相互引用。每个最终方案都集成了前辈的思想,在不断试错中达成一致。而世界又在不断的进步,当出现新的需求,方案又要调整,出现各种新的api。所以入了这行,除了卷,没别的办法。

附录

  1. 文章:JavaScript面向对象详解(一)
  2. 文章:JavaScript面向对象详解(二)
  3. 文章:JavaScript面向对象详解(三)
  4. 文章:JavaScript面向对象详解(四)