原型与原型链

7 阅读8分钟

彻底搞懂JavaScript原型与原型链:从底层原理到生产最佳实践

前言

原型与原型链是JavaScript的灵魂,也是前端面试100%必问的考点。但绝大多数开发者对它的理解都停留在"背概念"的层面:

  • 知道"每个函数都有prototype",但不知道为什么要有
  • 知道"实例的__proto__指向构造函数的prototype",但不知道MDN为什么说__proto__已弃用
  • 能写出ES5继承的代码,但不知道每一行到底在底层做了什么

这篇文章我会从JS引擎的底层设计出发,用最直观的图表和可运行的代码,带你彻底搞懂原型与原型链的本质。读完这篇,你不仅能轻松应对所有原型相关的面试题,还能写出更优雅、更安全、性能更好的JS代码。

一、先搞懂3个核心概念(90%的人都搞混了)

在看任何代码之前,先把这3个概念刻在脑子里,后面所有内容都围绕它们展开。

1.1 最容易混淆的两个属性

这是原型体系中最核心的区分,也是所有误解的根源:

属性拥有者本质状态作用
prototype只有函数才有函数的一个普通对象属性✅ 标准特性存放所有实例共享的属性和方法
__proto__所有对象都有浏览器实现的非标准访问器❌ 已弃用(遗留兼容)访问对象内部的[[Prototype]]

1.2 真正的核心:[[Prototype]]内部槽

[[Prototype]]是JavaScript语言最底层的核心机制,永远不会被弃用。它是每个对象内部的一个隐藏属性,指向该对象的原型,原型链就是由无数个[[Prototype]]指针串联而成的

__proto__只是早期浏览器厂商为了让开发者能操作这个隐藏属性,私自实现的一个"后门"。ES6虽然将其勉强纳入标准,但明确标记为"遗留特性",不推荐在生产代码中使用。

1.3 标准替代API

现在操作原型的标准方式是使用以下API,完全替代__proto__的所有功能:

  • 获取原型:Object.getPrototypeOf(obj) / Reflect.getPrototypeOf(obj)
  • 设置原型:Object.setPrototypeOf(obj, proto)(尽量避免)
  • 创建指定原型的对象:Object.create(proto)(强烈推荐)

二、一张图看懂原型的基础关系

这是JavaScript原型最经典、最权威的基础结构图,所有面向对象的底层逻辑都建立在这个结构之上。

image.png

逐关系解释

  1. 构造函数 ↔ 原型对象(黑色双向箭头)

    • JS在创建任何函数时,都会自动生成一个对应的原型对象
    • Person.prototype.constructor === Person 永远成立
    • 这组关系对所有构造函数都成立,包括内置的ObjectArrayFunction
  2. 实例 → 原型对象(蓝色箭头)

    • 当执行new Person()时,JS引擎会自动将新对象的[[Prototype]]指向Person.prototype
    • 这是实例能访问原型方法的根本原因
    • 验证:Object.getPrototypeOf(person) === Person.prototype
  3. 原型链的终点

    • 所有原型对象最终都会指向Object.prototype
    • Object.prototype是JS中最顶层的原型,它的[[Prototype]]指向null
    • null表示"没有对象",是属性查找的终止符

三、原型链的本质:属性查找机制

原型链不是什么神秘的东西,它就是JS引擎查找对象属性的一套规则。当你访问obj.xxx时,JS引擎会严格按照以下步骤执行:

开始
  │
  ▼
┌─────────────────────────────┐
│ 检查 obj 自身是否有 xxx 属性 │
└─────────────────────────────┘
  │           │
  │ 有        │ 没有
  ▼           ▼
┌─────────┐ ┌─────────────────────────────┐
│ 返回值  │ │ 检查 obj 的 [[Prototype]]   │
└─────────┘ │ 是否有 xxx 属性             │
            └─────────────────────────────┘
                        │           │
                        │ 有        │ 没有
                        ▼           ▼
                      ┌─────────┐ ┌─────────────────────────────┐
                      │ 返回值  │ │ [[Prototype]] 是 null 吗?  │
                      └─────────┘ └─────────────────────────────┘
                                          │           │
                                          │ 是        │ 否
                                          ▼           ▼
                                        ┌─────────┐ ┌─────────────────────────────┐
                                        │ undefined│ │ 将 obj 指向它的 [[Prototype]]│
                                        └─────────┘ └─────────────────────────────┘
                                                            │
                                                            └──────────────────┘
                                                                          回到开始

举个例子:

const person = new Person('张三', 18);
console.log(person.toString()); // 输出 "[object Object]"

查找过程:

  1. person自身没有toString方法
  2. Object.getPrototypeOf(person)(即Person.prototype),也没有
  3. Object.getPrototypeOf(Person.prototype)(即Object.prototype),找到了
  4. 执行toString方法,this自动绑定到person对象

这就是为什么所有对象都能调用toString()hasOwnProperty()等方法——它们都定义在Object.prototype上。

四、ES5实现继承的完整原理

ES5没有原生的classextends关键字,继承完全是通过构造函数借用 + 原型链来实现的。这是面试最高频的考点,我们逐行拆解。

4.1 完整代码

// 1. 定义父类
function Person(name, age) {
  this.name = name; // 实例自有属性
  this.age = age;
}

// 父类实例方法(所有实例共享)
Person.prototype.sayHello = function() {
  console.log(`我是${this.name},今年${this.age}岁`);
};

// 2. 定义子类
function Student(name, age, grade) {
  // 第一步:借用父类构造函数,继承自有属性
  Person.call(this, name, age);
  this.grade = grade; // 子类自有属性
}

// 第二步:原型链继承,继承父类方法
Student.prototype = Object.create(Person.prototype);

// 第三步:修正constructor指向(非常重要)
Student.prototype.constructor = Student;

// 第四步:添加子类自己的方法
Student.prototype.study = function() {
  console.log(`${this.name}正在${this.grade}学习`);
};

// 测试
const student = new Student('张三', 18, '高三');
student.sayHello(); // "我是张三,今年18岁"
student.study(); // "张三正在高三学习"

4.2 继承后的原型链图

执行完上面的代码后,完整的原型链结构如下:

flowchart LR
    A[Student<br>构造函数] -- prototype --> B[Student.prototype]
    B -- constructor --> A
    C[student<br>实例] -- "[[Prototype]]" --> B
    B -- "[[Prototype]]" --> D[Person.prototype]
    D -- constructor --> E[Person<br>构造函数]
    E -- prototype --> D
    D -- "[[Prototype]]" --> F[Object.prototype]
    F -- "[[Prototype]]" --> G[null]

    style A fill:#e1f5fe,stroke:#0288d1
    style B fill:#f3e5f5,stroke:#7b1fa2
    style C fill:#e8f5e9,stroke:#2e7d32
    style D fill:#fff3e0,stroke:#f57c00
    style F fill:#ffecb3,stroke:#ff9800
    style G fill:#ffcdd2,stroke:#c62828

4.3 每一步的底层作用

  1. Person.call(this, name, age)

    • 调用父类构造函数,但将this绑定到子类实例
    • 把父类的nameage属性复制到子类实例上
    • ❌ 错误写法:直接写Person(name, age)会导致this指向全局对象
  2. Student.prototype = Object.create(Person.prototype)

    • 创建一个新的空对象,它的[[Prototype]]指向Person.prototype
    • 这是原型链继承的核心,让子类实例能访问父类的原型方法
    • ❌ 绝对不能写Student.prototype = Person.prototype,会导致父子类原型污染
  3. Student.prototype.constructor = Student

    • Object.create()创建的新对象,constructor继承自Person.prototype
    • 如果不修正,student.constructor会指向Person而不是Student
    • 这是一个非常容易被忽略但极其重要的细节
  4. 添加子类方法

    • 必须在修正原型之后添加,否则会被Object.create()创建的新对象覆盖

五、ES6 class的底层真相

很多人以为ES6的class是JS引入了全新的面向对象系统,其实它只是原型链的语法糖,底层做的事情和我们上面手写的ES5继承完全一样。

// ES6 写法
class Person {
  constructor(name, age) {
    this.name = name;
    this.age = age;
  }

  // 等价于 Person.prototype.sayHello
  sayHello() {
    console.log(`我是${this.name},今年${this.age}岁`);
  }

  // 等价于 Person.create = function() {}
  static create(name, age) {
    return new Person(name, age);
  }
}

class Student extends Person {
  constructor(name, age, grade) {
    super(name, age); // 等价于 Person.call(this, name, age)
    this.grade = grade;
  }

  // 等价于 Student.prototype.study
  study() {
    console.log(`${this.name}正在${this.grade}学习`);
  }
}

extends关键字自动帮我们完成了:

  • 原型链的建立
  • constructor指向的修正
  • super关键字的正确绑定

这就是为什么现在生产环境推荐使用class语法——它更清晰、更易读,也避免了手写ES5继承时容易犯的错误。

六、90%开发者都会踩的5个坑

坑1:把实例方法写在构造函数里

// ❌ 错误:每创建一个实例都会生成一个新的sayHello函数
function Person(name) {
  this.name = name;
  this.sayHello = function() {
    console.log(`我是${this.name}`);
  };
}

// ✅ 正确:所有实例共享同一个sayHello函数
Person.prototype.sayHello = function() {
  console.log(`我是${this.name}`);
};

坑2:继承时直接赋值原型

// ❌ 错误:Student.prototype和Person.prototype指向同一个对象
// 给Student.prototype加方法会污染Person的实例
Student.prototype = Person.prototype;

// ✅ 正确:创建一个新的空对象,原型指向Person.prototype
Student.prototype = Object.create(Person.prototype);

坑3:忘记修正constructor指向

Student.prototype = Object.create(Person.prototype);
// 忘记写这行
// Student.prototype.constructor = Student;

const student = new Student();
console.log(student.constructor); // 输出 Person,而不是 Student!

坑4:滥用Object.setPrototypeOf

虽然Object.setPrototypeOf是标准API,但它和__proto__一样,会严重破坏JS引擎的性能优化。现代JS引擎通过"隐藏类"优化属性访问,修改原型会导致所有相关对象的隐藏类被销毁,性能下降10-100倍。

✅ 最佳实践:永远在创建对象时就指定原型(使用Object.create()),而不是事后修改。

坑5:原型污染攻击

这是Web开发中最常见的安全漏洞之一,根源就是__proto__的可写性。

// 攻击者提交的JSON数据
const userInput = JSON.parse('{"__proto__": {"isAdmin": true}}');

// 所有对象都被污染了!
console.log({}.isAdmin); // true
console.log([] .isAdmin); // true

✅ 防御方案:

  1. 使用JSON.parsereviver函数过滤__proto__
  2. 使用Object.create(null)创建无原型对象
  3. 使用成熟的库(如lodash.clonedeep)进行深拷贝

七、生产环境最佳实践总结

  1. 优先使用ES6 class语法,它是现在的标准写法,隐藏了原型操作的底层细节
  2. 永远不要使用__proto__,生产代码中使用Object.getPrototypeOf()获取原型
  3. 尽量不要修改任何对象的原型,如果必须创建指定原型的对象,使用Object.create()
  4. 绝对不要直接赋值原型,避免原型污染
  5. 处理用户输入时必须过滤__proto__,防止原型污染攻击
  6. 实例方法挂载到prototype,静态方法挂载到构造函数本身

八、面试考点速记

  • 原型链的本质是JS引擎的属性查找机制
  • prototype是函数的属性,[[Prototype]]是对象的内部槽
  • __proto__已弃用,标准替代是Object.getPrototypeOf()
  • ES5继承的4个步骤:借用构造函数、原型链继承、修正constructor、添加子类方法
  • ES6 class是原型链的语法糖,底层实现和ES5完全一致
  • 原型污染的原理和防御方法

最后

原型与原型链是JavaScript最独特、最核心的设计,也是区分前端入门和进阶的重要标志。很多人觉得它难,是因为没有从底层理解它的设计初衷。

当你真正搞懂了原型链,你会发现JS的面向对象其实非常优雅和灵活。希望这篇文章能帮你彻底打通原型这一关,写出更优秀的JavaScript代码。

如果觉得文章对你有帮助,欢迎点赞、收藏、评论交流,我会持续更新更多JavaScript核心原理的内容。

需要我把文中的Mermaid图表导出为高清PNG图片,或者补充一个原型污染攻击的完整防御代码示例吗?