深入浅出:手写 new 操作符与类数组 arguments 的前世今生

56 阅读3分钟

在日常开发中,我们几乎每天都在使用 new 来创建对象实例,也经常在函数内部接触到神秘的 arguments 对象。但你是否真正理解它们背后发生了什么?今天我们就来彻底拆解这两个 JavaScript 经典机制,并手把手实现一个完整的 new 模拟函数,顺便解决 arguments 不能直接用数组方法的老大难问题。

一、new 到底做了什么?

在使用 new 操作符时,JavaScript 引擎悄悄做了四件事:

  1. 创建一个全新的空对象 {}
  2. 将这个空对象的 proto 指向构造函数的 prototype
  3. 将构造函数的 this 绑定到这个新对象上,并执行构造函数
  4. 如果构造函数没有显式返回对象,则自动返回这个新对象

我们用最经典的例子来看:

JavaScript

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

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

const p = new Person('张三', 18);

执行 new Person('张三', 18) 后,p 就拥有了 name、age 属性,还能访问到原型上的 sayHi 方法。

二、手写一个完美的 new 模拟函数

我们来实现一个名为 objectFactory 的函数,完整模拟 new 的行为:

JavaScript

function objectFactory() {
    // 1. 创建一个空对象
    const obj = new Object();
    
    // 2. 取出构造函数(第一个参数),剩余的是构造函数参数
    const Constructor = [].shift.call(arguments);
    
    // 3. 链接原型链
    obj.__proto__ = Constructor.prototype;
    
    // 4. 绑定 this 并执行构造函数
    const result = Constructor.apply(obj, arguments);
    
    // 5. 如果构造函数返回了对象,则返回该对象;否则返回我们创建的 obj
    return typeof result === 'object' && result !== null ? result : obj;
}

注意:这里有一个关键点!很多人手写 new 时忽略了第5步:构造函数如果显式返回一个对象,应该以返回值为准。

验证一下我们的实现:

JavaScript

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

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

const ggg = objectFactory(Person, 'ggg', 20);

console.log(ggg.age);        // 20
console.log(ggg.species);    // 人类
ggg.sayHi();                 // 你好,我是ggg

完美!原型链、this 绑定、属性赋值全部正确。

进阶:处理构造函数返回对象的情况

JavaScript

function Strange() {
    this.a = 1;
    return { b: 2 };  // 显式返回对象
}

const s = objectFactory(Strange);
console.log(s); // { b: 2 } 而不是 { a: 1 }

这正是 new 的真实行为,我们的实现也完全一致。

三、神秘的 arguments 对象:它到底是什么?

在函数内部,arguments 是一个类数组对象,它有以下特点:

  • 有 length 属性
  • 可以用 arguments[0]、arguments[1] 访问参数
  • 不能直接调用 map、reduce、filter 等数组方法
  • 在非严格模式下,修改 arguments[i] 会影响对应的具名参数(反之亦然)

JavaScript

function test(a, b) {
    console.log(arguments.length); // 2
    console.log(arguments[0]);     // 传入的第一个参数
    
    arguments[0] = 100;
    console.log(a); // 100(非严格模式下会被修改)
}

如何把 arguments 变成真正的数组?

常见的三种方式(推荐使用扩展运算符):

JavaScript

function add() {
    // 方法1:Array.from(最直观)
    const args = Array.from(arguments);
    
    // 方法2:扩展运算符(最优雅)
    const args2 = [...arguments];
    
    // 方法3:借用数组原型 slice(经典老方法)
    const args3 = Array.prototype.slice.call(arguments);
    
    // 现在可以愉快地使用数组方法了
    return args.reduce((sum, cur) => sum + cur, 0);
}

console.log(add(1, 2, 3, 4, 5)); // 15

arguments 的 proto 指向哪里?

JavaScript

function fn() {
    console.log(arguments.__proto__ === Object.prototype); // true
    console.log(Object.prototype.toString.call(arguments)); // [object Arguments]
}

它本质上是一个普通对象,只是长得像数组。

四、实际应用场景举例

场景1:实现一个可变参数的求和函数

JavaScript

function sum() {
    return [...arguments].reduce((a, b) => a + b, 0);
}

// 或者更优雅地直接使用 rest 参数(推荐)
function sumModern(...args) {
    return args.reduce((a, b) => a + b, 0);
}

场景2:手写 bind 函数时需要用到 arguments

JavaScript

Function.prototype.myBind = function(context, ...bindArgs) {
    const self = this;
    return function(...callArgs) {
        return self.apply(context, bindArgs.concat(callArgs));
    };
};

五、ES6 class 的 new 还能被模拟吗?

答案是:完全可以!class 只是语法糖,本质还是原型继承:

JavaScript

class Animal {
    constructor(name) {
        this.name = name;
    }
    speak() {
        console.log(`${this.name} 发出声音`);
    }
}

const dog = objectFactory(Animal, '旺财');
dog.speak(); // 旺财 发出声音

我们的 objectFactory 依然完美工作!

总结

通过今天的手写实现,我们彻底明白了:

  • new 操作符的四个步骤缺一不可(尤其是很多人忽略的返回值处理)
  • arguments 是一个类数组对象,通过扩展运算符或 Array.from 可以轻松转为真数组
  • 手写 new 的核心是:创建对象 → 链接原型 → 绑定 this → 处理返回值
  • 理解这些底层机制,能让我们写出更健壮、更灵活的 JavaScript 代码

掌握了这些,你就已经站在了无数前端工程师的前面。去试试把今天的 objectFactory 加入你的工具库吧!