前言:
在 JavaScript 的世界里,没有蓝图(Class),只有模仿(Prototype)。
很多同学背八股文时,对 prototype、__ proto__ 和 constructor 这“三角虐恋”感到头秃。
今天咱们不谈哲学,谈谈怎么用 JS 的“钞能力”造一辆小米 SU7,顺便把原型链这条“赛道”跑通。
一、 也是一种“量产”艺术:构造函数
想象一下,如果我们要生产 1000 辆小米 SU7,在 ES5 时代,我们没有 3D 打印机,只有一位勤劳的构造函数师傅。
// 首字母大写,这是对构造函数起码的尊重
function Car (color) {
// this 指向正在诞生的那辆新车(实例)
this.color = color;
// 假设每辆车都有这些参数
// this.name = 'Su7';
// this.long = 4800;
// this.drive = function() { console.log('下赛道!') }
}
const car1 = new Car('霞光紫');
const car2 = new Car('海湾蓝');
这里有个大坑!
如果像上面注释里那样,把 name、long 甚至 drive 方法都写在函数体里,那就意味着每生产一辆车,都要重新开模具、重新写一遍驾驶系统。
- car1 有个 drive 方法。
- car2 也有个 drive 方法。
- 但这俩方法在内存里是两个不同的函数!
这就好比每卖出一辆车,雷总都得亲自去车里手抄一份《用户手册》给你。这内存开销,直接爆缸。
二、 共享经济:Prototype(原型)
为了解决“手抄手册”的愚蠢行为,JS 引入了 prototype(原型) 。这就好比在这个车厂里建立了一个公共零件库。
我们将公共的属性和方法,挂载到 Car.prototype 上:
Car.prototype = {
name: 'Su7',
height: 1.4,
weight: 1.5,
long: 4800,
drive () {
console.log('drive', '下赛道 !');
}
}
const car3 = new Car('璀璨洋红');
car3.drive(); // 输出:drive 下赛道
原理揭秘:
当你访问 car3.drive() 时,JS 引擎会先在 car3 只有“璀璨杨红”这个皮肤(实例属性)。它发现车里没有 drive 按钮,于是它会顺着一根神秘的“网线”去公共零件库(Car.prototype)找,哎!找到了!
这就是 “构造函数、原型、实例” 的初步关系:
- 构造函数(Car) :负责造车的流水线。
- 实例(car3) :造出来的具体车。
- 原型(Car.prototype) :所有车共享的“技术图纸”和“公共零件”。
三、 那根神秘的网线:__ proto__
刚才说的“网线”,学名叫做 隐式原型 (__ proto__) 。
在 JS 中,万物皆对象,只要是对象,体内就流淌着一条血脉,指向它的创造者给它的基因库。
function Person(name, age) {
this.name = name;
this.age = age;
}
Person.prototype.species = '人类';
const person1 = new Person('张三', 18);
console.log(person1.__proto__ === Person.prototype); // true ✅
这里有一张逻辑图):
- Person (构造函数) 有一个 prototype 属性,指向 Person.prototype (原型对象) 。
- person1 (实例) 有一个 __ proto__ 属性,也 指向 Person.prototype。
- Person.prototype 有一个 constructor 属性,指回 Person 也就是构造函数自己。
记个口诀:
孩子(实例)的 __ proto__,就是爸爸(构造函数)的 prototype。
(注:严格来说是妈妈,因为 JS 是母系氏族,属性都从这继承)
四、 拒绝“血统论”:原型链的查找规则
JS 的面向对象是“原型式”的,而不是传统类的“血缘式”的。
这不仅仅是哲学,更是实际的查找规则。
1. 查找机制
当你访问 p1.species:
- 自身查找:p1,你有 species 吗?(没有)
- 原型查找:那 p1.__ proto__ (即 Person.prototype),你有吗?(有!是“人类”) -> 返回“人类”
2. 遮蔽效应 (Shadowing)
let p1 = new Person("王五", 18);
// 这里的操作是给实例 p1 添加了自有属性,而不是修改了原型
p1.species = "和平精英达人";
console.log(p1.species); // "和平精英达人" —— 找到了!
console.log(p1.__proto__.species); // "人类" —— 原型还在那,没变
这就像你虽然是“人类”,但你在自己脑门上贴个标签“特种兵”,下次别人问你物种,你先看到标签说是“特种兵”。但这不影响你爸妈(原型)还是“人类”。
也就是说,这里p1(实例)的species属性并不是更改了原型对象的species属性,只是创建了一个属于自己的species属性,在调用时通过原型链先访问到了自身的sprcies属性,此时就不再向上层访问
这是不是很像JS中作用域的概念?
五、 禁止套娃:原型链的尽头
如果我们把车造得更复杂一点:
var obj = new Object();
obj.species = "动物";
function Animal() {}
Animal.prototype = obj; // Animal 继承自 obj
function Person() {}
Person.prototype = new Animal(); // Person 继承自 Animal
var su = new Person();
console.log(su.species); // "动物"
这就形成了一条长长的链条,也就是传说中的 原型链:
su (没有) -> su.__ proto__ (Person实例,没有) -> Person实例.__ proto__ (即 Animal.prototype,即 obj,有!) -> "动物"
问:那 obj 的上面是谁?
console.log(obj.__proto__ === Object.prototype); // true
console.log(Object.prototype.__proto__); // null
null 就是 JS 世界的虚无,是链条的终点。这就是为什么你调用 undefined.toString() 会报错,而对象可以,因为大家顺着链条最后都能找到 Object.prototype 上的方法,但再往上就没了。
六、 ES6 Class:穿了马甲我照样认识你
很多从 Java/C++ 转来的同学看到 ES6 的 class 喜极而泣。但别被骗了,那只是语法糖。
class Human {
constructor(name, age) {
this.name = name;
this.age = age;
}
sayHello() {
console.log(`Hello, 我是${this.name}`);
}
}
const human = new Human("小明", 18);
console.log(human.__proto__ == Human.prototype); // true
console.log(Human.prototype.constructor == Human); // true
你看,底层的 三角关系 依然稳如老狗。class 只是帮我们在写 prototype 时少敲了几行代码,让代码看起来更像“正经面向对象”语言而已。
七、 祖传技能:为什么大家都能 toString()?
造好车之后,我们发现一个神奇的现象:不管是一辆崭新的 小米 SU7(实例),还是用来做模具的 Car.prototype(原型),甚至是一个随手创建的空对象 {},它们好像生下来就自带一些“技能”。
比如,你试着对着它们喊一声:“你是谁?”(调用 toString())
const su7 = new Car('海湾蓝');
console.log(su7.toString()); // "[object Object]"
奇怪,我们在 Car 构造函数里没写 toString,在 Car.prototype 里也没写,这方法是哪来的?天上掉下来的吗?
1. 揭秘“老祖宗”的百宝箱
别猜了,看你提供的这张“DNA 图谱”:
我们顺着原型链往上找:
- su7 身上没有 toString。
- su7.proto (即 Car.prototype) 身上也没有。
- 但是!Car.prototype 的 proto 指向了 Object.prototype。
看截图里那个折叠起来的 [[Prototype]]: Object,展开后是不是豁然开朗?
这里面藏着 JS 赋予所有对象的“出厂设置”:
- toString() :转为字符串。
- valueOf() :获取原始值。
- hasOwnProperty() :判断属性是不是自己亲生的(而非继承的)。
- ...
Object.prototype 就是万物的“老祖宗”。它制定了 JS 世界的基本物理法则,只要你是个对象(除了 Object.create(null) 这种六亲不认的),你就能顺着原型链免费享用这些方法。
2. 也是一种“多态”:改写祖传技能
虽然老祖宗提供了 toString,但默认返回的 [object Object] 实在太丑了,根本没法体现我们小米 SU7 的尊贵身份。
这时候,原型链的 遮蔽效应(Shadowing) 又派上用场了。我们可以重写(Override)这个方法:
// 在我们的车型图纸上重写 toString
Car.prototype.toString = function() {
return `尊贵的 ${this.color} 小米 SU7 车主`;
}
const myCar = new Car('橄榄绿');
console.log(myCar.toString());
// 输出:"尊贵的 橄榄绿 小米 SU7 车主"
发生了什么?
当你调用 myCar.toString() 时,JS 引擎在 Car.prototype 上就找到了我们在上面写的定制版方法,于是立刻停止向上查找。老祖宗 Object.prototype 上的那个 toString 就被“挡住”了。
冷知识:数组 Array、函数 Function、日期 Date 早就偷偷干过这事儿了。
[1,2,3].toString() 返回 "1,2,3" 而不是 "[object Object]",就是因为 Array.prototype 上重写了 toString。
3. 灵魂拷问:hasOwnProperty 的重要性
在面试中,经常问到:“如何判断一个属性是实例自己的,还是继承来的?”
这时候千万别用 . 点出来看有没有,要用老祖宗传下来的 hasOwnProperty:
// 假设 su7.__proto__.drive = function... (这是继承的)
// su7.color = '蓝色' (这是自己的)
console.log(su7.hasOwnProperty('color')); // true ✅ 是亲生的
console.log(su7.hasOwnProperty('drive')); // false ❌ 是继承家族的
避坑指南:
虽然 su7.hasOwnProperty 可以用,但如果你的对象里恰好有个属性名叫 hasOwnProperty 怎么办?(被恶意覆盖)。
更安全的写法是直接请老祖宗出山:
Object.prototype.hasOwnProperty.call(su7, 'color'); // 稳如老狗
我们能随手调用 toString,不是因为对象天赋异禀,而是因为我们都站在 Object.prototype 这个巨人的肩膀上。这也再次印证了 JS 原型链的本质:通过引用,实现共享。
七、 总结:一张图看懂所有
如果把你扔进面试的“八卦炉”里,请记住这张原型关系全景图:
- 构造函数:Person是生产经理,手里攥着 prototype(图纸)。
- 实例:person是产品,身上贴着 __ proto__(防伪标签),指向图纸。
- 原型对象:Person.prototype也是对象,它的 __ proto__ 指向 Object.prototype(老祖宗)。
- Object.prototype 的 __ proto__ 指向 null(尘归尘,土归土)。
最后留个思考题:
Function.__ proto__ === Function.prototype,这句话是 true 还是 false?
(提示:构造函数本身也是个函数对象,是谁构造了它自己?)
点赞、收藏、评论,下次带你徒手实现一个 new 关键字!