🚀 揭秘 JS 魔法:手写 new,深度掌握面向对象!

53 阅读6分钟

嗨,各位未来的 JS\text{JS} 大佬们!👋

JavaScript\text{JavaScript} 的世界里,new\text{new} 关键字简直就是创造对象的“魔法棒”。每次我们写下 ‘new Person()‘\text{`new Person()`} 的时候,一个全新的、拥有特定属性和方法的对象就诞生了。但你有没有好奇过,这短短的三个字母背后,到底发生了什么?它又是如何把一个普普通通的函数(构造函数)变成一个对象的蓝图的呢?

今天,咱们就来一次硬核挑战:手写实现 new\text{new} 的功能! 这不仅能让你对 JS\text{JS}原型链面向对象有更深层次的理解,还能让你在面试中秀出真正的技术肌肉!💪

一、new\text{new} 运算符:实例化的过程 🛠️

在开始“造轮子”之前,咱们先快速回顾一下,当你使用 ‘new Constructor()‘\text{`new Constructor()`} 时,JS\text{JS} 引擎在幕后默默完成了哪几个关键步骤?

记住这个**“四步走”**战略:

1. 创建空对象(Empty Object\text{Empty Object}):

首先,JS 会创建一个全新的、普通的 JavaScript 空对象var obj = {}var obj = new Object()

2. 设置原型链Prototype Link:

新创建的对象的 proto 属性会被链接到构造函数的 prototype 对象上。这是实现继承的关键!这样,新对象就能访问构造函数原型上的所有属性和方法了。

obj.__proto__ = Constructor.prototype
3. 绑定 this\text{this} 并执行构造函Execute

将这个新对象作为构造函数Constructor内部的 this。然后,执行构造函数内部的代码,为这个新对象添加属性和方法。

4. 返回对象(Return\text{Return}) :
  • 如果构造函数没有显式返回一个对象,那么 new\text{new} 表达式会默认返回这个新创建的对象(即 this\text{this} 指向的那个对象)。

  • 如果构造函数显式返回了一个对象(非原始值),那么 new\text{new} 表达式会返回这个被返回的对象

💡 核心思想: new\text{new} 就是一个语法糖,它帮我们自动完成了从“创建空对象”到“设置原型”再到“执行初始化代码”的一系列步骤。


二、手写 objectFactory\text{objectFactory} 🏭

现在,咱们就用一个普通的函数 ‘objectFactory‘\text{`objectFactory`} 来模拟实现 new\text{new} 的功能。目标是让 ‘let zzp = objectFactory(Person, ’郑zp’, 18)‘\text{`let zzp = objectFactory(Person, '郑zp', 18)`} 达到和 ‘let p = new Person(’张三’, 18)‘\text{`let p = new Person('张三', 18)`} 一样的效果。

1. 初版实现(使用 ...rest\text{...rest} 运算符)

为了方便,我们先使用 ES6\text{ES6}**rest**\text{**rest**} 运算符 **...args**\text{**...args**} 来收集除第一个参数(构造函数)之外的所有参数。

JavaScript

/**
 * 模拟 new 运算符
 * @param {Function} Constructor 构造函数
 * @param {...any} args 传递给构造函数的参数
 * @returns {object} 新创建的对象实例
 */
function objectFactory(Constructor, ...args) {
    // 1. 创建一个空对象
    // Object.create() 是一个更推荐的方式,因为它能直接设置原型
    // 但为了贴合 new 的原理,我们先用 new Object()
    const obj = new Object(); 

    // 2. 设置原型链:将新对象的原型链接到构造函数的原型
    // 记住:这是实现继承和方法共享的关键!
    obj.__proto__ = Constructor.prototype;

    // 3. 绑定 this 并执行构造函数
    // apply/call 可以改变函数执行时的 this 指向
    // 我们将 obj 设为 this,并传递参数 args
    const result = Constructor.apply(obj, args);

    // 4. 返回对象:处理构造函数返回值
    // 如果构造函数显式返回了一个对象,则返回该对象,否则返回新创建的 obj
    // 检查 result 是否是对象(非 null)且不是原始值
    const isObject = typeof result === 'object' && result !== null;
    const isFunction = typeof result === 'function'; 

    if (isObject || isFunction) {
        return result; // 返回构造函数显式返回的对象
    }

    // 默认返回新创建的对象
    return obj;
}

这段代码已经非常接近 new\text{new} 的完整功能了!

2. 深入理解:解决 Arguments\text{Arguments} 的难题(不用 ...rest\text{...rest}

在你的原始代码中,为了不用 ES6\text{ES6}...rest\text{...rest} 运算符(这在一些老旧环境或为了纯粹模拟 ES5\text{ES5} 时很有用),你巧妙地使用了 arguments\text{arguments} 这个类数组对象

来,咱们先把目光聚焦到这个老朋友:arguments\text{arguments}

💡 类数组 Arguments\text{Arguments} 的本质

arguments\text{arguments} 对象是在函数调用时自动创建的。它具备类数组的特性:

  • 它有 length\text{length} 属性(表示传入的参数个数)。
  • 你可以使用索引(如 ‘arguments[0]‘\text{`arguments[0]`})来访问参数。

但它不是真正的数组!它没有 Array.prototype\text{Array.prototype} 上的方法,比如 reduce\text{reduce}map\text{map}join\text{join} 等,这也是为什么你的代码中 ‘arguments.reduce(...)‘\text{`arguments.reduce(...)`} 会报 TypeError\text{TypeError}

运行一下Object.prototype.toString.call(argumwnts)可以看到它的实际类型是Arguments对象:

image.png


3. 终极 ES5\text{ES5} 版本(你代码的精髓)

在不使用 ...rest\text{...rest} 的情况下,我们需要手动从 arguments\text{arguments} 中分离出构造函数 Constructor\text{Constructor} 和参数 args\text{args}

JavaScript

function objectFactory() {
    // 1. 创建一个空对象
    var obj = new Object();

    // 2. 提取构造函数(它是 arguments 的第一个元素)
    // 技巧:使用 Array.prototype.shift.call(arguments)
    // 解释:[] 只是用来“借用” Array.prototype 上的 shift 方法。
    // shift 方法会移除并返回数组的第一个元素,同时修改原数组。
    // 这里的 arguments 被当作 this 传入,所以它被修改了。
    var Constructor = [].shift.call(arguments);

    // 3. 绑定 this 并执行构造函数
    // 此时 arguments 剩下的元素就是构造函数需要的参数
    // apply/call 在传入类数组时,可以正确地把它们当作参数列表展开。
    var result = Constructor.apply(obj, arguments);

    // 4. 设置原型链:将新对象的原型链接到构造函数的原型
    // 这一步最好在执行构造函数前完成,但放在这里也能工作
    obj.__proto__ = Constructor.prototype;
    
    // 额外:设置 constructor 属性(虽然不太必要,但更完整)
    // obj.constructor = Constructor; 
    
    // 5. 返回对象:处理构造函数返回值(与上面 ES6 版本逻辑相同)
    const isObject = typeof result === 'object' && result !== null;
    const isFunction = typeof result === 'function'; 

    if (isObject || isFunction) {
        return result;
    }

    return obj;
}

4. 测试一下效果!

有了这个 ‘objectFactory‘\text{`objectFactory`},咱们来验证一下它是不是真的拥有 new\text{new} 的魔力:

JavaScript

function Person(name, age) {
    this.name = name;
    this.age = age;
    // 故意返回一个非对象,例如 'hello',确保返回的是 obj
    // return 'hello'; 
}
Person.prototype.species = '人类';
Person.prototype.sayHi = function () {
    console.log('你好,我是' + this.name);
};

// 使用原生的 new
let p = new Person('张三', 18);
console.log(p.name, p.species); // 输出:张三 人类

// 使用我们手写的 objectFactory
let zzp = objectFactory(Person, '郑zp', 18);
console.log(zzp.name, zzp.species); // 预期输出:郑zp 人类
zzp.sayHi(); // 预期输出:你好,我是郑zp

完美!我们通过 ‘objectFactory‘\text{`objectFactory`} 成功创建了一个新对象 zzp\text{zzp},它不仅拥有 ‘name‘\text{`name`}‘age‘\text{`age`} 属性(构造函数执行的结果),还能访问到 Person.prototype\text{Person.prototype} 上的 ‘species‘\text{`species`}‘sayHi‘\text{`sayHi`}(原型链链接的结果)!这充分证明了我们的实现是正确的!


三、扩展阅读:如何把类数组变真数组?

既然提到了 arguments\text{arguments} 这个类数组,咱们就顺便解决一下它的痛点:不能用数组方法

将类数组变成真数组,在 JS\text{JS} 中有几种常见且优雅的方法:

1. ES6\text{ES6}:扩展运算符(Spread Operator\text{Spread Operator})⭐

这是最简洁现代的方式!

JavaScript

function add(...args) { // 直接使用 rest 运算符
    return args.reduce((prev, cur) => prev + cur, 0);
}

function legacyAdd() {
    // 将 arguments 转换为真正的数组
    const args = [...arguments]; 
    console.log(Object.prototype.toString.call(args)); // [object Array]
    return args.reduce((prev, cur) => prev + cur, 0);
}

console.log(legacyAdd(1, 2, 3)); // 输出:6

2. ES5\text{ES5}Array.prototype.slice.call()\text{Array.prototype.slice.call()} 🐂

这是 ES5\text{ES5} 时代最经典的“借用”方法:

JavaScript

function convertToArray() {
    // 借用 Array.prototype 上的 slice 方法
    // slice() 不传参数时会返回原数组的一个浅拷贝
    const args = Array.prototype.slice.call(arguments); 
    // 或者更短一点:[].slice.call(arguments)
    console.log(args.join('-')); // 成功使用数组方法
    return args;
}

convertToArray('A', 'B', 'C');

slice\text{slice} 方法之所以能将类数组转换为数组,是因为它只依赖于 **length**\text{**length**} 属性和索引访问这两个类数组具备的特性。


总结陈词:成为 JS\text{JS} 高手的第一步 🏁

恭喜你!你不仅学会了如何使用 new\text{new},更重要的是,你理解并亲手实现了它的底层逻辑!

掌握 new 的原理,本质上就是掌握 JavaScript原型式面向对象的核心:原型链。 当你清楚地知道 __proto__ 是如何被设置的,this 是如何被绑定的,以及构造函数的返回值是如何被处理的,你就真正拥有了 JS\text{JS} 高手的思维。