JavaScript - 构造函数、原型、原型链、继承

438 阅读18分钟

构造函数

概念

构造函数就是提供一个生成对象的模板并描述对象基本结构的函数。一个构造函数可以生成多个对象,这些对象都有相同的结构

构造函数本身就是一个函数,不过为了规范一般将其首字母大写

构造函数普通函数的区别在于使用 new 生成实例的函数就是构造函数,直接调用的就是普通函数

生成对象实例时必须使用 new 命令来调用构造函数,所以构造函数更合理的理解应该是 函数的构造调用

constructor 返回创建实例对象是构造函数的引用,此属性的值是对函数本身的引用,而不是一个包含函数名称的字符串

function Person(age) { 
  this.age = age; 
} 
var p = new Person(18); 
p.constructor === Person; // true 
p.constructor === Object; // false

普通函数创建的实例是不是一定没有 constructor 属性呢?不一定

// 普通函数
function person(age) {
  this.age = age;
}
var p = person(20); // undefined
p.constructor; // Cannot read property 'constructor' of undefined

// 普通函数
function person(age) {
  return {
    age: age
  }
}
var p = person(20);
p.constructor === Object; // true

Symbol 是否是构造函数

MDN 是这样介绍的:

The Symbol() function returns a value of type symbol, has static properties that expose several members of built-in objects, has static methods that expose the global symbol registry, and resembles a built-in object class but is incomplete as a constructor because it does not support the syntax "new Symbol()"

Symbol 是基本数据类型,但作为构造函数来说它并不完整,因为它不支持语法 new Symbol(),因此认为其不是构造函数,若要生成实例直接使用 Symbol() 即可(来自 MDN),每个从 Symbol() 返回的值都是唯一的

new Symbol(1); // Uncaught TypeError: Symbol is not a constructor
Symbol(1); // Symbol(1)

虽然是基本数据类型,但 Symbol(1) 实例可获取 constructor 属性值

var a = Symbol(1);  // Symbol(1)
console.log(a.constructor); // ƒ Symbol() { [native code] }

这里的 constructor 属性其实是 Symbol 原型上的,即 Symbol.prototype.constructor 返回创建实例原型的函数,默认为 Symbol 函数

constructor 值是否只读

对于引用类型来说 constructor 属性值是可以修改的,但对于基本类型来说是只读的

引用类型情况其值可修改这个很好理解,如原型链继承方案中就需对 constructor 重新赋值进行修正

function Foo() {
  this.value = 42;
}
Foo.prototype = {
  method: function() {}
};

function Bar() {}

// 设置 Bar 的 prototype 属性为 Foo 的实例对象
Bar.prototype = new Foo();
Bar.prototype.foo = 'Hello JS';

Bar.prototype.constructor === Object; // true
var test = new Bar() // 创建 Bar 的一个新实例
console.log(test); 

image.png

// 修正 Bar.prototype.constructor 为 Bar 本身
Bar.prototype.constructor = Bar;

var test = new Bar() // 创建 Bar 的一个新实例
console.log(test);

image.png

对于基本类型来说是只读的,如 1"1"trueSymbolnullundefined 是没有 constructor 属性的)

function Type() {};
const types = [1, "1", true, Symbol(1)];

for(let i = 0, len = types.length; i < len; i ++) {
  types[i].constructor = Type;
  types[i] = [types[i].constructor, types[i] instanceof Type, types[i].toString()];
};
console.log(types.join("\n"));

image.png

为什么呢?因为创建它们的是只读的原生构造函数 native constructors,这个例子也说明了依赖一个对象的 constructor 属性并不安全

模拟实现 new

new 运算符创建一个用户定义的对象类型的实例或具有构造函数的内置对象的实例 ——(来自于 MDN

当执行 new Foo(...) 时,会发生以下事情:

  • 一个继承自 Foo.prototype 的新对象被创建
  • 使用指定的参数调用构造函数 Foo 并将 this 绑定到新创建的对象上(new Foo 等同于 new Foo(),即没有指定参数列表,Foo 不带任何参数调用的情况)
  • 由构造函数返回的对象就是 new 表达式的结果,若构造函数没有显式返回一个对象,则使用步骤 1 创建的对象
// 第一版
function createNew() {
  // 创建一个空的对象
  let obj = new Object(); 
  // 获得构造函数,arguments 中去除第一个参数
  const Con = [].shift.call(arguments);
  // 链接到原型
  // 创建一个原型为构造器的 prototype 的空对象 obj
  // const obj = Object.create(constructor.prototype);  
  obj.__proto__ = Con.prototype;
  // 绑定 this 实现继承,使用 apply 改变构造函数 this 的指向到新建的对象,这样 obj 就可以访问到构造函数中的属性
  Con.apply(obj, arguments);
  // 返回对象
  return obj;
};

测试一下

function Car(color) {
    this.color = color;
}
Car.prototype.start = function() {
    console.log(this.color + " car start");
}

var car = createNew(Car, "black");
car.color;
// black

car.start();
// black car start

上面的代码已经实现了 80%,继续优化,构造函数返回值有如下三种情况:

  • 返回一个对象。this 失效,实例 person 中只能访问到返回对象中的属性

    function Person(age, name) {
      this.age = age;
      return {
        name: name
      }
    }
    
    const person = new Person(18, "donna");
    person.age; // undefined
    person.name; // "donna"
    
  • 没有 return,即返回 undefined

    实例 person 中只能访问到构造函数中的属性,和上面完全相反

    function Person(age, name) {
      this.age = age;
    }
    
    const person = new Person(18, "tn");
    person.age; // 18
    person.name; // undefined
    
  • 返回 undefined 以外的基本类型

    实例 person 中只能访问到构造函数中的属性,和情况 1 相反,结果相当于没有返回值

    function Person(age, name) {
      this.age = age;
      return "new person";
    }
    
    const person = new Person(18, "tn");
    person.age; // 18
    person.name; // undefined
    

所以需要判断返回值是不是一个对象,若是对象则返回该对象,不然返回新创建的 obj 对象,实现代码如下:

// 第二版 
function createNew() { 
  // 创建一个空的对象 
  let obj = new Object(); 
  // 获得构造函数,arguments 中去除第一个参数 
  const Con = [].shift.call(arguments); 
  // 链接到原型,obj 可以访问到构造函数原型中的属性 
  obj.__proto__ = Con.prototype; 
  // 绑定 this 实现继承,obj 可以访问到构造函数中的属性 
  const ret = Con.apply(obj, arguments); 
  // 优先返回构造函数返回的对象 
  return ret instanceof Object ? ret : obj; 
};

可继续优化实现 new,这里不使用 __proto__

function create() {
  // 1、获得构造函数,同时删除 arguments 中第一个参数
  Con = [].shift.call(arguments);
  // 2、创建一个空的对象并链接到原型,obj 可以访问构造函数原型中的属性
  var obj = Object.create(Con.prototype);
  // 3、绑定 this 实现继承,obj 可以访问到构造函数中的属性
  var ret = Con.apply(obj, arguments);
  // 4、优先返回构造函数返回的对象
  return ret instanceof Object ? ret : obj;
};

扩展:如何确保构造函数只能被 new 调用而不能被普通调用?

JS 中的函数一般有两种使用方式:

  • 当作构造函数使用:new Func()
  • 当作普通函数调用:Func()

JS 内部并没有区分两者的方式,我们人为规定构造函数的函数名首字母要大写作为区分,但构造函数被当成普通函数调用不会有报错提示

function Person(firstName) {
  this.firstName = firstName;
}
// 使用 new 调用
console.log(new Person("tn"));  // Person {firstName: 'tn'}

// 当作普通函数调用
console.log(Person("tn"));  // undefined

使用 new 调用函数和普通调用函数最大的区别在于函数内部 this 指向不同:newthis 指向实例,普通调用则一般会指向 window

new 绑定/默认绑定

  • 通过 new 来调用构造函数会生成一个新对象,且把这个新对象绑定为调用函数的 this
  • 若普通调用函数,非严格模式下 this 指向 window,严格模式下指向 undefined

因此要限制构造函数只能被 new 调用,可以使用以下几种方案:

  • 借助 instanceofnew 绑定的原理,适用于低版本浏览器

    instanceof 运算符用于检测构造函数的 prototype 属性是否出现在某个实例对象的原型链上,使用语法如下:

    object instanceof constructor
    

    可使用 instanceof 检测某个对象是不是另一个对象的实例,如 new Person() instanceof Person --> true

    若为 new 调用,this 指向实例,this instanceof 构造函数 --> true,普通调用的则返回 false。代码实现:

    function Person(firstName) {
      // this instanceof Person
      // 若返回 false 说明为普通调用
      // 返回类型错误信息--当前构造函数需要使用 new 调用
      if(!(this instanceof Person)) {
        throw new TypeError('当前构造函数需要使用 new 调用');
      }
      this.firstName = firstName;
    }
    // 当作普通函数调用时
    console.log(Person("tn")); // Uncaught TypeError: 当前构造函数需要使用 new 调用
    

    由上可见,定义的 Person 构造函数已经无法被普通调用

    但这种方案存在一点小瑕疵,可以通过伪造实例的方法骗过构造函数里的判断。具体实现:JS 提供的 apply/call 方法可以修改 this 指向,若调用时将 this 指向修改为 Person 实例,就可成功骗过上面的语法

    console.log(Person.call(new Person(), "tn"));  // 输出 undefined
    

    ES6 中提供更好的方案

  • 借助 ES6 提供的 new.target 属性,可与 class 配合定义抽象类

    ECMAScript 6入门》中提到:ES6new 命令引入了一个 new.target 属性,该属性一般用在构造函数中,返回 new 命令作用于的那个构造函数,若构造函数不是通过 new 命令或 Reflect.construct() 调用的,new.target 会返回 undefined,因此该属性可以用来确定构造函数是怎么调用的

    new.target 就是为确定构造函数的调用方式而生的,用法如下:

    function Person() {
      console.log(new.target);
    }
    
    // new 调用
    console.log("new: ", new Person()); // new:  Person {}
    
    // 普通调用
    console.log("not new: ", Person()); // not new:  undefined
    

    可以使用 new.target 来非常简单的实现对构造函数和调用的限制:

    function Person() {
      if(!(new.target)) {
        throw new TypeError('当前构造函数需要使用 new 调用');
      }
    }
    console.log("not new: ", Person()); // Uncaught TypeError: 当前构造函数需要使用 new 调用
    
  • 面向对象编程使用 ES6 Class(最佳方案)

    ES6Class 限制构造函数只能被 new 调用

    ES6 提供的 Class 作为构造函数的语法糖,实现语义化更好的面向对象编程,且对 Class 进行的规定:类的构造器必须使用 new 调用,因此后续在进行面向对象编程时,强烈推荐使用 ES6Class

    Class 修复了很多 ES5 面向对象编程的缺陷,如类中的所有方法都是不可枚举的类的所有方法都无法被当作构造函数使用

    class Person {
      constructor(name) {
        this.name = name;
      }
    }
    
    console.log(Person()); // Uncaught TypeError: Class constructor Person cannot be invoked without 'new'
    

扩展:既然 Class 必须使用 new 来调用,那 new.target 属性的意义是什么?

答案:可以实现抽象类

Class 内部调用 new.target,会返回当前 Class

《ECMAScript 6入门》中也讲到:需要注意,子类继承父类时,new.target 会返回子类

class Animal {
  constructor(type, name, age) {
    this.type = type;
    this.name = name;
    this.age = age;
    console.log(new.target);
  }
}
// extends 是 Class 中实现继承的关键字
class Dog extends Animal {
  constructor(name, age) {
    super("dog", "bao", "1")
  }
}
const dog = new Dog();

image.png

通过上面例子可以发现,子类调用和父类调用的返回结果是不同的,利用这个特性就可以实现父类不可调用而子类可以调用的情况(面向对象中的抽象类)

什么是抽象类?

  • 以上面为例,定义了一个动物类 Animal 且通过这个类来创建动物
  • 动物是个抽象的概念,当提到动物类时并不知道会创建什么动物,只有将动物实体化,如猫、狗,这才是具体的动物且每个动物的行为都会有所不同,因此不应通过创建 Animal 实例来生成动物
  • Animal 只是动物抽象概念的集合,这里 Animal 就是一个抽象类,应该通过它的子类如 Dog 等来生成对应的 dog 实例

new.target子类调用和父类调用的返回值是不同的,因此可以借助 new.target 实现抽象类

class Animal{
  constructor(type, name, age) {
    if(new.target === Animal) {
      throw new TypeError('Abstract class cannot new');
    }
    this.type = type;
    this.name = name;
    this.age = age;
  }
}
class Dog extends Animal{
  constructor(name, age) {
    super("dog", "bao", "1")
  }
}

const dog = new Animal("dog", "bao", 18); // Uncaught TypeError: Abstract class cannot new

函数对象和普通对象

经常看到一句话说:万物皆对象,对象就是属性的集合(对象里的一切都是属性,只有属性没有方法,方法也是一种属性,因为它的属性表示为键值对的形式)

而在 JS 中,创建对象有几种方式,如对象字面量 、通过构造函数 newObject.create()

image.png

image.png

暂且先不管上面的代码有什么意义,至少能看出它们都是对象却存在着差异性

JS 中可以将对象分为函数对象普通对象

函数对象JS 中用函数来模拟类实现,如 ObjectFunction 就是典型的函数对象

下述代码中 obj1obj2obj3obj4 都是普通对象,fun1fun2fun3 都是 Function 的实例,即函数对象

function fun1() {};
const fun2 = function() {};
const fun3 = new Function('name','console.log(name)');

const obj1 = {};
const obj2 = new Object();
const obj3 = new fun1();
const obj4 = new new Function();

console.log(typeof Object); // function
console.log(typeof Function); // function
console.log(typeof fun1); // function
console.log(typeof fun2); // function
console.log(typeof fun3); // function

console.log(typeof obj1); // object
console.log(typeof obj2); // object
console.log(typeof obj3); // object
console.log(typeof obj4); // object

因此,所有 Function 的实例都是函数对象,其他均为普通对象,其中包括 Function 实例的实例

JS 中万物皆对象,而对象皆出自构造(构造函数)。这里的理解是

  • 所有对象均由 new 操作符后跟函数调用来创建的
  • 字面量表示法只是语法糖(即本质也是 new,功能不变、使用更简洁),无论是 function Foo() 还是 let a = { b : 1 }
function Foo() {};
// function 就是个语法糖
// 内部等同于 new Function()

let a = { b: 1 };
// 这个字面量内部也是使用了 new Object()

对于创建一个对象来说,更推荐使用字面量的方式创建,因为使用 new Object() 的方式创建对象需要通过作用域链一层层找到 Object,但是使用字面量的方式就没这个问题

NumberStringBooleanArrayObjectFunctionDateRegExpError 等都是函数,是内置的原生构造函数,在运行时会自动出现在执行环境中

构造函数是为了创建特定类型的对象,通过同一构造函数创建的对象有相同原型、共享某些方法。如:所有的数组都可以调用 push 方法,因为它们有相同原型

原型原型链均来源于对象而服务于对象的概念

原型(prototype)

概念

JS 常被描述为一种基于原型的语言 (prototype-based language) ,这个和 Java 等基于类的语言不一样

原型是 JavaScript 语言面向对象系统的根本

类是对一类实体的结构、行为的抽象。在基于类的面向对象语言中,首先关注的是抽象 —— 需要先把具备通用性的类给设计出来,才能用这个类去实例化一个对象,进而关注到具体层面的东西

而在 JS 这样的原型语言中,首先需要关注的就是具体 —— 具体的每一个实例的行为。根据不同实例的行为特性,把相似的实例关联到一个原型对象里去。在这个被关联的原型对象里,就囊括了那些较为通用的行为和属性。基于此原型的实例,都能 “复制” 它的能力

在原型编程范式中,正是通过复制来创建新对象。但这个“复制”未必一定要开辟新的内存、把原型对象照着再实现一遍 —— 复制的是能力,而不必是实体。如在 JS 中就是通过使新对象保持对原型对象的引用来做到了 “复制”

每个对象拥有一个原型对象,对象以其原型为模板,从原型继承方法和属性,这些属性和方法定义在对象的构造器函数的 prototype 属性上,而非对象实例本身

prototype - object that provides shared properties for other objects

在规范里 prototype 被定义为:给其它对象实例提供共享属性的对象。因此 prototype 自己本身也是对象,只是被用以承担某个职能罢了

只有函数才拥有该属性,它是 function 对象 的一个显式原型属性,当声明一个函数时该属性就被自动创建了,它定义了构造函数制造出来的对象实例的公共祖先,通过该构造函数产生的对象可以继承该原型上的属性和方法

基本上所有函数都有这个属性,但也有例外。若用以下方法创建一个函数,可发现这个函数是不具有 prototype 属性

因为 Function.prototype 是引擎创建出来的函数对象,引擎认为不需要给这个对象添加 prototype 属性。Function.prototype.bind 也是一样

let fun = Function.prototype.bind();

在 prototype 上添加属性和方法,每个构造出来的对象实例都可继承这些属性和方法。虽然每个对象都是独立的,但它们都有共同的祖先,当访问这个对象的属性时,若对象本身没有该属性则会往上找到它的原型,然后在原型上访问这个属性

constructor

prototype 有个默认属性 constructor,指向一个函数,这个函数就是该对象的构造函数

Person.prototype.constructor === Person // true

constructor 是个公有且不可枚举属性,一旦改变了函数的 prototype,那新对象就没有这个属性(可通过原型链取到 constructor

image.png

注意,每个对象都有其对应的构造函数,本身或继承而来

  • 单从constructor 这个属性来讲只有 prototype 对象才有
  • 每个函数在创建时 JavaScript 会同时创建一个该函数对应的 prototype 对象
函数创建的对象.__proto__ === 该函数.prototype

函数.prototype.constructor === 该函数本身

故通过函数创建的对象即使自己没有 constructor 属性,它也能通过 __proto__ 找到对应的 constructor,所以任何对象最终都可以找到其对应的构造函数

其实这个属性可以说是一个历史遗留问题,它有两个作用:

  • 让实例对象知道是什么函数构造了它
  • 若想给某些类库中的构造函数增加一些自定义的方法,就可以通过 xx.constructor.method 来扩展

__proto__

  • 首先需要明确:__proto__ 和 constructor 是对象独有的;prototype 是函数独有的
  • 但在 JavaScript 中,函数也是对象,因此函数也拥有 __proto__ 和 constructor 属性

每个对象都有该隐式原型属性,指向了原型(若是构造函数创建的对象,则指向创建该对象的构造函数的原型)

这里用 __proto__ 获取对象的原型,__proto__ 是每个对象实例上都有的属性,prototype是构造函数的属性,这两个并不一样,但 __proto__ 和 prototype 指向同一个对象

image.png

__proto__ 指向了 [[prototype]](一个对象或 null),因 [[prototype]] 是内部属性,并不能从外部访问到,因此有些浏览器实现了 __proto__ 来访问

因此,ECMAScript 规范说 prototype 应当是一个隐式引用:

  • __proto__ 属性在 ES6 时被标准化,以确保 Web 浏览器的兼容性,但不推荐使用,除了标准化的原因之外还有性能问题,为了更好的支持,推荐使用 Object.getPrototypeOf() 访问指定对象的 prototype 对象

  • 通过 Object.setPrototypeOf(obj, anotherObj) 设置指定对象的 prototype 对象

  • 部分浏览器实现了 __proto__ ,使得可以通过 obj.__proto__ 直接访问原型,通过 obj.__proto__ = anotherObj 直接设置原型 ;ES6 规范只好向事实低头,将 __proto__ 属性纳入了规范的一部分,以确保 Web 浏览器的兼容性

__proto__ 属性既不能被 for...in 遍历出来,也不能被 Object.keys(obj) 查找出来

其实 __proto__ 是个定义在 Object.prototype 上的访问器属性(即用 gettersetter 定义的属性),访问对象的 obj.__proto__ 属性,默认走的是 Object.prototype 对象上 __proto__ 属性的 get/set 方法

Object.defineProperty(Object.prototype, '__proto__', { 
  get() { 
    console.log('get');
  } 
}); 
({}).__proto__; 
console.log((new Object()).__proto__); 
// get 
// get

const weakMap = new WeakMap();
Object.prototype = {
  get __proto__() {
    return this['[[prototype]]'] === null ? weakMap.get(this) : this['[[prototype]]'];
  },
  set __proto__(newPrototype) {
    if (!Object.isExtensible(newPrototype)) throw new TypeError(`${newPrototype} is not extensible`);

    const isObject = typeof newPrototype === 'object' || typeof newPrototype === 'function';
    if (newPrototype === null || isObject) {
      // 若之前通过 __proto__ 设置成 null
      // 此时再通过给 __proto__ 赋值的方式修改原型都是徒劳
      // 表现就是 obj.__proto__ = { a: 1 } 就像一个普通属性 obj.xxx = { a: 1 }
      if (this['[[prototype]]'] === null) {
        weakMap.set(this, newPrototype);
      } else {
        this['[[prototype]]'] = newPrototype;
      }
    }
  },
  // ... 其它属性如 toString,hasOwnProperty 等
};

若一个对象的 __proto__ 属性被赋值为 null,这时它的原型确实已经被修改为 null,但想再通过对 __proto__ 赋值的方式设置原型时是无效的,这时 __proto__ 和一个普通属性没有区别,只能通过 Reflect.setPrototypeOf()Object.setPrototypeOf() 才能修改原型

Reflect.setPrototypeOf() 之所以能修改原型是因为它是直接修改对象的原型属性,即内部直接对对象的 [[prototype]] 属性赋值,而不会通过 __proto__getter

const obj = { name: 'xiaoming' };

obj.__proto__ = null;
console.log(obj.__proto__); // undefined
console.log(Reflect.getPrototypeOf(obj)); // null

// 再次赋值为 null
obj.__proto__ = null;
console.log(obj.__proto__); // null

obj.__proto__ = { a: 1 };
console.log(obj.__proto__); // { a: 1 }
// __proto__ 就像一个普通属性一样 obj.xxx = { a: 1 }
// 并没有将原型设置成功
console.log(Reflect.getPrototypeOf(obj)); // null

Reflect.setPrototypeOf(obj, { b: 2 });
// __proto__ 被设置为 null 后,obj 的 __proto__ 属性和一个普通的属性没有区别
console.log(obj.__proto__); // { a: 1 }
// 使用 Reflect.setPrototypeOf 是可以设置原型的
console.log(Reflect.getPrototypeOf(obj)); // { b: 2 }

通过改变一个对象的 [[Prototype]] 属性来改变和继承属性会对性能造成非常严重的影响且性能消耗的时间也不是简单的花费在 obj.__proto__ = ... 语句上,它还会影响到所有继承自该 [[Prototype]] 的对象,若关心性能就不应该修改一个对象的 [[Prototype]]

若要读取或修改对象的 [[Prototype]] 属性,建议使用如下方案,但此时设置对象的 [[Prototype]] 依旧是一个缓慢的操作,还是那句话:若性能是一个考虑问题,就要避免这种操作

// 获取
Object.getPrototypeOf();
Reflect.getPrototypeOf();

// 修改
Object.setPrototypeOf();
Reflect.setPrototypeOf();

总结:__proto__ 存在于所有的对象上,是对象所独有的且指向它的原型对象。它的作用就是:当在访问一个对象属性时,若该对象内部不存在这个属性,则会去它的 __proto__ 属性所指向的对象(原型对象,原型也是对象也有它自己的原型)上查找,若原型对象不存在这个属性,则去其原型对象的 __proto__ 属性所指向的原型对象上去查找... 以此类推,直到找到 null,返回 undefined,这个查找的过程也就构成了常说的 原型链

因为在 JS 中是没有类的概念的,为了实现类似继承的方式,通过 __proto__ 将对象和原型联系起来组成原型链,得以让对象可以访问到不属于自己的属性

image.png

Object.create()

之前说对象的创建方式主要有两种,一种是 new 操作符后跟函数调用,另一种是字面量表示法

第三种就是 ES5 提供的 Object.create() 方法,该方法会创建一个新对象,第一个参数接收一个对象,将会作为与新创建对象关联的原型对象,第二个可选参数是属性描述符(不常用,默认是 undefined

平常所看到的空对象其实并不是严格意义上的空对象,它的原型对象指向Object.prototype,还可以继承 hasOwnPropertytoStringvalueOf 等方法

  • 若要创建一个新对象同时继承另一个对象的 [[Prototype]] ,推荐使用 Object.create()
  • 若想生成一个不继承任何属性的对象,可使用 Object.create(null)
  • 若想生成一个平常字面量方法生成的对象,需要将其原型对象指向 Object.prototype
let obj = Object.create(Object.prototype);
// 等价于
let obj = {};

const obj= Object.create(Object.prototype);
obj.__proto__ === Object.prototype; // true

const obj = Object.create(null);
obj.__proto__ === Object.prototype; // false;
console.log(obj.__proto__); // undefined

模拟实现 Object.create

// 简易版
function createObj(proto) {
    const F = function() {};
    F.prototype = proto;
    return new F();
}
// 完整版
function createObj(proto, propertyObject = undefined) {
  if(typeof proto !== 'object' && typeof proto !== 'function') throw new TypeError('Object prototype may only be an Object or null.');
  if(propertyObject == null) new TypeError('Cannot convert undefined or null to object');
  
  function F() {};
  F.prototype = proto;
  const obj = new F();
  
  if(propertyObject != undefined) Object.defineProperties(obj, propertyObject);
  
  // 创建一个没有原型对象的对象,Object.create(null)  
  if(proto === null) obj.__proto__ = null;
  
  return obj;
}

原型链

概念

当在一个对象 obj 上访问某个属性时:

  • 若该属性不存在于该 obj 上,则会通过 __proto__ 去对象的原型即 obj.__proto__ 上去找这个属性

  • 若有则返回该属性,没有则继续去对象 obj 的原型的原型即 obj.__proto__.__proto__ 去找,依此类推...

  • 一直访问到 纯对象的原型Object.prototype,没有的话则继续往上找即 Object.prototype.__proto__,即 null,此时直接返回 undefined

console.log(new Object().__proto__.__proto__); // null
Object.prototype.__proto__ === null; // null

这就可以得到原型链之所以叫原型链而不叫原型环,说明它是有始有终的,原型链的顶层就是 null,返回 undefined,所以原型链不会无限的找下去

因此原型链可以描述为由对象的 __proto__ 属性将对象和原型联系起来直到 Object.prototype.__proto__ 为 null 的链就是原型链

function Student(name, grade) {
  this.name = name;
  this.grade = grade;
}

const stu = new Student();
console.log(stu.gender); // => undefined

访问 stu.gender 的整个过程如下图:

image.png

而函数 Student 的原型链应该是这样的:

image.png

上文介绍了 prototype__proto__ 的区别,其中原型对象 prototype 是构造函数的属性,__proto__ 是每个实例对象上都有的属性,这两个并不一样,但指向同个对象,如上面例子 stu.__proto__Student.prototype 指向同个对象

那原型链的构建是依赖于 prototype 还是 __proto__ 呢?

  • 上图中,Student.prototype 中的 prototype 并没有构建成一条原型链,其只是指向原型链中的某一处
  • 原型链的构建依赖于 __proto__,如上图通过 stu.__proto__ 指向 Student.prototypestu.__proto__.__proto__ 指向 Object.prototyp,如此一层一层最终链接到 null

可以这么理解:Student 是一个 constructor 也是一个 function,它身上有着 prototypereference,只要调用 stu = new Student() 就会将 stu.__proto__ 指向到 Studentprototype 对象

不要使用类似 Bar.prototype = Foo,因为这不会执行 Foo 的原型,而是指向函数 Foo。因此原型链将会回溯到 Function.prototype 而不是 Foo.prototype,因此 Foo 原型上的方法将不会在 Bar 的原型链上

function Foo() {
  return 'foo';
}
Foo.prototype.getMethod = function() { 
  return 'method';
}
function Bar() {
  return 'bar';
}
Bar.prototype = Foo; // Bar.prototype 指向到函数 Foo
const bar = new Bar();
console.dir(bar);
bar.method(); // bar.getMethod is not a function

bar.__proto__ === Foo.prototype; // false

原型链上属性的增删改查

通过一个对象改变了原型上的引用值类型的属性,则所有对象实例的这个属性值都会随之更改

image.png

依据当自身没有这个属性时就会向上往原型查询的说法,再次删除这个属性是不是就可以删除原型上的属性了?事实并没有,由此可见对象实例并不能删除原型上的属性

image.png

谁调用这个方法,这个方法中的 this 就指向这个调用它的对象

image.png

instanceof 操作符

概念

上面说过平常判断一个变量的类型经常会使用 typeof 运算符,但对于引用类型来说并不能很好区分(除了函数对象会返回 function 外其他都返回 object

来看一下 MDN 上对于 instanceof 运算符的描述:

  • instanceof 运算符用于测试构造函数的 prototype 属性是否出现在对象实例的原型链中的任何位置

instanceof 和 typeof 非常的类似:instanceof 用于判断对象是否是某个构造函数的实例,若 obj instanceof A,就说明 obj 是 A 的实例

它的原理一句话概括就是:obj instanceof 构造器 A 等同于判断 Aprototype 是不是 obj 的原型

  • instanceof 操作符左边是一个对象,右边是一个构造函数,在左边对象的原型链上查找(通过 __proto__)直到找到右边构造函数的 prototype 属性就返回 true
  • 或查找到顶层 nullObject.prototype.__proto__),就返回 false
// 定义构造函数
function C(){} 
function D(){} 

const o1 = new C();
o1 instanceof C; // true,因为 Object.getPrototypeOf(o1) === C.prototype

o1 instanceof D; // false,因为 D.prototype 不在 o1 的原型链上

o1 instanceof Object; // true,因为 Object.prototype.isPrototypeOf(o1) 返回 true
C.prototype instanceof Object; // true,同上

// 修改 C.prototype
C.prototype = {};
const o2 = new C();

o2 instanceof C; // true
o1 instanceof C; // false,C.prototype 指向了一个空对象,这个空对象不在 o1 的原型链上

D.prototype = new C(); // 继承
const o3 = new D();
o3 instanceof D; // true
o3 instanceof C; // true 因为 C.prototype 现在在 o3 的原型链上

与 typeof 方法不同的是,instanceof 方法要求开发者明确地确认对象为某特定类型

模拟实现

简单模拟实现

// 第一种:递归查找原型链
// 参数 obj 表示 instanceof 左边的对象 
// 参数 Constructor 表示 instanceof 右边的构造函数
function myInstanceOf(obj, Constructor) {
  // 取构造函数显示原型 
  let rightP = Constructor.prototype;
  // 取对象隐式原型 
  let leftP = obj.__proto__;
  // 到达原型链顶层还未找到则返回 false 
  if (leftP === null) return false; 
  
  // 对象实例的隐式原型等于构造函数显示原型则返回 true 
  if (leftP === rightP) return true; 
  
  // 递归查找原型链上一层 
  return myInstanceOf(obj.__proto__, Constructor)
}

// 第二种:迭代
function myInstanceof(left, right) {
  let prototype = right.prototype;
  left = left.__proto__;
  while (true) {
    if (left === null) return false;
    if (prototype === left) return true;
    left = left.__proto__;
  }
}

现在就可以解释一些比较令人费解的结果了

let fn = function() {};
let arr = [];
fn instanceof Function; // true
fn instanceof Object; // true
// 1. fn.__proto__ === Function.prototype;
// 2. fn.__proto__.__proto__ === Function.prototype.__proto__ === Object.prototype;

arr instanceof Array; // true
arr instanceof Object; // true
// 1. arr.__proto__ === Array.prototype;
// 2. arr.__proto__.__proto__ === Array.prototype.__proto__ === Object.prototype;

Object instanceof Object; // true
// 1. Object.__proto__ === Function.prototype;
// 2. Object.__proto__.__proto__ === Function.prototype.__proto__ === Object.prototype;

Function instanceof Function; // true
Function instanceof Object; // true
// 1. Function.__proto__ === Function.prototype;
// 2. Function.__proto__.__proto__ === Object.prototype;

Foo instanceof Function; // true 
Foo instanceof Foo; // false
// 1. Foo.__proto__ === Function.prototype;
// 2. Foo.__proto__.__proto__ === Function.prototype.__proto__ = Object.prototype;
// 3. Foo.__proto__.__proto__.__proto__ === Object.prototype.__proto__ = null;
// 4. null

总结:instanceof 运算符用于检查右边构造函数的 prototype 属性是否出现在左边对象的原型链中的任何位置,其实它表示的是一种原型链继承的关系

Object & Function

上面提到的 Object.__proto__ === Function.prototype 和 Function.__proto__ === Function.prototype 到底是为什么呢?

Object.prototype

ECMAScript 上的定义: The value of the [[Prototype]] internal property of the Object prototype object is null, the value of the [[Class]] internal property is "Object", and the initial value of the [[Extensible]] internal property is true

Object.prototype 表示 Object 的原型对象,其 [[Prototype]] 属性是 null,访问器属性 __proto__ 暴露了一个对象的内部 [[Prototype]]

Object.prototype 并不是通过 Object 函数创建的,为什么呢?看如下代码:

function Foo() {
  this.value = 'foo';
}
let foo = new Foo();
foo.__proto__ === Foo.prototype; // true

实例对象的 __proto__ 指向构造函数的 prototype,即 foo.__proto__ 指向 Foo.prototype,但 Object.prototype.__proto__null,所以 Object.prototype 并不是通过 Object 函数创建的 --> 其实 Object.prototype 是引擎根据 ECMAScript 规范创造的一个对象

所以可以说:所有实例都是对象,但是对象不一定都是实例

不考虑 null 的情况下,Object.prototype 就是原型链的顶端,所有对象实例都可以继承它的 toString 等方法和属性

Function.prototype

ECMAScript 上的定义:

  • The Function prototype object is itself a Function object (its [[Class]] is "Function").

  • The value of the [[Prototype]] internal property of the Function prototype object is the standard built-in Object prototype object.

  • The Function prototype object does not have a valueOf property of its own; however, it inherits the valueOf property from the Object prototype Object

从定义中可知道:Function.prototype 对象是一个函数(对象),其 [[Prototype]] 内部属性值指向内建对象 Object.prototypeFunction.prototype 对象自身没有 valueOf 属性,其从 Object.prototype 对象继承了 valueOf 属性

Function.prototype[[Class]] 属性是 Function,所以这是一个函数,但又不大一样,因为只有函数才有 prototype 属性,但并不是所有函数都有这个属性,因为 Function.prototype 这个函数就没有

Function.prototype; // ƒ () { [native code] }

Function.prototype.prototype; // undefined

下面这个函数也没有 prototype 属性:

let fun = Function.prototype.bind(); // ƒ () { [native code] }

fun.prototype; // undefined

为什么没有呢?我的理解是 Function.prototype 是引擎创建出来的函数,引擎认为不需要给这个函数对象添加 prototype 属性,不然 Function.prototype.prototype… 将无休无止并且没有存在的意义

Function.prototype 不可写、不可配置、不可遍历,即它永远指向固定的一个对象且是其他所有函数的原型对象,所有函数本身的 __proto__ 指向它

引擎首先创建了 Object.prototype ,然后创建了 Function.prototype 并且通过 __proto__ 将两者联系了起来

Object

JS 中 Obejct 和 Function 均是构造函数(构造函数也是函数),和 objectfunction 不是一个东西,分别用于创建 对象 与 函数 实例

ECMAScript 上的定义:The value of the [[Prototype]] internal property of the Object constructor is the standard built-in Function prototype object

Object 作为构造函数时,其 [[Prototype]] 内部属性值指向 Function.prototype,即:

Object.__proto__ === Function.prototype; // true

Object 的全貌是:function Object() {},它是普通对象的构造函数,当 var foo = {} 时相当于实例化 Object,即 new Object()

使用 new Object() 创建新对象时,这个新对象的 [[Prototype]] 内部属性指向构造函数的 prototype 属性,对应就是 Object.prototype

当然也可以通过对象字面量等方式创建对象:

  • 使用对象字面量创建的对象,其 [[Prototype]] 值是 Object.prototype

  • 使用数组字面量创建的对象,其 [[Prototype]] 值是 Array.prototype

  • 使用 function f(){} 函数创建的对象,其 [[Prototype]] 值是 Function.prototype

  • 使用 new fun() 创建的对象,其中 fun 是由 JS 提供的内建构造器函数之一(ObjectFunctionArrayBooleanDateNumberString 等),其 [[Prototype]] 值是 fun.prototype

  • 使用其他 JS 构造器函数创建的对象,其 [[Prototype]] 值就是该构造器函数的 prototype 属性

// 原型链:o.__proto__ -> Object.prototype -> null
let o = {a: 1};

// 原型链:a -> Array.prototype -> Object.prototype -> null
let a = ["yo", "whadup", "?"];

// 原型链:f -> Function.prototype -> Object.prototype -> null
function f() {
  return 1;
}

// 原型链:fun -> Function.prototype -> Object.prototype -> null
let fun = new Function();

// 原型链:foo -> Foo.prototype -> Object.prototype -> null
function Foo() {}
let foo = new Foo();

// 原型链:foo -> Object.prototype -> null
function Foo() {
  return {};
}
let foo = new Foo();

Function

ECMAScript 上的定义:The Function constructor is itself a Function object and its [[Class]] is "Function". The value of the [[Prototype]] internal property of the Function constructor is the standard built-in Function prototype object

Function 构造函数是一个函数对象,其 [[Class]] 属性是 FunctionFunction 的 [[Prototype]] 属性指向了 Function.prototype,即:

Function.__proto__ === Function.prototype; // true

Function 的全貌是:function Function() {},它是函数对象的构造函数,当 function foo() {} 时相当于实例化 Function,即 new Function()

我们知道函数的本质是通过 new Function() 生成的,但Function.prototype 是引擎自己创建的,所以又可以得出一个结论

不是所有函数都是 new Function() 产生的

Function & Object 鸡蛋问题

先看下面代码:

Object instanceof Function; // true
Object.__proto__ === Function.prototype; // true

Function instanceof Object; // true
Function.__proto__.__proto__ === Object.prototype; // true

Object instanceof Object; // true
Object.__proto__.__proto__ === Object.prototype; // true

Function instanceof Function; // true
Function.__proto__ === Function.prototype; // true

Object 构造函数继承了 Function.prototype,一切函数对象都直接继承自 Function 对象(系统内置的构造函数),函数对象 包括了 FunctionObjectArrayStringNumberRegExpDate 等,Function 其实不仅用于构造函数,它也充当了 函数对象 的构造器

同时 Function 构造函数继承了 Object.prototype,这里就产生了 鸡和蛋 的问题。因为 Function.prototype 和 Function.__proto__ 都指向 Function.prototype

对于 Function.__proto__ === Function.prototype 这一现象有 2 种解释,争论点在于 Function 对象是不是由 Function 构造函数创建的一个实例?

  • YES:按照 JavaScript 中实例的定义,ab 的实例即 a instanceof btrue,默认判断条件就是 b.prototypea 的原型链上。而 Function instanceof Functiontrue,本质上即 Object.getPrototypeOf(Function) === Function.prototype,正符合此定义

  • NOFunctionbuilt-in 的对象,即并不存在 Function 对象由 Function 构造函数创建 这样显然会造成鸡生蛋蛋生鸡的问题。实际上当直接写一个函数时(如 function f() {}x => x),也不存在调用 Function 构造器,只有在显式调用 Function 构造器时(如 new Function('x', 'return x'))才有

个人偏向于第二种解释,即先有 Function.prototype 然后有 function Function(),所以就不存在鸡生蛋蛋生鸡问题了,把 Function.__proto__ 指向 Function.prototype,个人的理解是:

  • 其他所有的构造函数都可以通过原型链找到 Function.prototype,且 Function 本质也是一个函数对象,事实上 Function 只是一个祖先、一个构造函数,并不是一个实例出来的函数对象,所以本来没必要拥有 __proto__ 这个属性,但这样的话会显得 Function 很另类,于是也给它加上属性 __proto__ 并指向 Function.prototype

  • 只是为了表明 Function 作为一个原生构造函数,本身也是一个函数对象,且这也保证了原型链的完整,让 Function 可以获取定义在 Object.prototype 上的方法

一切函数对象(包括 Object 对象) 都直接继承自 Function 对象,Function 对象直接继承自己,最终继承自 Object 对象,ObjectFunction 是互相继承的关系

有了 Function.prototype 以后才有了 function Function(),然后其他的构造函数都是 function Function() 生成的

一切对象都继承自 Object.prototype,而一切函数对象都继承自 Function.prototype (Function.prototype 最终继承自 Object.prototype),即可知道普通对象和函数对象的区别是:

  • 普通对象直接继承了 Object.prototype,而函数对象在中间还继承了 Function.prototype

总结

  • Object 是所有对象的爸爸,所有对象都可以通过 __proto__ 找到它
  • Function 是所有函数的爸爸,所有函数都可以通过 __proto__ 找到它
  • 所有通过字面量表示法创建的普通对象的构造函数为 Object
  • 所有原型对象都是普通对象,构造函数为 Object
  • 所有函数的构造函数是 Function
  • Function.prototypeObject.prototype 没有原型对象
  • Function.prototypeObject.prototype 是两个由引擎创建出来的特殊对象,除了这两个特殊对象,其他对象都是通过构造器 new 出来的
  • 函数的 prototype 是一个对象,即原型。对象的 __proto__ 指向原型,__proto__ 将对象和原型连接起来组成了原型链
  • Function.prototype.__proto__ === Object.prototype
  • Object.__proto__ === Function.prototype
  • Function.__proto__ === Function.prototype
  • Function.__proto__ === Object.__proto__
  • Object.prototype.__proto__ === null
  • Object => Function.prototype => Object.prototype => null
  • Function => Function.prototype => Object.prototype => null
  • 若是自定义的构造函数,形成的原型链如下:Foo => Function.prototype => Object.prototype => null
  • 通过自定义构造函数实例化的对象,形成的原型链如下:obj => Foo.prototype => Object.prototype => null

image.png

image.png

内置类型构建过程

JavaScript 内置类型是浏览器内核自带的,浏览器底层对 JavaScript 的实现基于 C/C++,则浏览器在初始化 JavaScript 环境时都发生了什么?

  • C/C++ 构造内部数据结构创建一个 OPObject.prototype,以及初始化其内部属性但不包括行为
  • C/C++ 构造内部数据结构创建一个 FPFunction.prototype,以及初始化其内部属性但不包括行为
  • FP[[Prototype]] 指向 OP
  • C/C++ 构造内部数据结构创建各种内置引用类型
  • 将各内置引用类型的 [[Prototype]] 指向 FP
  • Functionprototype 指向 FP
  • Objectprototype 指向 OP
  • Function 实例化出 OPFP,以及 Object 的行为并挂载
  • Object 实例化出除 Object 以及 Function 的其他内置引用类型的 prototype 属性对象
  • Function 实例化出除 Object 以及 Function 的其他内置引用类型的 prototype 属性对象的行为并挂载
  • 实例化内置对象 Math 以及 Grobal 至此所有内置类型构建完成

原型污染

曾经 Lodash 爆出了一个严重的安全漏洞:Lodash 库爆出严重安全漏洞,波及 400 万+项目,这个安全漏洞就是由于原型污染导致的,Lodash 库中的函数defaultsDeep很有可能会被欺骗添加或修改 Object.prototype 的属性,最终可能导致 Web 应用程序崩溃或改变其行为,具体取决于受影响的用例

虽说任何一个原型被污染了都有可能导致问题,但一般提原型污染说的就是 Object.prototype 被污染

原型污染的危害

性能问题:原型被污染会增加遍历的次数,每次访问对象自身不存在的属性时也要访问原型上被污染的属性

导致意外的逻辑 bug:看下面这个从别的大佬的文章中看到的例子

'use strict';

const express = require('express');
const bodyParser = require('body-parser');
const cookieParser = require('cookie-parser');
const path = require('path');

const isObject = (obj) => obj && obj.constructor && obj.constructor === Object;

function merge(a, b) {
  for (var attr in b) {
    if (isObject(a[attr]) && isObject(b[attr])) {
      merge(a[attr], b[attr]);
    } else {
      a[attr] = b[attr];
    }
  }
  return a;
}

function clone(a) {
  return merge({}, a);
}

// Constants
const PORT = 8080;
const HOST = '127.0.0.1';
const admin = {};

// App
const app = express();
app.use(bodyParser.json());
app.use(cookieParser());

app.use('/', express.static(path.join(__dirname, 'views')));
app.post('/signup', (req, res) => {
  var body = JSON.parse(JSON.stringify(req.body));
  var copybody = clone(body);
  if (copybody.name) {
    res.cookie('name', copybody.name).json({
      done: 'cookie set',
    });
  } else {
    res.json({
      error: 'cookie not set',
    });
  }
});
app.get('/getFlag', (req, res) => {
  var аdmin = JSON.parse(JSON.stringify(req.cookies));
  if (admin.аdmin == 1) {
    res.send('hackim19{}');
  } else {
    res.send('You are not authorized');
  }
});
app.listen(PORT, HOST);
console.log(`Running on http://${HOST}:${PORT}`);

这段代码的漏洞就在于 merge 函数上,可以这样攻击:

curl -vv --header 'Content-type: application/json' -d '{"__proto__": {"admin": 1}}' 'http://127.0.0.1:4000/signup';

curl -vv 'http://127.0.0.1/getFlag'

首先请求 /signup 接口,在 NodeJS 服务中调用了有漏洞的 merge 方法,并通过 __proto__Object.prototype(因为 {}.__proto__ === Object.prototype) 添加上一个新的属性 admin 且值为 1

再次请求 getFlag 接口访问了 Object 原型上的 admin,条件语句 admin.аdmin == 1true,服务被攻击

预防原型污染

实际情况下,原型污染大多发生在调用会修改或扩展对象属性的函数时,如 LodashdefaultsjQueryextend,预防原型污染最主要还是要有防患意识,养成良好的编码习惯

Object.create(null)

Object.create(null) 创建没有原型的对象,即便对它设置 __proto__ 也没用,因为它的原型一开始就是 null,没有 __proro__ 的 setter

image.png

Object.freeze(obj)

可以通过 Object.freeze(obj) 冻结对象 obj,被冻结的对象不能被修改属性,成为不可扩展对象。不能修改、不可扩展对象的原型,否则会抛 TypeError

const obj = Object.freeze({ name: 'xiaoHong' });
obj.xxx = 666;
console.log(obj); // => { name: 'xiaoHong' }
console.log(Object.isExtensible(obj)); // => false
obj.__proto__ = null; // => TypeError: #<Object> is not extensible

关于原型污染可阅读:Lodash 严重安全漏洞背后你不得不知道的 JavaScript 知识

继承

原型存在的意义就是组成原型链:引用类型皆对象,每个对象都有原型,原型也是对象,也有它自己的原型,一层一层的组成了原型链

原型链存在的意义就是继承:访问对象属性时,在对象本身找不到,就在原型链上一层一层往上找,说白了就是一个对象可以访问其他对象的属性

继承存在的意义就是属性共享:好处一是代码重用(字面意思);好处二是可扩展,不同对象可能继承相同的属性,也可以定义只属于自己的属性

ES5 继承实现方式

原型链继承

将父类的实例作为子类的原型

function Parent() {
     this.name = 'tn';
}
Parent.prototype.getName = function () {
    console.log(this.name);
}
function Son () {};
// 关键,创建 Parent 的实例并将该实例赋值给 Son.prototype
Son.prototype = new Parent();
const son1 = new Son();
console.log(son1.getName());  // tn 

// 缺点
function Parent () {
    this.names = ['licy', 'tn'];
}
function Son () {}
Son.prototype = new Parent();
const son1 = new Son();
son1.names.push('yayu');
console.log(son1.names); // ["licy", "tn", "yayu"]

const son2 = new Son();
console.log(son2.names); // ["licy", "tn", "yayu"]

image.png

优点:父类方法可以复用,父类的属性与方法子类都能访问

缺点:

  • 父类的引用属性会被所有子类实例共享,子类会继承过多没有用的属性,造成大量的浪费且多个实例对引用类型的操作会被篡改
  • 由于子类实现的继承是靠其原型 prototype 对父类进行实例化实现的,因此在构建子类实例时是无法向父类传递参数的,因而在实例化父类时也无法对父类构造函数内的属性进行初始化

借用构造函数继承

使用父类的构造函数来增强子类实例,利用 callapply 可改变 this 指向的特点,将父类构造函数内容复制给子类构造函数,由于父类中给 this 绑定属性,因此子类自然也就继承父类的共有属性,这是所有继承中唯一不涉及到 prototype 的继承

function Parent (name) {
    this.books = ['js','css'];
    this.name = name;
}
Parent.prototype.showBooks = function() {
  console.log(this.books);
}

function Son (name) {
    Parent.call(this, name);
}
const son1 = new Son('tn');
console.log(son1.name); // tn
son1.showBooks(); // TypeError: son1.showBooks is not a function

const son2 = new Son('licy');
console.log(son2.name); // licy

function SuperType(){ 
   this.color=["red","green","blue"]; 
}
function SubType(){ 
   //继承自 SuperType 
   SuperType.call(this);
}
const instance1 = new SubType(); 
instance1.color.push("black"); 
console.log(instance1.color); //["red", "green", "blue", "black"] 

const instance2 = new SubType(); 
console.log(instance2.color); //["red", "green", "blue"]

优点:

  • 父类的引用属性不会被共享,避免了引用类型的属性被所有实例共享且避免了多个实例对引用类型的操作会被篡改的问题
  • 子类构建实例时可以向父类传递参数

缺点:

  • 不能继承父类原型上的属性/方法(若原型上的属性/方法想被子类继承,就必须放到构造函数中)
  • 父类的方法和属性不能复用,子类实例的方法每次都是单独创建的,这样就违背了代码复用的原则
  • 每个子类都有父类实例函数的副本,影响性能

组合继承

原型链继承借用构造函数继承 结合

用原型链实现对原型属性和方法的继承,用借用构造函数技术来实现实例属性的继承

function SuperType(name){ 
    this.name = name; 
    this.colors = ["red", "blue", "green"];
}    
SuperType.prototype.sayName = function(){ console.log(this.name); }; 
function SubType(name, age){ 
    // 继承属性
    // 第二次调用 SuperType() 
    // 第二次又给子类的构造函数添加了父类的 name, colors 属性
    // 使用子类创建的实例对象上的同名属性覆盖了子类原型中的同名属性,这造成了性能浪费
    SuperType.call(this, name); 
    this.age = age; 
} 
// 继承方法,构建原型链 
// 第一次调用 SuperType() 
// 第一次给子类的原型添加了父类的 name, colors 属性
SubType.prototype = new SuperType(); 

// 重写 SubType.prototype 的 constructor 属性,指向自己的构造函数 SubType 
SubType.prototype.constructor = SubType; 
SubType.prototype.sayAge = function(){ alert(this.age); }; 

const instance1 = new SubType("Nicholas", 29); 
instance1.colors.push("black"); 
console.log(instance1.colors); //["red", "blue", "green", "black"]
instance1.sayName(); //"Nicholas"; 
instance1.sayAge(); //29 

const instance2 = new SubType("Greg", 27); 
console.log(instance2.colors); //["red", "blue", "green"]
instance2.sayName(); //"Greg"; 
instance2.sayAge(); //27

优点:

  • 父类的方法可以被复用
  • 父类的引用属性不会被共享
  • 子类构建实例时可以向父类传递参数
  • 可以继承父类的属性和方法,同时也可以继承原型的属性和方法

缺点:

  • 使用子类创建实例对象时,父类调用了两次,因此产生了两份实例,其原型中会存在两份相同的属性/方法

原型式继承

ES5 Object.create 的模拟实现,利用一个空对象作为中介,将传入的对象作为该空对象构造函数的原型,object() 对传入的对象执行了一次浅复制

function object(obj){
  // 声明一个过渡对象 
  function F(){};
  // 过渡对象的原型继承传入的对象
  F.prototype = obj;
  // 返回过渡对象的实例
  return new F();
}

const person = { 
  name: "Nicholas", 
  friends: ["Shelby", "Court", "Van"] 
};

const anotherPerson = object(person); 
const yetAnotherPerson = object(person); 
anotherPerson.name = "Greg"; 
anotherPerson.friends.push("Rob"); 

console.log(anotherPerson.name) // "Greg"
console.log(yetAnotherPerson.name) // "Nicholas"
console.log(yetAnotherPerson.friends) //["Shelby", "Court", "Van", "Rob"]

缺点:

  • 父类的引用类型的属性值会被所有子类实例共享,改动一个会影响另一个,这点跟原型链继承一样
  • 子类构建实例时不能向父类传递参数

ES5 中 Object.create() 的方法能够代替上面的 object方法,Object.create() 方法规范化了原型式继承

寄生式继承

  • 在原型式继承的基础上,创建一个仅用于封装继承过程的函数,该函数在内部以某种形式来增强对象,最后返回对象
  • 这样新创建的对象不仅仅有父类的属性和方法,还可新增了别的属性和方法
function createAnother(original){ 
   const clone = object(original); // 通过调用 object() 函数创建一个新对象 
   // 或
   const clone = Object.create(o);
   clone.sayHi = function(){ 
       // 以某种方式来增强对象 
       alert("hi"); 
   }
   return clone; // 返回这个对象 
}   
const person = {
    name: "Nicholas",
    friends: ["Shelby", "Court", "Van"]
};

var anotherPerson = createAnother(person);
anotherPerson.sayHi(); //"hi"
  • 缺点(同原型式继承)

寄生组合式继承

寄生组合式继承是寄生式继承和借用构造函数继承的组合,只调用了一次父类构造函数,解决了组合继承有会两次调用父类的构造函数造成浪费的缺点

function object(o) {
    //声明一个过渡对象
    function F() {}
    //过渡对象的原型继承父对象
    F.prototype = o;
    //返回过渡对象的实例,该对象的原型继承了父对象
    return new F();
}
function prototype(child, parent) {
    // 复制一份父类的原型副本到变量中
    const prototype = object(parent.prototype);
    // 增强对象,修正因为重写子类的原型导致子类的 `constructor` 属性被修改
    prototype.constructor = child;
     // 设置子类原型
    child.prototype = prototype;
}
// 使用时
prototype(Child, Parent);

引用《JavaScript高级程序设计》中对寄生组合式继承的夸赞就是:

这种方式的高效率体现它只调用了一次 Parent 构造函数并且因此避免了在 Parent.prototype 上面创建不必要的、多余的属性。与此同时原型链还能保持不变,因此能够正常使用 instanceofisPrototypeOf。开发人员普遍认为寄生组合式继承是引用类型最理想的继承范式,也是现在很多库实现的方法

封装

function inherit (Target, Origin) {
    // 声明一个过渡对象
    function F () {};
    // 过渡对象的原型继承父对象,创建了父类原型的浅复制
    F.prototype = Origin.prototype;
    // 返回过渡对象的实例,该对象的原型继承了父对象
    Target.prototype = new F();
    // 修正子类原型的构造函数
    Target.prototype.constructor = Target;
    // 无法知道自己真正继承至谁(记住最好,也不强求)
    // 为了保存一下它的父类,也用一个 uber 来记录一下父类
    // 因为 super 是保留字不能使用,所以使用了 uber
    Target.prototype.uber = Origin.prototype; 
}    

雅虎的高端写法,采用闭包的私有化变量

var inherit = (function () {
    var F = function () {};
    return function (Target, Origin) {
        F.prototype = Origin.prototype;
        Target.prototype = new F();
        Target.prototype.constructor = Target;
        Target.prototype.uber = Origin.prototype;
    }   
}());      

混入方式继承多个对象

Object.assign 会把 OtherSuperClass 原型上的方法属性拷贝到 MyClass 原型上,使 MyClass 的所有实例都可使用 OtherSuperClass 上的方法

function MyClass() {
     SuperClass.call(this);
     OtherSuperClass.call(this);
}

// 继承一个类
MyClass.prototype = Object.create(SuperClass.prototype);
// 混合其它
Object.assign(MyClass.prototype, OtherSuperClass.prototype);
// 重新指定constructor
MyClass.prototype.constructor = MyClass;

MyClass.prototype.myMethod = function() {
     // do something
};

ES6 类继承 extends

ES6 的继承和寄生组合继承相似,本质上 ES6 继承是 ES5 继承的一种语法糖extends 关键字主要用于类声明或类表达式中,以创建一个类表示该类是另外某个类的子类

constructor 表示构造函数,一个类中只能有一个构造函数,有多个会报出SyntaxError 错误,若没有显式指定构造方法,则会添加默认的 constructor 方法,例子如下

class Rectangle { 
   // constructor 
   constructor(height, width) { 
      this.height = height; this.width = width;
   } 
   // Getter 
   get area() { return this.calcArea() } 
   // Method 
   calcArea() { return this.height * this.width; } 
}      
const rectangle = new Rectangle(10, 20); 
console.log(rectangle.area); // 输出 200 

// 继承 
class Square extends Rectangle { 
   constructor(length) { 
      super(length, length); // 如果子类中存在构造函数,则需要在使用“this”之前首先调用 super()。 
      this.name = 'Square'; 
   }    
   get area() { return this.height * this.width; } 
}   
const square = new Square(10); 
console.log(square.area); // 输出 100

extends 继承的核心代码如下,其实现和上述的寄生组合式继承方式类似

// extends 继承的核心代码如下,其实现和上述的寄生组合式继承方式相似
function _inherits(subType, superType) { 
    // 创建对象,创建父类原型的一个副本 
    // 增强对象,弥补因重写原型而失去的默认的constructor 属性 
    // 指定对象,将新创建的对象赋值给子类的原型 
    subType.prototype = Object.create(superType && superType.prototype, { 
        constructor: { 
            value: subType, 
            enumerable: false, 
            writable: true, 
            configurable: true 
        } 
    }); 
   
    if (superType) { 
        Object.setPrototypeOf ? 
        Object.setPrototypeOf(subType, superType) : 
        subType.__proto__ = superType; 
    } 
}   

总结

  • 函数声明和类声明的区别:函数声明会提升,类声明不会。首先需要声明类然后访问它,否则像下面的代码会抛出一个 ReferenceError

    let p = new Rectangle(); 
    // ReferenceError
    class Rectangle {}
    
  • ES6 Class extends 是 ES5 继承的语法糖

  • ES5 继承和 ES6 继承的区别

    • ES5 的继承实质上是先创建子类的实例对象,然后再将父类的方法添加到 this

      Child.prototype = new Parent() || Parent.apply(this) || Parent.call(this)
      
    • ES6 的继承有所不同,在 ES6 class 中,实质上是先创建父类的实例对象 this,然后再用子类的构造函数修改 this。子类必须在 constructor 方法中调用 super 方法,否则新建实例时会报错。这是因为子类没有自己的 this 对象而是继承父类的 this 对象,然后对其进行加工

扩展

一道关于原型的题目

function Page() {
  return this.hosts;
}
Page.hosts = ['h1'];
Page.prototype.hosts = ['h2'];

const p1 = new Page();
const p2 = Page();

console.log(p1.hosts);  // undefined
console.log(p2.hosts); // Uncaught TypeError: Cannot read property 'hosts' of undefined

原因分析:

  • 之前文章提过 new 时若 return 了对象,则会直接拿这个对象作为 new 的结果,因此 p1 应该是 this.hosts 的结果,而在 new Page() 时,this 是一个以 Page.prototype 为原型的 target 对象,所以这里 this.hosts 可以访问到 Page.prototype.hosts['h2']。因此 p1 就是等于 ['h2']['h2'] 没有 hosts 属性所以返回 undefined
  • console.log(p2.hosts) 会报错是因为 p2 是直接调用 Page 构造函数,这个时候 this 指向全局对象,全局对象并没 hosts 属性,因此返回 undefined,往 undefined 上访问 hosts 当然报错

ES5 中的“类”和 ES6 中的 class 有什么区别?

ES5 中主要是通过构造函数方式和原型方式来定义一个类,在 ES6 中可以通过 class 来定义类

  • class 类必须 new 调用,不能直接执行

    class Foo {
      constructor(color) {
        this.color = color;
      }
      like() {
        console.log(`like${this.color}`);
      }
    }
    
    Foo();
    // Uncaught TypeError: Class constructor Foo cannot be invoked without 'new'
    

    class 类执行的话会报错,而 ES5 中的类和普通函数并没有本质区别,执行肯定是 OK

  • class 类不存在变量提升

    let foo = new Foo();
    function Foo(color) {
      this.color = color;
    }
    Foo.prototype.like = function() {
      console.log(`like${this.color}`);
    }
    foo; // Foo {color: undefined}
    
    let foo = new Foo('red');
    class Foo {
      constructor() {
        this.color = color;
      }
      like() {
        console.log(`like${this.color}`);
      }
    }
    // Uncaught ReferenceError: Foo is not defined
    

    上面示例说明 class 方式没有把类的定义提升到顶部

  • class 类无法遍历它实例原型链上的属性和方法

    function Foo(color) {
      this.color = color;
    }
    
    Foo.prototype.like = function() {
      console.log(`like${this.color}`);
    }
    
    let foo = new Foo();
    for(let key in foo) {
      // 原型上的 like 也被打印出来了
      console.log(key); // color like
    }
    
    // class 
    class Foo {
      constructor(color) {
        this.color = color;
      }
      like() {
        console.log(`like${this.color}`);
      }
    }
    
    let foo = new Foo('red');
    for(let key in foo) {
      // 没有打印原型链上的 like
      console.log(key); // color
    }
    
  • new.target 属性

    ES6new 命令引入了一个 new.target 属性,它会返回 new 命令作用于的那个构造函数,若不是通过 new 调用或 Reflect.construct() 调用,则 new.target 会返回 undefined

    function Person(name) {
      if(new.target === Person) {
        this.name = name;
      } else {
        throw new Error('必须使用 new 命令生成实例');
      }
    }
    let obj = {};
    // 此时使用非 new 的调用方式则会报错
    Person.call(obj, 'xx'); // Uncaught Error: 必须使用 new 命令生成实例
    
  • class 类有 static 静态方法

    static 静态方法只能通过类调用,不会出现在实例上;若 static 静态方法包含 this 关键字,这个 this 指的是类而不是实例

    static 声明的静态属性和方法均可被子类继承

    class Bar {
      static bar() {
        this.baz(); // 此处的 this 指向类
      }
    
      static baz() {
        console.log('hello'); // 不会出现在实例中
      }
    
      baz() {
        console.log('world');
      }
    }
    Bar.bar(); // hello