
很多前端开发者第一次接触原型链都是在刷面试题的时候。背下“构造函数、prototype、proto”三者关系那张图再记两句“函数是Function的实例对象是Object的实例”就觉得自己过关了。但等到真正在项目里排查一个诡异的“xxx is not a function”报错或者需要设计一套可复用的插件机制时才发现自己对原型链的理解还停留在“背图”阶段完全不知道它到底在代码运行中扮演什么角色。这篇文章我不想再画一张标准的三角关系图而是从一个工程师的实际视角把原型链从设计动机、运行机制、面试考点到实战应用完整拆一遍。你会看到为什么JavaScript需要原型链new关键字背后到底做了哪几件事class语法糖和真正的原型继承有什么区别以及那些年在原型链上踩过的坑。无论你是准备前端面试还是想真正夯实JS基础这篇都值得你花二十分钟读完。1. 为什么JavaScript选择了原型链从设计动机说起1.1 一门“十天设计出来”的语言如何解决代码复用JavaScript诞生于1995年Brendan Eich只用了十天就完成了第一版设计。这门语言被要求“长得像Java”但又不能真的成为一个静态类型、基于类继承的语言。于是一个非常巧妙的折中方案诞生了表面语法向Java靠拢底层对象系统却走了一条完全不同的路——基于原型而非基于类。在传统的类继承体系里类是模板对象是模板的实例。你先定义“Animal”这个类再通过它new出“dog”这个对象。类与类之间有继承关系对象与类之间有实例关系。而JavaScript的做法完全不同没有“类”的概念ES6之前只有对象。如果想让两个对象共享方法就让其中一个对象作为另一个对象的“原型”。当你访问一个对象上不存在的属性时JavaScript引擎会沿着一条链去找直到找到那个属性或走到尽头。这条对象与对象之间的关联链就是原型链。我在实际教学和面试中经常用一个比喻来解释这两者的区别类继承像是图纸和楼房的关系你得先有图纸类再按图纸盖楼实例原型链则像是“盗梦空间”式的关联你的对象上找不到某个方法时就顺着链条去问“你的原型有没有”一直问到源头。1.2 原型链解决的是“属性查找”问题而不是“复制”问题这是一个关键认知。很多初学者以为继承意味着“把父类的属性和方法复制一份到子类上”这完全不适用于JavaScript的原型链机制。JavaScript中的“继承”不是复制而是动态关联。当访问obj.sayHi时引擎执行的步骤是先看obj自身有没有sayHi属性有就直接用。没有就去obj.__proto__指向的对象上找。再没有就继续往上找obj.__proto__.__proto__。直到Object.prototype它的__proto__是null查找结束返回undefined。这个过程在每次属性访问时都会发生不是提前复制好的。这意味着你在原型上新增一个方法所有关联到该原型的对象都能立刻访问到不需要做任何额外操作。这种设计让JavaScript在极简的语法下实现了灵活的对象扩展和多态机制。1.3 原型链与“类”的思维差异组合优于继承因为原型的关联是动态的JavaScript在代码组织上有一种天然倾向组件化和动态扩展。相比Java或C那种严格的类层级原型链允许你在运行期修改对象行为这让它极为灵活也正因如此它成为很多“组合式”设计模式的基础。比如后来的mixin混入模式、函数式编程里的装饰器思想在原型环境下实现起来都相当自然。你只需要往一个对象的原型链上挂几个方法就能让对象“长出”新能力。这种灵活性是传统类继承语言做不到的。2. new关键字与构造函数原型链的入口在这里2.1 new操作符的四步内部流程在ES6的class出现之前JavaScript靠“构造函数 new”来模拟类的创建过程。理解new做了什么就等于理解了原型链的入口。来看一个最基础的例子function Person(name, age) { this.name name; this.age age; } Person.prototype.sayHi function () { console.log(Hi, I am this.name); }; const p1 new Person(Alice, 25); p1.sayHi(); // Hi, I am Alicenew Person(Alice, 25)执行时JavaScript引擎在内部做了四件事创建一个全新的空对象obj。将obj的内部原型[[Prototype]]指向构造函数的prototype属性。以obj为this执行构造函数体内的代码。如果构造函数显式返回了一个对象则返回那个对象否则返回obj。其中第2步就是原型链建立的瞬间。p1之所以能调用sayHi不是因为p1自己身上有这个函数而是因为p1的原型链通向了Person.prototype而Person.prototype上有sayHi。2.2 prototype与__proto__两套属性的区别在讨论原型链时prototype和__proto__是最容易被混淆的两个属性我把它们的区别放在表格里对比着看更清楚属性存在于谁身上作用标准状态prototype只有函数对象才有定义由该函数作为构造函数创建的所有实例共享的属性和方法标准属性ES规范定义__proto__几乎所有对象都有指向创建该对象的构造函数的prototype是原型链的物理指针历史遗留现由Object.getPrototypeOf()替代代码里看更直观function Foo() {} const f new Foo(); console.log(Foo.prototype); // Foo 的原型对象 console.log(f.__proto__); // 同 Foo.prototype console.log(f.__proto__ Foo.prototype); // true // 真正规范的访问方式 console.log(Object.getPrototypeOf(f) Foo.prototype); // true需要特别注意的是普通对象没有prototype属性只有函数才有。但箭头函数是个例外——它也没有自己的prototype因为箭头函数不能作为构造函数使用。2.3 原型链的源头Object.prototype和null任何一条原型链走到尽头最终都会抵达Object.prototype这个对象的__proto__指向null。null就是原型链的终点。验证一下const obj {}; console.log(obj.__proto__ Object.prototype); // true console.log(Object.prototype.__proto__); // null所有普通对象默认都继承自Object.prototype所以它们都能使用Object.prototype上定义的方法比如toString()、hasOwnProperty()、valueOf()等。这也解释了为什么在控制台里输入一个空对象却能调用出一堆方法——这些方法不是对象自己拥有的而是顺着原型链从Object.prototype上拿到的。3. 原型链运行机制一次完整的属性查找路径还原3.1 从p1.sayHi看整条链路的查找过程我始终认为与其死记那张三角关系图不如亲手跑一遍查找过程。继续用上面的Person例子来分析访问p1.sayHi时引擎到底做了什么。function Person(name, age) { this.name name; this.age age; } Person.prototype.sayHi function () { console.log(Hi, this.name); }; const p1 new Person(Alice, 25); const p2 new Person(Bob, 30); p1.sayHi();查找顺序是查找p1自身属性p1上用this.name name给了name和age但并没有sayHi继续。查找p1.__proto__即Person.prototype这个对象上定义了sayHi找到以p1为this调用。再比如访问p1.toString()p1自身没有toString。Person.prototype上大概率也没有toString。继续向上Person.prototype.__proto__就是Object.prototype这里有toString找到。第三步能找到toString是因为Person.prototype本身是一个普通对象它的原型自然指向Object.prototype。3.2 链路的尽头为什么Object.prototype.__proto__是nullObject.prototype是所有对象默认原型的终点它的__proto__是null。把null作为终点是一个刻意的设计目的是给属性查找一个明确的终止条件。如果终点不是null而是一个对象那就可能形成循环引用属性查找就永远无法结束了。所以任何以普通对象为原型的原型链最长也就是四条链路[实例对象] → [构造函数.prototype] → [Object.prototype] → null3.3 hasOwnProperty如何区分“自己的属性”和“原型上的属性”在实际开发中我们经常需要区分某个属性到底是对象自身的还是从原型链上继承来的。hasOwnProperty就是干这个事的const p1 new Person(Alice, 25); console.log(p1.hasOwnProperty(name)); // truename 是构造函数里赋的 console.log(p1.hasOwnProperty(sayHi)); // falsesayHi 在原型上 console.log(sayHi in p1); // truein 操作符会遍历原型链in操作符会检查整个原型链而hasOwnProperty只检查自身属性。这个区别在遍历对象、拷贝对象、判断属性存在性时非常关键。比如用for...in遍历对象时会遍历到原型上的可枚举属性所以实践中通常要配合hasOwnProperty做过滤。4. 面试必问的原型链经典考点从原理到追问4.1 一句话说清prototype、proto、constructor三者的关系面试最基础的问题就是让你说清这三个概念的关联。我的回答公式是prototype函数特有的属性指向一个对象这个对象会被所有实例共享。__proto__每个对象都有除null外指向创建它的构造函数的prototype。constructor原型对象上的一个属性指回与该原型关联的构造函数。所以有恒等式function F() {} const f new F(); f.__proto__ F.prototype // true F.prototype.constructor F // true f.__proto__.constructor F // true注意箭头函数、对象字面量、内置对象在constructor上会有一些细节差异但构造函数——原型——实例这条主链是恒定的。4.2 instanceof的底层逻辑遍历原型链找匹配instanceof的原理和原型链紧密相关。它的定义是检查右侧函数的 prototype是否存在于左侧对象的原型链上。模拟实现一版就很容易理解function myInstanceof(left, right) { let proto Object.getPrototypeOf(left); while (true) { if (proto null) return false; if (proto right.prototype) return true; proto Object.getPrototypeOf(proto); } } function F() {} const f new F(); console.log(myInstanceof(f, F)); // true console.log(myInstanceof(f, Object)); // true因为 f 的原型链上有 Object.prototype console.log(f instanceof F); // true console.log(f instanceof Object); // true这也解释了为什么[] instanceof Array是true[] instanceof Object也是true数组对象的原型链是[] → Array.prototype → Object.prototype → null。4.3 原型链判断方式大对比typeof、instanceof、Object.prototype.toString.call面试追问一般还会延伸到类型判断。三种方式的适用场景完全不同方式适用场景示例缺陷typeof判断基本类型typeof a stringtypeof null object对引用类型只能区分function和objectinstanceof判断引用类型的具体构造函数arr instanceof Array无法判断基本类型且受原型链修改影响Object.prototype.toString.call精确判断内置类型Object.prototype.toString.call([])返回[object Array]对自定义类也只能返回[object Object]面试时我会建议先说清楚原理再给使用场景。比如const a []typeof a是objecta instanceof Array是trueObject.prototype.toString.call(a)是[object Array]三种方式从不同维度回答了“a 是什么”。4.4 Function和Object的鸡生蛋问题这是原型链面试中最高频的追问。Function和Object的关系看起来有点“循环论证”Function是函数所以它的__proto__指向Function.prototype但Function.prototype本身又是一个函数对象它的__proto__又指向Object.prototype。核心结论就两个Function.__proto__ Function.prototype因为Function是函数它自身是自己的构造函数。Function.prototype.__proto__ Object.prototype因为Function.prototype是一个对象由Object创建。加上Object本身也是一个函数Object.__proto__ Function.prototype // trueObject 是函数它的原型是 Function.prototype把这个映射到原型链上一切都能自洽。面试时如果能把这个关系说清楚通常就能证明你对原型链的理解不是停留在背图上。5. class语法糖背后extends和super如何操作原型链5.1 class本质构造函数的另一种写法ES6引入的class并不是一种全新的面向对象机制它只是基于原型链的语法糖。看这段代码class Animal { constructor(name) { this.name name; } eat() { console.log(this.name is eating); } } const animal new Animal(Tom);它和下面这种基于构造函数的写法在原型链上等价function Animal(name) { this.name name; } Animal.prototype.eat function () { console.log(this.name is eating); }; const animal new Animal(Tom);class里的方法最终都会被定义在Animal.prototype上constructor对应的就是原来的构造函数体。class 的语法让代码更整洁但底层机制并没有变。5.2 extends继承链式关联的创建class 的extends背后是JavaScript帮我们完成了一套原型链的“链式关联”class Dog extends Animal { constructor(name, breed) { super(name); // 必须先调用 super this.breed breed; } bark() { console.log(this.name is barking); } } const dog new Dog(Buddy, Labrador);extends的实际效果是生成了如下结构Dog.prototype的内部原型指向Animal.prototype所以dog实例可以向上查找到eat。同时Dog.__proto__指向Animal这是为了让Dog能继承Animal的静态方法。console.log(Object.getPrototypeOf(Dog.prototype) Animal.prototype); // true console.log(Object.getPrototypeOf(Dog) Animal); // true5.3 super的解析时机静态绑定而非动态查找super是class语法中最容易让面试者困惑的概念。它并不仅仅是在原型链上继续向上找方法那么简单。super.method()会把this绑定到当前实例同时在解析时基于“定义super所在位置的原型链”去找方法而不是基于运行时this的原型链。这种机制叫“原型链上的静态绑定”。在 class 里super的实现依赖于Object.getPrototypeOf(Class.prototype)获取父类原型再以当前实例为this调用父类方法。理解了这一点才能真正理解为什么super在子类构造函数中必须最先调用——它要确保父类构造函数先完成对this的初始化。5.4 静态方法的继承一条容易忽视的链原型链不仅存在于“实例 → 原型”之间构造函数之间也有一条链。class的extends会把子类构造函数的__proto__指向父类构造函数所以子类可以继承父类的静态方法class Animal { static create() { return new Animal(); } } class Dog extends Animal {} console.log(Dog.create); // 函数存在 console.log(Dog.__proto__ Animal); // true这条链在面试中容易被忽略但它是理解extends完整语义的关键一环。6. 私有属性、Object.create与继承方案的演进6.1 Object.create显式指定一个对象的原型Object.create(proto)可以创建一个新对象并显式指定它的原型。这是原型链操作中最直接的工具const person { sayHi() { console.log(Hi); } }; const worker Object.create(person); worker.type programmer; console.log(worker.type); // programmer自身属性 console.log(worker.sayHi); // 函数来自原型 person6.2 经典继承组合继承的实现缺陷在没有class的ES5时代最常见的继承模式是“组合继承”function Animal(name) { this.name name; } Animal.prototype.eat function () { console.log(eating); }; function Dog(name, breed) { Animal.call(this, name); // 继承实例属性 this.breed breed; } Dog.prototype new Animal(); // 继承原型方法 Dog.prototype.constructor Dog;这个模式有两个明显问题Animal被调用了两次同时Dog.prototype上会残留一份无用的实例属性。后来有了Object.create才演进出更干净的Dog.prototype Object.create(Animal.prototype)。这段演进历史在面试“经典继承到class”的演变时经常被问到理解它有助于你更清楚地知道 class 到底解决了什么问题。6.3 class里的私有属性#号的实现逻辑现代class还支持#开头的私有字段class Counter { #count 0; increment() { this.#count; } getCount() { return this.#count; } }私有字段并不是通过原型链实现的它是通过 WeakMap 或闭包机制在实例层面做隔离的本质上和原型链没有直接关系。但在面试中经常被放在一起问主要是考察你对“class 语法糖”边界的认知——不要以为所有新语法都是基于原型搞出来的。7. 原型链实战从代码复用、框架机制到问题排查7.1 在项目中用原型链做基础代码复用虽然现代前端开发大多用ESModule、组合式API但在一些老项目或Node工具链中原型链的复用能力依然有价值。比如多个工具类共享一个方法集const baseLogger { log(level, message) { console.log([${level}] ${message}); } }; const errorLogger Object.create(baseLogger); errorLogger.error function (msg) { this.log(ERROR, msg); }; const requestLogger Object.create(errorLogger); requestLogger.request function (url) { this.log(INFO, Request: url); }; requestLogger.error(boom); // [ERROR] boom requestLogger.request(/api); // [INFO] Request: /api这里的三层对象通过原型链共享了log方法同时不同的层级扩展了自己特有的方法。不需要定义任何类就能得到一套灵活、可扩展的记录器。7.2 Vue / React中与原型相关的机制虽然现代Vue 3和React都倾向于函数式组件但在不少底层机制和生态库中原型链依然无处不在地起着作用。以Vue 2为例Vue.prototype.$http ...、Vue.prototype.$router这种挂载方式本质就是利用原型链让所有组件实例都能通过this.$http访问到挂载的资源。组件实例的原型链最终会连到Vue.prototype上所以你会看到一种持久化的“全局注入”效果。在React方面React.Component是一个类所有类组件都继承自它。以PureComponent为例它的构造函数和shouldComponentUpdate机制本质上就是通过原型链实现的。即便今天大多数新代码用函数组件理解这条原型链仍然能帮助你阅读历史项目和底层源码。7.3 污染和误用给Object.prototype扩展方法的代价原型链最大的灵活性同时也是最大的危险源。给Object.prototype直接扩展方法是初学者容易踩的坑Object.prototype.remove function () { // ... };这会让所有对象的原型链上都带着这个方法直到你因为for...in遍历到多余的remove而抓狂或者第三方库和这个扩展撞名出现难以定位的bug。更严重的是如果你在项目里用了Object.prototype.toString相关的精确类型判断这种扩展会直接污染结果。规范建议永远不要修改Object.prototype和Array.prototype这类内置原型上的属性。7.4 实战排查一段“函数不存在”报错如何定位到原型链最后给你一个真实场景的排查链路。假设你遇到这个报错TypeError: obj.getName is not a function如果obj本身看起来数据结构正确但就是找不到getName排查思路应该是先确认obj自身属性里有没有getNameobj.hasOwnProperty(getName)。再打印Object.getPrototypeOf(obj)看看它的原型对象上有什么方法是不是getName被定义在别的原型分支上。顺着原型链逐层打印Object.getPrototypeOf(obj.__proto__)直到找到或到null。常见的实际场景是一个对象是从接口返回的普通JSON被直接用Object.create改了原型或者某个组件实例被Object.assign打平了把原型链上的方法覆盖掉了。这时候通过constructor或instanceof就能快速定位到它到底产自哪个构造函数。我把一套基于浏览器的快速排查路径总结如下你可以直接复制到控制台里跑function tracePrototypeChain(obj) { let proto Object.getPrototypeOf(obj); let depth 0; while (proto ! null) { console.log([${depth}], proto.constructor ? proto.constructor.name : (null)); console.log(proto); proto Object.getPrototypeOf(proto); depth; } console.log(reach end: null); } // 用法 tracePrototypeChain({}); // 输出大致为 // [0] Object // {constructor: ƒ, ...} // reach end: null排查思路层层递进先定位对象来源再判断方法和错误到底是在哪一层断掉的。这类问题在组件库、工具库和大型业务代码中都很常见掌握原型链的调试方法能帮你快速收窄问题范围。7.5 原型链相关的一个性能小细节原型链查找比自身属性访问要慢。因为每次访问原型上的属性引擎都要先从自身属性开始找找不到再逐层向上。虽然现代JavaScript引擎做了大量内联缓存优化但在高性能循环中如果频繁访问原型链较深处的属性性能差距是真实存在的。实际操作中如果性能敏感可以先把需要的原型方法存成本地变量再使用而日常业务代码中不必过度优化但要清楚这条链的查找成本。8. 我对原型链学习路径的一点建议回顾我自己的学习过程最有效的路线其实是一条“倒推式”的路径先做项目遇到奇怪问题顺着报错往深处查查到底发现是原型链在起作用。等到你亲手在控制台里反复打印过原型链亲手写过一个基于原型实现的继承再回头看那些面试题会发现它们已经不是需要背的八股文而是一套描述JavaScript对象底层的物理事实。如果你现在正好在准备前端面试我的建议是不要只背结论。多问自己几个“为什么”为什么Function和Object能互相引用而不报错为什么数组能调用map而普通对象不能为什么class的extends能同时继承静态方法和实例方法把每个问题都自己写一段代码验证一遍你的原型链知识就不只是“八股文”而是真正长在脑子里的底层认知。最后分享一个实用的小技巧面试或实战中如果你对某个对象的原型关系拿不准最快的方法就是在浏览器控制台里执行console.dir(对象)它会以树形结构完整展示对象的原型链包括每一层原型上的方法。比起死记硬背这可能是最快建立直觉的方式了。