“new 明明没写 return,为什么能给我一个对象?”
“prototype、proto、constructor 到底谁在想念谁?”
“继承为啥既要改 prototype,又要 Parent.call(this)?”
就像第一次听杰伦的《晴天》,刚开始只觉得好听,过一阵才发现词、编曲、和声都埋了细节。
JavaScript 原型也是这样:一开始觉得乱,真懂了之后会有一种“原来你都在这里”的温柔。
下面用几段“杰伦宇宙”的代码,把原型这一块讲清楚。题材轻一点,内容硬一点,方便你以后在面试里“说学逗唱”。
一、构造函数:new 像一场制作专辑的流程🎶
先来个“歌手”构造函数:
function Singer(name, style) {
// 每张专辑(每个实例)独有的信息
this.name = name;
this.style = style;
}
// 大家公认的标签:R&B 教父
Singer.prototype = {
title: '华语流行歌手',
sayHi() {
console.log(`大家好,我是 ${this.name},主要风格是 ${this.style}`);
}
};
const jay = new Singer('杰伦', 'R&B / 嘻哈 / 中国风');
关键问题:new Singer(...) 到底做了什么?
可以把它想象成一整套“制作专辑的工序”(伪代码):
// 1. 公司先创建一个“空专辑壳子”
var album = {};
// 2. 把这个壳子的原型,连到 Singer.prototype
album.__proto__ = Singer.prototype;
// 3. 用这个壳子当 this,执行 Singer 这个“专辑制作流程”
Singer.call(album, '杰伦', 'R&B / 嘻哈 / 中国风');
// 4. 如果流程里没有主动 return 别的“成品”,
// 默认就把这个填好信息的壳子当成成品
return album;
于是:
const jay = /* 上面四步之后的 album */;
这就是那句“把这个新对象返回给变量”的真实含义——
不是你代码里写了 return,而是 new 帮你“温柔地 return 了” 。
二、实例属性 vs 原型属性:谁是《告白气球》,谁是“杰伦味儿”🎶
换一个更贴近日常的构造函数:粉丝。
function Fan(name) {
this.name = name; // 每个粉丝自己的名字
}
// 所有粉丝共享的“追星属性”
Fan.prototype = {
idol: '杰伦',
favoriteSong: '晴天',
};
创建两个粉丝:
const f1 = new Fan('小李');
const f2 = new Fan('小王');
console.log(f1.name, f1.idol); // 小李 杰伦
console.log(f2.name, f2.idol); // 小王 杰伦
属性查找是怎么走的?
访问 f1.idol 时,JS 大致这样想:
- 先看
f1自己有没有idol属性; - 没有,就顺着
f1.__proto__,去Fan.prototype上找; - 找到
idol: '杰伦',于是返回'杰伦'。
现在小李突然说:“我其实也追林俊杰”:
f1.idol = '杰伦 & 林俊杰';
这行代码只是:
- 在
f1自己身上新增了一个idol属性; - 不会动到
Fan.prototype.idol。
此时:
console.log(f1.idol); // 杰伦 & 林俊杰 —— 实例自己的
console.log(f2.idol); // 杰伦 —— 还是原型上的
console.log(Fan.prototype.idol); // 杰伦
就像:
- 原型上的
idol = '杰伦'是“默认的杰伦味儿”; - 实例上的
f1.idol是“小李自己特调过的口味”,只影响他自己。
记住这句话:
“实例属性会遮蔽同名原型属性,但不会修改原型本身。 ”
三、prototype / proto / constructor:一段三角恋
再看一个稍完整的
Person 例子:
function Person(name, age) {
this.name = name;
this.age = age;
}
Person.prototype = {
constructor: Person, // 手动补回 constructor
species: '人类',
sayHi() {
console.log(`你好,我是 ${this.name}`);
}
};
const p = new Person('杰伦', 18); // 想象成刚出道
这里有三个角色:
- Person:构造函数,像“制作人”;
Person.prototype:原型对象,放“大家共享的设定”;p.__proto__:指向Person.prototype的那条隐藏线。
几个要记的事实:
-
实例的原型来自构造函数的
prototype:p.__proto__ === Person.prototype; // true -
你如果这样整块替换原型:
Person.prototype = { species: '人类', sayHi() { ... } };默认的
constructor就丢了,此时:Person.prototype.constructor === Object; // 而不是 Person所以推荐像上面那样手动补一个:
constructor: Person
面试常问:
__proto__和prototype有啥区别?- 为啥重写原型后要手动加
constructor?
一句话总结:
- 函数身上的
prototype是“模具” ; - 对象身上的
__proto__是“这块面团是用哪个模具压出来的记录” ; constructor是“模具反过来指向制作人”的那根线。
四、为什么谁都能 toString:原型链的“最上游公司”
看一个很普通的对象:
const song = {
name: '晴天',
year: 2003
};
console.log(song.toString());
你没写 toString,却可以直接用。原因是:
song自己没toString;- JS 会顺着
song.__proto__去上面找; - 最终会走到一个通用原型对象,上面有内置的
toString、hasOwnProperty等方法。
这一层就是“所有歌最后都要交给同一家发行公司”:
现实中公司只有一家;
JS 里就是 Object.prototype。
再往上看一眼:
Object.prototype.__proto__ === null; // 原型链终点
面试点:
- “原型链的终点是什么?”
- “为什么几乎所有对象都可以直接用
toString和hasOwnProperty?”
五、继承:让 Singer 继承 Person,就像“从路边弹琴到开个人演唱会”🎶
设计一个“人类基类”和一个“歌手子类”:
function Human(name) {
this.name = name;
}
Human.prototype = {
species: '人类',
};
function Singer(name, style) {
// 借用 Human 的构造逻辑,把 name 填到当前实例上
Human.call(this, name);
this.style = style;
}
现在解决“方法继承”这半截:
Singer.prototype = Object.create(Human.prototype); // 以 Human.prototype 为原型,创建一个新对象
Singer.prototype.constructor = Singer; // 修正 constructor
Singer.prototype.sing = function(song) {
console.log(`${this.name} 唱起了《${song}》`);
};
const jay = new Singer('杰伦', '中国风 / R&B');
console.log(jay.species); // 人类 —— 来自 Human.prototype
jay.sing('夜曲'); // 杰伦 唱起了《夜曲》
这里的继承关系就像:
Human给了所有人“人类”这个身份;Singer在这个基础上加了“唱歌技能”和“风格信息”。
面试点:
“如何用原型链 + 构造函数实现继承?”
为啥很多库里喜欢用
Object.create(Parent.prototype)?
- 避免
new Parent()带来的副作用(比如多执行一遍构造函数)。为什么继承里要同时用到:
Parent.call(this, ...)(继承属性)Child.prototype = Object.create(Parent.prototype)(继承方法)
六、再把 call 说透一点:那是一次“带着情绪的召唤”
还记得你问的那句:
“
Person.call(temp, '舒老板', 19)是啥意思?”
抽象成一个模板:
someFunction.call(someThis, arg1, arg2, ...);
含义可以理解为:
“用
someThis当this,执行someFunction(arg1, arg2, ...)。”
带上点“杰伦味儿”的类比:
someFunction是一段旋律;call把这段旋律“交给某个人来唱”,并把情绪、歌词(参数)一起传进去;this就是“被指定来唱这段旋律的人”。
回到构造函数继承:
function Human(name) {
this.name = name;
}
function Singer(name, style) {
Human.call(this, name); // 把“给 this 填 name”这段逻辑借过来
this.style = style;
}
Human.call(this, name); = “请 Human 这段逻辑,在当前这个 Singer 实例上执行一次”。
七、用这些示例整理一份“杰伦版原型面试清单”🎶
-
关于 new
- new 的四步:创建对象 → 链接原型 → 执行构造函数 → 自动 return 对象。
- 构造函数没写 return 时,返回值是什么?
-
关于原型和属性查找
- 实例属性 vs 原型属性:谁共享、谁独立?
- 实例属性如何遮蔽原型属性?
- 为什么方法推荐挂在 prototype 上?
-
关于 prototype / proto / constructor
- 实例的
__proto__从哪来? - 重写 prototype 为何要补 constructor?
Object.getPrototypeOf(obj)和__proto__的关系?
- 实例的
-
关于原型链与 Object.prototype
- 原型链终点是什么?
- 为什么对象都有
toString/hasOwnProperty等方法?
-
关于继承
- 原型链继承、组合继承、寄生组合继承的大致思路?
Parent.call(this, ...)的作用?- 为什么很多库喜欢用
Object.create(Parent.prototype)?
尾声:写在最后的一点“周式情绪”📻
杰伦的歌第一次听,只能分清“这首好听、那首更好听”;
慢慢学会看词、听编曲,才发现每一首背后都有一套精心设计。
JavaScript 原型也一样:
- 刚开始只会写
new Person(); - 再往前一步,你能说清楚 new 的四步;
- 再往前一步,你能画出原型链,说明每个属性是在哪一层找到的;
- 再往前一步,你能用自己的例子讲明白继承与
call的故事。
那时候,再遇到面试官追问“底层怎么实现”的时候,
你心里大概会有一种《以父之名》开头那段管风琴一样的感觉——
我知道这首歌(这段代码)里面,都藏了些什么。