听着《晴天》把 new、prototype 和继承整明白🎶🌈

159 阅读5分钟

“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 大致这样想:

  1. 先看 f1 自己有没有 idol 属性;
  2. 没有,就顺着 f1.__proto__,去 Fan.prototype 上找;
  3. 找到 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,却可以直接用。原因是:

  1. song 自己没 toString
  2. JS 会顺着 song.__proto__ 去上面找;
  3. 最终会走到一个通用原型对象,上面有内置的 toStringhasOwnProperty 等方法。

这一层就是“所有歌最后都要交给同一家发行公司”:
现实中公司只有一家;
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 的故事。

那时候,再遇到面试官追问“底层怎么实现”的时候,
你心里大概会有一种《以父之名》开头那段管风琴一样的感觉——
我知道这首歌(这段代码)里面,都藏了些什么。