面试会考的手写new,看完这一篇就够了

365 阅读9分钟

前言

在手撕new关键字之前,我希望你能足够理解new的机制和用法,当然你也可以直接跳过前半段基础讲解部分。

查缺补漏

new 运算符允许开发人员创建一个用户定义的对象类型的实例或具有构造函数的内置对象的实例。

浅尝new

function Person(name,age){
    this.name=name;
    this.age=age;
}

Person.prototype.sayName=function(){
    console.log(this.name);
}

// new 实例化运算符
// 1.创建一个空对象 {} 和 Person 没有血缘关系
// {}  __proto__Object.prototype
// 2.手动的__proto__ 指向Person.prototype
// 3.构造函数 this 指向 {} 执行, 给{}赋值

const awei = new Person("awei",20);

语法

new constructor
new constructor()
new constructor(arg1)
new constructor(arg1, arg2)
new constructor(arg1, arg2, /* …, */ argN)

参数

  • constructor

    一个指定对象实例的类型的类或函数。

  • arg1arg2、……、argN

    一个用于被 constructor 调用的值列表。new Foo 与 new Foo() 等价,换句话说:如果没有指定参数列表,则在不带参数的情况下调用 Foo

描述

当使用 new 关键字调用函数时,该函数将被用作构造函数。new 将执行以下操作:

  1. 创建一个空的简单 JavaScript 对象。为方便起见,我们称之为 newInstance

  2. 如果构造函数的 prototype 属性是一个对象,则将 newInstance 的 Prototype 指向构造函数的 prototype 属性,否则 newInstance 将保持为一个普通对象,其Prototype为 Object.prototype

    备注:  因此,通过构造函数创建的所有实例都可以访问添加到构造函数 prototype 属性中的属性/对象。

  3. 使用给定参数执行构造函数,并将 newInstance 绑定为 this 的上下文(换句话说,在构造函数中的所有 this 引用都指向 newInstance)。

  4. 如果构造函数返回非原始值,则该返回值成为整个 new 表达式的结果。否则,如果构造函数未返回任何值或返回了一个原始值,则返回 newInstance。(通常构造函数不返回值,但可以选择返回值,以覆盖正常的对象创建过程。)

(只能用 new 运算符实例化——尝试不使用 new 调用一个类将抛出 TypeError

创建一个用户自定义的对象需要两步:

  1. 通过编写指定对象名称和属性的函数来定义对象类型。例如,创建 Foo 对象的构造函数看起来可能像这样:

    function Foo(bar1, bar2) {
      this.bar1 = bar1;
      this.bar2 = bar2;
    }
    
  2. 通过 new 来创建对象实例。

    const myFoo = new Foo("Bar 1", 2021);
    

备注:  对象的属性可以是另一个对象。请参阅下面的示例。

你始终可以对已定义的对象添加新的属性。例如,car1.color = "black" 语句给 car1 添加了一个新的属性 color,并将其赋值为 "black"

但是,这不会影响任何其他对象。要将新属性添加到相同类型的所有对象,你必须将该属性添加到构造函数的 prototype 属性中。其定义了由该函数创建的所有对象所共享的属性,而不仅仅是对象类型的其中一个实例。以下代码将一个值为 "原色" 的 color 属性添加到 Car 类型的所有对象,然后仅在实例对象 car1 中用字符串 "黑色" 覆盖该值。

function Car() {}
car1 = new Car();
car2 = new Car();

console.log(car1.color); // undefined

Car.prototype.color = "原色";
console.log(car1.color); // '原色'

car1.color = "黑色";
console.log(car1.color); // '黑色'

console.log(Object.getPrototypeOf(car1).color); // '原色'
console.log(Object.getPrototypeOf(car2).color); // '原色'
console.log(car1.color); // '黑色'
console.log(car2.color); // '原色'

备注:  虽然构造函数可以像任何常规函数一样被调用(即不使用 new 运算符),但在这种情况下并不会创建一个新的对象,this 的值也是不一样的。

函数可以通过检查 new.target 来知道它是否是通过 new 被调用的。当函数在没有使用 new 的情况下被调用时,new.target 的值为 undefined。例如,你可以有一个在被调用时和被构造时具有不同表现的函数:

function Car(color) {
  if (!new.target) {
    // 以函数的形式被调用。
    return `${color}车`;
  }
  // 通过 new 被调用。
  this.color = color;
}

const a = Car("红"); // a 是“红车”
const b = new Car("红"); // b 是 `Car { color: "红" }`

在 ES6(引入了)之前,大多数 JavaScript 内置对象既可调用也可构造,尽管其中许多对象表现出不同的行为。举几个例子:

  • [Array()]、[Error()] 以及 [Function()] 在被调用时和被构造时表现一致。
  • [Boolean()]、[Number()] 以及 [String()] 在被调用时将它们的参数强制转换为相应的原始类型,而在被构造时返回包装对象。
  • [Date()] 在被调用时返回表示当前日期的字符串,相当于 new Date().toString()

在 ES6 之后,语言对哪些是构造函数、哪些是函数有更严格的要求。例如:

  • [Symbol()] 和 [BigInt()] 只能在不使用 new 的情况下被调用。尝试构造它们将抛出 TypeError
  • [Proxy] 和 [Map] 只能通过 new 构造。尝试调用它们将抛出 TypeError

示例

对象类型和对象实例

假设你要创建一个汽车的对象类型。你希望这个类型叫做 Car,这个类型具备 make、model、year 属性。要做到这些,你需要编写以下函数:

function Car(make, model, year) {
  this.make = make;
  this.model = model;
  this.year = year;
}

现在,你可以如下所示创建一个 myCar 的对象:

const myCar = new Car("小米", "XIAOMI SU7", 2024);

该语句创建了 myCar 并将其属性赋为指定的值。于是 myCar.make 的值“小米”,myCar.year 的值为整数 2024,以此类推。

你可以通过调用 new 来创建任意个 car 对象。例如:

const guosCar = new Car("保时捷", "Panamera", 2024);

对象属性为其他对象

假设你定义了一个叫做 Person 的对象:

function Person(name, age, sex) {
  this.name = name;
  this.age = age;
  this.sex = sex;
}

然后实例化了两个新的 Person 对象如下:

const pw = new Person("PW", 22, "男");
const guo = new Person("过总", 20, "男");

然后你可以重写 Car 的定义,添加一个值为 Person 对象的 owner 属性,如下:

function Car(make, model, year, owner) {
  this.make = make;
  this.model = model;
  this.year = year;
  this.owner = owner;
}

要实例化新的对象,你可以用如下代码:

const car1 = new Car("小米", "XIAOMI SU7", 2024 );
const car2 = new Car("保时捷", "Panamera", 2024 );

创建对象时,并没有传字符串或数字,而是将对象 pw 和 guo 作为参数传递,来代表所有者。要查找 car2 的所有者的名称,你可以访问以下属性:

car2.owner.name;

使用 new 和类

class Person {
  constructor(name) {
    this.name = name;
  }
  greet() {
    console.log(`你好,我的名字是${this.name}`);
  }
}

const p = new Person("PW");
p.greet(); // 你好,我的名字是PW

手撕new

我们需要满足new关键字描述中的四个操作。

代码如下:

es5版本

function Person(name, age) {
    this.name = name;
    this.age = age;
}

function objectFactory() {
    // 创建一个新的空对象
    const obj = new Object();
    // 获取传入的第一个参数,即构造函数
    const Constructor = [].shift.call(arguments);
    // 输出[Function: Person]
    console.log(Constructor);
    // 将新对象的原型指向构造函数的原型
    obj.__proto__ = Constructor.prototype;
    // 调用构造函数,并将this指向新对象,传入剩余的参数
    Constructor.apply(obj, arguments);
    // 输出Person { name: 'awei', age: 20 }
    console.log(obj);
    return obj;
}

// 使用objectFactory函数创建一个Person对象实例,并传入参数"awei"和20
let awei = objectFactory(Person, "awei", 20);
// 输出:awei
console.log(awei.name);

// 为Person构造函数的原型添加一个sayName方法,用于打印对象的name属性
Person.prototype.sayName = function() {
    console.log(this.name);
};
// 调用awei对象的sayName方法,输出"awei"
awei.sayName();
  • 1. arguments 对象: arguments 是一个局部变量,存在于所有非箭头函数的作用域内,它包含了一个类似数组的对象,其中每个元素都是传递给该函数的参数。需要注意的是,虽然arguments看起来像数组,但它并不是真正的数组,因此它不具备数组的一些方法,如pushpopshift等。

  • 2. Array.prototype.shift(): shift() 方法是数组的一个原型方法,用于移除数组的第一个元素,并返回该元素。调用这个方法后,原数组会被修改,长度减一。

  • 3. [].shift: 这里的[]创建了一个空数组实例,然后通过点符号访问了数组的shift方法。实际上,这是获取了Array.prototype.shift方法的引用,但以一种更简短的方式书写。

  • 4. .call(arguments) :.call() 是函数的一个方法,允许你指定函数执行时的this值以及传递给函数的参数。在这个例子中,我们把this设置为arguments,即把shift方法应用到arguments上,就好像arguments是一个真正的数组一样。这样做是因为arguments本身不是数组,没有自己的shift方法,但我们希望对它使用shift方法的功能

es6版本:

function objectFactory(fn, ...args) {
    // 创建一个新的空对象
    const obj = {};
    // 将新对象的原型指向构造函数的原型
    obj.__proto__ = fn.prototype;
    // 调用构造函数,并将this指向新对象,传入剩余的参数
    const ret = fn.apply(obj, args);
    // 打印构造函数的返回值
    console.log(ret);
    // 如果构造函数返回的是一个对象,则返回该对象,否则返回新创建的对象
    return typeof ret === 'object' ? ret : obj;
}

function Person(name, age) {
    // 将传入的name参数赋值给当前对象的name属性
    this.name = name;
    // 将传入的age参数赋值给当前对象的age属性
    this.age = age;
    // 返回一个新的对象,覆盖了构造函数默认的返回值
    return {
        name: "dailao",
        age: 18,
        tag: '123'
    };
}

// 为Person构造函数的原型添加一个sayHello方法,用于打印对象的name属性和"hello"
Person.prototype.sayHello = function () {
    console.log(this.name + "hello");
};

// 使用objectFactory函数创建一个Person对象实例,并传入参数"闵老板"和18
const dailao = objectFactory(Person, "闵老板", 18);

如果刚好这是你的一道面试题,以下几点是你需要表达出来的。

创建一个新的空对象

objectFactory函数中,首先创建一个空的对象obj = {}。这个对象将作为构造函数调用时的上下文(即this)。

绑定原型链

接下来,设置新对象的__proto__属性指向构造函数的prototype属性。这一步是建立原型链的关键,它使得新对象能够访问构造函数原型上的方法和属性:

obj.__proto__ = fn.prototype;

执行构造函数并绑定this

使用apply方法来执行构造函数,并将新创建的对象作为this值传递给构造函数。同时,将剩余参数通过...args传递给构造函数:

const ret = fn.apply(obj, args);

处理构造函数返回值

如果构造函数返回了一个非null的对象类型(包括FunctionArray等),那么new表达式的返回结果将是这个对象;否则,new表达式会忽略构造函数返回的任何原始类型的值(如undefinedstringnumber等),并返回新创建的对象。因此,在objectFactory中需要检查构造函数的返回值是否为对象,并据此决定最终返回哪个对象:

return typeof ret === 'object' && ret !== null ? ret : obj;

ES5 vs ES6 参数处理

  • ES5:以前版本的JavaScript中,我们可能会使用arguments类数组对象,并结合[].shift.call(arguments)来获取第一个参数(构造函数)。
  • ES6:新的标准引入了剩余参数...args,可以更方便地收集所有传递给函数的参数,并且不需要显式地移除或处理第一个参数。

构造函数的返回值与原型

当构造函数返回一个对象时,new表达式的结果就是这个对象,而不是新创建的那个对象。这意味着即使没有定义原型方法,返回的对象也可以正常使用。不过,通常情况下,我们不从构造函数中返回对象,而是依赖于new关键字自动返回新创建的对象。

null 和 空对象

如果构造函数返回null或者没有任何返回值(隐式返回undefined),则new表达式会返回新创建的对象。

小结

手写new对我们的提升点:

深入理解JavaScript内部机制

  • 原型链:通过手动设置新对象的__proto__属性指向构造函数的prototype,可以更深入地了解JavaScript中原型继承的工作原理。
  • this绑定:使用applycall方法将构造函数中的this绑定到新创建的对象上,有助于掌握this在不同上下文中的行为。

ES6+特性的理解

  • 使用现代JavaScript特性(如剩余参数...args)来简化参数处理,可以帮助我们更加熟悉这些新特性,并在日常开发中更有效地利用它们。

面向对象编程(OOP)概念

  • 手动实现new有助于更好地理解OOP的核心概念,如构造函数、类、继承和多态性。

手写new本身其实不难,我们需要关注的是其中的细节,往往是这些细节才能让面试官对我们有更深的印象,这也是区分高手和小白的一个关键点。

51330ad45a2842d5af2533cac81140d8~tplv-5jbd59dj06-image.png

希望这篇文章能对你有帮助。