likes
comments
collection
share

Javascript 的继承,原型与构造函数

作者站长头像
站长
· 阅读数 40

前言

虽说自ES6之后,有了class以及extend关键字,已经可以让JavaScript和其他强类型语言一样在语法上实现面向对象编程(与别的语言的类还是有很多明显区别的),但是JavaScript的类的概念实际是上在ES6之前对原型(prototype)的抽象的基础上的一个语法糖,理解类背后的原型还是有必要的。

class vs prototype

举一个例子,使用类来编写Shape及其子类可以这么写:

class Shape { 
  constructor (id, x, y) { 
    this.id = id; 
    this.move(x, y); 
  } 
  
  move (x, y) { 
    this.x = x;
    this.y = y; 
  } 
}

class Rectangle extends Shape {
  constructor (id, x, y, width, height) {
    super(id, x, y)
    this.width  = width
    this.height = height
  }
}

使用原型则需要这么写:

var Shape = function (id, x, y) { 
  this.id = id; 
  this.move(x, y); 
}; 

Shape.prototype.move = function (x, y) { 
  this.x = x; 
  this.y = y; 
};


var Rectangle = function (id, x, y, width, height) {
  Shape.call(this, id, x, y);
  this.width  = width;
  this.height = height;
};
Rectangle.prototype = Object.create(Shape.prototype);
Rectangle.prototype.constructor = Rectangle;

因此在ES6之前也可以使用这种函数加原型的组合来实现继承的效果,不过可读性确实很差,尤其是别的语言的使用者阅读这种代码是很难理解的。上述使用原型实现集成的代码后续会进行讲解,让我们先进入下一部分,了解一下JavaScript的原型。

原型(prototype)与继承

首先只有对象类型有原型,原始类型是没有原型的,具体来说,如果一个object的__proto__属性的值是另一个object,那么就可以说后者是前者的原型,因此多个这种通过__proto__属性关联的对象之间可以形成一条原型链(prototype chain),并且终点一定是null。我们可以直接通过obj.__proto__属性(已不再推荐使用)或者 Object.getPrototypeOf()/Reflect.getPrototypeOf()来获取一个对象的原型。 原型有如下的特性:

  • 核心特性: 如果一个对象b是对象a的原型,那么访问对象a的属性时,如果找不到则会沿着原型链一直往上找,直至找到该属性或者碰到null为止。 还是以上述代码为例,这行代码

    Rectangle.prototype = Object.create(Shape.prototype);

    以Shape函数对象的prototype属性为原型创建了一个新的对象(后面以Shape.prototype对象指代),并以此作为Rectangle函数对象的原型(在关于继承的部分会阐述为何要用Shape的原型而不是直接使用Shape本身),因此下方代码可以正常运行。

    new Rectangle().move(4,5);
    

    使用new 操作符以Rectangle函数作为构造函数创建出来的对象是没有自己的move函数的,因此会沿着原型链寻找,由于所有构造函数创建出来的对象实例其原型都是构造函数的一个名为prototype的属性(后面也会说明这一部分),故下方的原型链是成立的。

    Rectangle对象
    Shape.prototype对象
    Shape.prototype

    所以调用Rectangle创建出来的对象实例的move方法最终会调用先前在Shape.prototype上定义的move方法。

    Shape.prototype.move = function (x, y) { this.x = x; this.y = y; };

    考虑到原型的这种特性,我们就可以使用类似如下代码

    Shape.prototype.move = function (x, y) { this.x = x; this.y = y; };

    去给原型添加属性,同时所有通过此构造函数构建出来的实例都可以拥有这个属性。

  • 原型被所有实例共享,因此可以节省运行时的空间占用。

  • 所有对象的原型链最终都会指向 Object.prototype,同时Object.prototype的原型是null。还是以上述的Shape和Rectangle为例,那么它们之间的原型链如下图所示:

Javascript 的继承,原型与构造函数

原型与构造函数

通过上述的描述就可以发现原型和构造函数的关系是十分密切的,下面再继续深入一下。

何为构造函数?

简单来说但凡可以使用new操作符的函数都是构造函数,而ShapeRectangle是可以使用new操作符创建实例的,因此它们都是构造函数,并且所有通过构造函数创建出来的实例的都会以构造函数的prototype属性作为它们的原型,因此下方代码会有这样的输出:

console.log(Object.getPrototypeOf(new Rectangle()) === Rectangle.prototype) // 输出 true

此外构造函数还有如下特性:

  1. 构造函数的prototype属性默认有一个constructor的属性,这个constructor属性指向构造函数本身,即该构造函数的引用。因此下方代码会有这样的输出:
console.log(Rectangle.prototype.constructor === Rectangle) // 输出 true

注意,构造函数的prototype属性和构造函数的原型没有任何关系,前者只是在构造实例的时候才有用。

拓展:

1.何种函数可以作为构造函数?

由于构造函数会在构造实例时将其的prototype属性作为所有实例的原型,因此一个函数首先得有这个属性才能作为构造函数,因此下方这几种函数都不能作为构造函数。

 const method = { foo() {} }.foo; // 在对象字面量添加函数的一种简明写法
 const arrowFunction = () => {}; // 箭头函数
 async function asyncFunction() {} // 异步函数

但是光有prototype的属性也不一定能作为构造函数,比较典型的是生成器函数(generator function), 如果要获取一个生成器函数对象,不能使用new操作符,如下所示。

// 使用 function* 关键字定义生成器函数
function* myGenerator() {
    yield { name:'Apple'};
    yield { name:'Banana'};
    yield { name:'Cherry'};
}

// 创建生成器对象
const generator = myGenerator();

此时生成器函数myGeneratorprototype属性也会跟一般的构造函数一样作为生成器函数实例generator的原型,因此下方代码会有如下输出。

console.log(Object.getPrototypeOf(generator) === myGenerator.prototype) // 输出true

除了生成器函数,SymbolBigInt 也无法作为构造函数,即使它们也有prototype属性,使用new操作符会抛出异常xx is not a constructor。对此,MDN上的解释是

Symbol.prototype and BigInt.prototype are only intended to provide methods for the primitive values, but the wrapper objects should not be directly constructed

大意是包装类型不应该被直接构建,事实上在我们使用原始值时,JavaScript会自动将这些原始值包装成对应的所谓wrapper objects

When a property is accessed on a primitive value, JavaScript automatically wraps the value into the corresponding wrapper object and accesses the property on the object instead.

所以在正常编码中我们大可直接使用原始值,而不是使用new操作符直接构建一个包装类型对象。不过比较有趣的是,同为包装类型的Number,BooleanString却可以作为构造函数,这主要还是历史原因,对于这些历史包装类型在后续的标准中并没有废弃其使用new调用的方式。

并且关于是否支持ES11新添加的BigInt类型使用new操作符,ECMA标准委员会还在github中有过一段讨论,最终还是决定遵循此前Symbol的做法,即不能使用new调用。基于讨论中一些人的意见,大部分人对于此前那三种包装类型可以使用new调用的方式都觉得是legacy pattern, footgun

users should never use new with a Number object tho, because it's widely considered a very bad practice to ever use boxed primitives

后续如果再有新的包装类型大概率也是不支持使用new去调用的。

2.new操作符做了什么?

  1. 创建一个空的简单 JavaScript 对象

    • 这个空对象将成为新实例的原型。
    • 为方便起见,我们称之为 newInstance
  2. 设置原型链

    • 如果构造函数的 prototype 属性是一个对象,那么将 newInstance 的 [[Prototype]] 指向构造函数的 prototype 属性。
    • 否则,newInstance 将保持为一个普通对象,其 [[Prototype]] 为 Object.prototype
  3. 绑定 this 上下文

    • 使用给定参数执行构造函数,并将 newInstance 绑定为 this 的上下文。
    • 在构造函数中的所有 this 引用都指向 newInstance
  4. 执行构造函数的代码

    • 构造函数内部可以对新对象进行初始化,添加属性和方法。
  5. 返回新对象实例

    • 如果构造函数返回非原始值(即对象或函数),那么该返回值成为整个 new 表达式的结果。
    • 否则,如果构造函数未返回任何值或返回了一个原始值,那么返回 newInstance

关于最后一个步骤还是挺有意思的(虽然绝大多数情况下构造函数都不会返回一个值),基于上述的描述,下方的代码会有如下输出:

function circle(){
  return { radius: 2 }
}
//输出 { "radius": 2 }
console.log(new circle());

function circle1(){
  return 1;
}
//输出 circle: {}
console.log(new circle());
转载自:https://juejin.cn/post/7352552915981189120
评论
请登录