ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

JavaScript原型链超全解析:从prototype到继承面试题

JavaScript原型链超全解析:从prototype到继承面试题 先说明一下1分26秒真正把原型链整明白是不可能的除非你只是背两个结论。但如果你愿意花上几分钟把我这篇文章里的代码敲一遍、把每一条链走一遍那以后再聊js原型和原型链你就不是“好像听过”而是真的能接住面试官的各种追问了。这个知识点被叫“前端八股”不是没道理的几乎所有前端面试都会问到而且问法千奇百怪让你解释原型链、让你手写new、让你分析一段继承代码、甚至让你说说Object.prototype和Function.prototype的关系。我自己带团队面试别人的时候也喜欢从原型链切入因为通过三四个连续追问基本能判断一个人是背题还是真懂JavaScript的底层设计。这篇文章就按我在内部技术分享时的思路来写把三角关系、查找机制、继承方案、高频面试题一次性讲透。1. 为什么原型和原型链会成为“必考题”1.1 它不是偏门知识而是JavaScript的根基很多初学者有个误解觉得原型链离业务开发很远平时写业务代码谁整天搞继承啊。但事实是你写的每一个对象每一次使用数组的map方法每一次调用字符串的trim方法背后都是原型链在工作。JavaScript本身没有传统意义上的“类”它实现代码复用的方式就是通过原型。对象与对象之间通过一个隐藏的关联关系分享属性和方法这个关联关系就是“原型”而由一层层原型串起来的结构就是“原型链”。你创建一个普通对象const obj {}; console.log(obj.toString()); // [object Object]obj上根本没有定义toString但它能调用就是因为JS沿着obj的原型链一路往上找在Object.prototype上找到了这个方法。可以说从你写出第一行JavaScript代码开始你就在使用原型链只是没意识到而已。1.2 面试官到底在考察什么面试官问原型链表面上是考概念实际上是考察你对JavaScript这门语言“设计哲学”的理解程度。能讲清楚原型链的人往往对new、class、继承、instanceof这些关键特性都有更深刻的理解看框架源码也不会那么吃力。而且原型链是一个特别适合“连续追问”的知识点。面试官可以从“什么是原型”问到“手写new”再问到“Function.prototype和Object.prototype的关系”一路深挖。如果你只是背了“每个函数都有prototype属性每个对象都有__proto__属性”这两句话一旦被追问“为什么Object是一个函数它的prototype和__proto__分别指向谁”就会露馅。1.3 工作场景中的真实应用实际业务中原型链并不是纯理论。做组件库的同学经常会用原型链做基础类的扩展写工具库的同学会用Object.create来做继承和属性拷贝做框架二次开发的同学需要理解class继承的底层实现。甚至在做前端安全防护时也需要了解“原型链污染”的攻击原理才能写出更健壮的代码。所以别把它当八股它就是你每天在用的东西只是你没有把它系统梳理过。2. 核心三兄弟prototype、proto、constructor2.1 先记住三条硬规则再复杂的原型链图归根到底就是三条规则。先把这三条背熟后面所有的推导都靠它们每个“函数”都有一个prototype属性指向一个“原型对象”。每个“对象”都有一个__proto__属性指向“创建它的那个构造函数的prototype”。“原型对象”里有一个constructor属性指回“构造函数本身”。这里要特别注意函数也是对象所以函数既有prototype属性又有__proto__属性。这两者指向的东西完全不一样这是新手最容易混淆的地方。拿最常见的构造函数举例function Person(name) { this.name name; } Person.prototype.sayHi function() { console.log(Hi, I am this.name); }; const p new Person(Tom); console.log(p.__proto__ Person.prototype); // true console.log(Person.prototype.constructor Person); // true console.log(p instanceof Person); // true2.2 用“模具、工具箱、产品”来理解这组关系用生活化的比喻会好记很多。把构造函数看成“模具”把prototype看成“共享工具箱”把new出来的实例看成“产品”。模具构造函数负责决定产品的基础形状比如Person这个模具规定了每个人都有一个name属性。但模具本身还背着一个共享工具箱prototype里面放着所有产品都能共用的工具比如sayHi方法。每个产品实例对象虽然没有自己的工具箱但它有一条“小抄链接”proto可以找到模具的共享工具箱来借用工具。当产品需要一个属性或方法时会先看自己身上有没有如果没有就顺着__proto__这条链接去共享工具箱里找再找不到就往工具箱自己的原型上继续找这就是原型链。用这种方式理解prototype和__proto__的职责就很清晰了一个是“共享库的存放地”一个是“指向共享库的链接”。2.3 prototype 和proto的区别对照表对比项prototypeproto谁有只有函数有箭头函数例外所有对象都有包括函数指向哪里指向该函数作为构造函数时实例对象的共享原型对象指向创建该对象的构造函数的prototype主要用途给实例提供共享方法和属性构成原型链实现属性查找是否标准ECMAScript标准属性非标准但浏览器普遍实现的属性推荐用Object.getPrototypeOf代替修改影响影响该构造函数所有实例只影响该对象及其原型链我见过不少人在简历里写“熟悉原型链”但问起来却分不清prototype和__proto__这是最基础也是最重要的区分。记住一句话prototype是函数的“共享库”__proto__是对象的“链接”。3. 原型链的查找机制一条不断向上的“责任链”3.1 属性查找的完整流程当你访问一个对象的属性时JavaScript引擎会按照以下步骤查找先查找对象自身属性own property如果存在直接返回。如果自身没有就沿着__proto__往上去它的构造函数的prototype对象里找。如果还没找到就继续沿着该prototype对象自己的__proto__往上找。一直找到Object.prototype为止。Object.prototype的__proto__是null到这就停止返回undefined。这个过程就像一层层向上汇报每一层都没有就递归往上。我用一段代码来演示function Animal() {} Animal.prototype.eat function() { console.log(eating...); }; function Dog() {} Dog.prototype Object.create(Animal.prototype); Dog.prototype.bark function() { console.log(barking...); }; const dog new Dog(); dog.bark(); // barking... 实例自身没有去Dog.prototype找到了 dog.eat(); // eating... Dog.prototype没有eat去Animal.prototype找到了 dog.toString(); // 去Object.prototype找到了很多前端同学能画出原型链的图但是一上手分析具体对象就懵核心原因是没掌握好“从哪开始、到哪结束”这个顺序。这里我给一个自己总结的答题套路先找自身再找当前原型再一路向上直到Object.prototype再往上是null返回undefined。3.2 分清“实例链”和“函数链”接下来是重头戏。很多让人绕晕的题目本质是分不清“对象的原型链”和“函数对象的原型链”。普通实例对象的原型链比较直观const person new Person(); // person.__proto__ → Person.prototype // Person.prototype.__proto__ → Object.prototype // Object.prototype.__proto__ → null但函数对象本身还有一种“函数链”。因为函数也是对象它也有__proto__这个__proto__指向的是Function.prototypefunction Person() {} // Person.__proto__ → Function.prototype // Function.prototype.__proto__ → Object.prototype // Object.prototype.__proto__ → null注意这里的区别Person.prototype是Person函数自己的共享库里面放的是给Person实例用的东西而Person.__proto__是Person这个函数对象作为“对象”时的原型指向Function.prototype里面放着apply、call、bind这些方法。一个函数同时拥有“属于它的实例共享库prototype”和“它自己作为对象时的原型proto”这两条线互不干扰但面试题最爱在这里挖坑。3.3 instanceof的原理和手写实现instanceof是面试常客它的原理实际上就是沿着原型链查找。a instanceof B的判断逻辑是在a的__proto__链上是否存在B.prototype。如果存在返回true如果一直走到null都没找到返回false。手写实现function myInstanceof(instance, Constructor) { let proto Object.getPrototypeOf(instance); const prototype Constructor.prototype; while (proto ! null) { if (proto prototype) return true; proto Object.getPrototypeOf(proto); } return false; } function Person() {} const p new Person(); console.log(myInstanceof(p, Person)); // true console.log(myInstanceof(p, Object)); // true console.log(myInstanceof({}, Person)); // false写完这个手写版本你会更深刻地理解为什么“所有对象都instanceof Object”会返回true因为顺着原型链最终都能找到Object.prototype。同时你会明白一个易错点instanceof检查的是原型链上的关系而不是“构造过程”的关系所以即使一个对象不是通过某个构造函数new出来的只要它的原型链上存在该构造函数的prototype结果也是true。4. 经典继承方案逐一点评从原型链到class4.1 原型链继承最原始的继承方式直接把子类的prototype指向父类的一个实例function Animal() { this.msg [eat]; } Animal.prototype.eat function() { console.log(eat); }; function Dog() {} Dog.prototype new Animal(); const dog1 new Dog(); const dog2 new Dog(); dog1.msg.push(sleep); console.log(dog2.msg); // [eat, sleep] 问题出来了问题很明显父类的引用类型属性被所有子类实例共享一个实例修改了msg其他实例全被影响。这在实际项目中几乎是不可接受的。4.2 构造函数继承为了解决共享问题出现了构造函数继承在子类构造函数里调用父类构造函数function Animal() { this.msg [eat]; } function Dog() { Animal.call(this); } const dog1 new Dog(); const dog2 new Dog(); dog1.msg.push(sleep); console.log(dog2.msg); // [eat] 这次不共享了 console.log(dog1.eat); // undefined 但方法没了这个方案虽然解决了引用属性共享的问题但带来新问题父类原型上的方法无法被子类继承因为根本没有建立原型链关联。如果方法定义在父类构造函数内部又会导致每个实例都创建一份方法浪费内存。4.3 组合继承结合两者的优点既用原型链继承方法又用构造函数保证属性独立function Animal(name) { this.name name; this.msg [eat]; } Animal.prototype.eat function() { console.log(this.name is eating); }; function Dog(name) { Animal.call(this, name); // 第一次调用Animal } Dog.prototype new Animal(); // 第二次调用Animal Dog.prototype.constructor Dog; const dog new Dog(Tom); dog.eat(); // Tom is eating console.log(dog.msg); // [eat]这是ES5时代比较常用的继承方案但它有一个小瑕疵调用了两次父类构造函数导致子类原型上会存在一份多余的父类实例属性只是被实例自身的属性屏蔽了。很多面试官喜欢让你分析这个方案的缺点其实就是等你说出“两次调用、原型上有多余属性”这两个点。4.4 寄生组合继承更好的方案是不通过new父类实例来建立原型关系而是直接拷贝父类的prototypefunction Animal(name) { this.name name; } Animal.prototype.eat function() { console.log(eating); }; function Dog(name) { Animal.call(this, name); } Dog.prototype Object.create(Animal.prototype); Dog.prototype.constructor Dog; const dog new Dog(Tom); dog.eat(); // eatingObject.create(Animal.prototype)会创建一个新对象它的原型指向Animal.prototype这个操作只创建了一个空对象不会触发Animal构造函数所以避免了多余的属性。ES6的class extends底层走的也是类似这套逻辑。4.5 ES6 class extends现代开发直接使用class语法class Animal { constructor(name) { this.name name; } eat() { console.log(this.name is eating); } } class Dog extends Animal { constructor(name) { super(name); // 必须手动调用super } bark() { console.log(barking); } }class语法只是语法糖底层依然是原型链。但有两个细节要记住子类构造函数里必须调用super()否则会报错class定义的方法是不可枚举的这和ES5时代直接在prototype上赋值的行为不完全一样。面试时如果能顺带说出这两点会让面试官觉得你是真的写过。5. 面试高频题实战这些变种题别翻车5.1 new操作符到底做了什么手写new是前端面试的高频题。new一个构造函数JS内部做了四件事创建一个全新的空对象。将这个空对象的__proto__指向构造函数的prototype。将构造函数内部的this绑定到这个空对象上。如果构造函数返回了一个对象则返回这个对象否则返回第一步创建的对象。手写实现function myNew(Constructor, ...args) { // 第一步创建新对象并建立原型链 const obj Object.create(Constructor.prototype); // 第三步绑定this并执行 const result Constructor.apply(obj, args); // 第四步如果返回对象就返回它否则返回obj return (typeof result object result ! null) || typeof result function ? result : obj; } function Person(name) { this.name name; } const p myNew(Person, Tom); console.log(p.name); // Tom console.log(p instanceof Person); // true很多人能背出“创建对象、绑定原型、绑定this、返回对象”但一写代码就漏掉“构造函数返回对象的情况要特殊处理”这一点。记住如果构造函数里明确返回了一个对象比如return {x: 1}那么new出来的结果就是这个对象而不是新创建的对象。5.2 关于constructor的经典陷阱看这行代码function Person() {} const p new Person(); console.log(p.constructor Person); // true console.log(Person.prototype.constructor Person); // true但如果你手动修改了prototype情况就会变function Person() {} Person.prototype { sayHi() {} }; const p new Person(); console.log(p.constructor Person); // false console.log(p.constructor Object); // true因为新赋值的对象字面量没有constructor指向Person所以顺着原型链往上找最后在Object.prototype上找到了Object。解决办法就是手动补上constructorPerson.prototype { constructor: Person, sayHi() {} };这个细节我在代码评审里见过不止一次大家把prototype整个覆盖后却忘了把constructor补回来结果导致一些基于constructor判断类型的逻辑悄悄失效。5.3 一张表解决“函数与对象”的终极关系很多面试题会绕到Function和Object的关系上这也是最容易晕的地方。我直接给你整理成一张表表达式结果解释Object instanceof FunctiontrueObject是一个构造函数它的__proto__指向Function.prototypeFunction instanceof ObjecttrueFunction函数对象也可以沿着__proto__找到Object.prototypeObject.proto Function.prototypetrue所有函数对象的__proto__都指向Function.prototypeFunction.proto Function.prototypetrueFunction自己也是函数它的__proto__也指向Function.prototypeFunction.prototype.proto Object.prototypetrueFunction.prototype本身也是对象再往上是Object.prototypeObject.prototype.protonull原型链的终点记住一个核心结论Object.prototype是原型链的终点Function.prototype是所有函数的“共享库”。Function和Object互为对方的instanceof这不算循环引用只是它们的原型链相互交叉。5.4 一道综合题的完整分析套路我拿一道经典题来说明怎么用上面的知识点。题目是这样的function Foo() {} Object.prototype.a function() {}; Function.prototype.b function() {}; const foo new Foo();问foo有a方法吗有b方法吗分析步骤找自身foo自身没有a和b。沿原型链往上foo.__proto__指向Foo.prototypeFoo.prototype里没有a和b。继续往上Foo.prototype.__proto__指向Object.prototypeObject.prototype上有a所以foo.a是函数。那foo.b呢Object.prototype上没有b再往上就是null了所以foo.b是undefined。答案就是foo有a没有b。这里很容易被误导觉得Function.prototype上有bfoo应该也有b。但foo是普通对象不是函数它的原型链根本不会经过Function.prototype。这就是“实例链”和“函数链”的区别普通对象实例永远到达不了Function.prototype。6. 原型链的实战价值与避坑指南6.1 在框架源码中理解原型链读Vue、React源码时你会频繁看到原型链的影子。比如Vue 2的全局API设计很多方法就是定义在Vue.prototype上的目的就是让所有组件实例都能通过this访问到这些方法。React的class组件继承自React.Component如果你不清楚继承的原理遇到super(props)的调用顺序问题就会一头雾水。我建议你去看源码前先自己写一个简单的“超级构造器”实现体会一下“子类先调用super才能使用this”的规则。写一遍之后你会明白这不是框架的刻板要求而是JavaScript继承机制的必然结果因为this的初始化依赖父类的构造逻辑。6.2 原型链污染一个必须知道的安全知识这个名字听起来很吓人其实原理很简单如果某个操作使用递归的方式合并用户传入的对象并且没有限制键名那么用户可能通过构造特殊对象修改Object.prototype从而污染所有对象的原型链。攻击思路大概是这样的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 payload JSON.parse({__proto__: {polluted: true}}); merge({}, payload); console.log({}.polluted); // true 原型链被污染了因为__proto__这个键在JSON.parse后是一个普通属性merge递归时会把污染写入Object.prototype。防御方案就是在merge时检查key是否为__proto__、constructor、prototype这几个危险键直接跳过。我为什么要提这个因为现在不少前端岗位在考察原型链时会顺带问“你是否了解原型链污染”能答上这个点说明你是真的关注过实践中的风险。6.3 日常开发中的几条避坑建议第一优先使用class、Object.create这类现代语法不要在业务代码里手写ES5继承。class可读性更强也更不容易出错。第二不要随意修改内置对象的原型。比如给Array.prototype加一个自定义方法在一个小项目里可能很爽但团队协作时很容易导致命名冲突甚至和未来JS标准里新增的方法撞车。第三判断属性是否存在时用hasOwnProperty区分“自身属性”和“原型链属性”。可以用Object.prototype.hasOwnProperty.call(obj, key)这种写法避免obj自身如果覆盖了hasOwnProperty方法导致判断失效。第四遍历对象时for...in会遍历到原型链上可枚举的属性如果只想遍历自身属性用Object.keys配合for...of或者配合hasOwnProperty过滤。最后再分享一个我自己的调试小技巧在浏览器控制台里用console.dir打印任意对象展开后能看到__proto__的完整链路这是最直观的“看原型链”的方式。我当年学这块时就是把浏览器里各种内置对象打印了一遍挨个点开看它们的关系比看十篇博客都管用。遇到原型链相关的题拿起控制台验证一下印象会深得多。
返回列表