JavaScript 进阶:深入理解并手写 new 运算符(从类数组到原型链)

35 阅读5分钟

在 JavaScript 的面向对象编程中,new 运算符是一个神奇的存在。它能将一个普通的函数变成构造函数,并生成一个拥有特定属性和方法的实例对象。但你是否想过,这个“实例化”的过程底层究竟发生了什么?

本文将结合代码实例,带你一步步拆解 new 的原理,并分别使用 ES5(基于 arguments)和 ES6(基于扩展运算符)两种方式手写实现一个 objectFactory

一、 核心理论:new 到底做了什么?

当我们执行 new Person('张三', 18) 时,JavaScript 引擎在后台默默完成了以下 4 个关键步骤

  1. 创建空对象:在内存中创建一个全新的空对象({})。

  2. 链接原型(关键) :将这个空对象的 __proto__ 属性指向构造函数的 prototype 属性。

    • 这一步是为了实现继承,让新对象能访问构造函数原型上的方法(如 speciessayHi)。
  3. 绑定 this 并执行:将构造函数内部的 this 指向这个新创建的对象,并执行构造函数。

    • 这一步是为了给新对象添加实例属性(如 nameage)。
  4. 返回对象:如果构造函数没有显式返回对象,则默认返回这个新创建的对象。

二、 前置知识:类数组对象 Arguments

在手写 new 之前,我们需要先解决一个棘手的问题:如何处理不定量的参数?

在 JS 函数内部,arguments 是一个非常特殊的对象。

1. Arguments 的特性

arguments 的特点:

  • 它是对象,不是数组:虽然它长得像数组(有 length 属性,可以通过索引 [0] 访问),但它的原型链直指 Object,而不是 Array

  • 类型检测

    Object.prototype.toString.call(arguments); // "[object Arguments]"
    Object.prototype.toString.call([1, 2]);    // "[object Array]"
    
  • 方法限制:它无法直接使用数组的高级方法,比如 reducemap 等。如果在 arguments 上直接调用 reduce 会报错。

2. 如何将 Arguments 转为真数组?

为了更灵活地操作参数,我们常需要将其转换为真数组。笔记中提到了三种主流方法:

  • 方法一:借用 slice(ES5 经典)

    原理是利用 call 让类数组借用数组的 slice 方法,遍历并返回新数组。

    var args = Array.prototype.slice.call(arguments);
    // 或者简写为
    var args = [].slice.call(arguments);
    
  • 方法二:扩展运算符(ES6 推荐)

    const args = [...arguments];
    
  • 方法三:Array.from

    const args = Array.from(arguments);
    

三、 手写实战:objectFactory 的两种实现

接下来,我们编写 objectFactory 函数来模拟 new 的行为。

版本一:ES5 写法(基于 arguments 和 apply)

这是最经典也是最考验基本功的写法。我们需要直接操作 arguments 对象来获取构造函数和参数。

代码实现:

function objectFactory() {
    // 1. 从空对象开始
    var obj = new Object();

    // 2. 解析参数:获取构造函数
    // arguments 是类数组,没有 shift 方法。
    // 我们利用 call 借用 Array.prototype.shift,取出参数列表的第一个参数(即构造函数)
    // 注意:shift 会改变原 arguments 对象,剩下的就是真正的参数了
    var Constructor = [].shift.call(arguments); 

    // 3. 绑定 this 并执行构造函数
    // apply 专门支持类数组对象作为参数列表,所以这里 arguments 不需要转换成真数组
    Constructor.apply(obj, arguments);

    // 4. 链接原型
    // 将新对象的隐式原型指向构造函数的显式原型
    obj.__proto__ = Constructor.prototype;

    // 5. 返回对象
    return obj;
}

代码解析:

  • [].shift.call(arguments) :这是本段代码的精华。arguments 本身没有 shift,我们强行“借用”数组的方法。这行代码不仅拿到了第一个参数(构造函数 Constructor),还顺便把 arguments 里第一个元素删除了,留下的全是后续需要的参数(如 '李四', 20)。
  • Constructor.apply(obj, arguments) :使用 apply 将构造函数内部的 this 强行绑定到我们刚创建的 obj 上。

版本二:ES6 写法(基于 Rest 参数)

ES6 引入了剩余参数(Rest Parameters),让参数获取变得极其优雅,避免了繁琐的 arguments 操作。

代码实现:

// 使用 ...args 将构造函数后面的所有参数收集到一个真数组 args 中
function objectFactory(Constructor, ...args) {
    // 1. 创建空对象
    const obj = new Object();

    // 2. 绑定 this 并执行
    // 因为 args 已经是真数组,也可以使用 apply(obj, args)
    // 或者使用 ES6 的扩展运算符 Constructor.call(obj, ...args)
    Constructor.apply(obj, args);

    // 3. 链接原型
    obj.__proto__ = Constructor.prototype;

    // 4. 返回对象
    return obj;
}

对比优势:

  • 语义清晰:函数签名 (Constructor, ...args) 明确地告诉调用者,第一个是构造器,后面是参数。
  • 无需借用方法args 是真正的数组,不需要使用 [].shift.call 这种黑魔法。

四、 测试与验证

我们要确保手写的 objectFactory 能完美复刻 new 的功能,包括实例属性和原型链属性。

测试用例(基于 index.html):

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

// 在原型上添加属性和方法
Person.prototype.species = 'human';
Person.prototype.sayHi = function () {
    console.log(`你好,我是${this.name}`);
}

// 1. 使用原生 new
let p = new Person('张三', 18);

// 2. 使用我们的 objectFactory
let p2 = objectFactory(Person, '李四', 20);

// --- 验证结果 ---

console.log(p2.name);    // 输出: "李四" (验证实例属性)
console.log(p2.age);     // 输出: 20    (验证实例属性)
console.log(p2.species); // 输出: "human" (验证原型链属性)
p2.sayHi();              // 输出: "你好,我是李四" (验证原型方法)

// 验证类型
console.log(p2 instanceof Person); // true
console.log(Object.getPrototypeOf(p2) === Person.prototype); // true

五、 总结

手写 new 是前端面试中的高频考题,它考察的不仅仅是代码记忆,更是对 JavaScript 核心机制的理解:

  1. 原型与原型链:理解 __proto__prototype 的连接关系。
  2. this 绑定:理解 apply/call 如何改变函数执行时的上下文。
  3. 参数处理:深入理解 arguments 类数组对象与 ES6 ...args 的区别与转换。

通过模拟这个过程,我们可以看到,所谓的“实例化”其实就是建立一个对象,并通过原型链和上下文绑定,让它“继承”构造函数的基因。