☰
深入理解JavaScript原型链:从原型继承到实际应用
2026/10/30 23:29:47 网站建设 项目流程

1. 原型链:从“靠背”到“看穿”

前端面试十次有八次会问原型链,但这道题恰恰是最容易暴露水平的。多数人的状态是:面试前背一遍prototype、__proto____、constructor的关系图,面完就忘,写代码时依然不知道怎么用它解决实际问题。这不能怪你,因为网上的教程把原型链讲成了一堆需要记忆的结论,而不是一条可以推导出来的规则。

这篇文章我想换个讲法——把原型链的“前世今生”拆开。从 JavaScript 为什么会有原型链,到它内部到底怎么运转,再到它在工程里的真实应用场景(包括“原型链补环境”这个很多人听过但没搞懂的操作),一步步把这条链子串起来。看完你会发现自己不需要死记任何图,原型链的规则是能自己推出来的。

适合谁看?正在准备面试的前端、用了几年class但没搞懂背后机制的开发者、以及做爬虫或工具类项目时需要对原型链做处理的朋友。我尽量讲得直白,但要求你至少写过一段 JavaScript 代码。

2. 为什么会有原型链——JavaScript 被迫做出的“妥协”

2.1 十天内诞生的语言,没空设计类

JavaScript 诞生于 1995 年,Brendan Eich 只用了十天就完成了第一版设计。当时 Netscape 想要一门能嵌入浏览器的脚本语言,定位是“胶水语言”,用来在网页上做一些表单校验、动画之类的轻量工作。

当时 Java 已经火了,类继承是主流思想。但 Java 那套“先定义类、再实例化对象”的体系非常重,对一个只用来操作 DOM、处理表单的语言来说,完全是杀鸡用牛刀。Brendan Eich 参考了 Self 语言——一门基于原型的面向对象语言,它的核心思想是:没有类,对象直接从一个已有对象克隆出来。

所以 JavaScript 从一开始就没打算走“类”这条路,而是选择了对象与对象之间直接关联的方式。这决定了它后续的一切设计,包括new、prototype、__proto__这些后来看起来有点扭曲的语法。

2.2 原型继承的本质:不是“拷贝”,是“关联”

很多人对原型继承有个误解,以为是对象把自己复制了一份给新对象。其实完全不是。

原型继承的本质是建立关联:新对象内部保存一个对原对象的引用,当你访问新对象的某个属性,它自己身上没有,就顺着这个引用去原对象身上找。

打个比方:小区里有一辆共享自行车,你没有买自行车,但你有钥匙(引用)。你想骑车的时候,拿着钥匙去小区车棚找那辆共享车。这个“车棚”就是原型对象,你的钥匙就是__proto__指向。

关键点来了——共享车只有一辆,所有人都引用它。如果你在共享车上放了一个新配件,所有持有钥匙的人都能看到这个配件。这就是原型链上属性共享的本质。

// 共享车(原型对象) const sharedBike = { brand: "永久" }; // 两个人拿到同一个引用 const user1 = {}; const user2 = {}; Object.setPrototypeOf(user1, sharedBike); Object.setPrototypeOf(user2, sharedBike); // 给共享车加配件 sharedBike.basket = true; // 两个用户都能看到新配件 console.log(user1.basket); // true console.log(user2.basket); // true

这个例子揭示了原型继承和类继承最本质的不同:类继承是模具与成品的关系,模具改了,已经生产出来的成品不会变。而原型继承是引用关系,原型改了,所有关联对象立刻能看到变化。

2.3 new 的妥协设计

既然选择了原型继承,为什么还要引入new和constructor?这纯粹是为了让习惯了 Java 的开发者用起来更顺手。new的语义是“创建一个实例”,但真正执行的却是原型继承的逻辑。

new的内部做了三件事:

  1. 创建一个新对象
  2. 把这个新对象的__proto__指向构造函数的prototype对象
  3. 调用构造函数,把this绑定到新对象上,执行构造函数体
  4. 如果构造函数返回的是对象,则返回该对象;否则返回新创建的对象

模拟一下new的实现:

function myNew(Constructor, ...args) { // 创建一个新对象,原型指向构造函数的 prototype const obj = Object.create(Constructor.prototype); // 调用构造函数,this 绑定为新对象 const result = Constructor.apply(obj, args); // 构造函数如果返回了对象,就返回那个对象;否则返回新对象 return (typeof result === "object" && result !== null) || typeof result === "function" ? result : obj; } function Person(name) { this.name = name; } const person = myNew(Person, "张三"); console.log(person.name); // 张三 console.log(person instanceof Person); // true

注意第 4 步这个坑:构造函数如果显式返回一个对象,new结果会被这个对象覆盖。React 老版本组件用class继承Component时内部就处理过类似问题。

提示:Object.create()是 ES5 提供的方法,它的作用就是创建一个新对象,并把新对象的原型指向传入的对象。理解了它,new的本质就懂了一半。

3. 图解原型链三大核心机制

3.1 prototype、proto、constructor 的关系

原型链让人头大的原因就三个词:prototype、__proto__、constructor。它们的关系如果画成图,对新手来说是一团乱麻。我来逐个拆解:

  • prototype:每个函数(除了箭头函数)都有一个属性,指向一个对象。这个对象会被作为通过new创建出来的实例的原型。
  • __proto__:每个对象都有一个属性(实际上是通过Object.prototype暴露的访问器),指向创建它的那个构造函数的prototype。
  • constructor:prototype对象上默认带的一个属性,指回这个构造函数本身。

我用代码演示它们三者的关系:

function Dog(name) { this.name = name; } const dog = new Dog("旺财"); // 普通对象有 __proto__ console.log(dog.__proto__ === Dog.prototype); // true // 函数有 prototype console.log(Dog.prototype.constructor === Dog); // true // 函数本身也是对象,所以也有 __proto__ console.log(Dog.__proto__ === Function.prototype); // true // 原型对象也是对象,也有 __proto__ console.log(Dog.prototype.__proto__ === Object.prototype); // true

一目了然的规则:

对象类型能访问 prototype?能访问proto?
普通对象否是
函数对象是是
原型对象否是

3.2 属性查找链路的完整流程

弄清楚了三个属性之间的关系,再来看属性查找就简单了。

当你写下obj.someProperty,JavaScript 引擎会按以下顺序查找:

  1. 在obj自己身上找(自有属性)
  2. 没找到,去obj.__proto__指向的对象上找
  3. 还没找到,继续沿着__proto__往上找
  4. 直到Object.prototype.__proto__——值为null,查找结束

这意味着所有对象最终都能找到Object.prototype上的方法,比如toString、hasOwnProperty。

const arr = [1, 2, 3]; // arr 的原型链 arr.__proto__ === Array.prototype; // true Array.prototype.__proto__ === Object.prototype; // true Object.prototype.__proto__ === null; // true // 所以 arr.toString() 实际是找到的 // 先查 arr 自身 -> 没有 // 再查 Array.prototype -> 也没有 toString // 最后查到 Object.prototype -> 找到了

3.3 Function 和 Object 的循环引用

这张图里最让人崩溃的其实是Function和Object互相指向的关系。很多人问:Function.__proto__ === Function.prototype到底是为什么?

要理解这个,得先接受一个设定:函数也是对象。而 JavaScript 中所有函数都是由Function构造函数创建的,包括Function自己。

所以:

Function.__proto__ === Function.prototype; // true Object.__proto__ === Function.prototype; // true Function.prototype.__proto__ === Object.prototype; // true

这就是传说中的“鸡生蛋、蛋生鸡”。Function创建了Object,但Function本身也是对象,所以它的原型最终也指向Object.prototype。

不需要刻意记,你只需要记住两个底层规则:

  • 所有函数的__proto__都指向Function.prototype
  • 所有对象的__proto__链路最终都会经过Object.prototype

谁是谁的构造者,看__proto__就能推出来;谁继承了谁的方法,沿着链路走就能查明白。

4. 继承方案进化史——从手写到 class 语法糖

4.1 早期原型链继承:简单但不完美

最原始的原型链继承方式就是直接让子类的prototype指向父类的实例:

function Animal(name) { this.name = name; } Animal.prototype.say = function() { console.log(this.name + " is speaking"); }; function Dog(name) { this.name = name; } // 让 Dog.prototype 指向 Animal 的实例 Dog.prototype = new Animal(); Dog.prototype.constructor = Dog; // 修复 constructor 指向 const dog = new Dog("旺财"); dog.say(); // 旺财 is speaking

这段代码有三个问题:

  1. 父类构造函数在创建原型时就被执行了一次,如果构造函数里有副作用的逻辑,会很危险。
  2. 子类实例无法向父类构造函数传参,因为Dog.prototype = new Animal()时,Animal已经执行完了。
  3. 多个子类实例共享了同一个父类实例上的引用类型属性,比如数组,一个实例改了,其他实例全变。

4.2 组合继承:解决共享问题,但性能有冗余

组合继承的核心思路是:用构造函数继承解决属性复制,用原型链继承解决方法复用。

function Animal(name) { this.name = name; this.foods = []; } Animal.prototype.eat = function(food) { this.foods.push(food); }; function Dog(name, breed) { Animal.call(this, name); // 第一次调用 Animal this.breed = breed; } Dog.prototype = new Animal(); // 第二次调用 Animal Dog.prototype.constructor = Dog; const d1 = new Dog("旺财", "柴犬"); const d2 = new Dog("来福", "金毛"); d1.eat("骨头"); console.log(d1.foods); // ["骨头"] console.log(d2.foods); // [] console.log(d1 instanceof Dog); // true console.log(d1 instanceof Animal); // true

每个实例拥有了独立的foods属性,方法复用也没有问题。但副作用依然在:Animal被执行了两次,子类prototype上残留了一份不必要的name和foods属性。

4.3 寄生组合继承:现在的主流方案

寄生组合继承能完美解决这个问题。它不再通过new Animal(),而是用Object.create(Animal.prototype)来拿父类原型的方法。

function Animal(name) { this.name = name; } Animal.prototype.say = function() { console.log(this.name + " is speaking"); }; function Dog(name) { Animal.call(this, name); } // 关键步骤 Dog.prototype = Object.create(Animal.prototype); Dog.prototype.constructor = Dog; const dog = new Dog("旺财"); dog.say(); // 旺财 is speaking console.log(dog instanceof Animal); // true

Object.create创建了一个新对象,它的原型指向Animal.prototype,但不会执行Animal构造函数。这样既拿到了父类原型上的方法,又不会产生多余的实例属性。

4.4 class 是语法糖,但带来了新约束

ES6 的class关键字并没有引入新的继承机制,它只是寄生组合继承的语法糖。但它增加了几个约束:

  • 不能直接调用class声明的构造函数,必须new
  • 子类要在super()之后才能使用this
  • 抽象方法和私有字段的支持让代码更规范
class Animal { constructor(name) { this.name = name; } say() { console.log(this.name + " is speaking"); } } class Dog extends Animal { constructor(name, breed) { super(name); this.breed = breed; } }

class用起来舒服,但如果你不理解它在背后做了什么,遇到super报错时就会很懵。super()的本质就是调用父类的构造函数来初始化this,所以它必须发生在访问this之前。

我的建议是:日常开发用class就够了,但面试和数据模拟场景里,手写一遍寄生组合继承是理解原型链最好的训练。

5. 原型链在工程中的真实战场

5.1 原型链补环境:为什么需要“补”原型方法

“原型链补环境”这个词在前端工程化不太常提,但在爬虫、浏览器自动化、SDK 开发、防检测工具这些领域,它是个高频词。

背景是这样的:在 Node.js 环境里模拟浏览器环境时(比如用 jsdom 或 puppeteer 做网页数据采集),有些网页会检查环境对象的原型链是否完整。浏览器里window.navigator、document这些对象都来自特定构造函数的原型链,而 Node.js 环境里没有这些构造函数,需要手动“补”出来。

// 在 Node.js 环境中模拟 navigator function Navigator() {} Navigator.prototype.userAgent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64)..."; Navigator.prototype.language = "zh-CN"; const navigator = new Navigator(); // 网页代码中常见检查 console.log(Object.prototype.toString.call(navigator)); // 期望 [object Navigator]

如果Object.prototype.toString.call(navigator)返回的不是预期的标记,网页就会判定环境异常。要让它返回[object Navigator],光给原型加属性不够,还得正确设置Symbol.toStringTag。

Object.defineProperty(Navigator.prototype, Symbol.toStringTag, { value: "Navigator", configurable: true }); console.log(Object.prototype.toString.call(navigator)); // [object Navigator]

这里涉及的原型链知识是:Object.prototype.toString会读取对象上Symbol.toStringTag属性,这个属性通常是从原型链上继承下来的。补环境时你既要补toString的返回值,也要补原型链上缺失的构造函数和属性名。

5.2 instanceof 的本质和跨环境失效问题

instanceof的原理字面上很简单:沿左操作数.__proto__向上查找,看能否遇到右操作数.prototype。

手写一个instanceof的模拟实现:

function myInstanceof(left, right) { let proto = Object.getPrototypeOf(left); const prototype = right.prototype; while (proto !== null) { if (proto === prototype) { return true; } proto = Object.getPrototypeOf(proto); } return false; } console.log(myInstanceof([], Array)); // true console.log(myInstanceof([], Object)); // true console.log(myInstanceof([], Function)); // false

理解了原理,你就明白为什么跨 iframe 的instanceof会失效。不同 iframe 有各自的全局环境,Array是两个不同的构造函数,[1,2,3]的原型链里当然找不到另一个Array.prototype。

在工程中,这个现象的实际影响很多:

  • 跨窗口传递数组时arr instanceof Array返回false
  • 类型检测应该用Array.isArray()或Object.prototype.toString.call()
  • SDK 里对传入对象做类型判断时,必须考虑跨环境场景

5.3 原型链污染:安全视角的警示

原型链不只是用来继承的,它也是安全攻击的入口。原型污染的核心思路是:通过修改Object.prototype来影响所有对象。

// 经典的原型污染 payload function merge(target, source) { for (const key in source) { if (typeof source[key] === "object" && source[key] !== null) { if (!target[key]) { target[key] = {}; } merge(target[key], source[key]); } else { target[key] = source[key]; } } } const data = JSON.parse('{"__proto__": {"polluted": true}}'); merge({}, data); console.log({}.polluted); // true

这个案例要说明的是:如果代码对不可信数据做了递归合并,并且没有过滤__proto__和constructor.prototype这些特殊键,攻击者就能污染全局原型链。防御手段是合并时用白名单过滤键名,或者用Object.create(null)创建没有原型的对象。

5.4 快速判断原型关系的标准方法

工程中判断原型关系通常用这几个标准 API,比操作__proto__安全得多:

方法作用适用场景
Object.getPrototypeOf(obj)获取对象的原型标准方式读取原型
Object.setPrototypeOf(obj, proto)设置对象的原型标准方式修改原型
obj.isPrototypeOf(other)判断 obj 是否在 other 的原型链上不依赖函数的 prototype
obj instanceof Constructor判断 Constructor.prototype 是否在 obj 的原型链上依赖函数的 prototype

其中isPrototypeOf是个冷门但很实用的方法。它不需要你拿到构造函数,只需要你有一个原型对象引用就能判断关系:

const animal = { kind: "animal" }; const dog = Object.create(animal); console.log(animal.isPrototypeOf(dog)); // true

6. 原型链常见问题与排查实录

6.1 经典报错对照速查表

我在实际开发和带新人过程中,整理了一些高频的原型链相关报错:

现象原因解决方式
xxx is not a constructor箭头函数或类方法没有 prototype改成普通函数
Cannot set property 'xxx' of undefined构造函数里this.xxx时 this 未正确绑定检查是否用了new,或者箭头函数构造函数
子类构造函数里访问this报错super()尚未被调用在this访问前先调用super()
继承来的数组属性被实例共享父类实例属性是引用类型,子类原型共享改用构造函数传递属性或使用类字段
instanceof返回 false对象来自不同 iframe 或修改了 prototype用Array.isArray或toString判断

6.2 排查案例一:数组方法在某个对象上突然不可用

一次排查中,同事发现某个对象上调用hasOwnProperty报not a function。原因是他用Object.create(null)创建了这个对象。

Object.create(null)创建的对象没有原型链,连Object.prototype上的方法都没有。这个特性用在纯数据存储上性能更好,也防止原型污染。但如果你代码里假设所有对象都有hasOwnProperty,就会在这里踩坑。

解决方式是换成调用Object.prototype.hasOwnProperty.call(obj, key),这也是主流源码里常见的写法。

6.3 排查案例二:函数声明和函数表达式的原型链差异

函数声明和函数表达式都能提升(hoisting),但细节不同,导致原型链表现也不同:

console.log(foo); // function foo() {} function foo() {} console.log(bar); // undefined var bar = function() {};

foo是函数声明,整个被提升,所以可以前置访问。bar是var变量提升,初始化不提升。这个区别不影响原型链本身,但会影响代码执行顺序,尤其是定义在if块里的函数声明,在不同严格模式下行为差异巨大,有的浏览器会把块级函数提升到外层,有的不会。

排查时如果发现函数“时有时无”,第一个要检查的就是声明方式。

6.4 排查案例三:class 继承中 super 的机制

class继承时最容易遇到的报错是“Must call super constructor in derived class before accessing 'this' or returning from derived constructor”。

这个报错背后的机制是:子类构造函数里,this必须先由父类构造函数创建,然后才能被修改。ES6 class 和 ES5 组合继承在这点的实现完全不同——ES5 里是先创建子类的this,再调用父类构造器给this挂属性。ES6 是先调用父类的构造函数拿到this,再用子类构造函数加工它。

class Animal { constructor(name) { this.name = name; } } class Dog extends Animal { constructor(name, breed) { // 必须先调用 super super(name); this.breed = breed; } }

如果忘了super(name)就访问this,Node 和浏览器都会直接报错。如果你真的需要完全跳过父类构造函数的逻辑,可以在super()之后再处理,但不能提前访问this。

7. 原型链补环境的工程化实践细节

7.1 补环境的一般流程

这个场景我不多讲原理,直接给一套可以落地的流程。假设你在 Node.js 里模拟浏览器环境,需要让网页的检测脚本认为你是一个真实浏览器:

  1. 先列出网页检测到的全局对象和原型链方法
  2. 对每个全局对象,创建一个对应的构造函数
  3. 把浏览器环境中原型链上缺失的属性、方法补到构造函数
  4. 实例化全局对象,并挂到 Node 的globalThis上
  5. 检查Object.prototype.toString的标记以及instanceof关系

7.2 一个完整的补环境案例

模拟一个简单的window.navigator:

function Navigator() { this.language = "zh-CN"; this.platform = "Win32"; this.userAgent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36..."; } Navigator.prototype.sendBeacon = function(url, data) { console.log("sendBeacon called", url, data); return true; }; Navigator.prototype.getBattery = function() { return Promise.resolve({ level: 0.98, charging: true }); }; Object.defineProperty(Navigator.prototype, Symbol.toStringTag, { value: "Navigator", configurable: true }); const navigator = new Navigator(); globalThis.navigator = navigator; // 验证 console.log(Object.prototype.toString.call(navigator)); // [object Navigator] console.log(navigator instanceof Navigator); // true

补环境常见的坑有三个:

  • 只补了实例属性,忘了补原型链上的方法。检测脚本用in运算符检查属性时,原型链上的属性也会被算进去,但忘了补的方法会直接出现undefined。
  • 忘了设置Symbol.toStringTag。现在很多检测脚本用Object.prototype.toString判断对象类型,不补这个标记就会返回[object Object]。
  • 搞错userAgent和platform的一致性。有些网页会同时检查navigator.userAgent、navigator.platform、navigator.language是否匹配,不一致就判定为异常。

7.3 原型链补环境的“检测侧”思路

从防御角度来看,网页检测原型链并非只能靠toString。更隐蔽的方式是检测构造函数名:

function detectEnvironment() { const navStr = Object.prototype.toString.call(navigator); const ctorName = navigator.constructor?.name; if (navStr !== "[object Navigator]" || ctorName !== "Navigator") { return "fake"; } return "real"; }

如果补环境时只补了原型方法、toStringTag,忘了补constructor,这里就会露出马脚。所以原型链补环境的完整程度,直接决定模拟环境的可信度。这也是为什么这个技术话题和原型链的关系这么紧密。

8. 关于原型链的最后一层理解

写到这儿,原型链周边的主要知识点都过了一遍。从历史原因(为什么会有),到核心机制(三个属性和查找链路),再到继承演进(从原型链继承到 class 语法糖),最后落到工程实战(补环境、instanceof、污染防护)。这是我对原型链的理解框架,不需要背,理解了每一步的动机就能顺下来。

我个人在实际操作中的体会是:遇到“原型链相关的问题”,不要先去翻图,而是先问自己三个问题——这个对象是谁创建的?它的原型上有什么?我修改这个原型会影响谁?三个问题能答上来,大多数场景都能直接定位原因。

最后再分享一个小技巧:你可以在浏览器控制台里直接输入Object.prototype、Function.prototype和Array.prototype,用鼠标点开看__proto__属性,沿着它一级级自己走一遍。这个操作比看任何教程都有效,五分钟后你会发现,那团缠绕在脑子里很久的线,一下就理顺了。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询