多彩编程 多彩编程MZPH · CODE BLOG
ARTICLE DETAIL

文章详情

深耕前端与后端开发技术的一线实战笔记与踩坑复盘。

从函数到原型链:彻底搞懂JavaScript对象与继承的核心机制

从函数到原型链:彻底搞懂JavaScript对象与继承的核心机制 作为这几年一直在跟 JavaScript 打交道的人我越来越觉得对象与原型是整个语言里最值得花时间弄清的部分。很多写了三五年业务代码的朋友能把 Vue、React 用得飞起但一问js 中函数是对象吗或者原型链到底是什么就说得含含糊糊。原因不怪他们日常开发里数组方法、事件绑定、组件实例这些抽象层已经把底层细节盖住了原型链就像一根看不见的绳子不主动去拉很难意识到它的存在。这篇文章不打算从 ECMAScript 规范的第一章开始念经而是从我实际遇到过的面试题、线上 Bug、还有那些突然想不通的时刻出发把对象到底是什么、原型链怎么串、创建对象几种方式的底层差异、原型链污染为什么必须警惕、以及判断对象为空、数组去重这类高频操作一次说清楚。适合刚学完基础语法但还没理顺对象模型的初学者也适合准备面试、想彻底弄懂 this 与继承背后原理的进阶读者。1. 函数也是对象到底怎么理解对象概念的认知起点1.1 typeof 的一个反直觉现象很多新手第一次被 JS 整懵就是从 typeof 开始的。typeof (function () {}); // function typeof {}; // object看到一个函数用 typeof 检测出来不是 object于是得出函数不是对象的结论。但紧接着又发现函数居然可以挂属性function greet() {} greet.author 张三; console.log(greet.author); // 张三能挂键值对说明函数内部实际具备存储属性的能力。那为什么 typeof 返回的是 function因为语言层面专门给可调用对象留了一个独立标识它仍然是一个对象只是额外多了一个可调用的内部行为。这就像一个人既是员工又是父亲身份证上写的是员工这个身份标签但不能因此说他不是父亲。1.2 对象在 JavaScript 中的最低门槛如果抛开各种高大上的说法JS 里一个对象其实就两样东西一组属性的集合加上一个指向原型的隐藏引用。不管你面对的是对象字面量、数组、函数、日期、正则底层结构本质上都是这套。const plainObj { name: Tom }; const arr [1, 2, 3]; function fn() {}它们三个都能通过点号访问属性、都能增删属性、都能被赋值给其他变量。区别只在于它们内部多了一些专属行为数组维护着 length 和索引函数可以被调用日期有时间运算逻辑。但对象的基本盘——属性集合和原型链——是共享的。另外提一个冷知识typeof null 返回 object其实是一个历史遗留 Bugnull 并不具备属性集合更不是对象。面试偶尔会问到知道就行。1.3 函数对象带来的连锁推论一旦真正接受函数是对象很多行为就说得通了函数可以作为参数传递本质上传的是对象引用函数可以挂在别的对象上作为方法比如const module { run: function() {} }函数也可以被返回形成闭包的基础jQuery 里$.ajax这种静态方法本质就是在函数对象上挂属性。这些特性叠加起来才有了高阶函数、柯里化、设计模式里的策略模式等等。不理解函数是对象看再多的设计模式教程都隔着一层纱。function compute(a, b, callback) { const result a b; callback(result); } compute(1, 2, (value) console.log(value)); // 3这里的箭头函数本质上也是一个对象被传进 compute。底层就是一次对象引用的传递。1.4 对象比较的是引用而不是内容还有一个被反复问到的点同样根植于对象是引用类型这一事实const a { value: 1 }; const b { value: 1 }; console.log(a b); // false两个不同对象只是内容相同 const c a; console.log(a c); // truec 和 a 指向同一个对象这个看似简单却是后面对象数组去重和深拷贝话题的地基。很多人写去重逻辑时希望结构相同的对象算重复但语言默认只比较引用所以必须自己实现比较规则。2. 原型链的真实运行机制顺着隐藏指针走一遍属性查找2.1 别把 prototype 和proto混为一谈原型链最大的认知障碍是这两个名字。prototype构造函数也就是函数对象上的一个普通属性它指向一个对象这个对象将来会成为实例的原型。__proto__实例对象上的访问器属性指向这个对象实际继承的那个对象。最简单的一句话prototype是构造函数的属性__proto__是实例的链接。实际项目中不要直接写__proto__去操作规范推荐用Object.getPrototypeOf来读取。function Person(name) { this.name name; } Person.prototype.sayHi function () { console.log(Hi, Im this.name); }; const tom new Person(Tom); console.log(tom.__proto__ Person.prototype); // true console.log(Object.getPrototypeOf(tom) Person.prototype); // true2.2 属性查找规则链上谁先找到算谁的当我访问tom.name引擎先看 tom 自己有没有这个属性如果有直接返回不会继续往上找。如果没有就顺着tom.__proto__指向的原型对象再找。这就是为什么tom.sayHi能在原型对象上被找到并调用。整个过程可以理解为一次单向的链表查询tom 实例对象 → Person.prototype → Object.prototype → null查找在每一环都会问你有没有这个问题说的属性有就返回没有继续向上直到链尾 null。找不到就返回 undefined。2.3 new 之后三者之间的三角关系用下面这个三角关系来记忆最省力Person.prototype是一个对象默认带着一个constructor属性指回Personnew Person()创建的实例其__proto__指向Person.prototypePerson.prototype.constructor Person成立。function Person() {} const p new Person(); console.log(Person.prototype.constructor Person); // true console.log(p.__proto__ Person.prototype); // true console.log(p.constructor Person); // true因为沿原型链找到了 constructor这里有个细节p.constructor并不是 p 自己的属性而是顺着原型链从Person.prototype上拿到的。很多人以为 constructor 是实例自带的其实它不是。2.4 数组方法为什么能用原型链的经典案例用数组来理解原型链最直观。const arr [1, 2, 3]; arr.map((x) x * 2); // [2, 4, 6]arr 本身只有索引、length 这些自有属性map 方法并不在它身上。调用时引擎顺着arr.__proto__找到了Array.prototypemap、filter、slice、push 这些方法全都在那里。再往上Array.prototype的原型是Object.prototype所以数组也能调用hasOwnProperty、toString这些对象通用方法。console.log(arr.hasOwnProperty(map)); // false console.log(Array.prototype.hasOwnProperty(map)); // true这一下就把数组为什么能调用对象方法解释清楚了。2.5 链的末端null 与 instanceof 的原理原型链的终点一定是Object.prototype.__proto__也就是 null。这个设计保证了属性查找不会无限循环。instanceof的原理也建立在这条链上object instanceof Constructor就是沿着 object 的原型链找有没有 Constructor.prototype 这个对象找到了返回 true找不到返回 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; }null instanceof X恒为 false这条也可以直接判断原型链的边界行为。3. 创建对象的四种方式与底层差异3.1 对象字面量和 new Object最基础的一层const obj1 { name: Tom }; const obj2 new Object();这两个写法本质上没有太大区别。{}只是new Object()的语法糖生成的实例原型都是Object.prototype。日常开发优先用字面量不是因为性能好多少而是更干净、可读性更好。3.2 构造函数模式new 的完整四步构造函数真正值得研究的是 new 执行时发生了什么创建一个全新的空对象把这个空对象的原型指向构造函数的 prototype执行构造函数把 this 绑定到这个新对象上如果构造函数显式返回了一个对象则返回那个对象否则返回新创建的对象。function Person(name) { this.name name; } const tom new Person(Tom);new 是把普通函数调用变成构造调用的关键。普通调用里 this 指向 window 或 undefined严格模式而构造调用里 this 指向新对象函数结束后这个新对象被返回。有一个容易忽略的点如果构造函数返回了一个原始值比如字符串或数字返回值会被忽略仍然返回新对象只有返回对象时才会覆盖默认行为。function A() { return hello; } const a new A(); console.log(a instanceof A); // true字符串被忽略 function B() { return { special: true }; } const b new B(); console.log(b instanceof B); // false对象覆盖了默认返回这两段代码是面试里很经典的陷阱。3.3 class 语法糖本质还是构造函数ES6 的 class 并没有引入一套全新的继承机制它只是把构造函数写法换成了更清晰的语法。class Person { constructor(name) { this.name name; } sayHi() { console.log(Hi, Im ${this.name}); } static create(name) { return new Person(name); } }sayHi 方法会挂到 Person.prototype 上create 静态方法会挂到 Person 函数对象自己身上。这一点和手写构造函数的本质完全一致只是写法上更贴近其他语言的类。class 还提供了一些额外约束比如类必须用 new 调用、类内方法不可枚举等等。3.4 Object.create手动指定原型的最直接方式Object.create(proto)的作用是创建一个对象并显式指定它的原型。这种方式让继承变得非常透明。const animal { eat() { console.log(eating...); } }; const dog Object.create(animal); dog.bark function () { console.log(barking...); };dog 的原型直接就是 animal不需要经过构造函数。Object.create(null)甚至能创建出完全没有原型的对象网上常说的纯字典一般就是它因为没有原型链可以避免hasOwnProperty被污染导致的问题。构造原型关系时我用得最多的是Object.create来实现原型式继承比手写__proto__或者直接设置Constructor.prototype 某个父类实例要规范得多。4. 原型链面试题的通用破解套路4.1 面试题为什么爱考原型链原型链题目的核心不是在考背诵而是在考一张关系图的掌握程度构造函数、prototype、实例、constructor、原型链、继承、属性遮蔽。把这些关系理顺了很多题不需要硬背顺着图一推就出来。4.2 解题三步法先画关系再查链拿到原型链相关题目我习惯按三步走找出参与对象哪个是构造函数哪个是它的 prototype 对象哪个是被创建的实例画出链接关系实例__proto__指向构造函数的 prototype按题目问法区分查属性值和查 instanceof是两条不同的检索路径。比如这种题function Foo() {} const foo new Foo(); console.log(foo.__proto__ Foo.prototype);答案显然 true。如果换成foo.prototype就会报错因为实例上没有 prototype 这个属性prototype 只挂在构造函数上。4.3 从两道高频题看替换原型和属性遮蔽第一类高频题是替换构造函数原型后旧实例的行为。function Foo() {} const oldInstance new Foo(); Foo.prototype { name: new proto }; const newInstance new Foo(); console.log(oldInstance.name); // undefined旧实例的原型仍指向旧 prototype 对象 console.log(newInstance.name); // new proto原因很简单实例的__proto__在创建那一刻就已经确定了替换Foo.prototype不会回头修改旧实例的原型链。第二类是属性遮蔽。function Foo() {} Foo.prototype.value 1; const foo new Foo(); foo.value 2; console.log(foo.value); // 2 delete foo.value; console.log(foo.value); // 1实例自有的 value 覆盖了原型上的 value删除实例属性之后原型上的又暴露出来。排查线上问题时这种同名属性遮蔽经常会造成莫名其妙的 Bug值得留个心眼。4.4 instanceof 的边界跨 window 等场景instanceof 在某些场景会给出不直观的结果。最典型的例子是 iframe 或 window 之间的对象传递来自不同全局环境的数组用当前 window 的 Array 去判定可能返回 false。原因是两边各有独立的构造函数与 prototype。还有一种常见场景是判断一个值是不是纯对象。直接obj instanceof Object不够严谨因为数组、函数、日期在这些内部也都是对象也可能返回 true。更常用的写法是排除 null 后用Object.getPrototypeOf(obj) Object.prototype或者Object.prototype.toString.call(obj) [object Object]。5. 原型链污染这是所有对象共同的软肋5.1 污染是什么在 Object.prototype 上动手脚原型链让所有对象共享祖先对象的能力但这也意味着如果祖先被改了子孙全会受影响。所谓原型链污染就是攻击者通过某种输入路径修改了Object.prototype或者某个关键原型对象上的属性导致整个应用所有对象都多出了一个不该有的属性。举一个最小的例子来说明危害Object.prototype.isAdmin true; const user { name: guest }; console.log(user.isAdmin); // trueuser 自己没有任何 isAdmin 相关定义但输出 true。这个 true 来自原型链上的 Object.prototype。在做权限判断时如果有人能控制原型链后果不用多说。5.2 为什么会发生递归合并传入proto最常见的触发场景是处理不可信数据的递归合并工具函数。很多代码库会写类似这样的 deep mergefunction merge(target, source) { for (const key in source) { if (typeof source[key] object source[key] ! null) { target[key] merge(target[key], source[key]); } else { target[key] source[key]; } } return target; }这里有几个关键细节for...in会枚举目标对象的属性source可能是 JSON.parse 出来的用户输入如果输入中出现__proto__、constructor.prototype这类键名for...in 也会把它们当作普通 key 处理赋值target[__proto__] ...时由于对象的__proto__有 setter 行为最终可能修改到原型对象。一个极端数据就能触发污染const payload JSON.parse({__proto__: {polluted: true}}); const result merge({}, payload); console.log({}.polluted); // trueObject.prototype 被改了这就是为什么很多库在处理用户对象时会校验或拦截危险键名。5.3 怎么防御三条底线我梳理了几条实践中必须守住的底线递归遍历时遇到__proto__、constructor、prototype这类键直接跳过合并、复制、路径操作工具函数内部用Object.hasOwn(target, key)这类自有属性判断而不是盲目target[key]取值再赋值对用户可控的数据优先存进Object.create(null)出来的对象让它没有原型链从根源上避免污染路径。另外不要随便给内置原型加方法。有些老项目习惯给Array.prototype或Object.prototype打补丁一旦上线整个应用的对象行为都被改变。排查了很久的诡异问题最后发现就是这种善意的扩展导致的得不偿失。5.4 顺带一提JSON.parse 与proto的一个易错细节有个很有意思的细节攻击者为什么喜欢用JSON.parse构造 payload而不是直接写字面量const literal { __proto__: { polluted: true } }; // 字面量会触发 setter直接改变原型 const fromJson JSON.parse({__proto__: {polluted: true}}); // 创建的是普通自有属性对象字面量中的__proto__会被解释为原型设置的语法而JSON.parse生成的是普通数据属性。所以在 merge 这类工具里真正落地到赋值那一步时target[__proto__]才会通过 setter 把污染传到原型链上。这个细节很容易被忽略但理解了它就能明白为什么攻击 payload 普遍采用 JSON 文本形式。6. 高频对象操作判空、去重、深拷贝的实操方案6.1 判断对象是否为空别只用 JSON.stringify判断一个对象是不是空对象最常见的错误写法是const isEmpty (obj) JSON.stringify(obj) {};这个写法有三个坑只包含 undefined、函数、Symbol 值会被忽略比如{ a: undefined }序列化出来也是 {}对象里有循环引用时直接抛错JSON 序列化本身有不可忽略的开销。更稳妥的写法是结合Object.keys和Object.hasOwnfunction isEmptyObject(obj) { if (obj null || typeof obj ! object) return false; return Object.keys(obj).length 0; }Object.keys只统计可枚举的自有属性。如果需要把不可枚举属性也纳入判断可以用Reflect.ownKeys(obj).length 0这会取出所有自有键包括不可枚举和 Symbol。6.2 对象数组去重按 key 去重和整体去重按某个 key 去重是业务里最常见的场景。比如按 id 去重const list [ { id: 1, name: Tom }, { id: 2, name: Jack }, { id: 1, name: Tom }, ]; const seen new Set(); const uniqueList list.filter((item) { if (seen.has(item.id)) return false; seen.add(item.id); return true; });用 Set 而不是数组 includes是因为 Set 查找复杂度是 O(1)数据量大时差距明显。如果需要按整个对象结构去重就不能直接用引用比较。需要先把对象序列化成稳定的字符串。要注意键的顺序问题{a:1, b:2}和{b:2, a:1}应该算同结构的对象。一种简单思路是先排序再序列化function stableStringify(obj) { if (Array.isArray(obj)) { return [ obj.map(stableStringify).join(,) ]; } if (typeof obj object obj ! null) { return { Object.keys(obj) .sort() .map((key) ${key}:${stableStringify(obj[key])}) .join(,) }; } return String(obj); }这个方法只够应付规整的数据如果对象里还嵌套函数、Date、循环引用就得上更成熟的方案。除开特殊场景我的经验是结构去重尽量少做能用唯一 key 去重就优先用 key因为结构完全相同的定义在业务里很容易有歧义。6.3 深拷贝的坑与低成本方案JSON.parse(JSON.stringify(obj))是流传最广的深拷贝写法但坑是真的不少值为 undefined、函数、Symbol 的属性会丢失Date 会变成字符串RegExp、Map、Set 会被转成空对象或普通对象循环引用直接抛错属性为 BigInt 时也会抛错。如果目标环境支持更推荐直接使用浏览器原生提供的structuredCloneconst copy structuredClone(original);它处理 Date、Map、Set、循环引用都比 JSON 方案靠谱得多。Node.js 17 以上的环境同样可用。如果项目环境太老再考虑 lodash 的 cloneDeep。手写一个通用深拷贝不算难但要处理各种边界情况并不轻松能在项目里少写就少写。还有一个概念要特别强调展开运算符是浅拷贝不是深拷贝。const original { user: { name: Tom } }; const copy { ...original }; copy.user.name Jack; console.log(original.user.name); // Jack嵌套对象还是同一份引用很多 Bug 就是这么产生的。用浅拷贝兜底时必须确认对象只有一层结构否则就老老实实深拷贝。最后再分享一个我自己踩过的坑。有段时间项目里出现一个诡异问题所有对象都莫名多了一个status字段排查了很久才发现是某个工具库在初始化时给Object.prototype添加了一个辅助方法结果连普通对象的遍历、合并逻辑全部受到影响。那次经历之后我给自己立了一个规矩永远不要往内置原型上加东西对原型链的理解也不再停留在知道 concept的层面而是真把它当成一份随时可以手画出来的关系图。如果你现在还在被对象和原型绕晕建议花半小时把Person.prototype、__proto__、constructor这三个节点之间的关系亲手抄一遍再跑几个题目验证基本就通了。
返回列表