
1. 面试官为什么总爱问原型与原型链如果你正在准备前端或JavaScript相关的面试我敢打赌原型和原型链绝对是绕不开的必考题。这几乎成了面试官检验一个JavaScript开发者是否“入门”的试金石。但很多朋友包括我当年刚入行时都曾有过这样的困惑这东西在实际业务代码里用得不多啊为什么面试官这么爱问背几个概念应付一下不就行了吗我以过来人的经验告诉你这种想法非常危险。面试官问原型链绝不仅仅是为了让你背出“每个对象都有一个__proto__属性指向它的原型”这种定义。他们真正想考察的是你对JavaScript这门语言核心运行机制的理解深度。原型链是JavaScript实现继承、属性查找、甚至理解new、this、class等语法糖背后原理的基石。如果你只是死记硬背一旦面试官追问几个“为什么”或者结合具体场景让你分析很容易就露馅了。举个例子你写arr.map(...)的时候有没有想过map方法是从哪里来的为什么一个普通的数组对象就能调用它再比如当你尝试用for...in循环遍历对象属性时为什么有时会遍历出一些你并没有直接定义的属性这些看似平常的现象其根源都在于原型链。理解它能让你在遇到诸如“修改了内置原型导致全局污染”、“继承方法时this指向错乱”、“深度拷贝时循环引用”等复杂问题时不再靠猜和试而是能清晰地分析出问题的根源。所以这篇内容我们不搞花架子也不堆砌晦涩的术语。我会从一个一线开发者的视角带你重新梳理一遍原型和原型链。我们会从最朴素的“对象”开始一步步构建出完整的知识图谱并结合大量的代码示例和我在实际开发、面试中遇到的真实案例让你不仅“知道”更能“理解”和“会用”。准备好了吗我们开始吧。2. 从对象到原型JavaScript的“血脉”传承要理解原型链我们必须先回到一切的基础对象。在JavaScript中几乎“一切皆对象”。函数是对象数组是对象甚至null以外的原始值在需要时也会被临时包装成对象。对象是什么简单说就是一堆键值对的集合。let person { name: 小明, age: 18, sayHello: function() { console.log(你好我是${this.name}); } };这个person对象有自己的属性name、age和方法sayHello。现在假设我们要创建很多个类似的人比如person2、person3……每次都这样写一遍显然太蠢了。于是我们想到了构造函数。function Person(name, age) { this.name name; this.age age; this.sayHello function() { console.log(你好我是${this.name}); }; } const xiaoming new Person(小明, 18); const xiaohong new Person(小红, 20);看起来好多了用new关键字调用Person函数可以批量创建对象。但这里有一个性能陷阱sayHello方法在每个实例xiaoming、xiaohong中都被重新创建了一份。如果创建一千个实例就会有一千个功能完全相同的函数这无疑是巨大的内存浪费。怎么解决我们自然而然地想到能不能让所有实例共享同一个方法这就是原型Prototype登场的动机。在JavaScript中每个函数除了箭头函数等少数特例外都有一个特殊的属性prototype原型对象。当我们使用new操作符调用函数时新创建的对象内部会有一个隐藏的链接指向该函数的prototype对象。让我们重构一下上面的例子function Person(name, age) { this.name name; this.age age; } // 将方法定义在构造函数的 prototype 上 Person.prototype.sayHello function() { console.log(你好我是${this.name}); }; const xiaoming new Person(小明, 18); const xiaohong new Person(小红, 20); xiaoming.sayHello(); // 输出你好我是小明 xiaohong.sayHello(); // 输出你好我是小红 console.log(xiaoming.sayHello xiaohong.sayHello); // 输出true看现在sayHello方法只存在一份就在Person.prototype这个对象上。而xiaoming和xiaohong实例都可以调用它。这就是原型最核心的作用提供共享的属性和方法。那么实例是怎么找到这个共享方法的呢这里就引出了另一个关键属性__proto__为了表述清晰我们先使用这个非标准但被广泛实现的属性ES5标准中获取它的方法是Object.getPrototypeOf(obj)。每个JavaScript对象null除外都有一个__proto__属性它指向创建该对象的构造函数的prototype对象。console.log(xiaoming.__proto__ Person.prototype); // 输出true console.log(xiaohong.__proto__ Person.prototype); // 输出true所以当我们在xiaoming上访问sayHello属性时JavaScript引擎会首先在xiaoming对象自身查找。如果没有找到它就会顺着xiaoming.__proto__也就是Person.prototype去找。如果在那里找到了就调用它。这个查找的路径就是原型链Prototype Chain的雏形。注意__proto__属性在ECMAScript规范中被称为[[Prototype]]它是一个内部隐藏属性。虽然大多数浏览器环境通过__proto__暴露了它但在生产代码中为了更好的兼容性和规范性建议使用Object.getPrototypeOf()来获取使用Object.setPrototypeOf()来设置但修改原型性能很差应尽量避免。2.1 构造函数、原型与实例的三角关系理解这三者的关系至关重要我画一个简单的示意图用文字描述帮你理清构造函数如Person它是一个函数拥有一个prototype属性指向它的原型对象。原型对象Person.prototype这是一个普通的对象它有一个constructor属性指回它的构造函数Person。我们定义在原型上的共享方法如sayHello就挂载在这个对象上。实例如xiaoming由new Person()创建。它有一个内部链接[[Prototype]]可通过__proto__或Object.getPrototypeOf()访问指向构造函数的原型对象Person.prototype。用代码验证一下function Person(name) { this.name name; } const obj new Person(测试); // 1. 构造函数的 prototype 属性指向原型对象 console.log(Person.prototype); // 输出: {constructor: ƒ} // 2. 原型对象的 constructor 属性指回构造函数 console.log(Person.prototype.constructor Person); // 输出: true // 3. 实例的 __proto__ 指向构造函数的原型对象 console.log(Object.getPrototypeOf(obj) Person.prototype); // 输出: true console.log(obj.__proto__ Person.prototype); // 输出: true (非标准但通用) // 4. 实例本身没有 constructor 属性但通过原型链可以访问到 console.log(obj.constructor Person); // 输出: true // 实际上查找路径是obj - obj.__proto__ (Person.prototype) - Person.prototype.constructor (Person)这个三角关系是JavaScript面向对象编程的基石。很多面试题都围绕它展开比如让你手写一个new操作符其核心步骤就是建立这个链接。3. 原型链的完整图谱一切对象的终点理解了单个构造函数与其实例的原型关系后我们把视野放大。原型对象本身也是一个对象那么它有没有__proto__呢当然有这就引出了链式结构。让我们从Person.prototype开始往上追溯function Person() {} const p new Person(); // Person.prototype 是一个对象它由 Object 构造函数创建 console.log(Person.prototype.__proto__ Object.prototype); // 输出: true // Object.prototype 是绝大多数原型链的顶端 console.log(Object.prototype.__proto__); // 输出: null看到了吗Person.prototype作为一个对象它的__proto__指向了Object.prototype。而Object.prototype的__proto__是null。null在这里表示“没有原型”是原型链的终点。那么Object.prototype上有什么它定义了所有对象最基础的方法比如toString()、valueOf()、hasOwnProperty()等。这就是为什么你创建的任何一个普通对象都能调用toString()方法的原因——属性查找沿着原型链找到了Object.prototype。现在让我们加入继承。假设我们有一个Student构造函数它要继承Person。function Person(name) { this.name name; } Person.prototype.sayHello function() { console.log(Hello from Person); }; function Student(name, grade) { Person.call(this, name); // 关键1: 在子类构造函数中调用父类构造函数继承实例属性 this.grade grade; } // 关键2: 建立原型继承关系 // 我们让 Student.prototype 的原型指向 Person.prototype Student.prototype Object.create(Person.prototype); // 关键3: 修复 constructor 指向 Student.prototype.constructor Student; // 为子类添加特有方法 Student.prototype.study function() { console.log(${this.name} is studying.); }; const stu new Student(小李, 3); stu.sayHello(); // 输出: Hello from Person (通过原型链找到 Person.prototype.sayHello) stu.study(); // 输出: 小李 is studying. (自身原型上的方法)我们来分析一下stu实例的原型链stu自身属性name来自Person.call、grade。stu.__proto__-Student.prototype这里有study方法和修复后的constructor。Student.prototype.__proto__-Person.prototype这里有sayHello方法。Person.prototype.__proto__-Object.prototype基础对象方法。Object.prototype.__proto__-null终点。这个stu - Student.prototype - Person.prototype - Object.prototype - null的链路就是完整的原型链。JavaScript引擎查找属性时就是沿着这条链一级一级往上找直到找到或抵达null返回undefined。3.1 内置构造函数的原型链理解了自定义构造函数的链再看JavaScript内置对象就豁然开朗了。const arr [1, 2, 3]; // arr - Array.prototype - Object.prototype - null console.log(arr.__proto__ Array.prototype); // true console.log(arr.map); // ƒ map() { [native code] }来自 Array.prototype console.log(arr.toString()); // 1,2,3Array.prototype 重写了 toString否则会调用 Object.prototype.toString const func function() {}; // func - Function.prototype - Object.prototype - null console.log(func.__proto__ Function.prototype); // true console.log(func.call); // ƒ call() { [native code] }来自 Function.prototype // 甚至构造函数本身也是函数所以也有 __proto__ console.log(Person.__proto__ Function.prototype); // true console.log(Array.__proto__ Function.prototype); // true console.log(Object.__proto__ Function.prototype); // true // 那么 Function.prototype 的原型呢 console.log(Function.prototype.__proto__ Object.prototype); // true这里有一个非常有趣且常被考到的点Function.prototype是一个函数但它的__proto__指向Object.prototype。而Object、Array、Function等内置构造函数它们自身的__proto__都指向Function.prototype。这形成了一个有点循环引用意味但逻辑自洽的关系。你可以这样记忆所有函数对象包括内置构造函数都继承自Function.prototype而Function.prototype本身作为一个对象继承自Object.prototype。4. 原型链的实战应用与高频面试题拆解知道了原理我们来看看它怎么用以及面试官会怎么考。4.1 属性屏蔽与hasOwnProperty当通过实例对象访问一个属性时JavaScript引擎会优先查找对象自身的属性。如果自身没有才沿着原型链向上找。如果自身和原型链上都有同名属性那么自身的属性会“屏蔽”掉原型链上的属性这被称为“属性屏蔽”。function Person() {} Person.prototype.name 原型上的名字; const p1 new Person(); console.log(p1.name); // 输出: 原型上的名字 (来自原型链) const p2 new Person(); p2.name 自身的名字; // 在实例自身添加同名属性 console.log(p2.name); // 输出: 自身的名字 (自身属性屏蔽了原型属性) console.log(p2.hasOwnProperty(name)); // 输出: true console.log(p1.hasOwnProperty(name)); // 输出: falsehasOwnProperty()是Object.prototype上的方法用于检查一个属性是否是对象自身的非继承的。它是遍历对象自身属性时的关键工具。例如在for...in循环中如果想过滤掉原型链上的可枚举属性就需要配合hasOwnProperty使用。4.2 实现继承的几种方式与演进这是面试绝对的重灾区。你需要不仅知道怎么写还要知道每种方式的优缺点。1. 原型链继承早期方式问题多function Parent() { this.names [小明, 小红]; } function Child() {} Child.prototype new Parent(); // 关键子类原型指向父类实例 const c1 new Child(); c1.names.push(小刚); console.log(c1.names); // [小明, 小红, 小刚] const c2 new Child(); console.log(c2.names); // [小明, 小红, 小刚] 问题c1修改影响了c2问题所有子类实例共享同一个父类实例的属性尤其是引用类型属性如数组一个实例修改会影响到所有其他实例。无法向父类构造函数传参。2. 构造函数继承借用构造函数function Parent(name) { this.name name; this.colors [red]; } function Child(name) { Parent.call(this, name); // 关键在子类构造函数内部调用父类构造函数 } const c1 new Child(c1); c1.colors.push(blue); console.log(c1.colors); // [red, blue] const c2 new Child(c2); console.log(c2.colors); // [red] 很好互不影响 console.log(c2.name); // c2 可以传参优点解决了引用类型共享问题和传参问题。缺点方法都在构造函数中定义每次创建实例都会创建一遍方法无法实现函数复用。而且子类实例无法访问到父类原型上的方法因为没建立原型链链接。3. 组合继承最经典的方式结合了原型链继承和构造函数继承。function Parent(name) { this.name name; this.colors [red]; } Parent.prototype.sayName function() { console.log(this.name); }; function Child(name, age) { Parent.call(this, name); // 第二次调用 Parent this.age age; } Child.prototype new Parent(); // 第一次调用 Parent Child.prototype.constructor Child; Child.prototype.sayAge function() { console.log(this.age); }; const c1 new Child(Tom, 10); c1.colors.push(blue); console.log(c1.colors); // [red, blue] c1.sayName(); // Tom c1.sayAge(); // 10 const c2 new Child(Jerry, 8); console.log(c2.colors); // [red] 引用属性独立 c2.sayName(); // Jerry优点融合了两种方式的优点是JavaScript中最常用的继承模式。缺点父类构造函数被调用了两次new Parent()和Parent.call导致子类原型Child.prototype和子类实例c1上存在两份相同的父类实例属性name,colors造成一点点不必要的浪费。4. 寄生组合式继承目前最理想的继承方式为了解决组合继承调用两次构造函数的问题。function inheritPrototype(child, parent) { const prototype Object.create(parent.prototype); // 创建父类原型副本 prototype.constructor child; // 修复constructor child.prototype prototype; // 赋值给子类原型 } function Parent(name) { this.name name; this.colors [red]; } Parent.prototype.sayName function() { console.log(this.name); }; function Child(name, age) { Parent.call(this, name); // 只调用一次父类构造函数继承实例属性 this.age age; } // 关键使用寄生式继承来继承父类原型 inheritPrototype(Child, Parent); Child.prototype.sayAge function() { console.log(this.age); };优点只调用一次父类构造函数避免了在子类原型上创建不必要的、多余的属性。同时原型链保持不变。这是ES5时代实现继承的“终极”方案。ES6的class语法糖其extends关键字背后的实现原理本质上就是寄生组合式继承。5. ES6的Class继承class Parent { constructor(name) { this.name name; this.colors [red]; } sayName() { console.log(this.name); } } class Child extends Parent { constructor(name, age) { super(name); // 相当于 Parent.call(this, name) this.age age; } sayAge() { console.log(this.age); } }本质class是语法糖其继承机制底层仍然是基于原型链。extends关键字实现了原型链的链接super关键字用于调用父类构造函数和方法。通过Babel等工具转译成ES5代码后其核心就是寄生组合式继承。面试时你至少需要熟练掌握组合继承和寄生组合式继承的手写并清楚阐述其优缺点和演进过程。4.3 手写new操作符理解了原型链手写new就水到渠成了。面试官常考。function myNew(constructor, ...args) { // 1. 创建一个空对象并将其 [[Prototype]] 链接到构造函数的 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, this.name); }; const p myNew(Person, Jack); console.log(p.name); // Jack p.sayHi(); // Hi, Jack console.log(p instanceof Person); // true关键步骤就是那三步创建链接原型的新对象、绑定this执行构造函数、处理返回值。4.4 手写instanceof操作符instanceof用于检测构造函数的prototype属性是否出现在某个实例对象的原型链上。function myInstanceof(instance, constructor) { // 基础类型直接返回 false if (instance null || (typeof instance ! object typeof instance ! function)) { return false; } let proto Object.getPrototypeOf(instance); // 相当于 instance.__proto__ const prototype constructor.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({}, Array)); // false其核心就是一个while循环不断获取对象的原型并与目标构造函数的prototype进行比较。5. 原型链相关的“坑”与最佳实践理解了原理我们还要知道如何安全地使用它避开常见的陷阱。5.1 谨慎扩展原生原型你可以通过修改Array.prototype、Object.prototype等来为所有数组或对象添加自定义方法但这被认为是一种非常危险的做法。// 不推荐 Array.prototype.myCustomMethod function() { /* ... */ }; const arr []; arr.myCustomMethod(); // 可以调用风险命名冲突如果未来ECMAScript标准新增了同名方法你的代码会覆盖标准方法导致难以追踪的Bug。可枚举性直接添加的属性默认是可枚举的这会导致在for...in循环中遍历出这个新增方法即使你通常用for...of或.forEach但其他库可能用for...in。代码维护性污染全局原型使得代码的依赖关系不清晰其他开发者很难知道这个方法是哪里来的。替代方案使用工具函数或组合成新的自定义类。// 推荐工具函数 function myCustomMethod(arr) { /* ... */ } myCustomMethod(myArray); // 推荐自定义类继承 class MyArray extends Array { myCustomMethod() { /* ... */ } } const myArr new MyArray(1, 2, 3); myArr.myCustomMethod();5.2 性能考量与Object.createvsnew在创建对象并指定其原型时我们常用Object.create(proto)。它与new的区别在于Object.create只创建对象并设置原型不执行构造函数。const proto { greeting: Hello }; const obj1 Object.create(proto); // obj1.__proto__ proto obj1.name World; // 等效于不完全严谨但便于理解 const obj2 {}; Object.setPrototypeOf(obj2, proto); // 不推荐性能差 obj2.name World;Object.setPrototypeOf可以在对象创建后修改其原型但性能非常差会破坏JavaScript引擎的优化假设应绝对避免在性能关键的代码或频繁调用的函数中使用。优先使用Object.create在创建时确立原型关系。5.3 深度拷贝与原型链进行深度拷贝深克隆时原型链是需要考虑的因素。一个简单的JSON.parse(JSON.stringify(obj))会丢失函数、undefined、循环引用并且会切断原型链新对象将是一个纯净的Object实例。如果你需要保留原型链的拷贝就需要更复杂的实现手动处理原型。function deepCloneWithProto(obj, hash new WeakMap()) { if (obj null || typeof obj ! object) return obj; if (hash.has(obj)) return hash.get(obj); // 处理循环引用 // 获取对象的原型 const proto Object.getPrototypeOf(obj); // 创建一个以 proto 为原型的新对象 const clone Object.create(proto); hash.set(obj, clone); // 复制自身属性包括Symbol const allKeys Reflect.ownKeys(obj); for (const key of allKeys) { clone[key] deepCloneWithProto(obj[key], hash); } return clone; } function Person(name) { this.name name; } Person.prototype.say function() { console.log(this.name); }; const original new Person(Original); const cloned deepCloneWithProto(original); console.log(cloned.name); // Original cloned.say(); // Original (方法来自原型链) console.log(Object.getPrototypeOf(cloned) Person.prototype); // true5.4__proto__、Object.getPrototypeOf与Object.setPrototypeOf__proto__非标准但被所有现代浏览器实现。可读可写。生产环境建议避免直接使用因为其可写性可能导致原型链被意外修改引发难以调试的问题。Object.getPrototypeOf(obj)ES5标准方法用于读取对象的原型。推荐使用。Object.setPrototypeOf(obj, proto)ES6标准方法用于设置对象的原型。如前所述性能极差应避免使用。原型关系最好在对象创建时通过Object.create或构造函数确定。6. 从ES5到ES6Class语法糖背后的原型本质ES6引入的class关键字让来自传统面向对象语言如Java、C的开发者感到亲切。但必须清醒认识到JavaScript的class本质上是语法糖其底层机制依然是基于原型和原型链的。class Animal { constructor(name) { this.name name; } eat() { console.log(${this.name} is eating.); } } class Dog extends Animal { constructor(name, breed) { super(name); this.breed breed; } bark() { console.log(Woof!); } } const myDog new Dog(Buddy, Golden Retriever); // 原型关系依然存在 console.log(myDog.__proto__ Dog.prototype); // true console.log(Dog.prototype.__proto__ Animal.prototype); // true console.log(myDog instanceof Dog); // true console.log(myDog instanceof Animal); // true // 方法定义在原型上 console.log(myDog.hasOwnProperty(eat)); // false console.log(myDog.__proto__.hasOwnProperty(eat)); // false (在 Animal.prototype 上) console.log(Animal.prototype.hasOwnProperty(eat)); // trueclass语法带来的重要改进和限制更清晰的语法将构造函数、原型方法、静态方法、继承等概念用更直观的语法组织起来。必须使用new调用class构造函数不能像普通函数一样直接调用否则会抛出错误。这避免了忘记写new导致的this指向全局对象的经典错误。不存在变量提升class声明不会像function声明一样被提升存在“暂时性死区”这更符合直觉。内部方法不可枚举在class中定义的方法默认是不可枚举的enumerable: false这在使用for...in遍历时更安全。super关键字提供了在子类中调用父类构造函数和方法的标准化方式比ES5中Parent.call(this, ...)更清晰。static静态方法直接定义在构造函数本身上的方法不会被实例继承。ES5中需要手动Constructor.method function() {}。理解class是语法糖至关重要。这意味着你仍然可以绕过class直接操作原型。当你调试时在控制台看到的依然是基于原型的结构。所有关于原型链的知识在class中依然完全适用。面试中经常会被问到“ES6的Class和ES5的构造函数有什么区别” 你现在可以这样回答本质上没有区别Class是构造函数的语法糖它让面向对象的写法更清晰、更接近传统语言并且引入了一些语法限制如必须用new调用和便利特性如super,static但其继承机制依然是基于原型链实现的。7. 从原型链视角理解JavaScript的其他核心概念原型链的知识像一把钥匙能帮你打开理解JavaScript其他重要概念的大门。this的绑定函数内部的this值取决于函数的调用方式但一旦函数被调用this的指向就确定了。当通过原型链调用一个方法时如obj.method()method函数内部的this指向发起调用的对象obj。这就是为什么定义在原型上的方法可以正确访问实例数据的原因。闭包与作用域链 vs 原型链这是两个完全不同的概念但初学者容易混淆。作用域链在函数创建时确定用于变量查找。它由函数定义的位置决定与执行上下文相关。原型链在对象创建时通过new、Object.create等确定用于属性查找。它由对象的[[Prototype]]链接决定。 一个函数内部访问一个变量首先在自己的作用域找然后沿着作用域链向上找词法作用域。一个对象访问一个属性首先在自身找然后沿着原型链向上找。两者并行不悖。Object.create(null)这个方法创建一个空对象并且这个对象的[[Prototype]]是null即它没有原型。因此这个对象不会继承Object.prototype上的任何方法如toString,hasOwnProperty。它常被用作一个纯净的字典Map以避免与原型上的属性名发生冲突或者在需要极致性能的场景中使用因为属性查找少了一层。在ES6之后通常更推荐使用真正的Map数据结构。回顾整个探索过程我们从最简单的对象开始一步步构建出原型、构造函数、实例的三角关系再延伸出完整的原型链最后用这套理论去剖析继承、new、instanceof等核心机制和面试考点。我个人的体会是原型链是JavaScript中“令人困惑的优雅”。初学时会觉得绕但一旦真正理解你就会发现它用一种相对简单只有对象和链接的机制实现了强大的继承和代码复用能力。在平时开发中虽然我们可能不会直接写很多操作__proto__的代码但深刻理解这套机制能让你在阅读框架源码、调试复杂对象关系、设计自己的类库时拥有洞若观火的清晰感。下次面试官再问你原型链希望你能从容地画出一张完整的图谱并从语言设计哲学的角度讲清楚它的来龙去脉。