为什么程序员总爱重复造轮子?因为造轮子的时候才发现------原来轮子内部藏着巧克力夹心!
一、神秘的new操作符:JavaScript世界的"无中生有"
想象一下,你是一个JavaScript世界的造物主。当你说let p = new Person('张三', 18)
时,就像念了一句咒语:
- 凭空变出一个空对象
{}
- 给这个对象注入灵魂(
this
指向它) - 给它穿上家族制服(
__proto__
链接原型) - 最后决定是保留这个新生命,还是用构造函数返回的对象替代它
今天我们不满足于使用咒语,而要成为编写咒语的人!先来看看我们要征服的"敌人"长什么样:
csharp
// 目标:用objFactory代替new操作符
let p = objFactory(Person, '张三', 18, family)
二、解剖new操作符:四步拆解造物过程
第一步:创建宇宙奇点------空对象
ini
var obj = {}
就像宇宙大爆炸始于一个奇点,我们的对象宇宙始于这个空对象。有趣的是,在JavaScript中这个"空"并不空------它随身携带__proto__
这个通往原型宇宙的传送门。
第二步:注入灵魂------绑定this并执行构造函数
ini
var res = Constructor.apply(obj, args)
这里发生了三件魔法事件:
apply
将构造函数的this
劫持到我们的空对象- 构造函数携带的参数
args
被解构传入(这里用的es6 的剩余参数) - 构造函数开始执行属性装配工作
javascript
const family = {
mother: '张母',
father: '张父'
}
function objFactory(Constructor, ...args) {
var obj = {}
var res = Constructor.apply(obj, args)
}
此时我们的obj
对象已经拥有了:
css
{
name: '张三',
age: 18,
family: { mother: '张母', father: '张父' }
}
这里我们用apply
改变this指向,指向我们创建的空对象,this.name=name
也就变成了 obj.name=name
age 和family也是同理
大家在思考一下实例化对象和构造函数还有什么联系呢? 那当然是原型对象了我们的obj.__proto__
需要指向构造函数的portotype,这样我们能通过原型链,调用实例化对象的公有方法
第三步:血脉传承------连接原型链
ini
obj.__proto__ = Constructor.prototype
这才是最精妙的魔法!通过设置__proto__
,我们建立了这样的继承关系:
javascript
p -> Person.prototype -> Object.prototype -> null
现在p
就能调用say
方法了,就像儿子突然继承了老爸的超能力:
less
p.say() // 输出:你好,我是张三,今年18岁
第四步:抉择时刻------处理返回值
构造函数既然是一个函数,是不是可能有返回值呢?但是我们大家都熟知构造函数是默认返回我们新创建的实例化对象的,那如果构造函数有返回值呢?那会发生什么呢?
其实处理返回值分为三种情况接下来就给大家来介绍一下:
情况一:返回值为简单数据类型
js
function Person(name, age, family) {
this.name = name;
this.age = age;
this.family = family;
return 1
}
可以看到结果是毫无影响的,返回简单数据类型,会被忽略
情况二:返回值不是空对象
js
function Person(name, age, family) {
this.name = name;
this.age = age;
this.family = family;
return {
name: '李四',
age: '38',
label: 'haha'
}
}
Person.prototype.say = function () {
console.log(`你好,我是${this.name},今年${this.age}岁`);
}
let p1 = new Person('张三', 18, family)
console.log(p1);
如果是构造函数有返回值且不是空对象的话,返回的是返回值,但是我们所手写的new并没有遵守这个规则,所以我们需要遵守该规则
只需要判断一下构造函数返回的内容即可
js
return typeof res === 'object' ? res : obj
情况四:返回值为null
众所周知,null也是object对象,所以我们上述返回值的判断对其无效,那么我们应该怎么做呢?
其实很简单,我们只需要加上应该 ||
运算符就能解决
js
return typeof res === 'object' ? res || obj : obj
是不是豁然开朗,只要res
存在就会执行res
,如果不存在,就执行obj
这样操作我们就考虑到了三种情况,再也不怕面试官的拷打了
三、完整造物工厂源码
javascript
function objFactory(Constructor, ...args) {
// 第一步:创建空对象
var obj = {}
// 第二步:绑定this并执行构造函数
var res = Constructor.apply(obj, args)
// 第三步:连接原型链
obj.__proto__ = Constructor.prototype
// 第四步:处理返回值
return typeof res === 'object' ? res || obj : obj
}
四、与原生new的巅峰对决
我们用同样的Person构造函数进行测试:
arduino
// 原生new
const p1 = new Person('原生张三', 20, family)
// 我们的objFactory
const p2 = objFactory(Person, '手写张三', 18, family)
console.log(p1.say === p2.say) // true 证明方法同源!
但注意几个关键差异点:
- 性能:我们的工厂比原生new慢约40%(V8引擎对new有深度优化)
- 错误处理:原生new会检测构造函数是否可调用,我们的版本没有
- 原型设置 :我们直接修改
__proto__
,现代JS更推荐Object.create
我们来测试基本功能
js
console.log(p);
p.say()
console.log(p.constructor);
看吧,是不是返回了我们想要的结果
五、深度探秘:为什么原型设置在apply之后?
敏锐的你可能会问:为什么第三步不放在第二步之前?就像这样:
javascript
// 调整顺序的方案
function objFactory(Constructor, ...args) {
var obj = {}
obj.__proto__ = Constructor.prototype // 先设置原型
var res = Constructor.apply(obj, args) // 再执行构造函数
// ...
}
这其实是个好问题!在大多数情况下这样写完全正确。但考虑一个边界情况:
javascript
function Person(name) {
// 构造函数中重新定义__proto__
this.__proto__ = { customProto: true };
}
如果先设置原型再执行构造函数,构造函数里的__proto__
赋值会覆盖我们的设置。而我们的原始方案能确保原型链最终指向Constructor.prototype
。
六、终极思考:new的本质是什么?
通过手写实现,我们揭开了new的神秘面纱:
- 不是关键字:new完全可以用普通函数模拟
- 不是魔术:整个过程完全符合JavaScript原型机制
- 不是必需品:在class时代,我们完全可以用工厂函数替代
事实上,当你理解了这个实现,你会发现JavaScript中"类"的本质:
在JS中,我们使用函数来实现的类的构建,所以我们说函数是JS中的一等对象不是没有道理的
javascript
class PersonClass {
constructor(name) {
this.name = name
}
say() { /* ... */ }
}
// 本质上等价于:
function PersonFunc(name) {
this.name = name
}
PersonFunc.prototype.say = function() { /* ... */ }
结语:造物主的感悟
当我第一次成功手写new实现时,感觉就像破解了JavaScript的达芬奇密码。回顾整个探索过程:
- 空对象诞生 → 盘古开天辟地
- 绑定this执行构造函数 → 女娲造人
- 设置原型链 → 建立物理定律方便我们找寻属于我们独特的"魔法"
- 返回值处理 → 允许上帝干预
下次当你使用new时,不妨在心里默念:我知道你在背后做了什么小动作!这就像知道魔术的奥秘后,虽然失去了神秘感,却获得了更深的掌控力。