原型与原型链:JavaScript 对象系统的灵魂

15 阅读11分钟

当被问到"解释一下 JavaScript 的原型链"时,你是否能背出标准答案:对象通过 __proto__ 连接到原型对象,形成原型链。

但你有没有想过:为什么 JavaScript 要设计得这么复杂?为什么有 prototype 又有 __proto__?直到深入研究了原型的设计思想,才开始理解这套看似混乱的机制背后的优雅逻辑。这篇文章是我的学习总结,希望能和你一起探索 JavaScript 对象系统的本质。

从一个困惑说起

先看一段让我困惑很久的代码:

// 环境: 浏览器 / Node.js 18+
// 场景: 理解 __proto__ 和 prototype 的关系

function Person(name) {
  this.name = name;
}

const alice = new Person('Alice');

console.log(alice.__proto__ === Person.prototype); // true
console.log(Person.prototype.constructor === Person); // true
console.log(alice.constructor === Person); // true

这三个等式让我产生了很多疑问:

  1. 为什么 alice__proto__ 等于 Person.prototype?
  2. 为什么需要两个看起来很像的属性?
  3. constructor 是从哪来的?

要理解这些问题,需要从 JavaScript 的设计初衷说起。

原型的本质:JavaScript 的对象系统

JavaScript 为什么选择原型而不是类?

JavaScript 诞生于 1995 年,Brendan Eich 在设计时面临一个选择:采用基于类的继承(如 Java)还是基于原型的继承?

他选择了原型,主要基于以下考虑:

1. 简单灵活

基于原型的系统更简单、更灵活。对象可以直接从其他对象继承,不需要预先定义类的层次结构。

2. 动态性

JavaScript 需要是一门动态语言。原型系统允许在运行时修改对象的行为,这在基于类的系统中很难做到。

3. 轻量级

作为浏览器脚本语言,JavaScript 需要轻量。原型系统的实现比类系统更简单。

原型解决了什么问题?

最核心的问题是:如何在多个对象之间共享属性和方法?

// 环境: 浏览器 / Node.js 18+
// 场景: 不使用原型的问题

// ❌ 方案 1: 每个对象都有自己的方法副本
function createPerson(name) {
  return {
    name: name,
    sayHi: function() {
      console.log(`Hi, I'm ${this.name}`);
    }
  };
}

const person1 = createPerson('Alice');
const person2 = createPerson('Bob');

console.log(person1.sayHi === person2.sayHi); // false
// 每个对象都有一个 sayHi 方法的副本,浪费内存

使用原型可以解决这个问题:

// 环境: 浏览器 / Node.js 18+
// 场景: 使用原型共享方法

function Person(name) {
  this.name = name;
}

// 方法定义在原型上,所有实例共享
Person.prototype.sayHi = function() {
  console.log(`Hi, I'm ${this.name}`);
};

const person1 = new Person('Alice');
const person2 = new Person('Bob');

console.log(person1.sayHi === person2.sayHi); // true
// 所有实例共享同一个方法,节省内存

对象、构造函数、原型的三角关系

理解原型的关键是理解这三者的关系:

graph LR
    Instance[实例对象 alice] -->|__proto__| Prototype[Person.prototype]
    Constructor[构造函数 Person] -->|prototype| Prototype
    Prototype -->|constructor| Constructor
    Instance -.->|constructor 继承自原型| Constructor

用代码表达:

// 环境: 浏览器 / Node.js 18+
// 场景: 三角关系的验证

function Person(name) {
  this.name = name;
}

const alice = new Person('Alice');

// 1. 实例的 __proto__ 指向构造函数的 prototype
console.log(alice.__proto__ === Person.prototype); // true

// 2. 原型的 constructor 指向构造函数
console.log(Person.prototype.constructor === Person); // true

// 3. 实例可以通过原型链访问到 constructor
console.log(alice.constructor === Person); // true
console.log(alice.hasOwnProperty('constructor')); // false
// alice 本身没有 constructor,是从原型继承来的

关键理解:

  • prototype构造函数的属性,指向原型对象
  • __proto__实例对象的属性,指向构造函数的原型
  • constructor原型对象的属性,指向构造函数

原型链:属性查找的秘密

原型链的完整查找过程

当访问对象的属性时,JavaScript 会沿着原型链向上查找:

// 环境: 浏览器 / Node.js 18+
// 场景: 原型链查找过程

function Person(name) {
  this.name = name;
}

Person.prototype.species = 'Human';

const alice = new Person('Alice');

// 查找过程:
// 1. 先在 alice 自身查找 name -> 找到了
console.log(alice.name); // 'Alice'

// 2. 在 alice 自身查找 species -> 没找到
// 3. 沿着 __proto__ 到 Person.prototype 查找 -> 找到了
console.log(alice.species); // 'Human'

// 4. 在 alice 自身查找 toString -> 没找到
// 5. 在 Person.prototype 查找 toString -> 没找到
// 6. 在 Object.prototype 查找 toString -> 找到了
console.log(alice.toString()); // '[object Object]'

原型链的可视化

graph TB
    alice[alice 实例] -->|__proto__| PersonProto[Person.prototype]
    PersonProto -->|__proto__| ObjectProto[Object.prototype]
    ObjectProto -->|__proto__| null[null]
    
    Person[Person 构造函数] -->|prototype| PersonProto
    Object[Object 构造函数] -->|prototype| ObjectProto
    
    style null fill:#f9f,stroke:#333,stroke-width:2px

原型链的尽头

所有原型链的尽头都是 null:

// 环境: 浏览器 / Node.js 18+
// 场景: 探索原型链的尽头

function Person(name) {
  this.name = name;
}

const alice = new Person('Alice');

console.log(alice.__proto__); // Person.prototype
console.log(alice.__proto__.__proto__); // Object.prototype
console.log(alice.__proto__.__proto__.__proto__); // null

// 验证原型链的尽头
console.log(Object.prototype.__proto__ === null); // true

为什么原型链要以 null 结尾?

这是一个有意的设计:

  1. 明确的终点: null 表示"这里什么都没有",是查找的明确终点
  2. 避免循环引用: 如果原型链是循环的,属性查找会陷入死循环
  3. 哲学意义 🐶: null 代表"虚无",所有对象最终都源于"虚无"

构造函数与 new 的魔法

new 操作符到底做了什么?

new 操作符看起来很神奇,但它的行为可以分解为几个步骤:

// 环境: 浏览器 / Node.js 18+
// 场景: new 操作符的行为

function Person(name) {
  this.name = name;
  this.sayHi = function() {
    console.log(`Hi, I'm ${this.name}`);
  };
}

const alice = new Person('Alice');

// new Person('Alice') 做了什么?
// 1. 创建一个新对象
// 2. 将新对象的 __proto__ 指向 Person.prototype
// 3. 将 Person 函数的 this 绑定到新对象
// 4. 执行 Person 函数
// 5. 如果 Person 返回对象,则返回该对象;否则返回新对象

手写 new 的实现

理解原理最好的方式是手写实现:

// 环境: 浏览器 / Node.js 18+
// 场景: 手写 new 操作符

function myNew(Constructor, ...args) {
  // 1. 创建一个新对象,原型指向构造函数的 prototype
  const obj = Object.create(Constructor.prototype);
  
  // 2. 执行构造函数,this 绑定到新对象
  const result = Constructor.apply(obj, args);
  
  // 3. 如果构造函数返回对象,则返回该对象;否则返回新对象
  return result instanceof Object ? result : obj;
}

// 测试
function Person(name) {
  this.name = name;
}

Person.prototype.sayHi = function() {
  console.log(`Hi, I'm ${this.name}`);
};

const alice = myNew(Person, 'Alice');
console.log(alice.name); // 'Alice'
alice.sayHi(); // 'Hi, I'm Alice'
console.log(alice instanceof Person); // true

constructor 属性的作用与陷阱

constructor 属性指向创建该实例的构造函数,但它很容易丢失:

// 环境: 浏览器 / Node.js 18+
// 场景: constructor 丢失的问题

function Animal(name) {
  this.name = name;
}

// ❌ 直接赋值会丢失 constructor
Animal.prototype = {
  eat: function() {
    console.log(`${this.name} is eating`);
  }
};

const cat = new Animal('Cat');
console.log(cat.constructor === Animal); // false!
console.log(cat.constructor === Object); // true!

// ✅ 修复方法 1: 手动添加 constructor
Animal.prototype = {
  constructor: Animal, // 手动指定
  eat: function() {
    console.log(`${this.name} is eating`);
  }
};

// ✅ 修复方法 2: 使用 Object.defineProperty 让 constructor 不可枚举
Object.defineProperty(Animal.prototype, 'constructor', {
  value: Animal,
  writable: true,
  enumerable: false, // 不可枚举,更接近原生行为
  configurable: true
});

继承:从原型到 ES6 Class

原型链继承

最简单的继承方式是让子类的原型指向父类的实例:

// 环境: 浏览器 / Node.js 18+
// 场景: 原型链继承

function Animal(name) {
  this.name = name;
  this.colors = ['black', 'white'];
}

Animal.prototype.eat = function() {
  console.log(`${this.name} is eating`);
};

function Dog(name) {
  this.name = name;
}

// 原型链继承
Dog.prototype = new Animal();

const dog1 = new Dog('Dog1');
const dog2 = new Dog('Dog2');

dog1.colors.push('brown');

console.log(dog1.colors); // ['black', 'white', 'brown']
console.log(dog2.colors); // ['black', 'white', 'brown'] ❌ 共享了引用类型

问题:

  1. 引用类型的属性被所有实例共享
  2. 创建子类实例时,无法向父类构造函数传参

构造函数继承

通过在子类中调用父类构造函数来继承属性:

// 环境: 浏览器 / Node.js 18+
// 场景: 构造函数继承

function Animal(name) {
  this.name = name;
  this.colors = ['black', 'white'];
}

Animal.prototype.eat = function() {
  console.log(`${this.name} is eating`);
};

function Dog(name, age) {
  Animal.call(this, name); // 借用构造函数
  this.age = age;
}

const dog1 = new Dog('Dog1', 3);
const dog2 = new Dog('Dog2', 5);

dog1.colors.push('brown');

console.log(dog1.colors); // ['black', 'white', 'brown']
console.log(dog2.colors); // ['black', 'white'] ✅ 互不影响

// ❌ 但是无法继承父类原型上的方法
dog1.eat(); // TypeError: dog1.eat is not a function

问题:

  1. 只能继承父类的实例属性,无法继承原型方法
  2. 每个实例都会执行一次父类构造函数

组合继承

结合原型链继承和构造函数继承:

// 环境: 浏览器 / Node.js 18+
// 场景: 组合继承

function Animal(name) {
  this.name = name;
  this.colors = ['black', 'white'];
}

Animal.prototype.eat = function() {
  console.log(`${this.name} is eating`);
};

function Dog(name, age) {
  Animal.call(this, name); // 第二次调用 Animal
  this.age = age;
}

Dog.prototype = new Animal(); // 第一次调用 Animal
Dog.prototype.constructor = Dog;

const dog1 = new Dog('Dog1', 3);
dog1.colors.push('brown');

const dog2 = new Dog('Dog2', 5);

console.log(dog1.colors); // ['black', 'white', 'brown']
console.log(dog2.colors); // ['black', 'white'] ✅
dog1.eat(); // 'Dog1 is eating' ✅

问题:

  • 父类构造函数被调用了两次,浪费性能

寄生组合式继承(最优方案)

使用 Object.create() 避免多次调用父类构造函数:

// 环境: 浏览器 / Node.js 18+
// 场景: 寄生组合式继承(推荐)

function Animal(name) {
  this.name = name;
  this.colors = ['black', 'white'];
}

Animal.prototype.eat = function() {
  console.log(`${this.name} is eating`);
};

function Dog(name, age) {
  Animal.call(this, name); // 只调用一次 Animal
  this.age = age;
}

// 关键: 使用 Object.create 创建父类原型的副本
Dog.prototype = Object.create(Animal.prototype);
Dog.prototype.constructor = Dog;

Dog.prototype.bark = function() {
  console.log(`${this.name} is barking`);
};

const dog = new Dog('Buddy', 3);
dog.eat(); // 'Buddy is eating'
dog.bark(); // 'Buddy is barking'

console.log(dog instanceof Dog); // true
console.log(dog instanceof Animal); // true

为什么这是最优方案?

  1. 只调用一次父类构造函数
  2. 原型链保持完整
  3. instanceof 正常工作

ES6 Class 的本质

ES6 的 class 看起来像是全新的语法,但本质上还是基于原型:

// 环境: 浏览器 / Node.js 18+ (支持 ES6)
// 场景: ES6 Class 语法

class Animal {
  constructor(name) {
    this.name = name;
  }
  
  eat() {
    console.log(`${this.name} is eating`);
  }
}

class Dog extends Animal {
  constructor(name, age) {
    super(name); // 调用父类构造函数
    this.age = age;
  }
  
  bark() {
    console.log(`${this.name} is barking`);
  }
}

const dog = new Dog('Buddy', 3);
dog.eat(); // 'Buddy is eating'
dog.bark(); // 'Buddy is barking'

// 本质上还是原型继承
console.log(Dog.prototype.__proto__ === Animal.prototype); // true
console.log(typeof Dog); // 'function'

ES6 Class 和传统原型的区别:

  1. 语法更清晰: class 语法更接近其他面向对象语言
  2. 严格模式: class 内部自动使用严格模式
  3. 不可枚举: class 中的方法不可枚举
  4. 必须用 new: class 不能像函数一样直接调用
  5. 没有提升: class 声明不会提升
// 环境: 浏览器 / Node.js 18+
// 场景: Class 的特殊行为

class Person {
  constructor(name) {
    this.name = name;
  }
  
  sayHi() {
    console.log(`Hi, I'm ${this.name}`);
  }
}

// ❌ class 不能直接调用
Person('Alice'); // TypeError: Class constructor Person cannot be invoked without 'new'

// ✅ 方法不可枚举
const p = new Person('Alice');
console.log(Object.keys(p)); // ['name']
console.log(Object.keys(Person.prototype)); // [] 方法不可枚举

实践应用

如何安全地检测属性

在实际开发中,我们经常需要判断属性来源:

// 环境: 浏览器 / Node.js 18+
// 场景: 属性检测的最佳实践

function Person(name) {
  this.name = name;
}

Person.prototype.species = 'Human';

const alice = new Person('Alice');

// 1. hasOwnProperty: 检查自身属性(不包括原型)
console.log(alice.hasOwnProperty('name')); // true
console.log(alice.hasOwnProperty('species')); // false

// 2. in 操作符: 检查自身和原型链上的属性
console.log('name' in alice); // true
console.log('species' in alice); // true
console.log('toString' in alice); // true

// 3. 安全的 hasOwnProperty 调用(避免对象覆盖)
const obj = Object.create(null);
obj.hasOwnProperty = 'hacked';

// ❌ 直接调用会出错
// obj.hasOwnProperty('name'); // TypeError

// ✅ 安全的调用方式
console.log(Object.prototype.hasOwnProperty.call(obj, 'name'));

// ✅ ES2022 新方法
console.log(Object.hasOwn(obj, 'name'));

常见陷阱与最佳实践

陷阱 1: 修改内置对象原型

// 环境: 浏览器 / Node.js 18+
// 场景: 修改内置原型的危险

// ❌ 不要这样做!
Array.prototype.first = function() {
  return this[0];
};

const arr = [1, 2, 3];
console.log(arr.first()); // 1

// 问题:
// 1. 污染全局命名空间
// 2. 可能与未来的标准冲突
// 3. 影响所有数组,包括第三方库的

// ✅ 替代方案: 使用工具函数
function first(arr) {
  return arr[0];
}

console.log(first(arr)); // 1

陷阱 2: 原型链污染攻击

原型链污染是一个严重的安全问题:

// 环境: 浏览器 / Node.js 18+
// 场景: 原型链污染示例

// 危险的 merge 函数
function merge(target, source) {
  for (let key in source) {
    if (typeof source[key] === 'object' && source[key] !== null) {
      target[key] = merge(target[key] || {}, source[key]);
    } else {
      target[key] = source[key];
    }
  }
  return target;
}

// 恶意输入
const maliciousInput = JSON.parse('{"__proto__": {"isAdmin": true}}');

const user = {};
merge(user, maliciousInput);

// 所有对象都被污染了!
const newUser = {};
console.log(newUser.isAdmin); // true ❌

// ✅ 防御方案 1: 检查键名
function safeMerge(target, source) {
  for (let key in source) {
    if (key === '__proto__' || key === 'constructor' || key === 'prototype') {
      continue; // 跳过危险键
    }
    
    if (typeof source[key] === 'object' && source[key] !== null) {
      target[key] = safeMerge(target[key] || {}, source[key]);
    } else {
      target[key] = source[key];
    }
  }
  return target;
}

// ✅ 防御方案 2: 使用 Object.create(null)
const safeObj = Object.create(null);
safeObj.__proto__ = 'safe'; // 只是普通属性
console.log(safeObj.__proto__); // 'safe'

// ✅ 防御方案 3: 使用 Map
const safeMap = new Map();
safeMap.set('__proto__', 'safe');
console.log(safeMap.get('__proto__')); // 'safe'

陷阱 3: instanceof 的局限性

// 环境: 浏览器 / Node.js 18+
// 场景: instanceof 的问题

function Person() {}
const alice = new Person();

console.log(alice instanceof Person); // true

// 问题 1: 可以被欺骗
Person.prototype = {};
console.log(alice instanceof Person); // false!

// 问题 2: 跨 iframe 失效
// const iframe = document.createElement('iframe');
// document.body.appendChild(iframe);
// const iframeArray = iframe.contentWindow.Array;
// const arr = new iframeArray();
// console.log(arr instanceof Array); // false!

// ✅ 替代方案 1: Object.prototype.toString
console.log(Object.prototype.toString.call([])); // '[object Array]'
console.log(Object.prototype.toString.call({})); // '[object Object]'

// ✅ 替代方案 2: Array.isArray (针对数组)
console.log(Array.isArray([])); // true

// ✅ 替代方案 3: constructor
console.log(alice.constructor === Person); // true (但也可能被修改)

最佳实践总结

// 环境: 浏览器 / Node.js 18+
// 场景: 原型使用的最佳实践

// ✅ 1. 使用 Object.create 实现继承
function Parent() {}
function Child() {}
Child.prototype = Object.create(Parent.prototype);
Child.prototype.constructor = Child;

// ✅ 2. 检查属性时使用 Object.hasOwn (ES2022)
const obj = { name: 'Alice' };
console.log(Object.hasOwn(obj, 'name')); // true

// ✅ 3. 创建纯数据对象时使用 Object.create(null)
const data = Object.create(null);

// ✅ 4. 不要直接修改内置对象原型
// ❌ Array.prototype.myMethod = ...
// ✅ function myMethod(arr) { ... }

// ✅ 5. 使用 class 语法(如果支持)
class MyClass {
  constructor() {}
}

// ✅ 6. 防御原型污染
function safeAssign(target, source) {
  const dangerousKeys = ['__proto__', 'constructor', 'prototype'];
  
  for (let key in source) {
    if (!dangerousKeys.includes(key)) {
      target[key] = source[key];
    }
  }
  
  return target;
}

深入理解相关 API

instanceof 的实现原理

// 环境: 浏览器 / Node.js 18+
// 场景: 手写 instanceof

function myInstanceof(obj, Constructor) {
  // 基本类型直接返回 false
  if (obj === null || typeof obj !== 'object') {
    return false;
  }
  
  // 获取构造函数的原型
  let proto = Constructor.prototype;
  
  // 获取对象的原型
  let objProto = Object.getPrototypeOf(obj);
  
  // 沿着原型链查找
  while (objProto !== null) {
    if (objProto === proto) {
      return true;
    }
    objProto = Object.getPrototypeOf(objProto);
  }
  
  return false;
}

// 测试
function Person() {}
const alice = new Person();

console.log(myInstanceof(alice, Person)); // true
console.log(myInstanceof(alice, Object)); // true
console.log(myInstanceof(alice, Array)); // false

Object.setPrototypeOf vs proto

// 环境: 浏览器 / Node.js 18+
// 场景: 设置原型的不同方式

const parent = { greet: () => console.log('Hello') };
const child = { name: 'Alice' };

// 方式 1: __proto__ (不推荐,性能差)
child.__proto__ = parent;

// 方式 2: Object.setPrototypeOf (不推荐,性能差)
Object.setPrototypeOf(child, parent);

// 方式 3: Object.create (推荐,创建时指定)
const betterChild = Object.create(parent, {
  name: { value: 'Bob', writable: true, enumerable: true, configurable: true }
});

// 为什么不推荐运行时修改原型?
// 1. 性能问题: 引擎无法优化
// 2. 影响所有访问该对象的代码
// 3. 可能破坏现有的继承链

// ✅ 最佳实践: 在创建对象时就确定原型

设计思想与演进

基于原型 vs 基于类

JavaScript 的原型系统和传统的类系统有本质区别:

基于类(如 Java):

// 类是模板,对象是实例
class Animal {
    void eat() { }
}

class Dog extends Animal {
    void bark() { }
}

// 类和实例是两种不同的东西
Dog dog = new Dog();

基于原型(JavaScript):

// 对象直接从其他对象继承
const animal = {
  eat: function() { }
};

const dog = Object.create(animal);
dog.bark = function() { };

// 一切都是对象

核心区别:

  1. 灵活性: 原型系统更灵活,可以在运行时修改继承关系
  2. 简洁性: 不需要定义类,对象可以直接继承对象
  3. 动态性: 原型可以随时添加属性和方法

JavaScript 的设计哲学

Brendan Eich 在设计 JavaScript 时有几个核心理念:

  1. 简单性: 让非程序员也能使用
  2. 灵活性: 支持多种编程范式
  3. 动态性: 运行时可以修改对象

原型系统完美体现了这些理念:简单(不需要理解类的概念)、灵活(多种继承方式)、动态(运行时修改)。

从原型到 Class: 语法的进化

ES6 引入 class 关键字,但这只是"语法糖":

// 环境: 浏览器 / Node.js 18+
// 场景: class 只是语法糖

// ES6 Class
class Person {
  constructor(name) {
    this.name = name;
  }
  
  sayHi() {
    console.log(`Hi, I'm ${this.name}`);
  }
}

// 等价的 ES5 代码
function Person(name) {
  this.name = name;
}

Person.prototype.sayHi = function() {
  console.log(`Hi, I'm ${this.name}`);
};

// 本质上没有改变,只是语法更清晰

延伸思考

在 AI 辅助编程时代,理解原型还重要吗?

这个问题我在前面两篇文章中也思考过。对于原型,我的看法是:

理解原型让你更好地使用现代工具:

  1. 调试能力: 当出现 prototype 相关的错误时,你能快速定位问题
  2. 性能优化: 知道什么时候该用实例属性,什么时候该用原型方法
  3. 框架理解: 很多框架(如 Vue 2)的响应式系统就是基于原型实现的

一个实际例子:

假设 AI 给你生成了这样的代码:

// AI 生成的代码
class TodoList {
  constructor() {
    this.items = [];
    this.render = function() { // ❌ 每个实例都有一个 render 副本
      console.log(this.items);
    };
  }
}

如果你理解原型,就会知道应该优化成:

// 优化后的代码
class TodoList {
  constructor() {
    this.items = [];
  }
  
  render() { // ✅ 所有实例共享同一个方法
    console.log(this.items);
  }
}

与 TypeScript 的关系

TypeScript 的类型系统和 JavaScript 的原型系统如何结合?

// TypeScript 中的类
class Animal {
  name: string;
  
  constructor(name: string) {
    this.name = name;
  }
  
  eat(): void {
    console.log(`${this.name} is eating`);
  }
}

// 编译后仍然是基于原型的 JavaScript
// TypeScript 只是在编译时提供类型检查

TypeScript 为 JavaScript 添加了静态类型,但并没有改变底层的原型机制。

现代框架如何使用原型

React:

// React 类组件基于原型
class MyComponent extends React.Component {
  // 方法定义在原型上
  render() { }
}

现代趋势:

随着函数式编程的流行,框架越来越少直接操作原型,转而使用:

  • 函数组件
  • Hooks
  • 组合式 API

但理解原型仍然有助于理解这些框架的底层实现。

小结

原型与原型链是 JavaScript 对象系统的核心,理解它们不仅仅是为了应付面试,更是为了:

  1. 理解 JavaScript 的本质: 知道对象是如何工作的
  2. 写出更好的代码: 知道何时用实例属性、何时用原型方法
  3. 调试更有效: 快速定位原型相关的问题
  4. 理解现代特性: class、私有字段等新特性都建立在原型之上

这篇文章是我的学习总结,而非权威教程。原型系统博大精深,我的理解可能也有偏差。如果你有不同的看法或补充,欢迎交流讨论。

参考资料