JavaScript 面向对象编程从零到精通:原型链、继承与现代 class 的前世今生

68 阅读7分钟

JavaScript 面向对象编程从零到精通:原型链、继承与现代 class 的前世今生

JavaScript 是一门「看起来像面向对象,其实是基于原型」的语言。
很多人学到 class 就以为自己已经掌握了 OOP,其实那只是语法糖,真正的精髓藏在 prototype__proto__ 和原型链里。

今天我们从最原始的对象字面量开始,一步一步带你捋清 JavaScript 面向对象的完整演进路径


1. 最原始的写法:对象字面量(根本算不上 OOP)

var cat1 = {
  name: '大橘',
  color: '橘色',
  eat: function() { console.log('吃 Jerry') }
}

var cat2 = {
  name: '加菲',
  color: '橘色',
  eat: function() { console.log('吃 Jerry') }
}

问题显而易见:

  • 每只猫都要重复写一遍 eat 方法 → 内存浪费
  • 猫与猫之间没有任何关系 → 无法体现「同类」的概念

这只是「对象」,不是「面向对象」。


2. 工厂函数:第一次封装(已经有了雏形)

function createCat(name, color) {
  return {
    name: name,
    color: color,
    eat: function() { console.log('吃 Jerry') }
  }
}

const cat1 = createCat('大橘', '橘色')
const cat2 = createCat('加菲', '橘色')

优点:不再手写重复代码
缺点:

  • 每次创建对象,eat 方法都会重新创建一份,仍然浪费内存
  • cat1.constructor === Object,看不出它们是「猫」

3. 构造函数模式:真正的「类」诞生了

function Cat(name, color) {
  this.name = name
  this.color = color
  // 注意:这里千万别写方法!会重复创建!
}

const cat1 = new Cat('大橘', '橘色')
const cat2 = new Cat('加菲', '橘色')

console.log(cat1.constructor === Cat) // true
console.log(cat1 instanceof Cat)      // true

new 到底干了什么?(面试必问!)

  1. 创建一个空对象 {}
  2. this 绑定到这个新对象
  3. 执行构造函数代码,给新对象添加属性
  4. 自动返回这个新对象(除非手动 return 其他对象)

这才是真正意义上的「实例化」!


4. 原型 prototype:解决方法重复创建的终极方案

如果把每个实例独有的属性(name、color)放 this 上,把所有实例共享的属性和方法放 prototype 上,就能完美解决内存浪费问题。

function Cat(name, color) {
  this.name = name
  this.color = color
}

Cat.prototype.type = '猫科动物'
Cat.prototype.eat = function() {
  console.log('喜欢吃 Jerry')
}

const cat1 = new Cat('大橘', '橘色')
const cat2 = new Cat('加菲', '橘色')

cat1.eat() // 正常调用
console.log(cat1.type) // 猫科动物

为什么这么香?

  • eat 方法只在内存中存在一份,所有实例共享
  • 修改原型会实时影响所有实例

易错点提醒:

cat1.type = '铲屎官的主人'   // 只改了 cat1 实例自身
console.log(cat2.type)         // 仍然是 '猫科动物'
Cat.prototype.type = '猫科'    // 改原型,所有实例立刻更新

判断属性来源的三个常用方法(建议熟记):

cat1.hasOwnProperty('name')    // true  → 实例自身
cat1.hasOwnProperty('type')    // false → 来自原型
'type' in cat1                 // true  → 原型或自身都有算

判断实例:谁是不是谁的实例

console.log(cat1 instanceof Cat)//true

这就是传说中的「原型链查找机制」:

当你访问一个对象的属性时,先查自身 → 没有就沿着 __proto__ 往上找 → 直到找到或到 null 为止

这也是为什么 cat1.toString() 能用,因为 Object.prototype 上有!


6. 继承:最让人头疼的知识点(我们用最清晰的方式讲)

目标:让 Cat 继承 Animal 的属性和方法。

有瑕疵做法(少用!)

function Animal() {
  this.species = '动物'
}
function Cat(name, color) {
  this.name = name
  this.color = color
}
Cat.prototype = new Animal()   // 改变了consreuctor属性!

正确做法:构造函数继承 + 原型链继承

// 父类
function Animal() {
  this.species = '动物'
  this.eat = function() {
    console.log('吃东西')
  }
}
Animal.prototype.sayHi = function() {
  console.log('你好呀~')
}

// 子类
function Cat(name, color) {
  Animal.call(this)    // ① 继承实例属性(关键!)
  this.name = name
  this.color = color
}

// ② 继承原型方法(关键!)
Cat.prototype = new Animal()   // 或者更现代的 Object.create
// 修复 constructor 指向
Cat.prototype.constructor = Cat

const cat1 = new Cat('加菲', '橘色')
console.log(cat1.species)   // 动物
cat1.eat()                  // 吃东西
cat1.sayHi()                // 你好呀~

两步缺一不可:

步骤目的方法
① 继承实例属性让子类拥有父类的 this.xxxAnimal.call(this)apply
② 继承原型方法让子类能访问父类原型上的方法Cat.prototype = new Animal()

现代推荐写法(ES5+):

Cat.prototype = Object.create(Animal.prototype)
Cat.prototype.constructor = Cat

更简洁,且不执行 Animal 构造函数。


7. ES6 class:只是语法糖,但写起来真香

小知识:什么是语法糖呢?

语法糖是指编程语言中那些让代码更易读、更简洁的语法特性,它不会增加语言的功能,只是提供了更便捷的表达方式。(底层实现相同)

class Animal {
  constructor() {
    this.species = '动物'
  }
  sayHi() {
    console.log('你好')
  }
}

class Cat extends Animal {
  constructor(name, color) {
    super()               // 必须先调用 super!
    this.name = name
    this.color = color
  }
  eat() {
    console.log('吃 Jerry')
  }
}

const cat = new Cat('大橘', '橘色')
cat.sayHi()   // 你好(来自父类原型)

注意:class 内部默认是严格模式!
注意:子类 constructor 必须先 super() 才能使用 this

底层实现?还是原型!

console.log(cat.__proto__ === Cat.prototype)        // true
console.log(Cat.prototype.__proto__ === Animal.prototype) // true

所以:class 只是让你用更像 Java/C++ 的方式写原型继承,本质没变!


总结:JavaScript OOP 全景图

阶段写法内存是否浪费是否有继承关系推荐度
对象字面量{}★☆☆☆☆
工厂函数createCat()★★☆☆☆
构造函数 + prototypenew Cat() + Cat.prototype★★★★☆
ES5 继承call + prototype = new Parent()★★★★☆
ES6 classclass Cat extends Animal★★★★★

原型链口诀

实例.__proto__ === 构造函数.prototype
构造函数.prototype.__proto__ === 父构造函数.prototype
Object.prototype.__proto__ === null
属性查找:先自身 → __proto__ → 再__proto__ → 直到 null
方法只在原型上放一份,所有实例共享
继承两步走:call 继承实例 + prototype 继承方法
class 是语法糖,底层还是原型链

灵魂追问环节(99% 的人死在这里)

Q1:为什么 Animal.call(this) 只能继承属性,不能继承原型上的方法?

因为 call/apply 只能“偷”父类构造函数里通过 this.xxx 添加的实例属性/方法,而真正的共享方法都应该放在 prototype 上,根本偷不到!

Q2:那 Cat.prototype = new Animal() 为什么说少用有瑕疵

因为它有两大致命副作用:

  1. 多执行一次 Animal 构造函数(可能有副作用)
  2. 彻底破坏了 constructor 指向(这就是罪魁祸首!)

那么这两个副作用又到底是什么意思呢 我们用最直观、最硬核的方式来告诉你:constructor 到底是个啥?被破坏后会发生什么?为什么必须手动修复?

1. constructor 本来是干什么的?

在 JavaScript 里,每个函数(包括构造函数)都会自动拥有一个 prototype 属性,而每个 prototype 对象上又天生自带一个 constructor 属性,它指向创建这个原型的函数本身

JavaScript

function Animal() {}
console.log(Animal.prototype.constructor === Animal)  // true

function Cat() {}
console.log(Cat.prototype.constructor === Cat)        // true

这就像身份证一样,告诉全世界:“我这个原型对象是由哪个构造函数创造的”。

2. 当你写 Cat.prototype = new Animal() 的时候,发生了什么?

JavaScript

function Cat() {}
console.log(Cat.prototype.constructor)  // 原来是 Cat

Cat.prototype = new Animal()            // ← 一行代码,天崩地裂!

console.log(Cat.prototype.constructor)  // 现在变成了 Animal!!!

Cat 的原型对象被彻底换掉了! 换成的是 new Animal() 创建出来的那个实例对象,而这个实例的 constructor 自然是 Animal!

text

原来的结构(正常):
Cat.prototype ──constructor ──→ Cat

现在的结构(被破坏了):
Cat.prototype(其实是 Animal 的实例) ──constructor ──→ Animal

3. 被破坏后到底会出什么问题?

问题①:instanceof 虽然还能用,但逻辑变得诡异

JavaScript

const cat = new Cat('大橘')
console.log(cat instanceof Cat)     // 仍然是 true(因为原型链还在)
console.log(cat instanceof Animal)   // 也是 true(正常)

但:
console.log(cat.constructor)        // Animal!!!而不是 Cat!
console.log(cat.constructor.name)   // "Animal" 而不是 "Cat"

这就很尴尬了:明明是一只猫,cat.constructor 却告诉你它是由 Animal 构造出来的!

问题②:很多框架/库/工具会偷偷依赖 constructor 判断类型

举几个真实场景(你以后一定会遇到):

JavaScript

// 场景1:JSON.stringify 自定义 toJSON 时可能判断 constructor
// 场景2:某些 ORM 框架(如 TypeORM)会用 constructor 做映射
// 场景3:React、Vue 某些旧插件会用 constructor.name 做调试或判断
// 场景4:你自己写工具函数时经常会用:

function getType(obj) {
  return obj.constructor.name   // 被破坏后就全乱了!
}

getType(cat)  // 返回 "Animal" 而不是 "Cat" → 彻底懵逼
问题③:new Cat() 时,某些老浏览器或特殊代码会依赖 constructor

极少数情况下(老 IE、某些 polyfill),new 操作会偷偷读取 prototype.constructor 来做一些判断。虽然现代浏览器不这么干,但历史上确实出过血案。

4. 正确修复方式(两行代码,永绝后患)

JavaScript

function Cat(name, color) {
  Animal.call(this)     // 继承实例属性
  this.name = name
  this.color = color
}

Cat.prototype = Object.create(Animal.prototype)  // 推荐写法
// Cat.prototype = new Animal()                  // 传统写法也行,但要修复

// 关键修复!必须加这一行!
Cat.prototype.constructor = Cat

修复后:

JavaScript

const cat = new Cat('大橘')
console.log(cat.constructor === Cat)      // true
console.log(cat.constructor.name)         // "Cat"
console.log(cat instanceof Cat)           // true
console.log(cat instanceof Animal)        // true

完美!所有问题全部解决!

搞懂原型链和继承,你就真正掌握了 JavaScript 的灵魂。从此不再被“call 还是 apply”“new Animal() 行不行”“constructor 到底要不要修”这些问题缠身。