尧图建网站 尧图建网站 YAOTU WEB BUILD 免费咨询
ARTICLE DETAIL

资讯详情

深耕网站建设与建站编程的一线实战洞察。

JavaScript原型链深度解析:从prototype、__proto__到继承与面试实战

JavaScript原型链深度解析:从prototype、__proto__到继承与面试实战 作为前端面试的高频考点原型链几乎每次面试都会被拎出来遛一遛。尤其是想进中大厂的同学这道题答得好不好往往直接决定了面试官对你JS基础扎实程度的判断。但说实话我面过不少候选人能把prototype、__proto__、constructor这三者的关系讲清楚的人真的不多。大部分人停留在“背得出定义画不出图讲不透原理”的阶段。这篇文章不打算给你念教科书而是从面试答题的角度、从实际开发中会遇到的问题出发把原型链这个东西彻底拆开揉碎。你不仅能搞懂它是什么还能知道怎么把它用在代码里更重要的是知道面试官问你这个问题时他到底想听什么。1. 原型链到底是什么先建立整体认知1.1 用生活类比理解原型机制先忘掉代码想一个场景。你是一个刚入职的新人公司有一套《员工手册》里面写了请假流程、报销规范、考勤制度。但你入职的时候并没有人手发一份纸质版HR只是告诉你制度都在内网Wiki上你自己去看如果Wiki上没有就问你的导师。这就是原型机制的核心思想对象本身不一定要拥有所有属性和方法它可以通过某种关联到“别的地方”去查找自己没有的东西。在JavaScript里这个“别的地方”就是原型对象。再具体一点。假设你创建了一个数组const arr [1, 2, 3]你只是写了三个数字进去但为什么arr可以用.map()、.filter()、.reduce()你没定义过这些方法啊。原因就是数组对象本身没有map方法但它的原型对象上挂了这些方法。你调用arr.map()时JS引擎先去arr身上找没找到就顺着原型链往上找在Array.prototype上找到了于是执行。这个机制的好处是什么复用。所有数组只需要共享一份Array.prototype不需要每个数组都复制一份map、filter的实现代码。这在内存和性能上的优势是巨大的。1.2 面试官为什么爱问原型链面试官问原型链表面上看是在考察你对JavaScript对象机制的理解实际上他在隐晦地考察三件事第一你的基础扎不扎实。原型链是JS的核心机制之一不理解它很多代码行为你都无法解释比如为什么对象能有toString方法为什么函数能有call和apply。第二你的抽象思维能力。原型链涉及对象、函数、继承、属性查找等多层抽象能把这条链捋清楚的人通常具备良好的逻辑推演能力。第三你有没有踩过相关的坑。比如for...in遍历会带上原型链上的属性比如用Array.prototype.xxx扩展方法会导致什么问题这些都是实际开发中的高频坑。所以这篇文章不只是写给面试的人看的也是对日常开发中很多“诡异问题”的釜底抽薪式解答。2. 三个核心概念与一个查找规则2.1 prototype、__proto__与constructor这三个概念是理解原型链的基石但也是初学者最容易搞混的地方。先把它们分开讲清楚。prototype只有函数才有。它是函数的一个属性指向一个对象。当这个函数作为构造函数被new调用时新创建的对象的原型就指向这个prototype对象。简单说prototype是“给未来实例准备的公共属性仓库”。__proto__现在标准写法是Object.getPrototypeOf()每个对象都有除了Object.create(null)创建的。它指向创建这个对象的构造函数的prototype。也就是说实例的__proto__ 构造函数的prototype。constructor这是prototype上的一个属性指向构造函数本身。也就是说Fn.prototype.constructor Fn。这三者的关系可以用一句话概括函数的prototype是个对象对象里有个constructor指回函数用函数new出来的实例它的__proto__指向函数的prototype。口说无凭上代码验证一下function Person(name) { this.name name; } const p new Person(张三); console.log(p.__proto__ Person.prototype); // true console.log(Person.prototype.constructor Person); // true console.log(p.__proto__.constructor Person); // true console.log(Object.getPrototypeOf(p) Person.prototype); // true这里要特别提醒一点__proto__是浏览器实现的历史遗留属性虽然现在几乎所有环境都支持但规范并不推荐直接使用它。写代码时建议用Object.getPrototypeOf()和Object.setPrototypeOf()来替代。2.2 属性查找机制原型链是怎么“链”起来的现在把多个对象通过__proto__串联起来就形成了原型链。查找属性时JS引擎遵循一条简单粗暴的规则先在自己身上找找不到就沿着__proto__向上找直到Object.prototype再往上就是null找不到了就返回undefined。function Person(name) { this.name name; } Person.prototype.sayHello function() { console.log(你好我是 this.name); }; const p new Person(李四); p.sayHello(); // 自己身上没有sayHello通过__proto__找到Person.prototype上的方法 console.log(p.toString()); // Person.prototype上也没有toString继续向上到Object.prototype找到 console.log(p.nonExistent); // undefined整条链上都没有这里有三个关键节点需要记住实例的__proto__指向构造函数的prototype构造函数的prototype是一个普通对象它的__proto__指向Object.prototypeObject.prototype是原型链的顶端它的__proto__是null所以任何普通对象不是通过Object.create(null)创建的都能调用toString、hasOwnProperty这些方法因为它们定义在Object.prototype上位于原型链的最上层。画一张链路的文字图三行搞定p实例对象 __proto__ → Person.prototype __proto__ → Object.prototype __proto__ → null2.3 函数与对象的关系特例Function和Object这里有个面试必考点也是很多人怎么都绕不出来的死循环Function和Object的鸡生蛋问题。先说结论记住就完事了console.log(Function.__proto__ Function.prototype); // true console.log(Object.__proto__ Function.prototype); // true console.log(Function.prototype.__proto__ Object.prototype); // true console.log(Object.prototype.__proto__); // null什么意思呢Object本身是一个函数构造函数所以它的__proto__指向Function.prototype。Function本身也是函数它的__proto__也指向Function.prototype这就形成了“函数自己指向自己”的特殊情况。而Function.prototype本质是一个函数对象它的__proto__指向Object.prototype。面试时画这个关系图能让面试官眼前一亮。核心一句话一切对象都最终继承自Object.prototype一切函数都继承自Function.prototype而Function.prototype本身是一个对象所以它也在Object.prototype的管辖范围内。3. 原型链在实际开发与手写题中的应用3.1 手写new检验原型理解的第一道坎面试手写题里new的实现是原型链知识的直接应用。手写new的关键在于创建一个新对象让这个对象的原型指向构造函数的prototype然后执行构造函数最后根据返回值类型决定返回什么。function myNew(Constructor, ...args) { // 1. 创建空对象并将原型指向构造函数的prototype const obj Object.create(Constructor.prototype); // 2. 执行构造函数将this绑定到新对象上 const result Constructor.apply(obj, args); // 3. 如果构造函数返回了对象类型则返回那个对象否则返回新对象 return (typeof result object result ! null) || typeof result function ? result : obj; } // 测试 function Person(name) { this.name name; } const p myNew(Person, 王五); console.log(p instanceof Person); // true console.log(p.name); // 王五这段代码里Object.create(Constructor.prototype)就是最关键的一步。它等价于手动创建一个{__proto__: Constructor.prototype}的空对象。理解了这一步你就理解了new的本质不是“调用函数”而是“创建对象 绑定原型 初始化this”。面试时如果被追问“new的过程中发生了什么”你就按这三步答再加一句如果构造函数显式返回了引用类型那么new的结果是那个引用类型而不是新创建的对象。3.2 instanceof的原理顺着原型链找instanceof的原理也很简单拿构造函数的prototype去和实例的原型链上的每一个节点比较。function myInstanceof(instance, Constructor) { // 拿到实例的原型兼容ES5写法 let proto Object.getPrototypeOf(instance); // 拿到构造函数的prototype 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这个手写题的意义在于它迫使你理解原型链是“一条链”而不是“一个点”。数组既是Array的实例也是Object的实例就是因为Array.prototype的__proto__指向了Object.prototype。关于instanceof有一个经典陷阱要提醒基本类型用instanceof判断会出问题。hello instanceof String的结果是false因为字面量字符串不是对象。这也是为什么判断类型时基本类型要用typeof对象类型才考虑instanceof。3.3 原型式继承的几种写法对比面试另一个高频考点是继承。ES6的class虽然把继承简化了但面试官往往要求你讲清楚它背后的原型链转移过程。这里把几种常见的继承方式列出来对比并指出各自的优缺点。组合式继承构造函数继承 原型链继承function Parent(name) { this.name name; this.hobbies [篮球, 读书]; } 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;这种方式的缺点是Parent被调用了两次子类原型上会存在一份多余的父类实例属性。虽然不影响功能但不优雅。寄生组合式继承推荐的经典方案function inherit(child, parent) { child.prototype Object.create(parent.prototype); child.prototype.constructor child; } function Parent(name) { this.name name; } Parent.prototype.sayName function() { console.log(this.name); }; function Child(name, age) { Parent.call(this, name); this.age age; } inherit(Child, Parent); Child.prototype.sayAge function() { console.log(this.age); };这里关键就在Object.create(parent.prototype)它创建了一个干净的对象只继承了父类原型的属性不包含父类实例属性完美避免了组合式继承的缺点。但是使用Object.create实现继承时有个坑Child.prototype.constructor会被指向Parent因为parent.prototype里的constructor是Parent所以需要手动把constructor指回Child。这一步很容易漏漏了会导致instanceof结果怪异或者某些依赖constructor的库出错。话说回来ES6的class继承底层原理也是这一套只不过语法糖帮你封装好了。面试时如果能从class讲到它背后的Object.create操作会显得很有深度。3.4 面向对象实战给数组扩展方法的正确姿势我见过不少同学在项目里这么写Array.prototype.unique function() { return [...new Set(this)]; };如果你只是个人项目这么干问题不大。但在团队项目或开源库中我不建议这么做。原因有三点第一污染全局。你给Array.prototype扩展了unique意味着所有数组都能调用这个方法。如果将来另一位同事也定义了一个unique但实现逻辑略有不同后定义的会覆盖先定义的排查起来极其痛苦。第二for...in循环踩坑。for...in会遍历原型链上的可枚举属性。如果你扩展了Array.prototype所有使用for...in遍历数组的代码都会把unique遍历出来。第三与未来规范冲突。如果某天ECMAScript正式加入了Array.prototype.unique你的扩展就会覆盖原生方法或者被原生方法覆盖行为不可控。更稳妥的做法是在自己的项目里封装一个工具函数或者使用Object.defineProperty定义不可枚举的属性Object.defineProperty(Array.prototype, unique, { value: function() { return [...new Set(this)]; }, writable: true, configurable: true, enumerable: false // 关键不可枚举for...in就不会遍历到 });当然了我个人的习惯是能用工具函数就不用原型扩展。原型扩展更适合在“自己完全控制代码”的场景下使用比如小程序项目、内部工具库。4. 高频面试题精讲从基础到进阶4.1 那些年我们懵过的输出题原型链类的面试题常考的形式是“猜输出”。这类题看起来刁钻实际上一旦你掌握了原型链的查找规则就能稳稳拿下。来看一道经典题function Foo() {} Object.prototype.a function() {}; Function.prototype.b function() {}; const foo new Foo(); foo.a(); // ? foo.b(); // ? Foo.a(); // ? Foo.b(); // ?先自己做一遍再看答案。foo.a()foo实例上找没有__proto__指向Foo.prototype上面没有a继续向上到Object.prototype有a所以能执行。foo.b()foo实例没有Foo.prototype没有Object.prototype没有null所以报错foo.b is not a function。Foo.a()Foo是函数自己身上没有aFoo.__proto__指向Function.prototype没有a继续向上Function.prototype.__proto__指向Object.prototype有a所以能执行。Foo.b()Foo自身没有bFoo.__proto__指向Function.prototype有b所以能执行。这就是为什么在原型链上定义方法时要小心给Object.prototype扩展的属性所有对象都能访问到给Function.prototype扩展的属性只有函数能访问到。再出一道更刁钻的function A() {} A.prototype {}; const a new A(); console.log(a instanceof A); // ? console.log(a.constructor A); // ?答案是a instanceof A为truea.constructor A为false。原因a的__proto__指向A.prototype即{}instanceof检查时确实能匹配上。但A.prototype被重新赋值为一个普通对象字面量这个对象的constructor属性指向的是Object因为字面量对象默认继承Object.prototype.constructor而不是A。这个例子告诉我们如果你重写了构造函数的prototype一定要手动补上constructor指向。function A() {} A.prototype { constructor: A, // 其他方法... };4.2 高频简答题什么是原型链污染原型链污染这个词在面试中出现的频率越来越高尤其是和JSON.parse、对象合并相关的场景。它指的是攻击者通过修改一个对象的原型从而影响所有继承自该原型的对象。经典攻击案例是通过Object.prototype污染实现// 攻击者控制了这个对象 const userInput JSON.parse({__proto__: {isAdmin: true}}); // 不安全的合并 function merge(target, source) { for (const key in source) { if (source.hasOwnProperty(key)) { target[key] source[key]; } } } function mergeSafe(target, source) { for (const key of Object.keys(source)) { // 避免修改原型链上的属性 if (key __proto__ || key constructor || key prototype) continue; target[key] source[key]; } } const user {}; merge(user, userInput); console.log(user.isAdmin); // true被污染了 console.log({}.isAdmin); // true所有对象都被污染了用mergeSafe则可以避免。这个例子告诉我们任何对用户输入做对象合并的场景都要防止__proto__、constructor、prototype这三个键被写入。这也是为什么很多库的深拷贝实现里会有一个forbiddenKey检查。顺带一提Object.freeze(Object.prototype)可以在某些极端场景下防止原型污染但常规开发中用不上了解即可。4.3 面试答题框架从短答到加分答面试官问“什么是原型链”如果你只回答“对象可以通过__proto__找到它爸爸”那基本就凉了。我建议按下面的层次组织答案层层递进第一层定义层30秒内说完每个对象都有一个私有属性__proto__指向它的构造函数的prototype。当访问一个属性时如果对象自身没有会沿着__proto__找这个逐级查找的链路就是原型链。第二层示例层30秒举一个代码例子说明p.__proto__ Person.prototypePerson.prototype.__proto__ Object.prototypeObject.prototype.__proto__ null。第三层应用层1分钟说明原型链的作用实现继承、实现属性/方法的复用。举两个例子数组能调用map是因为Array.prototype上有这个方法通过Object.create()可以实现原型式继承。第四层进阶层1分钟加分项如果能旁征博引可以说理解了原型链就能理解instanceof的原理沿原型链查找构造函数的prototype、手写new的关键步骤Object.create(Constructor.prototype)、以及ES6 class继承的底层原理Object.setPrototypeOf和Object.create的组合。这么答下来面试官会感受到你不仅知道概念还能把概念串起来形成知识网络。这比死记硬背强得多。4.4 易混淆点速查手写一份自查清单我在面试别人时最怕听到的就是“好像也是这样”。为了帮你确认自己真懂我列一个自查清单每条都要能立刻说出原因Object.getPrototypeOf({}) Object.prototype 是Object.getPrototypeOf([]) Array.prototype 是Object.getPrototypeOf(Array.prototype) Object.prototype 是Object.getPrototypeOf(Object.prototype) null 是Object.getPrototypeOf(function(){}) Function.prototype 是Function.prototype Function.__proto__ 是Object.getPrototypeOf(Function.prototype) Object.prototype 是Object.getPrototypeOf(Object) Function.prototype 是这八条能一口答对说明你的原型链知识已经及格了。如果哪条有犹豫赶紧回去重新捋一遍。5. 原型链在框架和工程中的实际应用5.1 Vue和React里那些你不注意的原型操作很多同学写了好几年Vue或React却不知道这些框架内部大量使用了原型相关的机制。这里举一个比较直观的例子。Vue 2中你通过Vue.prototype.$http axios来挂载全局方法然后组件里用this.$http调用。为什么组件实例能访问到这个方法因为组件实例的原型链上有一层是Vue.prototype所以this.$http查找时实例自身没有会沿原型链往上找最终在Vue.prototype上找到。React也有类似机制。比如React.Component你自定义的类组件继承它以后this.setState、this.props、this.state这些属性和方法都来自Component.prototype。这本质上就是你在用class MyComponent extends React.Component时JS引擎帮你建立的MyComponent.prototype.__proto__ React.Component.prototype这条链路。理解了这一点当你看到报错“this.setState is not a function”时你就知道问题大概率不是React的问题而是你的组件类没有正确继承React.Component或者是继承链被搞断了。5.2 框架源码中常见的原型技巧很多开源库出于性能考虑会直接操作原型链。比如Object.create(null)创建无原型对象常被用作字典/映射表因为普通对象的__proto__会指向Object.prototype这会导致in判断和hasOwnProperty调用时多一层查找甚至在极少数情况下引发意外比如Object.create(null)的对象没有toString方法就没有安全隐患。另外Object.setPrototypeOf在框架中也有应用。比如一些类继承的场景动态地给实例设置原型链。但要注意Object.setPrototypeOf性能较差能不用尽量不用大多数场景下Object.create或Object.assign可以替代。还有一个框架里常见的操作通过原型链实现方法重写。比如你在调试某些库时想临时修改某个方法的行为可以这么做const originalFetch window.fetch; window.fetch function(...args) { console.log(请求拦截:, args[0]); return originalFetch.apply(this, args); };虽然不是直接操作原型但这个思路和原型链的属性遮蔽机制高度一致在更近的作用域里定义同名方法覆盖远处的实现。5.3 浏览器环境中的原型链调试技巧实际开发中排查原型链相关问题最常用的工具是Chrome DevTools。这里分享两个我平时高频用到的调试技巧。第一在Console里直接输出console.dir(某个对象)可以展开看到[[Prototype]]点击可以逐层查看原型链上的属性。这是最直观的查看方式。第二使用Object.getOwnPropertyNames()查看对象自身的属性用Object.getPrototypeOf()逐层排查用in操作符检测属性是否在原型链上const arr [1, 2, 3]; // 这个属性在实例自身上吗 console.log(Object.hasOwn(arr, length)); // true // 这个属性在整个原型链上吗 console.log(map in arr); // true console.log(Object.hasOwn(arr, map)); // false在Array.prototype上第三如果怀疑某个对象的原型被改过可以这样快速对比// 检查原型是否被污染 console.log(Object.getPrototypeOf({}) Object.prototype); // 如果为false说明被改了这些技巧在排查“某个对象为什么多了一个奇怪的属性”时特别有用。6. 原型链相关面试题的真实复盘与回答示例6.1 一个真实面试案例的完整复盘我之前在一次面试中遇到过这样一道题题面很短“解释一下Foo.prototype和Foo.__proto__的区别。”一个候选人的回答让我印象很深他说“Foo.prototype是给Foo的实例用的Foo.__proto__是Foo自己作为对象时的原型。”虽然简短但这个理解完全正确。面试官追问“那Foo.__proto__指向谁”他答“Function.prototype因为Foo是一个函数所有函数对象的原型都是Function.prototype。”他又被追问“那Function.prototype.__proto__呢”他答“Object.prototype因为Function.prototype不是普通函数它是一个函数对象所有对象的终点都是Object.prototype。”这个候选人的回答链路非常清晰实例 → 构造函数的prototype → Object.prototype → null。他虽然是应届生但这个基础扎实程度让面试官很满意。反面的例子我也见过不少。有人把prototype说成“一个对象”但问他“Object.prototype的constructor指向谁”时他犹豫了很久说不出来。这就是典型的“知其然不知其所以然”。6.2 经典题集锦10道题自测你的掌握程度给你准备了一份自测题先把答案写在纸上再对照后面的解析({}).__proto__ ?[].__proto__.__proto__ ?function f(){}; f.__proto__ ?Object.__proto__ ?Function.prototype.__proto__ ?function f(){}; f.prototype.__proto__ ?class A {}; A.__proto__ ?const a new A(); a.__proto__ ?str.__proto__ ?Object.getPrototypeOf(Object.create(null)) ?答案依次是Object.prototype、Object.prototype、Function.prototype、Function.prototype、Object.prototype、Object.prototype、Function.prototype、A.prototype、String.prototype、null。第7题要注意class关键字定义的本质也是函数所以A.__proto__ Function.prototype。第10题是Object.create(null)的特殊性它创建的对象没有原型所以getPrototypeOf返回null。如果你能一次全对恭喜你原型链这关你已经过了。如果错了一两道别急把前面几章再看一遍重点理解“函数也是对象”“原型链的终点是Object.prototype”这两句话。6.3 从原型链延伸到更广的面试知识网原型链在面试中不会孤立出现它经常会连带着考察以下知识点继承方式从原型链继承讲到class extends这是最自然的延伸。new和this绑定new过程中的原型绑定和this指向是同一个问题的两面。类型判断typeof、instanceof、Object.prototype.toString.call()三者之间的区别本质上都是围绕对象的内部属性做文章。深浅拷贝深拷贝的递归实现里一个关键点是判断属性是自身属性还是原型链上的属性通常用Object.hasOwn或Object.prototype.hasOwnProperty.call来区分。Symbol.iterator遍历数组的iterator方法定义在Array.prototype[Symbol.iterator]上这也是原型链的应用。所以我在准备面试时会把原型链相关的知识点做成一张知识网络而不是当成孤立的知识点去背。这样面试官问任何一个分支你都能自然地说到网里的其他节点展示出系统性的知识结构。7. 避坑指南原型链实战中容易踩的坑7.1 遍历对象时的原型链陷阱用for...in遍历对象时会遍历到原型链上可枚举的属性。这常常是新手最容易踩的坑。举个例子const obj { name: 张三 }; // 给Object.prototype添加一个可枚举的属性模拟不小心污染的场景 Object.prototype.school 清华大学; for (const key in obj) { console.log(key); // name, school }for...in会把school也打印出来而这个属性并不是obj自身的。正确做法是配合Object.hasOwn()来过滤for (const key in obj) { if (Object.hasOwn(obj, key)) { console.log(key); // 只打印name } }在ES2022之前一般用Object.prototype.hasOwnProperty.call(obj, key)因为直接调用obj.hasOwnProperty(key)时如果对象自身的hasOwnProperty被覆盖了会出问题。ES2022之后可以直接用Object.hasOwn()。7.2hasOwnProperty被覆盖的情况说到hasOwnProperty这里有个进阶陷阱如果对象的原型链上有人覆盖了hasOwnProperty方法就会导致误判。const obj { name: 张三, hasOwnProperty: function() { return false; // 恶意覆盖或意外覆盖 } }; console.log(obj.hasOwnProperty(name)); // false被坑了 console.log(Object.hasOwn(obj, name)); // true安全 console.log(Object.prototype.hasOwnProperty.call(obj, name)); // true安全这个问题在框架源码里也有体现所以现在官方推荐统一用Object.hasOwn()既干净又安全。7.3 扩展内置对象原型的影响范围我认识一位同事曾经在项目的公共文件里写下这样一行代码String.prototype.trimAll function() { return this.replace(/\s/g, ); };最开始运行得很好但后来引入了第三方图表库突然出现诡异的报错图表库内部某个字符拼接逻辑完全乱了。排查了半天发现图表库内部自己也定义了trimAll并且实现逻辑完全不同。两个定义相互覆盖导致各自的行为都不符合预期。这就是扩展内置对象原型的真实教训你以为你只影响了“自己的代码”实际上你影响了运行在你页面上的所有代码。在多人协作和第三方依赖复杂的环境下这等于是在公共草地里擅自种了一棵树随时可能被人拔掉也可能绊倒别人。我的建议是这类扩展只放在单独的模块里并且顶部加注释或者干脆封装成普通函数引入时用import解构。如果你确实需要在原型上挂方法至少用Object.defineProperty把它设为不可枚举这样可以规避for...in的遍历问题。7.4Object.create(null)到底解决了什么问题很多人看到Object.create(null)觉得奇怪好好的对象不用干嘛搞个没原型的出来举一个真实的场景。你在做前端埋点时需要维护一个“事件名 → 回调函数”的映射表。如果用普通对象const handlers {}; // 假设有一个事件名叫constructor或toString handlers[constructor] function() { console.log(test); }; console.log(handlers.hasOwnProperty(constructor)); // false! 因为constructor来自原型链更严重的是const handlers Object.create(null); handlers[toString] function() { console.log(test); }; // 普通对象你可能会这样判断是否有对应handler if (handlers[toString]) { // 这里会进入但如果用普通对象即使你不设置也可能会进 }使用Object.create(null)创建的对象没有hasOwnProperty、没有toString、没有constructor它是一个“纯净”的字典任何命名的键都不会和原型链上的属性冲突。这类对象也被称为“空原型对象”在需要键值对集合的低层库中非常常用。但要注意因为Object.create(null)创建的对象没有toString方法所以直接console.log(handlers)时控制台输出可能不如普通对象直观也不能直接用handlers.hasOwnProperty(...)而要用Object.hasOwn(handlers, key)。7.5 原型链上属性遮蔽导致的问题当实例自身定义了和原型链上同名的属性时就发生了属性遮蔽访问该属性时直接返回实例自身的值原型链上的值被遮住了。这通常是正常行为但有一种场景会导致难以排查的bug。看这个例子function Person() {} Person.prototype.name 默认姓名; const p1 new Person(); const p2 new Person(); p1.name 王五; // 只是在p1添加了自身属性p2不受影响 console.log(p1.name); // 王五 console.log(p2.name); // 默认姓名这看起来没问题。但如果你用delete删除p1.name再访问时就会重新回到原型链上拿值delete p1.name; console.log(p1.name); // 默认姓名因为原型链上有name这意味着删除对象属性并不能保证“这个属性不存在了”你只是把它“打回原形”。在写代码时如果一个属性可能是继承来的删除操作会有意外行为。此时更好的方式是把属性值设为undefined或null而不是delete。另外当你想判断一个属性是自身属性还是继承属性时用Object.hasOwn()可以准确区分。8. 原型链相关的工程实践建议8.1 日常开发中如何规范地使用原型与其整天纠结原型链的这些“坑”不如从源头建立良好的编码习惯。我在团队内部推过几条规范在这里也分享给你第一优先使用class语法避免手动操作prototype。虽然class本质也是原型链但语法糖让继承关系更清晰可读性和维护性都更好。第二禁止随意修改内置对象的原型。如果确实需要扩展必须在代码评审中特别说明理由并且设置不可枚举。第三对象合并、深拷贝时必须过滤__proto__等危险键。可以封装一个统一的深拷贝工具函数在内部处理这个安全问题。第四团队内统一使用Object.hasOwn()做自身属性判断替代obj.hasOwnProperty(key)。这些规范看着简单但能避免掉绝大多数原型链相关的线上事故。8.2 深拷贝、对象合并中的原型链问题写一个深拷贝函数时如果你不够小心很容易把原型链上的属性也拷贝进去。来看一个简化版的深拷贝function deepClone(target, map new WeakMap()) { if (target null || typeof target ! object) return target; // 处理循环引用 if (map.has(target)) return map.get(target); // 区分数组和对象 const result Array.isArray(target) ? [] : {}; map.set(target, result); // 关键只拷贝自身属性不要拷贝原型链上的属性 for (const key of Object.keys(target)) { result[key] deepClone(target[key], map); } return result; }关键点是用了Object.keys()而不是for...in因为Object.keys()只返回自身可枚举属性天然绕过了原型链上的属性。如果你用for...in就需要额外配合Object.hasOwn()过滤。在实现深拷贝时还容易忽略一个问题对特殊对象如Date、RegExp、Map、Set要单独处理。而且拷贝后最好保留原来的原型链关系但在常规业务场景中深拷贝只需要实现“数据”的复制不必纠结原型链的完整性。8.3 在团队代码评审中如何发现原型链问题我做了不少次code review总结出几个高频出现的原型链问题点第一个看到直接给Array.prototype、String.prototype、Object.prototype赋值的行为一律打回。不管动机多么合理都不推荐在项目里做这种事。第二个看到Object.create(someObject)时检查someObject是否是预期作为“原型”的对象。特别是Object.create(null)和普通对象混用时后续代码的hasOwnProperty调用可能报错。第三个看到for...in循环必然要有hasOwn过滤。没有过滤的for...in一旦遇到原型链被扩展的情况就会出现诡异bug。第四个看到自定义构造函数或class继承检查是否重写了prototype后忘了修正constructor指向。这四个点能在评审阶段拦截绝大部分原型链相关的问题。即使团队里有人对原型链不太熟有了这几条硬性规范也能避免出大乱子。8.4 从原型链延伸出来的“为什么我不建议用lodash的each”用for...in遍历对象时容易带到原型链属性这是我一直避免使用某些“魔法遍历”的原因。以lodash的_.forIn为例它默认会遍历原型链上的可枚举属性而这个行为在业务开发中几乎总是非预期的。我更推荐的做法是// 遍历自身可枚举属性 Object.keys(obj).forEach(key { const value obj[key]; // ... }); // 遍历自身所有属性包含不可枚举的 Reflect.ownKeys(obj).forEach(key { const value obj[key]; // ... });Object.keys已经能满足绝大多数业务场景。只有当你确实需要遍历不可枚举属性时才用Reflect.ownKeys但这种情况极其罕见。记住一个原则默认不去访问原型链上的属性除非你明确知道自己在做什么。9. 我的经验总结原型链学习路线图以我带过不少前端新人的经验来看原型链的学习可以分成三个阶段。第一阶段是“能用”知道怎么读代码、怎么排查基本的属性问题。第二阶段是“能写”能手写new、instanceof、能说出几种继承方式的差异。第三阶段是“能讲”能把原型链和闭包、事件循环、作用域链等知识点串起来形成一个完整的JS知识网络。如果你现在处于第一阶段我的建议是不要急着背面试题先去看几个实际出问题的案例。比如在控制台敲一敲代码看看{}.__proto__到底长什么样[].__proto__.map Array.prototype.map为什么是true。把这些问题亲手验证一遍比看十篇文章都管用。如果你处于第二阶段可以试着去读一下某个开源库的源码找找里面哪里用了Object.create、哪里用了setPrototypeOf、哪里判断了hasOwnProperty。读源码不是为了照抄而是为了理解真实项目里原型链是怎么被使用的。如果你已经到第三阶段那原型链对你的意义就不只是面试了。你会在写代码时本能地避开那些坑会在设计抽象层时自然地想到用原型链来复用逻辑会在排查线上问题时第一时间想到“是不是有属性被原型链上的同名属性遮蔽了”。最后再分享一个小技巧。面试或工作中如果你被问到原型链相关的问题别急着背定义先画一条链路的“地图”在脑子里实例对象 → 构造函数的prototype → Object.prototype → null 函数对象 → Function.prototype → Object.prototype → null这两条链路记熟了大部分原型链的题都能迎刃而解。剩下的就是多写、多练、多排查把它变成肌肉记忆。原型链这个东西你越怕它它越玄乎你正面硬刚它其实就那么点事儿。
返回列表