美文网首页
面向对象的程序设计

面向对象的程序设计

作者: 了凡和纤风 | 来源:发表于2019-07-12 16:48 被阅读0次

    本文主要介绍 对象、创建对象的方式、继承、原型等基本概念及多种组合继承方式。需要注意的是:本文不涵盖 ES6 的语法特性。

    面向对象(Object-Oriented,oo)的语言有一个标志,那就是他们都有类的概念,而通过类可以创建任意多个具有相同属性和方法的对象。ECMASCript 5中没有类的概念(ES 6有),因此它的对象也与基于类的语言有所不同。

    理解对象

    ECMA-262把对象定义为:“无序属性的集合,其属性可以包含基本值、对象或者函数。”

    // 通过构造函数 创建对象
    const person = new Object()
    person.name = '了凡'
    person.age = 1
    person.sayName = function() {
      console.log(this.name)
    }
    
    // 字面量方式创建
    var cat = {
      name: '圣诞',
      age: 2,
      sayName() {
        console.log(this.name)
      }
    }
    
    属性类型

    ECMA-262第五版在定义只要内部才用的特效(attribute)时,描述了属性(property)的各种特征,定义这些特性是为了实现JavaScript引擎用到,所以在JavaScript中不能直接访问它们。为了表示特性是内部值,该规范把它们放在了两对儿方括号中。例如[ [ Enumerable ] ]

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

    1. 数据属性
      数据属性包含一个数据值的位置。在这个位置可以读取和写入值。数据属性有4个描述其行为的特性
      • [[ Configurable ]]:表示能否通过delete删除属性从而重新定义属性,能否修改属性的特性,或者能否把属性修改为访问器属性。默认 true
      • [[ Enumerable ]]:表示能否通过 for-in 循环返回属性。默认 true
      • [[ Writable ]]:表示能否修改属性的值。默认 true
      • [[ Value ]]:包含这个属性的值。在读取属性值的时候,从这个位置上读;写入属性值的时候把新值保存在这个位置。默认 undefined
    let person = {
      name: 'cxk'
    }
    

    这里创建一个名为 name 的属性, 为它指定的值是'cxk',也就是说, [[ Value ]]将特性被设置为 'cxk',而对这个值的任何修改都将被反映在这个位置。

    要修改属性默认的特性,必须使用ECMAScript 5的 Object.defineProperty() 方法。这个方法接受三个参数:属性所在的对象、属性的名字、一个描述符对象。其中,描述符( descriptor )对象的属性必须是:configurable、enumerable、writable、value。设置其中的一或多个值,可以修改对应的特性值。

    let person = {}
    Object.defineProperty(person, 'name', {
      writable: false,
      value: 'hlele'
    })
    console.log(person.name) // hlele
    person.name = 'rainbow'
    console.log(person.name)  // hlele
    

    上面创建了一个名为name的属性,它的值"hlele"是只读的。这个属性值不可修改,如果尝试为它指定新值,则在非严格模式下,赋值操作将会被忽略。严格模式下抛出异常

    类似的规则也适用于不可配置的属性:例如:

    let person = {}
    Object.defineProperty(person, 'name', {
      configurable: false,
      value: 'rain'
    })
    console.log(person.name)
    delete person.name
    console.log(person.name)
    

    一旦把属性定义为不可配置的,就不能再把它变回可配置了。此时,在调用Object.defineProperty() 方法修改除 writable之外的特性,就会导致错误

    let person = {}
    Object.defineProperty(person, 'name', {
      configurable: false,
      value: 'xxxx'
    })
    
    
    Object.defineProperty(person, 'name', {
      configurable: ture, // 抛出错误
      value: 'xxx'
    })
    

    也就是说,可以多次调用Object.defineProperty()方法修改同一个属性,但是把configurable特性设置为false之后 就会有限制。
    此外,在调用Object.defineProperty()方法创建一个新属性时,如果不指定,configurable、enumerable、writable特性的默认值都是false。如果调用 Object.defineProperty()方法只是修改已定义的属性的特性,则无此限制。
    多数情况下可能都没有必要利用到Object.defineProperty()。不过,理解这些概念对理解JavaScript对象却非常有用。

    1. 访问器属性
      访问器属性不包括数据值; 他们包含一堆getter和setter函数。在读取访问器属性时,会调用getter函数,这个函数负责返回有效的值;在写入访问器属性时,会调用 setter函数并传入新值,这个函数绝对如何处理数据。访问器有如下4个属性:
      • [[ Conifgurable ]]
      • [[ Enumerable ]]
      • [[ Get ]]:在读取属性时调用的函数。默认值为undefined
      • [[ Set ]]:在写入属性时调用的函数。默认值为undefined
        访问器属性不能直接定义,必须使用Object.defineProperty() 来定义。请看下面的例子
    // get 和 set 是指向 getter 和 setter 的指针
    let book = {
      _year: 2004,
      edition: 1
    }
    Object.defineProperty(book, 'year', {
      get() {
        return this._year
      },
      set(newVal) {
        if (newVal > this._year) {
          this._year = newVal
          this.edition++
        }
      }
    })
    book.year = 2007
    console.log(book.edition) // 2
    

    不一定非要同时指定 getter 和 setter。但是在严格模式下,只指定一个会抛出异常。

    由于在应用中,对一个对象添加多个属性的可能性很大,ECMAScript 5 又 定义了一个 Object.defineProperties()方法。该方法接受两个参数:添加和修改其属性的对象、对象的属性(与第一个对象中要添加修改的属性一一对应)

    const book = {}
    Object.defineProperties(book, {
      _year: {
        writable: true,
        value: 2004
      },
      edition: {
        writable: true,
        value: 1
      },
      year: {
        get() {
          return this._year
        },
         set(newVal) {
          if(newVal < this._year) return
          this._year = newVal
          this.edition++
        }
      }
    })
    console.log(book.edition) // 1
    book.year = 2010
    console.log(book.edition) // 2
    
    读取属性的特性

    使用ECMAScript 5 的Object.getOwnPropertyDescriptor() 方法,可以取得给定属性的描述符。这个方法接受两个参数:属性所在的对象、要读取其描述符的属性名称

    const descriptor = Object.getOwnPropertyDescriptor(book, '_year')
    console.dir(descriptor)
    /*
    Objectconfigurable: falsee
    numerable: false
    value: 2010
    writable: true
    __proto__: Object
    */
    

    实现单向数据绑定

    <body>
      <input type="text" id="text">
      <br />
      数据绑定:<span id="sp"></span>
      
      <script>
        const input = document.getElementById('text')
        const span = document.getElementById('sp')
    
        const obj = {}
        Object.defineProperty(obj, 'val', {
          get() {
            return this.val
          },
          set(newVal) {
            span.innerText = newVal
          }
        })
        input.addEventListener('keyup', function() {
          obj.val = this.value
        })
      </script>
    </body>
    

    创建对象

    工厂模式
    fucntion person(name, age, male) {
      var o = new Object()
      o.name = name
      o.age = age
      o.male = male
      o.sayName = function() {
        console.log(this.name)
      }
      return o
    }
    person('小花', 16, '女孩儿')
    

    工厂模式虽然解决了相似对象的重复创建问题,但却没有解决对象识别的问题。

    构造函数模式
    function Person(name, age, male) {
      this.name = name
      this.age = age
      this.male = male
      this.sayName = function() {
        console.log(this.name)
      }
    } 
    const person = new Person('小玥', 16, 'female')
    

    构造函数方法创建对象与工厂模式,除了函数名首字母大写外(这个做法借鉴与其他OO语言),还有以下不同之处:
    1. 没有显示地创建对象
    2. 直接将属性和 方法赋给了 this对象
    3. 没有 return 语句
    要创建Person()的实例,必须使用 new 操作符。这种方式会经历以下四个步骤:

    1. 常见一个新对象
    2. 将构造函数的作用域给新对象(因为this就指向这个对象)
    3. 执行构造函数中的代码(为这个新对象添加属性)
    4. 返回新对象
      对象constructor属性用来标识对象类型,但是检查类型的话,还是instanceof 操作符更可靠一些。
      将构造函数当做函数
      构造函数与其他函数的唯一区别,在于他们的调用方式不同。任何函数只要通过new 操作符来调用,那它就可以作为构造函数。而任何函数,如果不通过new 操作符来调用,那它跟普通函数就不会有什么两样。
    // 当做构造函数使用
    const person = new Person('小花', '20', 'female')
    person.sayName() // 小花
    
    // 作为普通函数调用
    Person('小草', 17, 'female')
    window.sayName() // 小草
    
    // 在另一个对象的作用域中调用
    const o = {}
    Person.call(o, '小树', 22, 'male')
    o.sayName() // 小树
    

    构造函数的问题
    构造函数的模式虽然好,但也并非没有缺点。使用构造函数的主要问题,就是每个方法都要在每个实例上重新创建一遍。为了解决这个问题,我们可以通过把函数定义转移到构造函数外部来解决这个问题。

    function Person(name, age, job) {
      this.name = name
      this.age = age
      this.job = job
      this.sayName = sayName
    }
    function sayName() {
      console.log(this.sayName)
    }
    
    const person1 = new Person('小绿', 23, 'Doctor')
    const person2 = new Person('小天', 22, 'Coder')
    

    如上这种方式就可以解决声明多个函数的问题。但是随着会出现另外一个问题。在全局作用域中定义的函数实际上只能被某个对象调用,这让全局作用域有点名不副实。如果对象需要很多方法,那么就需要定义多个全局函数,那么我们自定义的引用类型就丝毫没有封装性可言了。
    原型模式
    原型模式可以解决上述的问题。我们创建的每一个函数都有一个 prototype(原型) 属性,这个属性是一个指针,指向一个对象,而这个对象的用途是包含可以由特定类型的所有实例共享的属性和方法。也就是说:通过构造函数创建的所有实例的原型对象。使用原型对象的好处是可以让所有对象实例共享它所包含的属性和方法。

    function Person() {}
    
    Person.prototype.name = '云裳'
    Person.prototype.age = 18
    Person.prototype.male = 'female'
    Person.prototype.sayName = function() {
      console.log(this.name)
    }
    const person1 = new Person()
    person1.sayName() // 云裳
    
    const person2 = new Person()
    person2.sayName() // 云裳
    
    console.log(person1.sayName === person2.sayName) // true
    

    理解原型对象

    无论什么时候,只要创建一个新函数,就会根据一组特定的规则为该函数创建一个 prototype 属性,这个属性执行函数的原型对象。默认情况下,所有原型对象都会自动获得一个 constructor 属性,指向 prototype 属性所在的函数。创建了自定义的函数之后,其原型对象默认只会取得 constructor 属性;至于其他方法,则是通过Object 继承而来的。实例 内部可以通过 __proto__访问构造函数的原型

    理解原型对象.png

    此外还有一些方法用于判断原型、实例和对象之间的关系。

    • isPrototypeOf()
      判断一个实例 和 对象原型 的关系
    console.log(Person.prototype.isPrototypeOf(person1)) // true
    console.log(Person.prototype.isPrototypeOf(person2)) // true
    
    • Object.getPrototypeOf()
      这个方法 返回 [[ prototype ]] 的值
    console.log(Object.getPrototypeOf(person1) === Person.prototype ) // true
    console.log(Object.getPrototypeOf(person2).name) // '云裳'
    

    我们可以通过实例去访问原型上的值,但却不能直接通过实例重写原型中的值。

    function Person() {}
    Person.prototype.name = '红'
    
    const person1 = new Person()
    const person2 = new Person()
    // 访问原型的 name值
    console.log(person1.name) // 红
    // 修改 person2的 name值
    person2.name = '绿'
    
    // 查看两个 实例的 name值,观察变化
    console.log(person1.name) // 红 --- 来自原型
    console.log(person2.name) // 绿 --- 来自实例
    

    实例访问值及赋值的过程

    当实例访问一个值的时候,会先去当前实自身上去寻找这个属性,如果没有就回去 当前实例的原型上寻找这个属性,如果还没有就会顺着原型链(如上图)继续往上,直到 Object 的原型。如果依然没有则为undefined
    当为对象实例添加一个属性的时候,这个属性就会屏蔽原型对象中保存的同名属性;话句话说,添加这个属性就会阻止我们访问原型中的那个属性,但不会删除那个属性。即使随后将实例中的属性设置为 null也只会在实例中设置这个属性,而不会恢复其执行原型的连接。不过可以通过 delete 操作符来完全删除实例属性。

    console.log(person2.name) // 绿
    // 将实例属性设置为 null
    person2.name = null
    console.log(person2.name) // null
    // 彻底删除实例属性
    delete person2.name
    console.log(person2.name) // 红
    

    hanOwnProperty()
    使用 hasOwnProperty() 方法可以检测一个属性 是属于当前实例的属性, 还是原型中的属性。如果是实例的属性 返回 true,反之原型的属性 返回 false

    function Person() {}
    Person.prototype.name = 'Meco'
    
    const person1 = new Person()
    const person2 = new Person()
    
    person1.name = 'cola'
    
    console.log(person1.name) // 'cola' --- 来自实例
    console.log(person1.hasOwnProperty('name')) // true
    console.log(person2.name) // 'Meco' --- 来自原型 
    console.log(person2.hasOwnProperty('name')) // false
    
    console.log(Person.prototype.getOwnPropertyDescriptor())
    

    原型 与 in 操作符
    有两种方法使用in操作符:单独使用和放在 for-in 循环中使用。在单独使用时,in操作符会在通过对象能够访问给定属性时返回 true,无论该属性存在于实例还是原型中。

    function Person() {}
    Person.prototype.name = '圣诞'
    Person.prototype.age = 2
    
    const person = new Person()
    person.name = '怪诞'
    console.log('name' in person) // true -- 实例存在
    console.log('age' in person) // true -- 原型存在
    console.log('nothing' in person) // false -- 都不存在
    

    同时使用 hasOwnProperty() 方法 和 in 操作符,就可以确定该属性是否存在于原型中。

    /*
    @methods: hasPrototypeProperty
    @desc: 判断传入属性是否时原型上的属性
    @param1: 要判断属性的实例
    @param2: 要判断的属性
    */
    function hasPrototypeProperty(obj, key) {
      return !obj.hasOwnProperty(key) && (key in obj)
    }
    // 测试
    function Person() {}
    Person.prototype.name = '了凡'
    
    const person = new Person()
    person.age = 2
    
    console.log(hasPrototypeProperty(person, 'name')) // true -- 原型
    console.log(hasPrototypeProperty(person, 'age')) // false -- 实例
    

    在使用 for - in 循环的时候,返回的是所有能够通过对象访问的、可枚举的(enumerated)属性。其中既可枚举实例中的属性,也可枚举原型中的属性。此外可以通过 ECMAScript 5 的 Object.keys() 方法。这个方法接受一个对象作为参数,返回一个包含所有可枚举属性的字符串数组。

    function Person() {}
     // 原型的属性
    Person.prototype.name = 'wdf'
    Person.prototype.hobby = 'watch movie' 
    
    const person = new Person()
     // 实例的属性
    person.age = 30
    person.gender = 'man'
    
    // 查看 实例可 每股的属性
    console.log(Object.keys(person)) // ["age", "gender"]
    // 查看原型上可枚举的属性
    console.log(Object.keys(person.__proto__)) // ["name", "hobby"]
    

    更简单的原型语法
    在之前,每个例子中添加原型上的属性或方法都需要敲一遍 Person.prototype。为了减少不必要的输入,也为了视觉上更好地封装原型的功能,常用时用一个包含属性和方法的字面量对象来重写整个原型对象

    function Person() {}
    Person.prototype = {
      name: '梅茜',
      age: -1,
      sayName() {
        console.log(this.name)
      }
    }
    const person = new Person()
    person.sayName() // 梅茜
    console.log(person.age) // -1
    

    上面的这种写法也存在一个问题。本来在创建一个函数的时候,就会同时创建它的prototype对象。如上的写法,本质上完全重写了默认的 prototype 对象,因此 constructor 属性也就变成了新对象(用来覆盖的字面量对象)的 constructor 属性(指向Object构造函数)。

    const person2 = new Person()
    
    // 但是这种清空下 instanceof 还是可用的
    console.log(person2 instanceof Object) // true
    console.log(person2 instanceof Person) // true
    console.log(person2.constructor === Object) // true
    console.log(person2.constructor === Person) // false
    

    如上,所有我们一般在用上面这种,使用字面了对象重写 prototype时,会重写直到 constructor 的指向

    function Person() {}
    Person.prototype = {
      constructor: Person,
      name: '怪诞'
    }
    

    但是上面这种方式不是最完美的,也会造成一个问题。通过上面这种方式重设 constructor 属性会导致它[[ Enumerable ]] 特性被设置为 true,默认情况下,这个属性的值 时 false(不可枚举)。所以我们可以通过 Object.defineProperty()

    function Person() {}
    // 重写原型对象
    Person.prototype = {
      name: '卢锡安',
      position: 'ADCarry'
    }
    //  重设构造函数
    Object.defineProperty(Person.prototype, 'constructor', {
      enumerable: false, // 不可枚举
      value: Person // 指向构造函数
    })
    
    

    原型的动态性
    由于在原型中查找值的过程是一次搜索,因此我们对原型对象所做的任何修改都能够立即从实例上反映出来

    function Person() {}
    
    // 先创建实例
    const person = new Person()
    
    // 在添加原型上的方法
    Person.prototype.sayHi = function() {
      console.log('Hello World')
    }
    person.sayHi() // Hello World
    

    尽管可以随时为原型添加属性和方法,并且修改能够立即在所有对象示例中反映出来,但如果是重写整个对象,那么情况就不一样了。这是因为,调用构造函数时会为实例添加一个指向最初原型的[[ prototype ]]指针,而把原型修改为另一个对象就等于切断了构造函数于最初原型之间的联系

    function Person() {}
    // 创建实例
    let person = new Person()
    
    // 重写前
    console.log(person.__proto__ ===  Person.prototype) // true
    // 重写原型对象
    Person.prototype = {
      constructor: Person,
      name: 'xxx',
      sayHi() {
        console.log('你好!!')
      }
    }
    // 重写后
    // 此时 person的 [[ Prototype ]]指针(__proto__)指向重写后的对象
    console.log(person.__proto__ ===  Person.prototype) // false
    person.sayName() // throw error
    

    原生对象的原型

    原型模式的重要性不仅体现在创建自定义类型方面,就连所有原生的引用类型,都是采用这种模式创建的。所有原生引用类型(Object、Array、String,等等)都在其构造函数的原型上定义了方法。例如 Array.prototype.soft()

    因此我们也可以修改原生对象的原型,为实例添加自定义的方法。如下:

    // 定义一个让单词首字母大写的方法
    String.prototype.firstUpperCase = function() {
      // 获取 当前调用实例的值
      let text = this.valueOf()
      // 正则匹配 替换
      let upperText = text.replace(/^(.)| (.)/g, function(match) {
        return match.toUpperCase()
      })
      // 返回
      return upperText
    }
    
    var str = "is life always this hard or is it just when you are a kid"
    // Is Life Always This Hard Or Is It Just When You Are A Kid
    console.log(str.firstUpperCase()) 
    

    原型对象的问题

    原型对象模式最大的问题是由其共享的本性所导致的。原型中的所有属性是被很多实例共享的,这种共享对于函数非常合适。对于包含基本值的属性也说得过去。然而,对于包含引用类型的值属性来说,问题就比较突出了。

    function Animal() {}
    
    Animal.prototype = {
      constructor: Animal,
      name: '圣诞',
      age: 2,
      friends: ['了凡', '纤风']
    }
    const petOne = new Animal()
    const petTwo = new Animal()
    
    petOne.friends.push('迪仔')
    
    console.log(petOne.friends) // ["了凡", "纤风", "迪仔"]
    console.log(petTwo.friends) // ["了凡", "纤风", "迪仔"]
    console.log(petOne.friends === petTwo.friends) // true
    

    由于 friends 数组存在于 Person.prototype 中 而非petOne 的实例中,所有我们的修改影响到 其他实例。一般来说我们都希望实例有自己的全部属性。而这个问题正是我们很少看到有人单独使用原型模式的原因所在。

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

    创建自定义类型的最常见方式,就是组合使用构造函数模式与原型模式。构造函数模式用于定义实例属性,而原型模式用于定义方法和共享属性。

    // 属性
    function Animal(name, age, male) {
      this.name = name
      this.age = age
      this.male = male
      this.friends = ['小伊', '哈比']
    }
    
    // 方法
    Animal.prototype = {
      constructor: Animal,
      sayName() {
        console.log(this.name)
      }
    }
    
    const pet1 = new Animal()
    const pet2 = new Animal()
    
    pet1.friends.push('叮当')
    console.log(pet1.friends) //  ["小伊", "哈比", "叮当"]
    console.log(pet2.friends) //  ["小伊", "哈比"]
    console.log(pet1.friends === pet2.friends) // false
    console.log(pet1.sayName === pet2.sayName) // true
    

    这种构造函数与原型混成的模式,是目前创建定义类型中认同度最高的方式

    动态原型模式

    相对于其他OO语言,JavaScript独立的构造函数和原型是比较怪异的。动态原型模式正是致力于解决这个问题的一个方案,他把所有信息都封装在构造函数中。可以通过检查某个应该存在的方法是否有效,来决定是否需要初始化原型。

    function Person(name, age) {
      // 属性
      this.name = name
      this.age = age 
    
      // 判断是否 初始化 方法
      if( typeof this.sayHi !== 'function') {
        // 初始化
        Person.prototype.sayHi = function() {
          console.log('Hello')
        }
      }
    }
    const person = new Person()
    person.sayHi() // Hello
    
    寄生构造函数模式

    通常,在前述的机中模式都不适用的情况下,可以使用寄生(parasitic)构造函数模式。

    function Person(name, age, job) {
      const o = new Object()
      o.name = name
      o.age = age
      o.job = job
      o.sayName = function() {
        console.log(this.name) 
      }
      return o
    }
    const friend = new Person('xxx', 19, 'coder')
    friend.sayName()
    

    上面这个例子,从表面上看,这个函数很像典型的构造函数。这种模式可以在特殊的情况下用来为对象创建构造函数。假设我们想创建一个具有额外方法的特殊数组。由于不能直接修改Array构造函数,因此可以使用这个模式。

    function SpecialArray() {
      // 创建数组
      const values = new Array()
      // 添加值
      values.push.apply(values, arguments)
      // 添加方法
      values.toPipedString = function() {
        return this.join('|')
      }
    
      return values
    }
    
    const colors = new SpecialArray('red', 'cyan', 'pink', 'violet')
    console.log(colors.toPipedString()) // red|cyan|pink|violet
    

    关于寄生构造函数模式,有一点需要说明:首先,返回对象于构造函数或者于构造函数的原型属性之间没有关系;也就是说,构造函数返回的对象于在构造函数外部创建的对象没有说明不同。为此不能依赖于 instanceof 操作符来确定对象类型。

    console.log(colors instanceof Array) // true
    console.log(colors instanceof SpecialArray) // false
    
    稳妥构造函数模式

    道格拉斯·克罗克福德(Douglas Crockford)发明了JavaScript中的稳妥对象(durable objects)这个概念。所谓稳妥对象,指的是没有公共属性,而且其方法也不引用this的对象。适用在一些安全的环境中,或者放在数据被其他应用程序改动时使用。
    稳妥构建函数遵循与寄生函数类似的模式,但是有两点不同:一是新创建对象的实例方法不引用this;二是不使用new操作符调用构造函数。

    function Person(name, age, job) {
      // 创建要返回的对象
      const o = new Object()
      // 可以在这里定义私有变量和函数
      
      // 添加方法
      o.sayName = function() {
        console.log(name) // 不使用this
      } 
      // 返回对象
      return o
    }
    const friend = Person('Nick', 22, 'Doctor')
    friend.sayName() // Nick
    

    这样, friend 就是一个稳妥的对象。除了调用 sayName() 办法外,没有别的方式可以访问其数据成员。

    继承

    继承是OO语言中一个最为人津津乐道的概念。许多OO语言都支持两种继承方式:接口继承实现继承。接口继承只继承方法的签命,而实现继承则继承实际的方法。ECMAScript中只支持实现继承,而且其实现继承主要依靠原型链来实现的。

    原型链

    ECMAScript中将原型链作为实现继承的主要方法。假如我们让原型对象等于另一个类型的实例,会怎么样勒?显然,此时的原型对象将包含一个指向另一个原型的指针,相应地,另一个原型中也包含着一个指向另一个构造函数的指针。如此层层递进,就构成了实例与原型的链条。这就是所谓原型链的基本概念。

    function SuperType() {
      this.property = true
    }
    SuperType.prototype.getSuperValue = function() {
      return this.property
    }
    
    function SubType() {
      this.subproperty = false
    }
    
    // 继承了SuperType(SuperType的实例)
    SubType.prototype = new SuperType()
    
    SubType.prototype.getSubValue = function() {
      return this.subproperty
    }
    
    let instance = new SubType()
    console.log(instance.getSuperValue()) // true
    

    以上代码定义了两个类型:SuperType 和 SubType。每个类型分别有一个原型。然后让SubType继承了 SuperType,而继承是通过创建SuperType 的实例,并将该实例赋给SubType.prototype实现的。可参考上方的 [理解原型对象.png]
    默认的原型
    事实上,前面例子中展示的原型链还少一环。所有的引用类型都继承了Object,而这个继承也是通过原型链实现的。所有的函数的默认原型都是Object的实例。
    确定原型和实例的关系
    可以通过两种方式来确定原型和实例之间的关系。第一种方式是使用 instanceof 操作符。第二种方式使用 inPrototypeOf() 方法。

    console.log(instance instanceof Object) // true
    console.log(instance instanceof SuperType) // true
    console.log(instance instanceof SubType) // true
    
    // 实例 和 原型判断
    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.subporperty = false
    }
    
    // 继承了 SuperType
    SubType.prototype = new SuperType()
    
    // 添加新方法
    SubType.prototype.getSubValue = function() {
      return this.subporperty 
    }
    
    // 重写超类型中的方法
    SubType.prototype.getSuperValue = function() {
      return false
    }
    
    const instance = new SubType()
    console.log(instance.getSuperValue()) // false
    

    此外还需要注意的是,即通过原型链实现继承时,不能使用对象字面量创建原型方法。因为这样做就会重写原型链

    // 继承
    SubType.prototype = new SuperType()
    
    // 使用字面量添加新方法。导致上面无效
    SubType.prototype = {
       //...
    }
    

    原型链的问题
    原型链虽然强大,但是也存在一些问题。其中,最主要的问题来自包含引用类型值的原型。与前面原型上定义引用类型的属性类似。

    function SuperType() {
      this.colors = ['red', 'bule', 'green']
    }
    function SubType() {}
    
    // 继承 SuperType
    SubType.prototype = new SuperType()
    
    const instance1 = new SubType()
    const instance2 = new SubType()
    
    instance1.colors.push('pink')
    console.log(instance1.colors) // ["red", "bule", "green", "pink"]
    console.log(instance2.colors) // ["red", "bule", "green", "pink"]
    

    第二个问题:在创建子类型的实例时,不能向超类型的构造函数中传递参数。实际上,应该说是没有办法在不影响所有对象实例的情况下,给超类型传递参数。
    借用构造函数
    借用构造函数(constructor stealing)的技术(有时候也叫做伪造对象或经典继承)。即在子类型构造函数的内部调用超类型构造函数。

    function SuperType() {
      this.colors = ['red', 'cyan', 'violet']
    }
    
    function SubType() {
      // 继承SuperType
      SuperType.call(this)
    }
    console.dir(SubType)
    
    const instance = new SubType()
    instance.colors.push('green')
    
    const instance2 = new SubType()
    
    console.log(instance.colors) //  ["red", "cyan", "violet", "green"]
    console.log(instance2.colors) //  ["red", "cyan", "violet"]
    

    上面的做法相当于 将超类型上面的属性。 赋值了一份注册到了SubType构造函数上
    传递参数
    借用构造函数还有一个很大的优势,即可以在子类型构造函数中向超类型构造函数传递参数。

    function SuperType(name) {
      this.name = name
    }
    
    function SubType() {
      // 继承了SuperType,同时传递参数
      SuperType.call(this, 'hanLL')
    
      // 定义属性
      this.age = 17
    }
    
    const  instance = new SubType()
    console.log(instance.name) // hanLL
    console.log(instance.age) // 17
    

    借用构造函数的问题
    在超类型的原型中定义的方法,对子类型而言是不可见的。结果所有类型都只能使用构造函数模式。考虑到这些问题,借用构造函数的技术也是很少单独使用。
    组合继承
    组合继承( combination inheritance),有时候也叫作伪经典继承,指的是将原型链和借用构造函数的技术组合到一块,从而发挥两者之长的一种继承模式。背后的思路是使用原型链实现对原型属性和方法的继承,而通过借用构造函数来实现对实例属性的继承。

    function SuperType(name) {
      this.name = name
      this.color = ['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()
    SubType.prototype.constructor = SubType
    SubType.prototype.sayAge = function() {
      console.log(this.age)
    }
    
    const instance1 = new SubType('了凡', 2)
    console.dir(instance1)
    instance1.sayName()
    instance1.sayAge()
    instance1.colors.push('violet')
    
    const instace2 = new SubType('纤风', 1)
    console.dir(instance2)
    

    这种方式巧妙在于,虽然指定原型的时候会在原型上出现超类型的属性。但是因为继承属性,在访问的时候,会将原型上的属性忽略。组合继承避免了原型和借用构造函数的缺陷,融合了他们的优点,成为JavaScript中最常用的继承模式(ES5)。而且, instanceof ()和 isPrototyoeOf() 也能够识别基于组合继承创建的对象。
    原型式继承
    道格拉斯·克罗克在 Prototypal Inheritance in JavaScript( JavaScript中的原型式继承 2006 )中介绍了一种实现继承的方法。这种方法并没有使用严格意义上的构造函数。他相反是借助原型可以基于已有的对象创建新对象,同时还不必因此创建自定义类型。为此,他给出了如下函数。

    function object(o) {
      function F() { }
      F.prototype = o
      return new F()
    }
    

    在 object() 函数内部,先创建了一个临时性的构造函数,然后将传入的对象作为这个构造函数的原型,最后返回了这个临时类型的一个新实例。从本质上来讲,object()对传入其中的对象执行了一次浅复制,如下:

    let person = {
      name: 'xxx',
      friends: ['x', 'xx']
    }
    let anotherPerson = object(person)
    anotherPerson.name = 'gray'
    anotherPerson.friends.push('gg')
    
    const yetAnotherPerson = object(person)
    yetAnotherPerson.name = 'linda'
    yetAnotherPerson.friends.push('cc')
    
    console.log(person.friends) // ["x", "xx", "gg", "cc"]
    

    ECMAScript 5 通过新增Object.create() 方法规范了原型式继承。这个方法接受两个残花:一个用作新对象原型的对象和(可选的)、一个为新对象定义额外属性的对象。在传入一个参数的情况下,Object.create() 和 object() 方法的行为相同。

    const person = {
      name: 'xx',
      friends: ['shelby', 'van']
    }
    
    const anotherPerson = Object.create(person)
    anotherPerson.name = 'Greg'
    anotherPerson.friends.push('Rob')
    
    const yetAnotherPerson= Object.create(person)
    yetAnotherPerson.name = 'Linda'
    yetAnotherPerson.friends.push('Baribe')
    
    console.log(person.friends) // ["shelby", "van", "Rob", "Baribe"]
    

    Object.create()方法的第二个参数与Object.defineProperties() 方法的第二个参数格式相同;每个属性都是通过自己的描述符定义的。以这种方式指定的任何属性都会覆盖原型对象上的同名属性。例如:

    const person = {
      name: 'xxx',
      friends: ['x', 'xx']
    }
    const anotherPerson = Object.create(person, {
      name: {
        value: 'Greg'  
      }
    })
    console.log(anotherPerson.name) // Greg
    

    在没有必要兴师动众创建构造函数,而只想让一个对象与另一个对象保持类似的情况下,原型式继承是完全可以胜任的。不过需要注意的是,包含引用类型的属性始终会共享相应的值,就像使用原型模式一样
    寄生式继承
    寄生式(parasitic)继承是与原型式继承紧密相关的一种思路,并且同样也是由克罗克福德 推广的,寄生式继承的思路与寄生构造函数和工厂模式类似,即创建一个仅用于封装继承过程的函数,该函数在内部以某种方式来增强对象,最后再像真的是它做了所有工作一样返回对象。

    function object(o) {
      function F() { }
      F.prototype = o
      return new F()
    }
    
    function createAnother(original) {
      const clone = object(original) // 调用函数创建一个新对象
      clone.sayHi = function() {  // 以某种方式增强这个对象
        console.log('hi')
      }
      return clone // 返回这个对象
    }
    

    可用像下面这样来使用 createAnother() 这个函数

    const person = {
      name: 'xxx',
      friends: ['shelby', 'Court', 'Van']
    }
    const anotherPerson = createAnother(person)
    anotherPerson.sayHi() // hi
    

    在主要考虑对象而不是自定义类型和构造函数的情况下,寄生式继承也是一种有用的模式。前面示范继承模式时使用的object() 函数也不是必须的;任何能够返回新对象的函数都适用于此模式。

    寄生组合式继承
    前面说过,组合继承是JavaScript最常用的继承模式;组合继承最大的问题是无论什么情况下,都会调用两次超类型构造函数;一次是在构建子类型原型的时候,另一次是在子类型构造函数内部。子类型最终会包含超类型对象的全部实例属性,但我们不得不在调用子类型构造函数时重写这些属性。

    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() // 第一次调用
    SubType.prototype.constructor = SubType
    SubType.prototype.sayAge = function() {
      console.log(this.age)
    }
    

    调用两次构造函数,会造成有两组 name 和 colors 属性,一组在实例上,一组在 SubType 的原型上。好在已经找到了解决这个问题的方法——寄生组合式继承。
    所谓寄生组合式继承,即通过借用构造函数来继承属性,通过原型链的混成形式来继承方法。其背后的基本思路是:不必为了制定子类型的原型而调用超类型的构造函数,我们所需要的无非就是超类型原型的一个副本而异。本质上就是使用寄生式继承来继承超类型的原型,然后再将结果指定给子类型的原型。

    function object(o) {
      function F() { }
      F.prototype = o
      return new F()
    }
    
    function inheritPrototype(subType, superType) {
      const prototype = object(superType.prototype) // 创建对象
      prototype.constructor = subType // 增强对象
      subType.prototype = prototype
    }
    
    function SuperType(name) {
      this.name = name
      this.colors = ['red', 'green', 'yellow']
    }
    SuperType.prototype.sayName = function() {
      console.log(this.name)
    }
    
    function SubType(name, age) {
      SuperType.call(this, name)
      
      this.age = age
    }
    
    // 用这个函数 代替了 之前的 SubType.prototype = new SuperType() 
    // 值继承原型上面的属性或方法
    inheritPrototype(SubType, SuperType)
    
    SubType.prototype.sayName = function() {
      console.log(this.age)
    }
    

    这个例子的高效率体现在他只调用一次 SuperType 构造函数,并且因此避免了在 SubType.prototype 上面创建不必要的、多余的属性。于此同时,原型链还能保持不变;因此,还能够正常使用 instanceof 和 isPrototypeof(). 普片认为寄生组合式继承是引用类型最理想的继承模式。

    小结

    ECMAScipt支持面向对(OO)编程,但不是使用类或者接口的情况下,可以采用下列模式创建对象

    • 工厂模式
      • 使用简单的函数创建对象,为对象添加属性和方法,然后返回对象。这个模式后来被构造函数模式所取代。
    • 构造函数模式
      • 创建自定义引用类型,就可以创建内置对象实例一样使用new 操作符。不过, 构造函数模式也有缺点, 即它的每个成员都无法得到复用,包括函数。由于函数可以不局限与任何对象(即与对象具有松散耦合的特点),因此没有理由奴在多个对象之间共享函数。
    • 原型模式
      • 使用构造函数的prototype 属性来指定那些应该共享的属性和方法。组合使用构造函数和原型模式时,是有构造函数定义实例属性,使用原型定义共享的属性或方法。、

    JavaScript主要通过原型链实现继承。原型链的构建是通过将一个类型的实例赋值给另一个构造函数的原型实现的。原型链的问题是对象实例共享所有继承的属性和方法,因此不适宜单独使用。解决这个问题的技术是借用构造函数,即在在类型构造函数的内部调用超类型构造函数。这样就可以做到每个实例都具有自己的属性,同时还能保证只是有构造函数模式来定义类型。使用最多的继承模式是组合继承模式这种模式使用原型链继承共享的属性和方法,而通过构造函数继承实例属性。
    此外,还存在下列可供选择的继承模式

    • 原型式继承
      • 可以在不必预先定义构造函数的情况下实现继承,其本质是执行对给定对象的浅复制。而复制得到的副本还可以进一步改造。
    • 寄生式继承
      • 与原型式继承非常相似,也是基于某个对象或某些信息创建一个对象,然后增强对象,最后返回对象。为了解决组合继承模式由于多次调用超类型构造函数而导致的低效率问题,可以将这个模式与组合继承一起使用。
    • 寄生组合式继承
      • 集寄生式继承和组合继承的优点于一身,是实现基于类型继承的最有效方式。

    相关文章

      网友评论

          本文标题:面向对象的程序设计

          本文链接:https://www.haomeiwen.com/subject/wgolkctx.html