作者:令川 | 发布时间:2021-05-17

用 new 把 JavaScript 中的知识点串起来

通过本篇文章,你可以学习 / 复习下面的知识点:

new 运算符

new 用于创建具有构造函数的对象类型的实例。语法如下:

new Constructor[([args])]

其中,Constructor 即构造函数,构造函数的参数 args 是可选的;当没有 args 时,new Constructornew Constructor() 是等同的。

执行 new 时,大致做了这些事情:

  1. 创建一个空对象 {}

  2. 让空对象的原型指向 Constructor.prototype (实现类型的继承,构建原型链)

  3. 将创建的对象作为构造函数中的 this,执行构造函数

  4. 构造函数没有 return 的情况下返回构造函数内部的 this,这就是 new 运算符的运算结果(你也可以在构造函数中主动选择返回对象,来覆盖正常的对象创建步骤,但这不是建议的行为——在 TypeScript 中明确要求构造函数返回类型必须为 void

因此,如果对一个空函数进行 new 运算,将返回一个空对象 {}

new (function() {}) // {}

对于构造函数,执行 new Constructor()Constructor() 得到的结果通常也是不一样的:

typeof Date(); // 'string'
typeof new Date(); // 'object' 

new 除了可以对构造函数进行运算,还可以对 ES6 中的 class 进行运算。但 ES6 的 class 只是一个语法糖,本质上还是构造函数,因此先让我们把重点放在 构造函数

内置构造函数

内置的构造函数有很多,日常使用的有:Date()Promise()Map()Set()

在 JavaScript 中,约定构造函数使用大驼峰方式命名。

我们可以将构造函数的名称,作为该构造函数生产出来的实例的类型,如 new Date() 类型为 Date,new Error('出错了') 类型为 Error,[] 类型为 Array,{} 类型为 Object,100 类型为 Number,'nice !' 类型为 String,true 类型为 Boolean…对于构造函数的名称,我们可以通过Constructor.name 获取到。(后面借助这一点,实现获取 任意 对象的类型)

数据类型对应的构造函数

八大数据类型 中,除了 nullundefined,其余 6 种数据类型都有相应的构造函数的,分别是: Object()Boolean()Number()String()Symbol()BigInt()

SymbolBigInt 是 ES6 中新增的基础类型,它们无法被 new 运算,只能通过调用构造函数的方式,获得一个原始值,如:const typeSymbol = Symbol('type')

之所以无法被 new 运算,是因为在 ES6 中规定:不能对基础类型的构造函数执行 new 运算。而Boolean()Number()String() 还支持 new 运算,更多是为了兼容性考虑。现在推荐使用字面量方式创建原始值。

如果真的想获得一个原始值包装器(含有 [[PrimitiveValue]] 的对象),可以使用构造函数Object(),调用valueOf()方法即可获取到原始值:

image.png

new Object()Object() 的效果几乎是一样的。

在 JavaScript 中,当我们说到「对象」或「object」时,通常指的是包含键值对的实例;当我们说到「Object」时,通常指的是构造函数 Object()

原始值包装器

Boolean 为例,执行 new Boolean(0)

image_1.png

控制台中打印的 Boolean {false} 就是一个原始值包装器,它有两个特点:

如果不使用运算符 new 只执行构造函数,将只返回原始值(有需要的话进行数据转换):

image_2.png

基本类型

7 个基本类型:

基本类型代表了最底层的语言实现。

基本类型的值被称为原始值、原始数据,如 nullundefinedtrue100'hello'…都是原始值。

通过我们的日常开发可以发现,基本类型在代码中被大量使用,所以从一开始设计 JavaScript 这门语言时,基本类型必须要保证 高效性 。(其实在其他语言中也是同样的)

为了达到这一目标,原始值有如下特征:

JavaScript 中,除了原始值(基础类型的值),其他都是引用值(复杂类型的值,继承自Object.prototype),即使函数也是引用值,只不过函数内部有个[[callable]],可以执行()语法。

原始值是不可变的

let a = 1;
((num) => num++)(a);
a; // 1 

由于原始值不可改变的特性,将原始值作为参数传给函数时,其实是 复制 一份原始值的副本传入的,函数内部操作的是这份副本,原本的原始值不受任何影响。

但如果函数接收一个引用值,它自身会随着函数体的操作而改变:

let arr = [];
((value) => value.push('Oh!'))(arr);
arr; // ["Oh!"]

原始值没有属性、方法

上面提到,为了保证高效性,原始值是没有属性和方法的,但是我们却经常进行如下操作:

const str = 'abc';
str.substr(-1); // 'c'
str.length; // 3 

为什么可以调用属性 length,调用方法 .substr()

这是因为对原始值 str 调用方法、属性时,其实是 JavaScript 引擎根据原始值,创建了对应的原始值包装器,即 new String(str),然后在这个包装器上调用方法、属性。

同时由于原始值的不可变性,原始值包装器调用的所有方法,如 .substr().substring().toFixed() 等都不会改变原始值,函数运行结果作为一个全新的原始值被返回——这是所有原始值的特性。


讲完了 new 在内置构造函数中的应用,再来看看其在自定义构造函数中的应用。

自定义构造函数

// 定义对象类型:Phone
function Phone(make, model) {
  this.make = make;
  this.model = model;
}

执行 new Phone('Apple', 'iPhone 12') 控制台输出:

image_3.png

这里我们创建了一个 Phone 类型的实例,属性 makemodel 都在执行构造函数时正常赋值了。

此外,该实例还有一个属性__proto__,它是什么?

__proto__

每个由 new 运算得到的实例都会有属性__proto__,它只用来做一件事:指向当前实例的原型(父类),即该实例的 [[Prototype]]。上述例子中,它指向 Phone.prototype。对于使用对象字面量创建的对象,它指向 Object.prototype;对于使用数组字面量创建的对象,它指向 Array.prototype,使用字符串字面量创建的原始值,它指向 String.prototype

我们可以通过改变 __proto__,以实现改变当前实例的原型,前提是该对象必须通过 Object.isExtensible() 判断为可扩展的。要变更的值必须是一个对象或 null

因为性能缘故,__proto__ 已不被推荐使用,如果使用 obj.__proto__ = ... 极有可能出现问题!现在更推荐使用 Object.getPrototypeOf(o) 或者 Object.setPrototypeOf(o, proto)

那么,__proto__prototype 的关系是?

prototype

首先明确,prototype 属性出现在哪些对象上?

答:内置的构造函数和自定义的普通函数。

image_4.png

箭头函数没有prototype

image_5.png

实例也没有prototype

image_6.png

与之相应的,__proto__ 出现在对象实例上。

很多时候,我们发现原型也有 __proto__,这是因为:原型也是某个其他原型的实例。没有多层继承的话,它通常是Object.prototype

Number.prototype.__proto__ === Object.prototype // true

原型的两个基本属性

一个「纯净」的 Constructor.prototype 有两个属性:

constructor__proto__prototype 的关系如图:

image_7.png

除了__proto__ 为空的对象,其他所有的对象都是 Object 的实例,都会继承 Object.prototype 的属性和方法——尽管它们可能被覆盖了。

有时候会故意创建不具有典型原型链继承的对象,比如通过 Object.create(null) 创建的对象,或通过obj.__proto__ = ...Object.setPrototypeOf(obj, proto) 改变原型链。

改变 Object 原型,会通过原型链改变所有对象,这提供了一个非常强大的扩展对象行为的机制。下面代码通过扩展 Object.prototype,使我们很方便的在程序中任何地方、获取任一对象的数据类型:

Object.defineProperty(Object.prototype, Symbol.type = Symbol('type'), {
  get() {
    // 规定 NaN 的类型为 'NaN',而不是 'Number'
    if (this.__proto__.constructor.name === 'Number' && Number.isNaN(this.valueOf())) {
      return 'NaN';
    }
    return this.__proto__.constructor.name;
  }
});

之后,除了 nullundefined 之外的所有基础类型数据、复杂类型数据,都可以通过调用 [Symbol.type] 属性获取其类型:

image_8.png

prototype 在自定义构造函数中的应用

function Phone(make, model) {
  this.make = make;
  this.model = model;
  this.innerLogMake = function() {
    console.log('当前手机的厂商:', this.make);
  }
}

Phone.prototype.outerLogMake = function() {
    console.log('当前手机的厂商:', this.make);
}

const phone = new Phone('Apple', 'iPhone');

phone.innerLogMake(); // '当前手机的厂商: Apple'
phone.outerLogMake(); // '当前手机的厂商: Apple'

输出 Phone.prototype

image_9.png

outerLogMake 挂在了 Phone 的原型上,因此实例可以顺着原型链调用该方法。

在构造函数内部的 innerLogMake,事实上它被认为是实例的一个属性,而非方法。为了性能考虑,方法应该挂在 Phone.prototype 上,而不是每次在执行构造函数时重新生成一个方法。

箭头函数在构造函数中和原型上的差异

function Phone(make, model) {
  this.make = make;
  this.model = model;

  this.innerLogMake_arrow = () => {
    console.log('当前手机的厂商:', this.make);
  }
}

Phone.prototype.outerLogMake_arrow = () => {
      // 这里的 this 不指向 Phone 实例!!!
    console.log('当前手机的厂商:', this.make);
}

const phone = new Phone('Apple', 'iPhone');

phone.innerLogMake_arrow(); // '当前手机的厂商: Apple'
phone.outerLogMake_arrow(); // '当前手机的厂商: undefined'

改变实例的原型

实例与原型的连接是通过实例的 __proto__ 表现的。根据上面提到的,如果我们需要改变实例的原型,应该调用Object.setPrototypeOf(o, proto),而不是直接设置__proto__

Object.setPrototypeOf(phone, null);
typeof phone.outerLogMake; // undefined
Object.setPrototypeOf(phone, Phone.prototype);
phone.outerLogMake(); // '当前手机的厂商: Apple'

实现继承:

function Phone(make, model) {
  this.make = make;
  this.model = model;
}
Phone.prototype.logMake = function() {
    console.log('当前手机的厂商:', this.make);
}

function HuaweiPhone(model) {
  // 父类的构造函数必须执行一次!
  Phone.call(this, '华为', model); // *
}
Object.setPrototypeOf(HuaweiPhone.prototype, Phone.prototype); // *

const p40 = new HuaweiPhone('P40');

p40.logMake(); // '当前手机的厂商: 华为'

打印 p40

image_10.png

一般情况下,HuaweiPhone.prototype.__proto__Object.prototype,两个关键步骤实现对Phone.prototype 的继承:

  1. 在构造函数HuaweiPhone()(子类)中执行Phone()(父类),无论是用call()apply()还是其他方式,只要能实现Phone类型的实例的属性正确设置就行
  2. HuaweiPhone.prototype 的原型设置为 Phone.prototype,这样就能调用Phone原型上的属性、方法了

Class

首先明确的是,JavaScript 中的 Class 只是一个语法糖。

语法糖:指计算机语言中添加的某种 语法,这种 语法 对语言的功能没有影响,但是更方便程序员使用。 语法糖 让程序更加简洁,有更高的可读性。

JavaScript 中的 Class 本质上还是构造函数。下面用 Class、构造函数两种方式声明Phone类型的数据对象:

// class 声明式
class Phone {
  constructor(make, model) {
    this.make = make;
    this.model = model;
  }
  
   logMake() {
     console.log('当前手机的厂商:', this.make);
   }
} 
// 构造函数声明式
function Phone(make, model) {
   this.make = make;
   this.model = model;
}
Phone.prototype.logMake = function() {
  console.log('当前手机的厂商:', this.make);
}

先出结论:它们是一个东西。

让我们执行实例化代码看一下:

class 声明了对象类型,执行实例化:

image_11.png

可以清楚的看到 Phone.prototype.constructor 后面虽然是 class Phone,但其实就是一个函数,argumentscallerlengthname这些函数会有的属性它都有,最重要的是:

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

假 class,真 function,没跑了!

再来看下构造函数声明了对象类型,执行实例化:

image_12.png

可以看到,两个实例化输出的内容几乎没有差别。

class 中的继承

使用 extends 可以很方便的实现继承,也就是连接原型。

class Phone {
  constructor(make, model) {
    this.make = make;
    this.model = model;
  }
  
   logMake() {
     console.log('当前手机的厂商:', this.make);
   }
}

// 使用关键字 extends 实现继承
class HuaweiPhone extends Phone {
  constructor(model) {
    // super 表示执行 Phone 中的 constructor(),必须调用!
    super('华为', model)
  }
} 

执行实例化:

image_13.png


以上就是本篇文章全部内容了,如有错误欢迎指正!

参考资料

目录 / Contents

空。

令川 · 记