彻底搞懂 JavaScript 原型链:prototype、__proto__ 与继承原理
发布时间:2026/10/5 11:32:00 作者:尧图编辑部 阅读量:1,286

1. 为什么 JS 需要原型链从“对象复制”的失败说起JS 面试如果只允许问一个概念我一定押“原型链”。它是 JS 继承的底层原理也是无数人学了又忘的盲区平时业务代码写着挺顺一到 “new 一个对象到底发生了什么”“extends 是怎么把两个类串起来的” 就卡壳。这篇文章是 JS 入门通关手册的第 21 篇我把原型链彻底拆开从 JS 为什么非要用一条链做继承到 prototype、__proto__、constructor 三者怎么互相指认再到手写继承方法的完整演进最后落到 class 语法糖背后的真实结构。不管你是刚摸到 JS 的门还是已经写了两年框架看完都应该能对着控制台把一条链从头画到尾。1.1 先回答一个反直觉的问题JS 为什么非要搞一条“链”大多数人第一次学 JS 前脑子里已经有了 Java 或 C# 的“类”模型先写一个 class再用它 new 出一堆实例实例从类那里复制模板。但 ES6 之前的 JS 压根没有 class 关键字JS 设计者又希望对象之间能共享能力怎么办最直接的想法是复制。创建对象时把公共属性和方法原样拷贝到每个新对象上。这个方法不是不行但代价很大内存浪费。100 个对象就要存 100 份相同的函数副本更新不同步。改了 A 对象上的方法B 对象上还是旧版关系无法回溯。以后想判断“这个对象是不是从那个模板来的”复制模型里根本无从谈起。于是 JS 选了另一条路不复制而是让对象记住“我的公共知识放在哪个对象上”。需要某个属性时自己身上找找不到就去存的这个对象上找再找不到就继续往上找。这条“往上找”的路径就是原型链。打个比方你不把地铁图复印发给每个乘客而是告诉乘客站厅墙上挂着一张图墙上的图找不到答案就去问工作人员工作人员不确定再问调度中心。每个乘客都只是被指引到“上一层”具体信息由上一层提供。JS 对象找属性就是这个逐级询问的过程。1.2 属性查找的“降级路径”顺着链往上问到底引擎执行obj.name时不会直接返回 “obj 上有 name 吗”而是按下面顺序走在 obj 自身属性里找没找到就去 obj 的原型对象上找原型上也没有就去原型的原型上找一直找到 null返回 undefined。用一段最简单的代码验证const animal { eats: true }; const rabbit Object.create(animal); // rabbit 的原型被指定为 animal console.log(rabbit.eats); // true兔自身没有顺着链找到 animal 上 console.log(rabbit.hasOwnProperty(eats)); // false说明属性不在它自己身上这里 rabbit 没有任何自己的属性但它能访问 eats不是因为复制了一份而是因为“查得到”。这个“查得到”的机制在面向对象里就是继承子对象能访问父对象上的东西靠的不是拷贝而是链路查询。理解这一点后很多困惑会迎刃而解。比如你给一个实例动态加属性不会影响原型上的其他“兄弟”而你给原型加方法所有实例立刻都能用。原因就是实例从不复制方法只是每次调用时沿链查找。1.3 链的终点null 和那个“裸对象”链条不能无限延伸所以要有一个终点。JS 里终点就是Object.prototype.__proto__等于 null。也就是说Object.prototype是所有普通对象的最后一级它自己没有原型了。console.log(Object.prototype.__proto__); // null这个 null 不是设计疏忽而是查找的终止条件。如果没有它引擎每次找属性都会进入死循环。还有一个特殊对象值得了解const bare Object.create(null); bare.toString(); // TypeError: bare.toString is not a functionObject.create(null)创建的对象完全没有原型链连 toString、hasOwnProperty 这类基础方法都没有。它常被用作纯字典存储因为不会受原型上任何东西污染。遇到报错时很多人第一反应是“方法不存在”但更可能是对象本身就不在正常链路上。排错时先确认这个对象从哪条链上创建的往往能少走弯路。2. 原型链的三位主角prototype、__proto__、constructor几乎所有关于原型链的困惑都来自三个名字极其相近的成员函数的 prototype、实例的 __proto__、原型对象上的 constructor。我先给出一段最朴素的代码然后逐一拆解。2.1 用一段最朴素的代码拆出三个关键引用function Person(name) { this.name name; } Person.prototype.sayHi function () { console.log(Hi, Im ${this.name}); }; const alice new Person(Alice);现在回答三个问题Person 对象上有哪些东西alice 身上有哪些东西它们之间怎么连Person.prototype只有函数才有的属性箭头函数除外指向一个普通对象。这个对象就是“将来由 Person new 出来的实例们的公共仓库”。alice.__proto__实例访问自己原型对象的入口。标准 API 是Object.getPrototypeOf(alice)但所有主流浏览器都实现了__proto__。Person.prototype.constructor默认存在指回 Person 函数本身。运行关系如下console.log(Person.prototype alice.__proto__); // true console.log(Person.prototype.constructor Person); // true console.log(alice.hasOwnProperty(name)); // truename 是自己身上的 console.log(alice.hasOwnProperty(sayHi)); // falsesayHi 是原型上的一句话口诀函数有 prototype实例有 __proto__原型上的 constructor 指回函数。三者构成一个闭环Person - Person.prototype - Person而实例 alice 只是被挂在 Person.prototype 这条枝上。这里必须强调一个新手常犯的错误有人以为“对象的 __proto__ 等于构造函数”。其实 alice 的构造函数是 Person但alice.__proto__指向的是 Person.prototype不是 Person 本身。函数和原型对象是两个不同的东西只是通过 constructor 互相指认。箭头函数没有自己的 prototype 属性所以箭头函数不能当作构造函数来 new。这是很多人在类组件还是函数组件时代踩过的第一坑。2.2 三种常见创建对象方式原型分别指向哪同一个对象创建方式不同链条起点也不同。理解这点比背结论重要得多。创建方式代码示例对象原型的指向字面量const obj {}Object.prototype构造函数const d new Date()Date.prototype指定原型Object.create(proto)参数 proto字面量{}是最常见的对象它的原型直接是 Object.prototype这也是为什么任何普通对象都能调 toString。用 new Date 创建出来的对象原型是 Date.prototypeDate.prototype 的原型又指向 Object.prototype所以 date 既能调 getFullYear又能调 toString。Object.create(proto)是灵活性最高的一种也是后面手写继承的关键工具。它允许你显式指定原型甚至可以传 null 创建一个完全不继承任何东西的对象。const base { version: 1 }; const child Object.create(base); child.own own property; console.log(child.version); // 1原型链查找 console.log(child.own); // own property自有属性还有Object.setPrototypeOf(obj, proto)可以动态修改现有对象的原型。虽然是官方 API但我建议业务代码里尽量少用因为它会触发引擎优化失效性能开销远大于创建时就指定原型。2.3 Object 和 Function 互相咬尾巴先有鸡还是先有蛋原型链里最让人头晕的是 Object 和 Function 互相引用的那一段。很多人看到Object.__proto__ Function.prototype和Function.prototype.__proto__ Object.prototype就放弃了。我换个顺序讲就清楚多了。先记住两条规则所有函数都是 Function 的实例所以任何函数包括 Person、Object、Function 自己的 __proto__ 都指向 Function.prototype。Function.prototype 本身也是一个对象所以它的 __proto__ 指向 Object.prototype。于是出现这样的结构function Person() {} Person.__proto__ Function.prototype; // true所有函数的共同祖先 Function.prototype.__proto__ Object.prototype; // true函数对象也是对象 Object.prototype.__proto__ null; // true终点 Object.__proto__ Function.prototype; // trueObject 也是一个函数 Function.__proto__ Function.prototype; // trueFunction 自己也是函数所以“先有 Object 还是先有 Function”是个伪命题。JS 语言内部用两个枢纽对象互相咬合Function.prototype 代表“所有函数的模具”Object.prototype 代表“所有对象的终点”。两者环环相扣但查找顺序永远单向向上不会死循环。面试里如果被问到“Object 的原型是谁”要分两层回答作为构造函数Object 的原型是 Function.prototype作为 Object.prototype 这个对象本身它的原型是 null。一层是“函数这条链”一层是“实例这条链”很多人就是混在这里。3. 手写继承从能用、能用但有坑、到最优解很多人背得下 class extends却写不出一个标准的手写继承。我建议每个学 JS 的人都把下面的演进过程自己敲一遍因为它完整展示了“为什么要这么设计”。3.1 思路 0原型链继承——最小实现也是引用共享的坑最简单直接的做法把父类的实例直接挂到子类的 prototype 上。function Animal() {} Animal.prototype.eat function () { console.log(eat); }; function Dog() {} Dog.prototype new Animal(); // 子类原型直接替换为父类实例 const d new Dog(); d.eat(); // eat顺着 d - Dog.prototype - Animal.prototype 找到方法能用了但问题很大。Animal 构造函数里如果用 this 初始化了引用类型字段这些字段会被所有子实例共享function Animal() { this.foods []; } Dog.prototype new Animal(); const d1 new Dog(); const d2 new Dog(); d1.foods.push(骨头); console.log(d2.foods); // [骨头]d2 被污染了原因非常清楚Dog.prototype 上只有一份 foods 数组d1 和 d2 自身没有 foods沿链找到的都是同一个数组。这个坑在业务里表现成“一个按钮改了状态其他页面跟着变”极其隐蔽。3.2 思路 1借用构造函数——解决属性却丢了方法既然属性放原型会共享那把属性放实例自身行不行可以。做法是在子类构造函数里把父类构造函数当普通函数调一遍。function Animal(name) { this.name name; this.foods []; } Animal.prototype.eat function () { console.log(${this.name} eat); }; function Dog(name) { Animal.call(this, name); // 在 Dog 实例上执行 Animal 的逻辑 } const d1 new Dog(旺财); d1.foods.push(骨头); const d2 new Dog(来福); console.log(d2.foods); // []属性独立了 console.log(d1.name); // 旺财每个实例都有了自己的 name 和 foods互不干扰。但代价是Animal 原型上的 eat 方法彻底没接上因为 Dog.prototype 根本没动。如果 Dog 实例想调 eat会得到 undefined因为方法不在链上。而且如果父类构造函数里定义了 this.say function...每次 new 都会创建新函数内存浪费方法无法复用。这叫“借构造”解决属性牺牲方法。3.3 思路 2组合继承——经典但多执行了一次父类构造既然两个思路互补自然有人想到合在一起属性用借用构造函数方法用原型链继承。这就是经典的组合继承。function Animal(name) { this.name name; this.foods []; } Animal.prototype.eat function () { console.log(${this.name} eat); }; function Dog(name) { Animal.call(this, name); // 第一次执行 Animal } Dog.prototype new Animal(); // 第二次执行 Animal Dog.prototype.constructor Dog; const d new Dog(旺财); d.eat(); // 旺财 eat方法从 Animal.prototype 来 d.hasOwnProperty(name); // true属性在实例上方法能共享属性也独立看起来很美。但有个小瑕疵Animal 构造函数被执行了两次。第一次是 Dog 里的 call第二次是 Dog.prototype new Animal()导致 Dog.prototype 上残留了一份 name 和 foods。虽然因为属性遮蔽实例上还有一份同名属性用户平时感知不到但原型上那套多余属性既不干净也让 hasOwnProperty 的判断多了一处陷阱。你可以在控制台打印Dog.prototype.name会发现它也存在。这就是“组合继承多执行了一次父类构造函数”的痕迹。3.4 思路 3寄生组合式继承——手写继承的最终形态优化的方向很明确不让子类原型变成一个“父类实例”而是让子类原型直接继承父类原型。用 Object.create 拷贝父类原型就能在不执行父类构造函数的情况下建起原型链。function inherit(Child, Parent) { const proto Object.create(Parent.prototype); // 链继承但不执行 Parent 构造 proto.constructor Child; // 修复 constructor 指向 Child.prototype proto; } function Dog(name) { Animal.call(this, name); // 只执行一次父类构造 } inherit(Dog, Animal); Dog.prototype.bark function () { console.log(bark); }; const d new Dog(旺财); d.eat(); // 旺财 eat d.bark(); // bark d instanceof Dog; // true d instanceof Animal; // true console.log(Dog.prototype.name); // undefined干净了对比一下四种方式的差别继承方式父类构造执行次数子类原型上是否有父类属性方法是否共享原型链继承1 次new Animal有且引用类型被共享是借用构造函数每个实例 1 次无否方法也每实例一份组合继承2 次有但被实例属性遮蔽是寄生组合式继承1 次无是寄生组合式继承就是手写继承的标准答案。ES6 的 class extends 编译后底层思路和它基本一致。理解这个演进比背 class 语法有用得多因为你能解释“为什么 class 要这么设计”以及“哪些坑是 class 替你已经埋好的”。4. 我在真实项目里踩过的原型链坑面试背概念只是刻度尺真正决定水平的是踩坑后能不能快速定位。下面这几个坑是我在实际项目里见过或者自己踩过的每个都给排查思路。4.1 constructor 被覆盖对象的身份证丢了手写继承时很多人会写Dog.prototype new Animal()但忘了恢复 constructor。function Animal() {} function Dog() {} Dog.prototype new Animal(); const d new Dog(); console.log(d.constructor); // Animal不是 Dog后果是任何依赖obj.constructor做类型判断或实例化对象的代码全部失效。比如有些库会根据 constructor 来推断应该用哪个构造器创建新对象一旦指向父类子类实例会被重建为父类。排查时先打印d.constructor再看 Dog.prototype 上有没有 constructor。修复只需一行Dog.prototype.constructor Dog;。养成习惯只要替换了函数原型就必须修复 constructor这是手写继承的铁律。4.2 原型上放引用类型所有实例共用一个“购物车”除了构造函数的默认字段还有一种坑是直接往原型上塞引用类型function Cart() {} Cart.prototype.items []; const c1 new Cart(); const c2 new Cart(); c1.items.push(apple); console.log(c2.items); // [apple]排查思路很简单c1.items c2.items是否返回 true。如果是说明 items 是同一个数组操作它必然互相影响。修复方法是把可变数据放进构造函数里function Cart() { this.items []; } Cart.prototype.add function (item) { this.items.push(item); };记住一条准则原型上只放方法不要放可变数据。方法是函数引用天然只读共享数组、对象这种可变引用放原型就是埋雷。4.3 属性遮蔽实例属性把原型方法挡住了有一种诡异情况代码里明明给原型写了方法调用时却得到类型错误。比如function User(name) { this.name name; } User.prototype.name function () { return this.name; }; const u new User(tom); u.name(); // TypeError: u.name is not a function原因是构造函数的 this.name 覆盖了原型上的 name赋值发生在查找之前。实例自身的属性优先级永远高于原型链上的同名成员这叫属性遮蔽。排错时先用 hasOwnProperty 确认这个属性是不是对象自己的再看原型上是否存在同名字段。如果确认被遮蔽要么换字段名要么刻意从原型上调用User.prototype.name.call(u)。这个例子提醒我们给属性和方法命名尽量避开常见词name、value、length 这类最容易撞车。4.4 跨 iframe 的 instanceof 失灵链条断了iframe 场景很容易出现“明明看起来是数组instanceof Array 却是 false”。// iframe 里的代码 const arrFromIframe window.parent.someArray; arrFromIframe instanceof Array; // 可能是 false原因不是数据问题而是原型链断裂。iframe 里的数组其原型是 iframe 自己的 Array.prototype和主窗口的 Array.prototype 分属两套不同全局对象。instanceof 只检查构造函数的 prototype 是否出现在实例的原型链上而这条链上根本没有主窗口的 Array.prototype。这不是 JS 的 bug是多个全局环境的必然结果。跨 iframe、跨窗口传对象时不要用 instanceof改用Array.isArray()这类跨环境安全的方法。同理判断一个对象是不是普通对象也不要迷信 instanceof Object在某些场景会翻车。4.5 补原型链时最容易漏的一环这些年做浏览器环境模拟、或者对接一些宿主环境时我经常要构造出和原生对象长得一模一样的“替身对象”。很多人会犯一个错只往对象身上补属性忘了补链。比如要模拟一个 document你建一个普通对象给它贴上 title、body但原生 document 的很多功能依赖原型链上的特征。有些环境检测会直接判断document instanceof HTMLDocument或者访问document.__proto__.toString的形式。如果你的替身是{}原型是 Object.prototype整条链上根本没有 HTMLDocument 和 Document哪怕你属性补得再全检测也会失败。排查手法是顺着链逐级打印// 原生环境 console.log(document.__proto__); // HTMLDocument.prototype console.log(document.__proto__.__proto__); // Document.prototype // 模拟环境需要尽量复刻这些中间层解决思路是先用 Object.create 创建中间层原型再一层层挂 Object.create 关系构造出和原生链同构的结构。这个例子说明理解原型链不只是为了应付面试它是你在很多底层场景里排查“为什么环境检测不过”的关键。补链时最容易漏的就是中间层只关心头部和尾部中间断了没人告诉你。4.6 别乱往内置原型上挂东西老教程里经常看到给 Array.prototype 加自定义方法的教学。业务代码里我强烈不建议这么干for...in 会把这些方法枚举出来惹出莫名其妙的 bug第三方库可能已经定义了同名方法互相覆盖未来 ECMAScript 标准可能加入同名方法代码立刻冲突。如果团队真的需要扩展数组能力正确姿势是定义成模块里的工具函数或者用 Symbol 作为 key 再配合 Object.defineProperty 挂上去并确保不可枚举Object.defineProperty(Array.prototype, myLast, { enumerable: false, // 关键不可枚举 value: function () { return this[this.length - 1]; }, });但我的个人建议是能不改内置原型就别改。改一时方便维护十年还债。尤其是维护公共组件库的人改内置原型等于给整个项目的稳定性埋雷。5. class 继承不是新机制是旧机制的语法糖ES6 引入 class 之后很多人以为 JS 突然有了真正的类。实际上 class 从语法到行为都还是在原型链这条路上打转。理解它才算真正理解现代的 JS 继承。5.1 class 编译后长什么样class Person { constructor(name) { this.name name; } sayHi() { console.log(Hi, Im ${this.name}); } }这段代码去掉 class 语法等价于function Person(name) { this.name name; } Person.prototype.sayHi function () { console.log(Hi, Im ${this.name}); };对class 的方法就是放在 prototype 上。区别更多体现在约束上class 声明不会提升必须先定义后使用class 只能用 new 调用直接 Person() 会报 TypeErrorclass 内部定义的方法默认不可枚举而函数原型上手动挂的方法默认可枚举。所以很多人背了口诀“class 是语法糖”真正面试时却说不清糖里包的什么馅。答案就是它把构造函数和原型方法定义合并成一块并且加上更严格的行为约束。底层三件事——构造逻辑、prototype 方法、constructor 链接——一样不少。5.2 extends、super 与 static 在原型链上的位置class 继承最精彩的部分在 extends 怎么连接两条链。第一眼你可能以为它只做了“子类原型继承父类原型”这一步其实它还偷偷做了一件事让子类函数本身也能继承父类函数的静态属性。class Animal { static isAnimal() { return true; } eat() { console.log(eat); } } class Dog extends Animal { bark() { console.log(bark); } } const d new Dog(); console.log(d.eat()); // eat实例链d - Dog.prototype - Animal.prototype console.log(Dog.isAnimal()); // true静态链Dog.__proto__ - Animal看最后一行为什么 Dog 能调 isAnimal因为 extends 把子类构造函数的 __proto__ 指向了父类构造函数。也就是说静态方法的继承不依赖原型对象而是依赖函数本身的 __proto__。这是很多人理解 class 继承时最大的盲区。super 又是怎么工作的在构造函数里super() 的本质是用当前子类实例去调用父类构造函数保证父类的初始化逻辑作用在这个新实例上。在方法里super.xxx() 会被解析为“从父类原型上取 xxx 方法再把 this 绑定到当前实例”。记住一个核心原则super 不是复制父类的能力它是让父类逻辑参与当前实例的初始化与调用。这也解释了为什么子类构造函数里必须先调用 super 再访问 this因为实例的原型是在 new 时已经确定的但实例自身的属性需要父类构造函数来初始化。没走父类初始化就碰 this等于一个空壳没有地基。5.3 私有字段 # 与原型链表面无关其实很有关系ES2022 正式加入了#value私有字段。很多人以为它像 Java 的 private 一样是 class 层面的成员其实它的实现和原型链关系不大反而是进阶面试的好问题。class Counter { #count 0; increment() { this.#count; } }私有字段不放在 prototype 上也不在实例属性中可以枚举的列表里。它的本质是实例上挂一个槽位读取和写入都有严格语法限制外部无论通过什么方式都访问不到甚至同名的 #count 在不同 class 里互不相干。所以私有字段没有“继承”一说子类不能直接访问父类的私有字段只能通过父类暴露的公开方法间接操作。它不是原型链上的东西而是 class 语法在原生能力之上增加的一层封装。当你以后看到有人在原型链上模拟私有属性大可直接告诉他ES2022 有原生方案不用硬造。5.4 一道题检验你是否真的懂了讲完所有原理最后出一道我面试常问的题。你可以在控制台一步步验证function A() {} function B() {} B.prototype Object.create(A.prototype); B.prototype.constructor B; const b new B();问下面三个问题b.__proto__是什么B.prototype.__proto__是什么B.__proto__是什么答案是b.__proto__ B.prototype; // true实例的原型是构造函数.prototype B.prototype.__proto__ A.prototype; // true构造的原型链从 A.prototype 开始 B.__proto__ Function.prototype; // true函数本身的链归 Function 管能一次答对这三题说明你已经能从“实例链”和“函数链”两个维度看待原型链了。大部分人只盯着前两条忘了 B 本身还是个函数函数有自己的 __proto__。我个人在判断别人或者自测时最常用的一招就是打开控制台输入任意一个对象然后不断展开它的 __proto__一步步点到 null再从 constructor 一路指回来。这招走通你对 JS 的继承就没有盲区了。原型链本身不难难的是把它从“背概念”变成“画链路”。每次看到报错里的 “is not a function”先别急着怀疑代码写错静下心沿链走一遍往往就能定位到问题出在链条的哪一环上。