JavaScript 并不是传统意义上的面向对象语言,它没有类(class)的原生支持(直到 ES6 才引入语法糖),而是基于原型(prototype-based) 的动态语言。但开发者很早就开始用各种方式模拟"类"和"继承"。本文将沿着你代码中的演进路径,系统梳理 JS 面向对象的核心机制,并重点解析 原型共享、属性遮蔽、组合继承 这三大关键环节。
一、最初的"类":对象字面量只是模板
css
var Cat = {
name: '',
color: ''
}
这段代码看似定义了一个"类",但实际上它只是一个普通对象,也可以说更像是一个默认属性模版。它不能被 new,也无法生成多个独立实例。开发者只能手动创建:
ini
var cat1 = {}; cat1.name = '加菲猫'
var cat2 = {}; cat2.name = '黑猫警长'
问题很明显:
- 代码重复;
- 实例无关联;
- 无法共享方法 ------ 如果要给所有猫加
sayHello(),每个实例都得写一遍。
这促使我们思考:能否封装实例化过程?
二、构造函数:封装创建逻辑
为了解决上面的问题,于是有了构造函数 + 原型链 来模拟类:
ini
function Cat(name, color) {
this.name = name
this.color = color
}
const cat1 = new Cat('加菲猫', '橘色')
使用 new 时,JS 引擎会:
- 创建一个新的空对象
{}; - 将其
__proto__指向Cat.prototype; - 绑定
this到该对象并执行函数体; - 隐式返回该对象。
此时,cat1.constructor === Cat 成立,因为 Cat.prototype.constructor 默认指向 Cat。而 instanceof 的判断依据是原型链上是否存在构造函数的 prototype ,与 constructor 属性无关。
这说明:实例与构造函数的关联,靠的是
__proto__→prototype的链条,而非constructor字段。
但若把公共方法写在构造函数内部:
javascript
this.eat = function() { ... }
每次 new 都会创建新函数,浪费内存 。我们需要一种共享机制。
三、原型模式:解决复用与统一修改
这才是 JS 面向对象的精髓所在:
ini
function Cat(name, color) {
this.name = name
this.color = color
}
Cat.prototype.type = '猫科动物'
Cat.prototype.eat = function() { alert('喜欢Jerry') }
所有实例通过 __proto__ 共享 Cat.prototype 上的属性和方法。
关键机制:属性查找与遮蔽
当你执行:
ini
cat1.type = '铲屎官'
这不会修改原型 ,而是在 cat1 自身新增一个 type 属性,遮蔽(shadow) 了原型上的同名属性。
cat1.type→'铲屎官'(自身属性)cat2.type→'猫科动物'(从原型读取)
但如果你希望统一修改所有实例的公共属性,只需:
ini
Cat.prototype.type = '哺乳动物'
此时,只要实例没有遮蔽该属性,就会立即反映新值。
这正是原型模式的核心优势:既能共享,又能局部覆盖;既能统一更新,又不影响隔离性。
如何区分属性来源?
hasOwnProperty('type')→false(来自原型)hasOwnProperty('name')→true(来自实例)'type' in cat1→true(检查整个原型链)for...in→ 遍历所有可枚举属性(包括原型链)
这些工具让我们能精确控制属性行为,是理解原型链的关键。
四、继承:从借用构造函数到组合继承
JS 没有 extends,但可以通过两种方式实现继承:
1. 借用构造函数(仅继承属性)
javascript
function Animal() {
this.species = '动物'
}
function Cat(name, color) {
Animal.apply(this) // 借用父类构造函数
this.name = name
this.color = color
}
这行 Animal.apply(this) 的本质是:
- 立即执行
Animal函数; - 强制其内部
this指向当前Cat实例。
结果:cat 实例拥有了 species 属性。
注意:这只是属性拷贝 ,不建立原型链 ,因此无法继承
Animal.prototype上的方法。
2. 原型链继承(仅继承方法)
ini
Cat.prototype = new Animal()
这一步让 Cat.prototype 成为一个 Animal 实例,从而:
- 拥有
species(来自Animal构造函数); __proto__指向Animal.prototype,可访问sayHi()。
但问题来了:所有 Cat 实例共享同一个 species,且无法在创建时传参。
3. 组合继承:两者结合(经典方案)
javascript
function Cat(name, color) {
Animal.apply(this) // 实例属性隔离
this.name = name
this.color = color
}
Cat.prototype = new Animal() // 方法复用 + 原型链
这样:
- 每个
Cat实例都有自己的species(来自apply); - 所有实例共享
sayHi()(来自原型链)。
但存在一个经典问题:父类构造函数被调用了两次
Cat.prototype = new Animal()→ 第一次调用,species被写入原型;new Cat()→Animal.apply(this)→ 第二次调用,species被写入实例。
虽然实例属性会遮蔽原型属性,但原型上仍残留一个无用的 species,属于轻微内存浪费。
"原型上多了一个无用的 species 属性(会被实例自身的 species 遮蔽)"。
尽管如此,组合继承仍是早期最完整的继承方案,因为它同时解决了:
- 实例属性的独立性(通过构造函数调用);
- 方法的复用性(通过原型链)。
五、ES6 class:只是语法糖
javascript
class Cat {
constructor(name, color) {
this.name = name
this.color = color
}
eat() { console.log('eat') }
}
这等价于:
javascript
function Cat(name, color) { ... }
Cat.prototype.eat = function() { ... }
typeof Cat 返回 'function',证明 class 本质仍是函数。它不能被提升(有暂时性死区),也不能像普通函数那样随意调用。
所以,class 不是真正的类,JS 依然是基于原型的语言。
结语:OOP 在 JS 中的本质
"JS 是一种基于原型的面向对象语言,哪怕 ES6 有了 class,仍然是原型式的。"
这句话道出了全部真相。
从对象字面量 → 构造函数 → 原型共享 → 组合继承,每一步都是为了解决前一步的缺陷:
- 封装(构造函数);
- 复用(prototype);
- 继承(apply + 原型链)。
而贯穿始终的核心机制只有两个:
__proto__指向构造函数的prototype;- 属性查找沿原型链向上,直到
Object.prototype。
理解这两点,就能看透 JS 面向对象的全貌。无论未来语法如何演进,原型链永远是 JavaScript OOP 的根基。