JS面向对象

2020-04-23  本文已影响0人  杨晨1994

在JS中一切皆对象,但JS并不是一种真正的面向对象(OOP)的语言,因为它缺少类(class)的概念。
虽然ES6引入了class和extends,使我们能够轻易地实现类和继承。但JS并不存在真实的类,JS的类是通过函数以及原型链机制模拟的。

一、生成对象方式

1. 工厂模式

工厂模式抽象了创建具体对象的过程,下面的这个函数我们每次调用都会返回一个包含三个属性和一个方法的对象

function createPerson(name,age,job){
    var obj = new Object();
    obj.name = name;
    obj.age = age;
    obj.job = job;
    obj.sayName = function(){
      alert(this.name);
    }
    return obj;
  }
  var person1 = createPerson('yc',18,'web');
  console.log(person1 instanceof Object) // true
  console.log(person1 instanceof createPerson)// false

工厂模式虽然解决了创建多个相似对象的问题,但是却没有解决对象类型的问题(即怎样知道一个对象的类型),也就是说其实是person1是一个对象的实例,我们只知道他是一个对象的实例而我们却不知道person1和createPerson的关系

2. 构造函数模式

我们知道js中可以使用new Object() new Array()等原生的构造函数来创建特定类型的对象,此外也可以创建自定义的构造函数来创建自定义对象类型的属性和方法,我们来重写一下上面的例子

function Person(name,age,job){
    this.name = name;
    this.age = age;
    this.job = job;
    this.sayName = function(){
      alert(this.name);
    }
  }
  var person1 = new Person('yc',18,'web');
  var person2 = new Person('ycc',20,'web');
  console.log(person1 instanceof Object) // true
  console.log(person1 instanceof Person)// true

我们看的出来构造函数模式比较于工厂模式有几点不同

  • 没有显示的创建对象
  • 所有属性和方法赋值给了this对象
  • 没有return语句
    此外我们还要注意函数名Person首字母大写。按照惯例构造函数始终都应该以一个大写字母开头,而非构造函数要以一个小写字母开头,这个写法借鉴于其他OO语言,主要是为了区分其他函数,因为构造函数也是函数只不过能创建对象而已

要创建对象就必须使用new操作符,以这种方式调用会经历以下3个步骤

  • 创建一个新对象并继承其构造函数的prototype,这一步是为了继承构造函数原型上的属性和方法
  • 执行构造函数,方法内的this被指定为该新实例,这一步是为了执行构造函数内的赋值操作
  • 返回新实例(规范规定,如果构造方法返回了一个对象,那么返回该对象,否则返回第一步创建的新对象)
    我们可以模拟实现一个new 操作符
function myNew(foo, ...args) {
  // 创建新对象,并继承构造方法的prototype属性, 这一步是为了把obj挂原型链上, 相当于obj.__proto__ = Foo.prototype
  let obj = Object.create(foo.prototype)  
  
  // 执行构造方法, 并为其绑定新this, 这一步是为了让构造方法能进行this.name = name之类的操作, args是构造方法的入参, 因为这里用myNew模拟, 所以入参从myNew传入
  let result = foo.apply(obj, args)

  // 如果构造方法已经return了一个对象, 那么就返回该对象, 一般情况下,构造方法不会返回新实例,但使用者可以选择返回新实例来覆盖new创建的对象 否则返回myNew创建的新对象
  return typeof result === 'object' && result !== null ? result : obj
}

function Foo(name) {
  this.name = name
}
const newObj = myNew(Foo, 'zhangsan')
console.log(newObj)                 // Foo {name: "zhangsan"}
console.log(newObj instanceof Foo)  // true

构造函数模式也是有缺点的,使用构造函数的最大问题就是每个方法都要在实例上创建一遍,前面的例子可以这样写

function Person(name,age,job){
    this.name = name;
    this.age = age;
    this.job = job;
    this.sayName = new Function('alert(this.name)')
  }
  var person1 = new Person('yc',18,'web');
  var person2 = new Person('ycc',20,'web');

上面我们说的new操作符第二个步骤 (执行构造函数,方法内的this被指定为该新实例,这一步是为了执行构造函数内的赋值操作),这里定义了sayName函数并实例化一个对象,所以person1和person2虽然都有一个名为sayName的方法 但是这两个方法不相同

console.log(person2.sayName ===  person1.sayName) // false
console.log(person2.sayName ==  person1.sayName) // false

然而创建两个同样任务的方法其实是没有必要的 多个相同的方法也是会浪费没有必要的内存,有两个方法可以解决这个问题

  • 外部函数
var sayName = function () {
    alert(this.name)
  }
  function Person(name,age,job){
    this.name = name;
    this.age = age;
    this.job = job;
    this.sayName = sayName
  }
  var person1 = new Person('yc',18,'web');
  var person2 = new Person('ycc',20,'web');
  console.log(person2.sayName ===  person1.sayName) // true
  console.log(person2.sayName ==  person1.sayName) // true

使用外部函数的话可以解决这个问题 但是我们有多个方法的话那就需要多个外部函数,这样来说那我们的封装意义就不存在了,所以我们用另一种方法 原型模式

3. 原型模式

\color{red}{我们创建的每个函数都有一个prototype属性,这个属性是一个指针 指向一个对象},而这个对象的用途是包含可以由特定类型的所有实例共享的属性和方法(其实就是Person的原型对象,Person的所有实例都可以共享的其中的属性和方法),这样来说我们就不必像上面那样定义外部函数,我们可以直接把方法和属性都写在prototype(原型对象)上,就像这样

  function Person(){}
  Person.prototype.name = 'yc'
  Person.prototype.age = 18
  Person.prototype.job = 'web'
  Person.prototype.sayName = function () {
    alert(this.name)
  }
  var person1 = new Person();
  var person2 = new Person();
  console.log(person2.sayName ===  person1.sayName) // true
  console.log(person2.sayName ==  person1.sayName) // true

无论什么时候,只要创建了一个新函数,就会根据一组特定的规则为该函数创建一个prototype
属性,这个属性指向函数的原型对象。在默认情况下,\color{red}{所有原型对象都会自动获得-个constructor (构造函数)属性,这个属性包含一个指向prototype属性所在函数的指针。}就前面的例子来说Person.prototype. constructor指向Person。而通过这个构造函数,我们还可继续为原型对象添加其他属性和方法。
创建了 自定义的构造函数之后,其原型对象默认只会取得constructor属性;至于其他方法,则
都是从Object继承而来的。\color{red}{当调用构造函数创建一个新实例后,该实例的内部将包含一个指针(内部属性),指向构造函数的原型对象}。ECMA-262第5版中管这个指针叫[[Prototype]]。虽然在脚本中
没有标准的方式访问[[Prototype] ] , 但 Fircfox、Safari和Chrome在每个对象h都支持一个属性
\color{red}{\_\_proto\_\_};而在其他实现中,这个属性对脚木则是完全不可见的。不过,要明确的真正重要的一点就
是,这个连接存在于实例与构造函数的原型对象之间,而不是存在于实例与构造函数之间。

image.png

使用原型模式也不是没有缺点,首先他省略了为构造函数传递初始化参数这一步骤,所有实例都取得相同的属性和方法。这不是最主要的问题,最主要的问题是其共享的本质导致的
原型中所有属性是被很多实例共享的,这种共享对于函数非常合适。对于那些包含基本值的属栏也说得过去,毕竞通过在实例上添加一个同名属性,可以隐藏原型中的对应性。然而,对于包含引用类型值的属性来说,问题就比较突出了。来看下面的例子。

  function Person(){}
  Person.prototype.name = 'yc'
  Person.prototype.age = 18
  Person.prototype.job = 'web'
  Person.prototype.firends = ['jake','bob']
  Person.prototype.sayName = function () {
    alert(this.name)
  }
  var person1 = new Person();
  var person2 = new Person();
  person1.firends.push('tom')
  console.log(person1.firends)//["jake", "bob", "tom"]
  console.log(person2.firends)//["jake", "bob", "tom"]

在此,Person.prototype对象有一个名为friends的属性,该属性包含一个字符串数组。然后,
创建了 Person的两个实例。接着,修改了 personl. friends引用的数组,向数组巾添加了一个字符
中。由于friends数组存在于Person.prototype而非personl中,所以刚刚提到的修改也会通过
person 2 .friends (与person1. friends指向同一个数组)反映出来。假如我们的初衷就是像这样
在所有实例中共享一个数组,那么对这个结果我没有话可说。可是,实例一般都是要有属于自己的全部
属性的。而这个问题正是我们很少看到有人单独使用原型模式的原因所在

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

创建自定义类型的最常见方式,就是组合使用构造函数模式与原型模式。构造函数模式用于定义实例属性,而原型模式用于定义方法和共享的属性。结果,每个实例都会有自己的一份实例属性的副本, 同时又共享着对方法的引用,极大限度地节省了内存。另外,这种混合模式还支持向构造函数传递参 数;可谓是集两种模式之长。下面的代码重写了前面的例子。

function Person(name,age,job){
    this.name = name;
    this.age = age;
    this.job = job;
    this.firends = ['jake','bob'];
  }
  Person.prototype = {
    constructor:Person, // 注意这里为什么要加一个构造函数的指针 因为我们重写Person的原型所以他的构造函数就不是Person了,我们这里重新指向一下 当然了 实际操作中不写也问题不大
    sayName: function () {
      alert(this.name)
    }
  }
  var person1 = new Person('yc',18,'web');
  var person2 = new Person('ycc',20,'web');
  person1.firends.push('tom')
  console.log(person1.firends)//["jake", "bob", "tom"]
  console.log(person2.firends)//["jake", "bob"]

在这个例子中,实例属性都是在构造函数中定义的,而由所有实例共享的属性constructor和方法sayName ()则是在原型中定义的。而修改了person1. friends (向其中添加一个新字符串),并不会影响到person2. friends,因为它们分別引用了不同的数组。这种构造函数与原型混合的模式,是目前在ECMAScript中使用最广泛、认同度最高的一种创建自 定义类型的方法。可以说,这是用来定义引用类型的一种默认模式。

一、继承

1. 原型链

ECMAScript中描述了原型链的概念,并将原型链作为实现继承的主要方法。其基本思想是利用原 型让一个引用类型继承另一个引用类型的属性和方法。简单回顾一下构造函数、原型和实例的关系:每个构造函数都有一个原型对象,原型对象都包含一个指向构造函数的指针,而实例都包含个指向原型对象的内部指针。那么,假如我们让原型对象等于另一个类型的实例,结果会怎么样呢?显然,此时的原型对象将包含一个指向另一个原型的指针,相应地,另一个原型中也包含着一个指向另一个构造函数的指针。假如另一个原型又是另一个类型的实例,那么上述关系依然成立,如此层层递进,就构成了实例与原型的链条。这就是所谓原型链的基本概念。
简单来说原型链的核心只有三点:

  • 每个实例都有proto属性,该属性指向其原型对象,在调用实例的方法和属性时,如果在实例对象上找不到,就会往原型对象上找
  • 构造函数的prototype属性也指向实例的原型对象
  • 原型对象的constructor属性指向构造函数


    image

2.原型链继承

原型链继承的原理很简单,直接让子类的原型对象指向父类实例,当子类实例找不到对应的属性和方法时,就会往它的原型对象,也就是父类实例上找,从而实现对父类的属性和方法的继承

// 父类
  function Parent() {
    this.name = 'yc'
  }
  // 父类的原型方法
  Parent.prototype.getName = function() {
    return this.name
  }
  // 子类
  function Child() {}

  // 让子类的原型对象指向父类实例, 这样一来在Child实例中找不到的属性和方法就会到原型对象(父类实例)上寻找
  Child.prototype = new Parent()
  Child.prototype.constructor = Child // 根据原型链的规则,顺便绑定一下constructor, 这一步不影响继承, 只是在用到constructor时会需要

  // 然后Child实例就能访问到父类及其原型上的name属性和getName()方法
  const child = new Child()
  console.log(child.name)          // 'yc'
  console.log(child.getName())     // 'yc'

原型继承的缺点:

  • 由于所有Child实例原型都指向同一个Parent实例, 因此对某个Child实例的父类引用类型变量修改会影响所有的Child实例
  • 在创建子类实例时无法向父类构造函数传参
// 父类
  function Parent() {
    this.name = ['yc']
  }
  // 父类的原型方法
  Parent.prototype.getName = function() {
    return this.name
  }
  // 子类
  function Child() {}

  // 让子类的原型对象指向父类实例, 这样一来在Child实例中找不到的属性和方法就会到原型对象(父类实例)上寻找
  Child.prototype = new Parent()
  Child.prototype.constructor = Child // 根据原型链的规则,顺便绑定一下constructor, 这一步不影响继承, 只是在用到constructor时会需要

  // 然后Child实例就能访问到父类及其原型上的name属性和getName()方法
  const child1 = new Child()
  const child2 = new Child()
  child1.name.push('jake')
  console.log(child1.name)          // ["yc", "jake"]
  console.log(child2.name)     // ["yc", "jake"]

3.构造函数继承

构造函数继承,即在子类的构造函数中执行父类的构造函数,并为其绑定子类的this,让父类的构造函数把成员属性和方法都挂到子类的this上去,这样既能避免实例之间共享一个原型实例,又能向父类构造方法传参

// 父类
  function Parent(name) {
    this.name = [name]
  }
  // 父类的原型方法
  Parent.prototype.getName = function() {
    return this.name
  }
  // 子类
  function Child() {
    Parent.call(this,'yc') // 执行父类构造函数方法并绑定子类
  }

  const child1 = new Child()
  const child2 = new Child()
  child1.name.push('jake')
  console.log(child1.name)          // ["yc", "jake"]
  console.log(child2.name)     // ["yc"]
  console.log(child1.getName()) // child1.getName is not a function
  console.log(child2.getName()) // child2.getName is not a function

构造函数继承的缺点:

  • 继承不到父类原型上的属性和方法

4.组合继承

既然原型链继承和构造函数继承各有互补的优缺点, 那么我们为什么不组合起来使用呢, 所以就有了综合二者的组合式继承

// 父类
  function Parent(name) {
    this.name = [name]
  }
  // 父类的原型方法
  Parent.prototype.getName = function() {
    return this.name
  }
  // 子类
  function Child() {
    Parent.call(this,'yc') // 执行父类构造函数方法并绑定子类
  }
  Child.prototype = new Parent();
  Child.prototype.constructor = Child;
  const child1 = new Child()
  const child2 = new Child()
  child1.name.push('jake')
  console.log(child1.name)      // ["yc", "jake"]
  console.log(child2.name)      // ["yc"]
  console.log(child1.getName()) // ["yc", "jake"]
  console.log(child2.getName()) // ["yc"]

组合继承的缺点

  • 每次创建子类实例都执行了两次构造函数(Parent.call()和new Parent()),虽然这并不影响对父类的继承,但子类创建实例时,原型中会存在两份相同的属性和方法,这并不优雅

5. 寄生组合式继承

为了解决构造函数被执行两次的问题, 我们将指向父类实例改为指向父类原型, 减去一次构造函数的执行

// 父类
  function Parent(name) {
    this.name = [name]
  }
  // 父类的原型方法
  Parent.prototype.getName = function() {
    return this.name
  }
  // 子类
  function Child() {
    Parent.call(this,'yc') // 执行父类构造函数方法并绑定子类
  }
  Child.prototype = Parent.prototype;
  Child.prototype.constructor = Child;
  const child1 = new Child()
  const child2 = new Child()
  child1.name.push('jake')
  console.log(child1.name)      // ["yc", "jake"]
  console.log(child2.name)      // ["yc"]

但这种方式存在一个问题,由于子类原型和父类原型指向同一个对象,我们对子类原型的操作会影响到父类原型。
例如给Child.prototype增加一个getName()方法,那么会导致Parent.prototype也增加或被覆盖一个getName()方法

// 父类
  function Parent(name) {
    this.name = [name]
  }
  // 父类的原型方法
  Parent.prototype.getName = function() {
    return this.name
  }
  // 子类
  function Child() {
    Parent.call(this,'yc') // 执行父类构造函数方法并绑定子类
  }
  Child.prototype = Parent.prototype;
  Child.prototype.constructor = Child;
  Child.prototype.getName = function() {
    return '姓名:'+ this.name
  }
  const child1 = new Child()
  const child2 = new Child()
  console.log(new Parent('yc').getName())      // 姓名:yc

为了解决这个问题,我们给Parent.prototype做一个浅拷贝

// 父类
  function Parent(name) {
    this.name = [name]
  }
  // 父类的原型方法
  Parent.prototype.getName = function() {
    return this.name
  }
  // 子类
  function Child() {
    Parent.call(this,'yc') // 执行父类构造函数方法并绑定子类
  }
  Child.prototype = Object.create(Parent.prototype); //{...Parent.prototype}
  Child.prototype.constructor = Child;
  Child.prototype.getName = function() {
    return '姓名:'+ this.name
  }
  const child1 = new Child()
  const child2 = new Child()
  console.log(new Parent('yc').getName())      // ["yc"]

到这里我们就完成了ES5环境下的继承的实现,这种继承方式称为寄生组合式继承,是目前最成熟的继承方式,babel对ES6继承的转化也是使用了寄生组合式继承。

我们回顾一下实现过程:

  • 一开始最容易想到的是原型链继承,通过把子类实例的原型指向父类实例来继承父类的属性和方法,但原型链继承的缺陷在于对子类实例继承的引用类型的修改会影响到所有的实例对象以及无法向父类的构造方法传参。
  • 因此我们引入了构造函数继承, 通过在子类构造函数中调用父类构造函数并传入子类this来获取父类的属性和方法,但构造函数继承也存在缺陷,构造函数继承不能继承到父类原型链上的属性和方法。
  • 所以我们综合了两种继承的优点,提出了组合式继承,但组合式继承也引入了新的问题,它每次创建子类实例都执行了两次父类构造方法,我们通过将子类原型指向父类实例改为子类原型指向父类原型的浅拷贝来解决这一问题,也就是最终实现 —— 寄生组合式继承。
上一篇下一篇

猜你喜欢

热点阅读