在 JavaScript 的面向对象编程中,new 运算符是一个神奇的存在。它能将一个普通的函数变成构造函数,并生成一个拥有特定属性和方法的实例对象。但你是否想过,这个“实例化”的过程底层究竟发生了什么?
本文将结合代码实例,带你一步步拆解 new 的原理,并分别使用 ES5(基于 arguments)和 ES6(基于扩展运算符)两种方式手写实现一个 objectFactory。
一、 核心理论:new 到底做了什么?
当我们执行 new Person('张三', 18) 时,JavaScript 引擎在后台默默完成了以下 4 个关键步骤:
-
创建空对象:在内存中创建一个全新的空对象(
{})。 -
链接原型(关键) :将这个空对象的
__proto__属性指向构造函数的prototype属性。- 这一步是为了实现继承,让新对象能访问构造函数原型上的方法(如
species或sayHi)。
- 这一步是为了实现继承,让新对象能访问构造函数原型上的方法(如
-
绑定 this 并执行:将构造函数内部的
this指向这个新创建的对象,并执行构造函数。- 这一步是为了给新对象添加实例属性(如
name和age)。
- 这一步是为了给新对象添加实例属性(如
-
返回对象:如果构造函数没有显式返回对象,则默认返回这个新创建的对象。
二、 前置知识:类数组对象 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]" -
方法限制:它无法直接使用数组的高级方法,比如
reduce、map等。如果在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 核心机制的理解:
- 原型与原型链:理解
__proto__和prototype的连接关系。 - this 绑定:理解
apply/call如何改变函数执行时的上下文。 - 参数处理:深入理解
arguments类数组对象与 ES6...args的区别与转换。
通过模拟这个过程,我们可以看到,所谓的“实例化”其实就是建立一个对象,并通过原型链和上下文绑定,让它“继承”构造函数的基因。