最近在深入研读《你不知道的 JavaScript》上卷,突发奇想尝试手写一个new()
函数,想着自己对new的实现过程也清楚,这还不简单。不出意外的话要出意外了,上手一写,果然出了问题,经过一番研究,终于把其中的知识点串联成线。
先搞懂 new 到底干了啥
在没深入研究前,我一直觉得 new 就是个 “语法糖”—— 用new Person()
创建对象,和直接写{name: 'jack', age: 12}
没本质区别。直到有天尝试不用 new 调用构造函数,才发现问题:
function Person(name, age) {
this.name = name;
this.age = age;
}
// 不用new调用
const p = Person('jack', 12);
console.log(p); // undefined
console.log(name); // 'jack' 全局变量被污染了!
这时候才意识到,new 的第一个作用是改变 this 指向。不用 new 时,构造函数里的 this 指向全局对象(浏览器里是 window),所以属性会挂到全局;用 new 时,this 会指向一个新对象,属性自然就成了这个对象的私有属性。
再试原型上的方法:
Person.prototype.say = function() {
console.log(`${this.name} is ${this.age} years old`);
};
const p1 = new Person('jack', 12);
p1.say(); // 正常执行
const obj = {name: 'rose', age: 13};
obj.say(); // 报错:obj.say is not a function
这说明 new 还做了一件事:让新对象能访问构造函数原型上的方法。也就是说,new 不仅创建了对象,还帮它和构造函数的原型链搭上了线。
拆解 new 的执行步骤
查了 ECMA 规范才知道,new 操作符的执行过程在规范里被定义为 [[Construct]] 内部方法,大概分四步:
- 创建一个新的空对象(let obj = {})
- 让这个空对象的 [[Prototype]](即__proto__)指向构造函数的 prototype
- 调用构造函数,把 this 绑定到新对象上
- 根据构造函数的返回值决定最终返回什么:如果返回对象就用返回值,否则返回新对象
这四步看起来简单,但每一步都藏着细节。我决定按这个步骤一步步实现手写 new 函数。
第一步:创建空对象
很多教程里第一步直接写const obj = {}
,但我后来发现,这只是表象。JavaScript 引擎在创建对象时,会为对象分配内存空间,还会设置一些内部属性(比如 [[Prototype]]、[[Class]] 等)。我们用{}
创建的对象,默认 [[Prototype]] 指向 Object.prototype,而 new 创建的对象,后续会修改这个指向。
所以在手写函数里,这一步可以直接用const obj = Object.create(null)
吗?试了一下发现不行 ——Object.create (null) 创建的对象没有__proto__属性,后续没法设置原型链。还是老老实实用const obj = {}
更稳妥,虽然它默认继承了 Object.prototype,但后续会被覆盖。
第二步:连接原型链
这一步是最容易搞混的地方。我一开始写成obj.prototype = Constructor.prototype
,结果发现完全不对。后来才明白:
- prototype 是函数的属性:每个函数(包括构造函数)都有 prototype 属性,它是一个对象,里面存放着该构造函数实例共享的方法和属性
- __proto__是对象的属性:每个对象(除了 Object.create (null) 创建的)都有__proto__,它指向自己的原型对象,是原型链查找的关键
所以正确的写法应该是obj.__proto__ = Constructor.prototype
,这样新对象 obj 的原型就指向了构造函数的 prototype,当访问 obj 的属性或方法时,会沿着这条链往上找。
为了验证这一步的作用,我做了个实验:
// 手写new函数的第二步
function objectFactory(Constructor) {
const obj = {};
// obj.__proto__ = Constructor.prototype;
return obj;
}
const p = objectFactory(Person);
p.say(); // 报错:p.say is not a function
果然,没连接原型链的话,实例就访问不到构造函数原型上的方法。这也解释了为什么平时用 new 创建的实例能调用原型方法 —— 全靠这一步把原型链打通了。
第三步:绑定 this 并执行构造函数
这一步的核心是让构造函数里的 this 指向新创建的 obj。JavaScript 里改变 this 指向的方法有 call、apply、bind,这里用 apply 最方便,因为可以直接传数组参数。
const ret = Constructor.apply(obj, args);
这里的 args 是传给构造函数的参数,比如new Person('jack', 12)
里的 ['jack', 12]。用 apply 把 obj 作为 this 传入后,构造函数里的this.name = name
就相当于obj.name = name
,新对象也就有了私有属性。
我之前踩过一个坑:忘记处理没有参数的情况。比如new Person()
,这时候 args 是空数组,apply 会正常执行,不会报错,所以不需要额外判断。
第四步:处理返回值
这一步最反直觉,构造函数居然能返回一个新对象,让 new 操作符 “白干活”。比如:
function Person(name) {
this.name = name;
return { age: 18 }; // 返回一个新对象
}
const p = new Person('jack');
console.log(p.name); // undefined
console.log(p.age); // 18
这种情况下,new 创建的 obj 会被忽略,直接返回构造函数里的对象。但如果返回的是基本类型(数字、字符串、布尔值等),new 会忽略这个返回值,继续返回 obj:
function Person(name) {
this.name = name;
return 123; // 返回基本类型
}
const p = new Person('jack');
console.log(p.name); // 'jack' 正常拿到属性
更特殊的是返回 null 的情况:虽然 null 是对象类型(typeof null === 'object'),但 new 会把它当作 “无效对象”,依然返回 obj:
function Person(name) {
this.name = name;
return null;
}
const p = new Person('jack');
console.log(p.name); // 'jack' 没受影响
所以手写 new 的返回值处理逻辑应该是:
// 如果构造函数返回对象(且不是null),就返回这个对象;否则返回obj
return (typeof ret === 'object' && ret !== null) ? ret : obj;
这比我最初写的typeof ret === 'object' ? ret || obj : obj
更严谨,因为要排除 null 的情况。
完整实现与测试
综合以上步骤,完整的手写 new 函数是这样的:
function objectFactory(Constructor, ...args) {
// 1. 创建空对象
const obj = {};
// 2. 连接原型链
obj.__proto__ = Constructor.prototype;
// 3. 绑定this并执行构造函数
const ret = Constructor.apply(obj, args);
// 4. 处理返回值
return (typeof ret === 'object' && ret !== null) ? ret : obj;
}
为了测试各种边界情况,我写了几组测试用例:
测试 1:正常情况,无返回值
function Person(name, age) {
this.name = name;
this.age = age;
}
Person.prototype.say = function() {
console.log(this.name);
};
const p = objectFactory(Person, 'jack', 12);
console.log(p.name); // 'jack'
p.say(); // 'jack'
console.log(p instanceof Person); // true
测试 2:构造函数返回对象
function Person(name) {
this.name = name;
return { age: 18 };
}
const p = objectFactory(Person, 'jack');
console.log(p.name); // undefined
console.log(p.age); // 18
console.log(p instanceof Person); // false
测试 3:构造函数返回 null
function Person(name) {
this.name = name;
return null;
}
const p = objectFactory(Person, 'jack');
console.log(p.name); // 'jack'
测试 4:构造函数返回基本类型
function Person(name) {
this.name = name;
return 'hello';
}
const p = objectFactory(Person, 'jack');
console.log(p.name); // 'jack'
所有测试都符合预期,说明这个手写实现是靠谱的。
延伸
new 和 ES6 class 的关系
学过 ES6 的同学可能会问:class 里的 constructor 和 new 配合时,逻辑一样吗?试了一下:
class Person {
constructor(name) {
this.name = name;
}
say() {
console.log(this.name);
}
}
const p = new Person('jack');
// 用我们的objectFactory测试
const p2 = objectFactory(Person, 'rose');
p2.say(); // 'rose'
结果是一样的。因为 class 本质上是函数的语法糖,constructor 就是构造函数,class 的 prototype 属性和函数的 prototype 作用相同。所以 new 操作符对 class 的处理,和对普通构造函数的处理逻辑完全一致。
new.target 是什么?
在查资料时发现 ES6 有个 new.target 属性,专门用于检测函数是否通过 new 调用。比如:
function Person() {
if (!new.target) {
throw new Error('必须用new调用');
}
}
Person(); // 报错:必须用new调用
new Person(); // 正常执行
这给构造函数加了一层保护。那我们的手写函数能支持 new.target 吗?试了一下发现不行,因为 new.target 是由 JavaScript 引擎在 [[Construct]] 过程中自动设置的,手动调用 apply/call 不会触发。这也说明,手写 new 只能模拟核心功能,一些引擎层面的细节(比如 new.target、函数的 [[IsConstructor]] 内部属性)是没法完全模拟的。
最后:为什么要手写 new?
可能有人觉得 “会用 new 就行了,没必要知道底层”,但对我来说,手写 new 的过程让我彻底搞懂了几个核心概念:
- 原型链不是玄学:它就是通过__proto__和 prototype 连接起来的查找链
- this 的指向是动态的:在构造函数里,this 指向谁完全由调用方式决定(new 调用指向实例,普通调用指向全局)
- JavaScript 的 “类” 是模拟出来的:基于原型的继承和传统面向对象的类继承有本质区别,new 是这种模拟的关键一环
现在再看const p = new Person()
这句代码,我看到的不再是一个简单的对象创建,而是一系列精确的操作:创建对象、连接原型、绑定 this、处理返回值。这种 “看透语法糖” 的感觉,大概就是深入学习的乐趣吧。
如果你也是前端新手,建议你也动手试试手写 new,过程中遇到的困惑和最终解决问题的瞬间,一定会让你对 JavaScript 的理解更上一层楼。