04 - 对js的原型以及原型链的理解

105 阅读13分钟

本篇为阅读《红宝书》第八、九章节所作笔记,感受js的构造函数、原型等~

创建对象

工厂模式

  • 下面的例子展示了一种按特定接口创建对象的方式:

    • 可以用不同的参数多次调用这个函数,每次都会返回包含2个属性和1个方法的对象
    • 这种工厂模式虽然可以解决创建多个类似对象的问题,但没有解决对象标识问题(即新创建的对象是什么类型)
function createPerson(name, age) {
    let o = new Object();
    o.name = name;
    o.age = oge;
    o.sayName = function () {
        console.log(this.name);
    }
    return o
} 
let p1 = createPerson('john',18)
let p2 = createPerson('miki',18)

构造函数模式

按照惯例,构造函数名称的首字母都要大写,非构造函数则以小写字母开头

// 工厂模式的例子使用构造函数可以这样子写:
function Person(name, age) {
    this.name = name;
    this.age = age;
    this.sayName = function () {
        console.log(this.name);
    }
}
let p1 = new Person('john', 18)
let p2 = new Person('miki', 18)
p1.sayName()    // john
p2.sayName()    // miki

console.log(p1 instanceof Person);  // true
console.log(p1 instanceof Object);  // true
  1. ECMAScript中的构造函数是用于创建特定类型对象的,像Object和Array这样的原生构造函数,运行时可以直接在执行环境中使用。也可以自定义构造函数,以函数的形式为自己的对象类型定义属性和方法

  2. Person()内部代码和createPerson()基本一样,区别在于

    1. 没有显示地创建对象
    2. 属性和方法直接赋值给了this
    3. 没有return
  3. 要创建 Person 的实例,应使用 new 操作符,以这种方式调用构造函数会执行一下操作:

    1. 内存中创建一个新对象
    2. 这个新对象内部的[[Prototype]]特性被赋值为构造函数的prototype属性
    3. 构造函数内部的this被赋值为这个新对象(即this指向新对象)
    4. 执行构造函数内部的代码(给新对象添加属性)
    5. 如果构造函数返回非空对象,则返回该对象;否则,返回刚创建的新对象
  4. 定义自定义构造函数可以确保实例被标识为特定类型,例如以上的p1被认为是Object的实例,是因为所有自定义对象都继承自Object

  5. 赋值给变量的函数表达式也可以表示构造函数;在实例化时,如果不想传参数,构造函数后面的括号可加可不加,只要有 new 操作符,就可以调用相应的构造函数

    1. let Person = function() {}
      let p3 = new Person();
      let p4 = new Person;
      

构造函数也是函数

  1. 与普通函数的区别就是调用方式不同

  2. 任何函数只要使用 new 操作符调用就是构造函数

        function Person(name, age) {
            this.name = name;
            this.age = age;
            this.sayName = function () {
                console.log(this.name);
            }
        }
        // 作为构造函数
        let p1 = new Person('p1', 18);
        p1.sayName()  // p1
    
        // 作为函数调用  结果会将属性和方法添加到 全局对象(window)上
        Person('p2', 19)
        window.sayName();   // p2
    
        // 在另一个对象的作用域中调用
        let o = new Object();
        Person.call(o, 'p3', 20);   // 将对象o指定为Person内部的this值,执行完函数代码后,所有属性和方法都会添加到o上
        o.sayName()  // p3
    

构造函数的问题

  1. 其定义的方法会在每个实例上创建一遍,例如上述的sayName方法,在p1和p2实例中都会重新创建,即使函数同名且做一样的事情

        console.log(p1.sayName == p2.sayName);   // false
    
  2. 解决这个问题,可以将函数定义转移到构造函数外部

    1. 在构造函数内部,sayName属性中包含的只是一个指向外部函数的指针,所以p1和p2共享了定义在全局作用域上的sayName函数
    2. 虽然解决了相同逻辑的函数重复定义的问题,但是全局作用域因此被搞乱了,因为sayName函数实际上只能在一个对象上调用。如果这个对象需要多个方法,就需要在全局作用域定义多个函数,导致自定义类型引用的代码不能很好地聚集一起,该方法通过原型模式来解决
        function Person(name, age) {
            this.name = name;
            this.age = age;
            this.sayName = sayName
        }
        function sayName() {
            console.log(this.name)
        }
    

原型模式

  1. 每个函数都会创建一个 prototype 属性,这个属性是一个对象,包含一些共享的属性和方法

  2. 实际上,这个对象就是通过调用构造函数创建的对象的原型

  3. 使用原型对象的好处:在它上面定义的属性和方法可以被所有对象实例共享

        // 使用函数表达式也可以:
        let Person = function () {
            Person.prototype.sayName = function () {
                console.log('this is prototype.sayName');
            }
        }
        let p1 = new Person();
        p1.sayName()   // this is prototype.sayName
        let p2 = new Person();
        p2.sayName()   // this is prototype.sayName
        console.log(p1.sayName === p2.sayName);  // true
    

理解原型

  1. 只要创建一个函数,就会为这个函数创建一个prototype属性(指向原型对象

  2. 默认情况下,所有原型对象自动获取一个名为constructor的属性,指回与之关联的构造函数

  3. 在自定义构造函数时,原型对象默认只会获得 constructor属性,其它所有方法都继承Object

  4. 每次调用构造函数创建一个新实例,这个实例的[[prototype]]指针会被赋值为构造函数的原型对象。 在Chrome浏览器可以通过 __proto__ 属性访问对象的原型

  5. 理解:实例与构造函数原型之间有直接联系,实例与构造函数之间没有

    1. Person.prototype指向原型对象,Person.prototype.constructor指回Person构造函数
    2. 原型对象包含 constructor属性和其它后来添加的属性
    3. 两个实例p1和p2都只有一个内部属性指回Person.prototype,而且两者都与构造函数没有直接联系
    4. 注意:虽然p1和p2都没有属性和方法,但是可以调用sayName方法,是由于对象属性查找机制的原因
    5. 可以通过isPrototypeOf() 方法确定两个对象之间的关系
        console.log(Person.prototype.isPrototypeOf(p1)); // true
        console.log(Person.prototype.isPrototypeOf(p2)); // true
        // 因为这两个例子内部都有链接指向Person.prototype 所以都返回true
    
  • Object类型有 Object.getPrototypeOf() 方法,返回参数的内部特性 [[prototype]] 的值

  • 避免使用 Object.setPrototypeOf(),可以通过Object.create() 来创建一个新对象,同时为其指定原型

    • console.log(Object.getPrototypeOf(p1));   // {sayName: ƒ, constructor: ƒ}
      console.log(Object.getPrototypeOf(p1) === Person.prototype); // true
      Object.getPrototypeOf(p1).sayName()       // this is prototype.sayName
      

原型层级

  1. 通过对象访问属性时,会先在对象实例本身搜索,如果没找到这个属性,则会沿着指针进入原型对象,若找到对应属性,则返回对应的值,p1和p2调用sayName都是同意的搜索过程,这就是原型用于在多个对象实例间共享属性和方法的原理

  2. 只要给对象实例添加一个属性,这个属性就会遮蔽原型对象上的同名属性,虽然不会修改到原型对象上的属性值,但是搜索过程在对象实例中找到就不会到原型对象上查找。

    1. 即使把该属性设置为null,也是会遮蔽
    2. 可以使用delete操作符完全删除实例上的这个属性,从而让标识符解析过程能够继续搜索原型对象
  3. hasOwnProperty() 方法用于确定某个属性是在实例上还是原型对象上,存在实例上返回true,原型对象上返回false

    1. let Person = function () {
          Person.prototype.name = 'protoName'
      }
      console.log(p1.name);   // protoName
      console.log(p1.hasOwnProperty('name'));  // false  此时name存在于原型对象
      
      p1.name = 'p1Name'  
      console.log(p1.name);   // p1Name
      console.log(p1.hasOwnProperty('name'));  // true   此时name存在于实例上
      
      p1.name = null;
      console.log(p1.name);   // null 
      
      delete p1.name;
      console.log(p1.name);   // protoName
      

原型和in操作符

单独使用

  1. in操作符会在可以通过对象访问指定属性时返回 true

  2. 基于hasOwnProperty() 返回false 和 in返回true 可以确定某个属性是否存在于原型上

    1. let Person = function () {
          Person.prototype.name = 'protoName'
      }
      console.log('name' in p1);   // true
      console.log(p1.hasOwnProperty('name'));  // false  此时name存在于原型对象
      

在 for...in 中使用in操作符

  1. 可以通过对象访问且可以被枚举的属性都会返回,包括实例属性和原型属性

  2. Object.keys() 方法可以获得对象上所有可枚举的实例属性,该方法接收一个对象作为参数,返回包含该对象所有可枚举属性名称的字符串数组

    1. p1.name = 'p1Name';
      p1.age = 18
      console.log(Object.keys(Person.prototype)); // ['name', 'sayName']
      console.log(Object.keys(p1));   // ['name', 'age']
      for (let item in p1) {
          console.log(item);
      }    // name age sayName
      
      console.log(Object.getOwnPropertyNames(Person.prototype));  // ['constructor', 'name', 'sayName']
      console.log(Object.getOwnPropertyNames(p1));  // ['name', 'age']
      

继承

原型链

  1. ECMAScript-262把原型链定义为ECMAScript的主要继承方式,基本思想:通过原型继承多个引用类型的属性和方法,如下代码示例

    1. 我们定义了两个类型 a b,它们的主要区别是 b 通过创建 a 的实例并将该实例赋值给 b 的原型,实现对 a 的继承
    2. 这个赋值重写了 b 最初的原型,这意味着 a 实例可以访问的所有属性和方法也会存在于 b.prototype
    3. 这样实现继承后,又给 b 的原型(也是a的实例)添加了一个新方法,最后创建 b 的实例 c 并调用它继承的getAValue方法
        function a() {
            this.aName = 'a'
        }
        a.prototype.getAValue = function () {
            return this.aName
        }
        function b() {
            this.bName = 'b'
        }
        // 继承a
        b.prototype = new a()
        b.prototype.getBValue = function () {
            return this.bName
        }
        let c = new b()
        console.log(c.getAValue());    // a
        console.log(c.constructor);    // f a()
    

    1. 注意:getAValue方法还在a原型上,而 aName属性则在b原型上,这是因为getAValue是一个原型方法,而aName是一个实例属性,b.prototype现在是a的一个实例,因此aName才会存储在它上面
    2. 还要注意:b.prototype的 constructor 被重写为指向 a,所以c.constructor也指向a
  2. 默认原型

    1. 所有引用类型都继承自 Object,任何函数的默认原型都是一个Object的实例,这意味着这个实例有一个内部指针指向 Object.prototype
  3. 原型与继承关系

可以通过两种方式来确定

instanceof

  • 如果一个实例的原型链上出现过相应的构造函数,则返回true
console.log(c instanceof Object);  // true
console.log(c instanceof a);  // true
console.log(c instanceof b);  // true

isPrototypeOf

  • 原型链中的每个原型都可以调用这个方法,只要原型链中包含这个原型,则返回true
console.log(Object.prototype.isPrototypeOf(c));  // true
console.log(a.prototype.isPrototypeOf(c));  // true
console.log(b.prototype.isPrototypeOf(c));  // true
  1. 关于方法
  • 子类有时候需要覆盖父类的方法,或者增加父类没有的方法,必须在原型赋值后再添加到原型上

类定义

  1. 两种定义方式

类声明

Class Person {}

类表达式

Const Person = class {}

  • 函数声明可以提升,但是类定义不能

  • 函数受函数作用域限制,类受块作用域限制

    • console.log(a);
      function a() { }    // f a(){}
      console.log(b);
      class b { }         // Error: Cannot access 'b' before initialization
      
      {
          function c() { }
          class d { }
      }  
      console.log(c);    // f c(){}
      console.log(d);    // Error: Cannot access 'd'
      
  1. 类的构成

    1. 可以包含构造函数方法、实例方法、获取函数、设置函数和静态类方法,这些都是非必需的
    2. 一般建议类名首字母大写

类构造函数

  1. 通过 constructor 关键字在类定义块内创建类的构造函数,非必需

  2. 通过 new 操作符实例化,使用new调用类的构造函数会执行如下操作:

    1. 在内存中创建一个新对象
    2. 新对象内部的[[prototype]]指针指向构造函数的prototype属性
    3. 构造函数中的this指向该新对象
    4. 执行构造函数内部的代码(给新对象添加属性)
    5. 若构造函数返回非空对象,则返回该对象,否则返回刚创建的对象
  3. 实例化传入的参数会用作构造函数的参数,如果不需要参数,则类名后面的括号也是可选的

        class Person {
            constructor(name) {
                console.log(arguments.length);
                this.name = name || null
            }
        }
        let p1 = new Person;
        console.log(p1.name);  // 0  null
        let p2 = new Person();
        console.log(p2.name);  // 0 null
        let p3 = new Person('p3Name');
        console.log(p3.name);  // 1 p3Name
    
  4. 类构造函数 与 构造函数 的主要区别

    1. 调用类构造函数必须使用new操作符,如果没有使用new会抛出错误
    2. 普通构造函数如果不使用new调用,会以全局的this(通常为window)作为内部对象

实例、原型和类成员

  1. 实例成员

    1. 每个实例都对应一个唯一的成员对象,这意味着所有成员不会在原型上共享

          class Person {
              constructor() {
                  this.name = new String('Jack');
                  this.sayName = () => console.log(this.name);
                  this.chooseName = ['one', 'two']
              }
          }
      
          let p1 = new Person();
          let p2 = new Person();
          p1.sayName();    // String{'Jack'}
          p2.sayName();    // String{'Jack'}
          console.log(p1.name === p2.name, p1.sayName === p2.sayName, p1.chooseName === p2.chooseName); 
          // false false false
          p1.name = p1.chooseName[0];
          p2.name = p2.chooseName[1];
          p1.sayName();    // one
          p2.sayName();    // two
      
  2. 原型方法与访问器

    1. 在类块中定义的方法作为原型方法,在实例间可以共享

          class Foo {
              constructor() {
                  // 添加到this的所有内容都会存在于不同的实例上
                  this.locate = () => console.log('instance');
              }
              locate() {
                  console.log('prototype');
              }
          }
          let f = new Foo();
          f.locate();    // instance
          Foo.prototype.locate();  // prototype
      
    2. 类定义支持获取和设置访问器,语法与行为和普通对象一样

  3. 静态类方法

    1. 静态成员在类定义中使用 static 关键字作前缀,其内部this引用类自身
    2. 静态成员每个类上只能有一个

继承

  1. 使用 extends 关键字

    1. 可以继承任何拥有 [[Construct]] 和原型的对象,这意味着不仅可以继承一个类,也可以继承普通的构造函数
        class Person {
            identify() {
                console.log(this)
            }
        }
    
        class Bus extends Person { }
        let b = new Bus();
        console.log(b instanceof Bus);  // true
        console.log(b instanceof Person);  // true
        b.identify()   // Bus {}
    
  2. 派生类的方法可以通过 super 关键字引用它们的原型

    1. 该关键字只能在派生类中使用,并且仅限于类构造函数、示例方法、静态方法内部
    2. 在派生类构造函数中使用super关键字可以调用父类构造函数
        class Bus extends Person {
            constructor() {
                super();   // 不要在调用super之前引用this,否则抛出异常
                console.log(this);   // Bus {}
                console.log(this instanceof Person);  // true
            }
        }
    
  3. 抽象基类

    1. 通过new.target保存通过new关键字调用的类或函数,通过在实例化时检测 new.target 是不是抽象基类,可以实现本身不被实例化,但可供其他类继承
        class Vehicle {
            constructor() {
                console.log(new.target);
                if (new.target === Vehicle) {
                    throw new Error('no new self')
                }
            }
        }
        class v extends Vehicle { };
        new v()   // class v extends Vehicle
        new Vehicle();   // class Vehicle  -- Error: no new self
    

总结

  1. 构造函数、原型、实例间的关系:每个构造函数都有一个原型对象,原型中存在一个constructor属性指回构造函数,而实例中有一个内部指针(例Chrome暴露出的__proto__)指向原型
  2. 原型模式通过添加在构造函数的prototype上的属性和方法,实现实例间共享
  3. JS的继承主要通过原型链来实现,将构造函数的原型赋值为另外一个类型的实例,这样子,子类即可访问父类所有的属性和方法,就像基于类的继承。问题是无法做到实例私有。
  4. 类是基于原型机制的语法糖,其构造函数上的属性和方法为实例私有,其他实例间共享,通过extends关键字可以继承类
  5. 明白 new 操作符实例一个对象的过程,在内存中创建一个新对象,赋值[[prototype]],绑定this