揭开 `new` 的神秘面纱:从“黑盒”到“手写实现”的深度解析

4 阅读8分钟

揭开 new 的神秘面纱:从“黑盒”到“手写实现”的深度解析

在 JavaScript 的面向对象编程中,new 运算符就像一位神奇的魔术师。只需轻轻一行代码 const p = new Person('张三'),一个拥有独立属性、能继承原型方法的全新对象便诞生了。

但这位魔术师究竟施了什么魔法?如果让你亲手编写一个 new,你能做到吗?

本文将结合底层原理与实战代码,带你一步步拆解 new 的内部机制,并手写一个完美的 objectFactory


一、new 到底做了什么?(四步走战略)

当我们执行 new Person(...args) 时,JavaScript 引擎在幕后默默完成了四个关键步骤:

  1. 创建一个全新的空对象 这是故事的起点。引擎在内存中开辟了一块新空间,准备容纳未来的实例。

    var obj = new Object(); // 或者 var obj = {};
    
  2. 链接原型链(继承的关键) 这是新手最容易忽略的一步。新对象的内部原型(__proto__)被指向构造函数的 prototype 属性。

    • 意义:这让新对象能够访问构造函数原型上定义的所有方法(如 sayHi)和属性(如 species)。
    obj.__proto__ = Person.prototype;
    
  3. 绑定 this 并执行构造函数 构造函数被调用,但此时的 this 不再指向全局或 undefined,而是被强行绑定到了第1步创建的那个新对象上。构造函数内部定义的属性(如 this.name)直接被添加到了新对象身上。

    Person.call(obj, ...args); 
    // 或者 Person.apply(obj, args);
    
  4. 返回结果

    • 如果构造函数没有返回对象(通常情况),则返回第1步创建的新对象 obj
    • 如果构造函数显式返回了一个对象,则返回那个对象,忽略之前创建的 obj

二、手写实现:打造你的 objectFactory

基于上述原理,我们可以手动模拟 new 的功能。

❌ 初级版本(常见错误示范)

很多初学者会写出这样的代码:

function objectFactory(Constructor, ...args) {
    var obj = new Object();
    Constructor.apply(obj, args); // 只有这一步
    return obj;
}

缺陷分析: 这个版本只完成了“创建对象”和“执行构造函数”,缺失了最关键的原型链连接

  • 生成的对象虽然有 nameage 等自有属性。
  • 但它无法继承 Person.prototype 上的 sayHi 方法或 species 属性。
  • 它本质上只是一个普通的 Object,而不是 Person 的实例。

✅ 专业版本(完美模拟)

结合文档中的核心知识点,我们来编写一个健壮的 objectFactory

function objectFactory() {
    // 1. 获取参数
    // arguments 是一个类数组对象,包含所有传入的参数
    // 第一项是构造函数,后面的是传递给构造函数的参数
    
    // 将类数组 arguments 转换为真数组,方便操作
    const args = [...arguments];
    
    // 取出构造函数(数组第一项)
    const Constructor = args.shift();
    
    // 2. 创建一个空对象
    const obj = new Object();
    
    // 3. 【核心】链接原型链
    // 让新对象的原型指向构造函数的原型,实现方法继承
    obj.__proto__ = Constructor.prototype;
    // 或者使用: Object.setPrototypeOf(obj, Constructor.prototype);
    
    // 4. 绑定 this 并执行构造函数
    // 将 obj 作为 this 传入,并应用剩余的参数
    const result = Constructor.apply(obj, args);
    
    // 5. 【严谨】处理返回值
    // 如果构造函数返回了一个对象,则返回该对象;否则返回我们创建的 obj
    return (typeof result === 'object' && result !== null) ? result : obj;
}

三、深度解析:关键知识点

1. 为什么需要操作 arguments

在上面的实现中,我们使用了 [...arguments]shift()。这是因为 objectFactory 接收的参数是动态的:

  • 第一个参数永远是构造函数
  • 后面的参数个数不定,是传给构造函数的业务参数

arguments 是一个类数组对象(Array-like Object):

  • ✅ 有 length 属性。
  • ✅ 可以通过索引 arguments[0] 访问。
  • 不是真正的数组,不能直接使用 .map(), .reduce(), .shift() 等数组方法。

解决方案: 使用扩展运算符 [...arguments] 将其转化为真数组,从而可以使用 shift() 轻松分离构造函数和参数列表。

2. 原型链:继承的灵魂

如果没有 obj.__proto__ = Constructor.prototype 这一行:

  • p = new Person(...) -> p.__proto__ 指向 Person.prototype -> 可以找到 sayHi
  • zzp = objectFactory(...) (无原型连接) -> zzp.__proto__ 指向 Object.prototype -> 找不到 sayHi

这就是为什么手写 new 时,连接原型链比执行构造函数更重要。

3. 返回值的陷阱

考虑以下构造函数:

function Person(name) {
    this.name = name;
    return { hobby: 'coding' }; // 显式返回对象
}
  • 使用原生 new:返回的是 { hobby: 'coding' }this.name 的赋值被丢弃。
  • 使用不完善的 objectFactory:可能依然返回了包含 nameobj

因此,完善的实现必须包含对构造函数返回值的判断(见代码第 24 行)。


四、实战对比

让我们看看原生 new 和我们手写的 objectFactory 是否表现一致:

function Person(name, age) {
    this.name = name;
    this.age = age;
}
Person.prototype.species = '人类';
Person.prototype.sayHi = function() {
    console.log(`你好,我是${this.name}`);
};

// 原生方式
let p = new Person('张三', 18);

// 手写方式
let zzp = objectFactory(Person, '李四', 19);

// 验证结果
console.log(p.name, p.species);      // 张三 人类
console.log(zzp.name, zzp.species);  // 李四 人类 (成功继承!)

p.sayHi();      // 你好,我是张三
zzp.sayHi();    // 你好,我是李四 (成功调用!)

console.log(p instanceof Person);      // true
console.log(zzp instanceof Person);    // true (原型链连接成功)

五、深入理解 arguments 对象

在手写 new 的过程中,arguments 扮演了“参数搬运工”的关键角色。要真正掌握它,我们需要厘清以下几个核心概念:

1. 什么是 arguments

arguments 是一个类数组对象(Array-like Object),它自动存在于所有非箭头函数的内部。

  • 作用:它包含了函数被调用时传入的所有实参,无论函数定义时是否声明了对应的形参。
  • 特征
    • 拥有 length 属性,表示参数个数。
    • 可以通过索引访问,如 arguments[0], arguments[1]
    • 关键限制:它不是真正的数组(Array),因此不能直接调用 .push(), .shift(), .map(), .reduce() 等数组方法。

2. 为什么在手写 new 中需要转换它?

回顾我们的代码:

const args = [...arguments]; // 转换为真数组
const Constructor = args.shift(); // 提取构造函数

如果不进行 [...arguments] 转换,直接写 arguments.shift() 会报错: Uncaught TypeError: arguments.shift is not a function

原因shift 是数组原型上的方法,而 arguments 只是长得像数组的对象,它的原型链上并没有 shift

3. 如何优雅地操作 arguments

在现代 JavaScript (ES6+) 开发中,我们有两种主要策略来处理 arguments

策略 A:转为真数组(兼容性与灵活性)

使用扩展运算符或 Array.from 将其转换为真正的数组,从而享受所有数组方法的便利。

// 方法 1:扩展运算符 (推荐)
const realArray = [...arguments];

// 方法 2:Array.from
const realArray = Array.from(arguments);

// 现在可以随意使用了
realArray.shift(); 
realArray.map(item => item * 2);

这也是我们在 objectFactory 中采用的方法,因为它逻辑清晰且易于维护。

策略 B:借用数组方法(老派写法)

在不支持 ES6 的老旧环境中,可以通过 callapply 借用数组的方法直接操作 arguments

// 直接从 arguments 中移除第一项并返回
var firstArg = [].shift.call(arguments);

// 注意:这会直接修改 arguments 对象本身(长度变短,索引前移)
console.log(arguments.length); // 减少 1

这种写法虽然节省内存(不需要创建新数组),但可读性较差,且对 arguments 的修改在某些严格模式下可能引发性能问题或意外行为。

策略 C:直接使用 Rest Parameters (最佳实践)

其实,在现代手写 new 时,我们完全可以避免直接使用 arguments。 通过在函数签名中使用 剩余参数 (Rest Parameters) ...args,JS 引擎会自动将剩余参数收集为一个真正的数组

// 优化后的 objectFactory,完全抛弃 arguments
function objectFactory(Constructor, ...args) {
    // args 已经是真数组 ['李四', 19],无需转换,无需 shift
    // Constructor 直接通过形参获取
    const obj = new Object();
    obj.__proto__ = Constructor.prototype;
    Constructor.apply(obj, args); // 直接使用
    return obj;
}

4. arguments 与 箭头函数

重要警示arguments 不存在于箭头函数中。 如果你在箭头函数内部访问 arguments,它会沿着作用域链向上查找,找到的是外层普通函数arguments,这通常会导致逻辑错误。

function outer() {
    const arrow = () => {
        console.log(arguments); // 这里指向 outer 函数的 arguments,而非 arrow 的
    };
    arrow(1, 2); 
}
outer('a', 'b'); 
// 输出: ['a', 'b'] (而不是 1, 2)

因此,手写 objectFactory 这类需要动态处理参数的工具函数时,必须使用普通函数 (function),而不能使用箭头函数。

总结

arguments 是 JS 早期实现可变参函数的基石,但在 ES6 之后,剩余参数 (...args) 以其真正的数组特性和更好的可读性,成为了更优选。理解 arguments 的局限性(非数组、无箭头函数支持)以及转换技巧,是编写健壮高阶函数的必备技能。

六、总结

手写 new 不仅仅是一道面试题,更是理解 JavaScript 原型链闭包this 指向以及类数组对象特性的绝佳切入点。

核心公式记忆

手写 new = 创建空对象 + 连接原型链 + 绑定 this 执行函数 + 智能返回

掌握了这个公式,你就真正理解了 JavaScript 面向对象编程的基石。下次再看到 new,你看到的不再是黑盒,而是一行行清晰可控的逻辑代码。