ARTICLE DETAIL

资讯详情

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

图解JavaScript原型链:从new到__proto__再到constructor

图解JavaScript原型链:从new到__proto__再到constructor 我以前一直觉得原型链是JavaScript里最容易“背了又忘”的概念。面试前背一轮new、prototype、__proto__、constructor来回抄十遍可真到排查一个问题比如“为什么我给某个对象挂了个方法另外一处就莫名奇妙多出来了”脑子里的链条还是对不上。原因很简单我们背的是结论不是机制。最近同事又拿着“什么是原型链”的热搜词来问我说网上一搜全是长篇大论越看越晕。我把手里的项目代码一推干脆用图来聊一次——从它到底在解决什么问题到ES3到ES6一步步怎么演化的再到那些面试题背后的真实逻辑。这篇我就按这个思路写尽量不出现任何一句“废话八股”你跟着把图画一遍基本就再也忘不掉了。1. 原型链到底在解决什么问题从一次属性查找说起1.1 一个变量背后藏着多少层查找先看最简单的代码const obj { a: 1 }; console.log(obj.a); // 1 console.log(obj.toString); // function toString() { [native code] }obj这个对象里明明只定义了一个a属性为什么访问obj.toString还能拿到一个函数这个问题的答案就是原型链存在的全部意义。当你访问obj的某个属性时JavaScript引擎并不是“只看obj自己”而是从一个叫做“原型链”的查找链上逐级去找。查找顺序是这样的先在obj自身找找到就返回。没找到去obj.__proto__指向的那个对象找。再没找到继续去那个对象的__proto__找。一直找到__proto__为null为止找不到就返回undefined。obj的__proto__指向的是Object.prototype所以toString是在这一步被找到的。Object.prototype再往上的__proto__是null链条到这结束。这个机制很像你找东西的顺序先翻自己包没有就翻对象的包再没有就去翻爸妈的包一层层往上问。唯一和现实不同的是JavaScript这条“家族链”不是靠血缘而是靠__proto__这个指针手动指定的。1.2 为什么JavaScript要设计成“顺着链找”而不是“复制一份”很多从Java、C转过来的同学会不习惯类继承不是应该把父类的属性和方法“复制”到子类里吗为什么JavaScript偏偏要搞一套“查找”机制每次访问属性还要跑一条链问题在于JavaScript在设计之初就是一个“能动的”脚本语言它更关心的是“对象能不能响应这个操作”而不是“对象是不是某个类的新实例”。这种思路叫鸭子类型——只要会叫长得像鸭子那就是鸭子。于是它选择了“委托式继承”不是把方法复制过去而是让子对象持有一个指向父对象的引用。当你调用方法时子对象自己如果没有就委托给父对象去执行。这个设计带来三个非常明显的好处省内存一万个实例共享同一个方法方法只存一份。运行时可修改我可以在运行阶段往原型上动态挂方法已有的实例立刻就能拿到。多态天然发生子对象可以自己定义一个同名属性“屏蔽”掉原型链上的那个这就是最常见的属性遮蔽shadowing。坏处也不是没有找属性的成本不是O(1)而且如果你不小心改到了原型对象所有实例都会被影响——这一点在“原型链污染”那节我详细讲。2. 前世篇从ES3的prototype到ES6的class这个机制是怎么一步步长成今天这样的2.1 ES3时代构造函数是唯一的“类”在ES6的class出来之前JavaScript里根本没有“类”这个语法。你想创建一个“人类”的多个实例只能靠构造函数function Person(name) { this.name name; } Person.prototype.sayHello function() { console.log(Hello, I am this.name); }; const tom new Person(Tom); const jerry new Person(Jerry); tom.sayHello(); // Hello, I am Tomnew在执行的时候到底发生了什么用大白话说做了四件事创建一个全新的空对象。把这个空对象的__proto__指向构造函数的prototype对象。以这个空对象为this执行构造函数内部的代码给实例绑属性。如果构造函数没有显式返回对象就返回这个新对象。注意第二步这是整个原型链机制的关键扣环。Person.prototype负责存“公共方法”实例通过__proto__找到它。而构造函数里的this.name name是往新对象自身挂属性。你也许已经发现问题了方法放在Person.prototype上属性放在this上这几乎是一条铁律。为什么方法不放构造函数里因为每次new都会重新执行构造函数如果写成this.sayHello function(){}一万个实例就会创建一万个函数对象内存白白浪费。放到prototype上则只存在一份。2.2 ES5的补丁Object.create让“纯原型继承”成为可能ES3时代做继承常规路子是“把子类的prototype设为父类的一个实例”function Student(name, school) { Person.call(this, name); // 先借用父构造函数 this.school school; } Student.prototype new Person(); // 让子类原型链连上父类 Student.prototype.constructor Student; // 修正constructor指向这段代码能跑但很别扭。new Person()创建实例时会执行一次Person(name)此时name是undefined等于是白白给Student.prototype挂了一个没用的name属性。而且如果构造函数里有副作用这次执行就会造成意外。所以ES5推出了Object.createStudent.prototype Object.create(Person.prototype); Student.prototype.constructor Student;Object.create接受一个原型对象返回一个“以它为原型”的新对象不执行任何构造函数干净得多。你甚至可以Object.create(null)得到一个没有任何原型的“纯净对象”。你只要看看它的polyfill就能一眼看穿原型继承的本质Object.create function(proto) { function F() {} F.prototype proto; return new F(); };一个空壳子构造函数F把它的prototype指向目标原型再new一下就得到了一个“连接着指定原型”的对象。这比new Person()更符合“继承”的本意——我要继承的是Person.prototype而不是执行一次构造函数。2.3 ES6的class语法糖还是真机制ES6的class看起来非常像Java但它骨子里还是原型链class Person { constructor(name) { this.name name; } sayHello() { console.log(Hello, I am this.name); } }你把上面这段代码拿到Babel或浏览器里转译一下就会发现它最后变成的东西跟ES3时代手写构造函数几乎一模一样Person还是个函数sayHello还是挂在Person.prototype上。与手写构造函数相比class补充了几个语法层面的能力extends实现了更直观的继承。static方法直接挂到构造函数上。constructor里的super()负责先初始化父类。类主体天然处于严格模式。但请注意class只是语法糖不改变底层机制。它照样要通过prototype和__proto__来串联。我们经常说“JavaScript没有真正的类只有对象和原型链”这话在class出来后依然成立只是表达能力变强了。2.4 为什么理解“前世”才能理解今天的坑很多人不明白为什么JavaScript里同时存在prototype、__proto__、constructor这三个长得这么像的玩意儿为什么命名这么混乱。因为prototype是ES3时代就有的显式属性它只存在于函数对象上用来指定“用这个函数new出来的实例它们的原型是谁”。而__proto__是实例与原型之间的链接早期是由浏览器引擎实现的内部私有属性后来被广泛实现了才从ES6开始标准化但依然被标记为“不建议直接使用”。constructor则是一个“反向指针”默认存在于函数的prototype对象上指向函数自己。实例本身没有constructor属性它能访问到的constructor其实是从prototype上顺链找到的。所以函数上才有prototype。实例上才有__proto__。prototype里才有constructor。这三个词被历史原因凑在一起命名又互相暗示不搞清楚前因后果当然容易晕。3. 今生篇一张图拆穿new、prototype、__proto__与constructor的关系3.1 先把三个关键属性画清楚不要嫌这个图简陋把下面这段在脑子里画出来比背十遍定义都管用--------------------- | Function.prototype | --------------------- ^ | __proto__ ---------------- | | function Person| | ---------------- | | prototype |--------------------------- | __proto__ |----- | ---------------- | | | | v v ----------------------- --------------------- | Person.prototype | | Function.prototype | ----------------------- --------------------- | constructor: Person | | call/apply/bind | | sayHello: function | --------------------- ----------------------- ^ ^ | | __proto__ | __proto__ ---------------------- | | tom (instance) | | ---------------------- | | name: Tom | | | __proto__ |--------------------- ----------------------核心焦点是这行tom.__proto__ Person.prototype; // true Person.prototype.constructor Person; // true Person.__proto__ Function.prototype; // true tom.constructor Person; // 因为tom自己没有constructor顺着__proto__找到了Person.prototype.constructor我建议大家把“实例、构造函数、原型对象”看成三件套。给定任意一个都能推出另外两个从构造函数得到原型构造函数.prototype从实例得到原型实例.__proto__或者更推荐Object.getPrototypeOf(实例)从原型得到构造函数原型.constructorconstructor这个属性主要作用是让一个对象“能找回自己的构造函数”。实际开发中我们用它做类型判断时不推荐因为它是可变的Student.prototype Object.create(Person.prototype)之后如果不修正constructor指向就是错的但在理解链条时它是重要的锚点。3.2 读代码时建议从实例出发反推看别人代码时如果给你一段对象和构造函数我建议你按这个顺序画链第一步找到实例的__proto__画一条线指向它的原型对象。 第二步看这个原型对象的constructor确认它是哪个构造函数。 第三步看这个构造函数自己的__proto__即它的原型通常指向Function.prototype。 第四步看原型对象的__proto__通常是Object.prototype最后到null。举个例子function Foo() {} const f new Foo();完整链条是f.__proto__ Foo.prototypeFoo.prototype.__proto__ Object.prototypeObject.prototype.__proto__ null同时Foo.__proto__ Function.prototypeFunction.prototype.__proto__ Object.prototype所以f能访问到Object.prototype上的toString、hasOwnProperty、isPrototypeOf等所有方法。3.3 不能忽略的链尾Object.prototype与null之间发生了什么Object.prototype是所有普通对象原型链的终点它本身没有__proto__或者说它的是null。这意味着什么意味着当你访问一个对象的属性一路上找到Object.prototype都找不到就返回undefined不会再往上走了。这里有一个非常实用的技巧Object.create(null)可以造出一个“彻底干净”的对象const pure Object.create(null); pure.a 1; console.log(pure.a); // 1 console.log(pure.toString); // undefined console.log(pure.hasOwnProperty); // undefined它没有任何原型方法最适合当字典/哈希容器或者用来接收不可信的JSON数据避免脏数据污染到公共原型。我在后面讲原型链污染时会再提它。3.4 函数也是对象所以函数也有自己的原型链也许你早就发现了一个隐隐不对劲的地方函数Foo自己也能访问方法比如Foo.call、Foo.apply那函数作为“对象”它的原型是什么答案是Function.prototypeFoo.__proto__ Function.prototype; // true Function.prototype.__proto__ Object.prototype; // true所以你会发现Foo instanceof Function是true而Function instanceof Object也是true——因为Function.prototype顺着__proto__也能连到Object.prototype。再看一个经典题Object instanceof Function; // true Function instanceof Object; // true为什么因为Object本身是一个函数它的__proto__指向Function.prototype而Function.prototype的__proto__指向Object.prototype。所以它们彼此都能在对方的原型链上找到自己形成了JavaScript特有的“互相instanceof”现象。理解这条链比死记true有用得多。4. 八股文终结者用实例拆解原型链的判读与继承陷阱4.1 一组面试题但每一题都解释了它考什么常见的原型链相关面试题写来写去就那么几个。但如果你只是把答案背下来换个问法又懵。这里我挑几个典型的逐题解释它背后的机制。题1[].map Array.prototype.map为什么是true因为数组实例并没有“自己的”map方法它在原型链上找到的就是Array.prototype.map。所有数组调用map实际执行的都是这个同一个函数。这同时也说明你如果给Array.prototype.map做修改会影响所有数组。这也是“原型方法复用”的直观体现。题2字符串是基本类型为什么abc.charAt(0)不报错这里确实有一个“临时包装对象”的机制访问字符串属性时引擎会把字符串自动包装成String对象然后顺着String.prototype查找方法。查完丢弃包装对象。所以abc.charAt实际上指向String.prototype.charAt。同理数字对应Number.prototype布尔对应Boolean.prototype。原始值本来没有属性但JavaScript的口头禅是“一切皆对象”至少在API层面给你补上了。题3({}).toString和[].toString为什么结果不同都是Object.prototype.toString吗不是。数组在Array.prototype上重新定义了toString会输出数组元素拼起来的字符串而普通对象则一路找到Object.prototype.toString输出[object Object]。这就是同一种方法名在不同层级被“遮蔽”的典型案例。再往下走一招Object.prototype.toString.call([]); // [object Array]函数本体是Object.prototype.toString但通过call把this指向数组于是函数内部通过this拿到的Symbol.toStringTag和内部类型是Array所以输出[object Array]。这是一个非常强大的通用类型判断方式也是“方法的宿主是谁”和“this指向谁”可以分离的最好例子。题4(function(){}).bind({})之后它的prototype还在吗bind返回的函数是一个特殊函数它没有prototype属性。所以如果你试图把它当作构造函数new会报错。这提醒我们箭头函数、bind返回的函数行为上和普通函数不一致因为它们刻意禁用了new的某些能力。这本身就和“构造函数/原型”机制强相关。4.2 instanceof的判定原理以及它为什么不是“类型检查”很多人把instanceof当成“类型检查”这是最大的误区。instanceof的判定规则是判断右值一个构造函数的prototype对象是否存在左值对象的原型链上。所以[] instanceof Array; // true因为 Array.prototype 在 [] 的原型链上 [] instanceof Object; // true因为 Object.prototype 也在 [] 的原型链上 Object.create(null) instanceof Object; // false因为它的原型链上没有 Object.prototype你可以完全手动改变一个对象的原型链从而让instanceof结果反转const a {}; Object.setPrototypeOf(a, Array.prototype); a instanceof Array; // true这再次说明instanceof检查的是“继承关系”不是“创建者是谁”。判断创建者要靠xx.constructor但前面说过它也有被修改的风险。所以实践中我更推荐用Object.prototype.toString.call配合Symbol.toStringTag来判断内建类型。4.3 原型链继承的经典写法与各自的坑老前端都知道ES6之前实现继承有好几种写法每种都有坑。写法一原型链继承function Parent() { this.arr [1, 2, 3]; } function Child() {} Child.prototype new Parent();问题明显arr是引用类型且挂在Child.prototype上。于是两个Child实例共享同一个数组改一个另一个跟着变。这不是我们熟悉的实例独立性。而且无法向Parent传参。写法二构造函数继承function Child() { Parent.call(this); }用call在子实例上执行父构造函数属性变成“自己的”了引用类型不再共享解决了第一个问题。但父类原型上的方法没有继承过来子类实例用不了。你可以在Child.prototype上重新定义方法但复用性就差了。写法三组合继承function Child() { Parent.call(this); } Child.prototype new Parent();集合两者但父构造函数被执行了两次Child.prototype上会残留一份无用的父类属性。能用但不干净。写法四寄生组合继承现代标准答案function Child() { Parent.call(this); } Child.prototype Object.create(Parent.prototype); Child.prototype.constructor Child;核心就一句用Object.create复制父类的原型而不是new Parent()。既不执行父构造函数又能正确连上父类原型链。ES6class底层的extends大致就是寄生组合继承的思路。你看理解Object.create就是理解现代继承的关键。4.4 原型链污染与安全原型链的“动态共享”特性是一把双刃剑。有些库在合并对象时如果不加限制就可能把用户可控的__proto__写进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__: {admin: true}}); merge({}, payload); console.log({}.admin); // true全对象被污染JSON 解析后__proto__会作为普通键出现递归合并时如果直接赋值就会改写原型。防御手段有几种合并时跳过__proto__、constructor、prototype这几个键。用Object.create(null)作为容器它没有原型天然不受这类污染影响。对不可信的全局对象做Object.freeze(Object.prototype)冻结防止属性被改写。这块在Node后端和浏览器端都存在真实风险2022年之后前端安全清单里几乎必查。5. 热搜词“原型链补环境”到底补的是什么5.1 先搞清楚这个场景最近“补环境”这个词在社区里挺热但很多人在解释时说得神乎其神。简单说出现这个需求通常是因为一段JavaScript原本设计在浏览器里运行你要把它搬到没有浏览器API的宿主环境里继续跑比如Node.js 服务端执行一段浏览器脚本。自动化测试中模拟浏览器行为。小程序或WebWorker环境里跑老代码。需要从某个JavaScript运行日志里定位问题但它依赖window、document等对象。你可以在Node里先定义一个全局window、document、navigator保证代码不报错。但“属性存在”和“环境可信”是两回事——如果你创建的window跟真正的浏览器window结构差距太大代码中的很多分支会走错。这时候就需要“补环境”。而补环境的核心工作很大一部分就是在补原型链。5.2 为什么补环境要补原型链浏览器环境里几乎所有对象都有层层原型关系。比如document.createElement(div)返回一个HTMLDivElement实例它的原型链大致是htmlDivElement - HTMLDivElement.prototype - HTMLElement.prototype - Element.prototype - Node.prototype - EventTarget.prototype - Object.prototype - null业务代码或者检测代码经常会这么做element instanceof HTMLDivElement element.tagName DIV Object.prototype.toString.call(element) // 期望 [object HTMLDivElement] element.constructor HTMLDivElement如果你只是随便{ tagName: DIV }一个普通对象顶上去上面的判断会全部不同instanceof是 falsetoString输出[object Object]constructor也完全对不上。也就是说你要“补”不能只补属性还得把对象放到一条正确的原型链上。来看一个最小实现function FakeHTMLElement() {} FakeHTMLElement.prototype { constructor: FakeHTMLElement, tagName: , getAttribute(attr) { return this[attr] || null; }, setAttribute(attr, value) { this[attr] String(value); }, appendChild(child) { this.children.push(child); return child; } }; FakeHTMLElement.prototype.__proto__ Node.prototype; // 如果宿主里有Node就把链串上 function createFakeDiv() { const div {}; div.tagName DIV; div.children []; div.__proto__ FakeHTMLElement.prototype; return div; } const fakeDiv createFakeDiv(); console.log(fakeDiv instanceof FakeHTMLElement); // true console.log(Object.prototype.toString.call(fakeDiv)); // 需要有Symbol.toStringTag配合更优雅的做法是用Proxy加Reflect做一个“环境代理层”当取不到某个属性时再决定从哪个真实原型上“借”方法或者返回一个模拟属性。function createEnvProxy(target, proto) { Object.setPrototypeOf(target, proto); return new Proxy(target, { get(obj, prop) { if (prop in obj) return obj[prop]; const real window[prop]; // 如果宿主有真实对象可以借用 if (typeof real function) return real.bind(obj); return real; }, has(obj, prop) { return prop in obj || prop in obj.__proto__; } }); }这样业务代码里访问element.getBoundingClientRect时即使这个模拟对象上没有也能通过原型链找到或者临时借到一个兼容实现脚本就不会因为“方法不存在”直接中断。5.3 补环境的边界与合理用途聊到这里我必须把话说清楚。补环境这个技术本身是中性的它在你做客户端脚本调试、单元测试环境模拟、服务端渲染兼容时非常有用。我用它最多的场景是在Node里复现某个用户线上报错的执行路径把浏览器脚本塞进测试框架里跑。这类工作本质上是在补一个“可测试的执行环境”。有人会把它用在绕过某些网页的业务校验上那属于灰色地带我不展开也不支持。作为一个前端工程师我建议把注意力放在“理解原型链如何构成对象行为”这个正向价值上——当你真正理解了原型链的各个节点不管是做双端复用的SDK还是设计测试替身都会有很清晰的思路。6. 最后我踩过的几个原型链坑6.1 坑一扩展原生对象线上炸了几年前我负责一个活动页为了取数组最后一个元素方便在业务代码里给Array.prototype挂了一个last()方法Array.prototype.last function() { return this[this.length - 1]; };当时页面功能都正常但后来接入了一个第三方埋点SDK它在某个版本里用for...in遍历数组于是last这个可枚举属性也被遍历到了导致上报数据里多了一堆last:function后端做数据校验直接报错。排查了很久最后定位到问题出在我“污染”了原型。从那以后我的原则就两条能不改原生原型就不改。真要扩展用Symbolconst last Symbol(last); Array.prototype[last] function() { return this[this.length - 1]; }; const arr [1, 2, 3]; arr[last](); // 3for...in不会遍历到Symbol属性而且不会与任何第三方库冲突。6.2 坑二以为class继承是“复制”在super上栽跟头另一个常见误解是以为class的extends是“把父类代码复制到子类”。真不是。它还是靠原型链只是多了一条规则子类构造函数必须先调用super()否则拿不到this。我见过一个同事这么写class A { constructor() { this.a 1; } } class B extends A { constructor() { this.b 2; // Uncaught ReferenceError: Must call super constructor in derived class before accessing this super(); } }报错信息已经说得很清楚了。为什么因为ES6的extends里this是由父类构造函数初始化的。你不先执行父类那一段this压根没有绑定自然不能挂属性。理解了“this是由原型链上的父构造器贡献出来的”就不会犯这个错。6.3 坑三Object.create(null)当字典后obj.toString直接报错有段时间我重构一个缓存模块为了性能和安全把缓存对象全部用Object.create(null)来创建。结果某个老功能里有一段代码这样写if (cache.toString) { // do something }在普通对象上toString来自Object.prototype判断是true。但cache是纯净对象没有原型cache.toString是undefined于是分支直接跳过。排查的时候才发现原来“所有对象都有toString”这个直觉对Object.create(null)不成立。这个坑本身是好特性带来的副作用。用Object.create(null)当字典确实安全高效但要留意任何来自原型链的方法都不能假设存在。写库给外部用的时候最好用Object.prototype.hasOwnProperty.call(obj, key)来判断属性而不是obj.hasOwnProperty(key)后者在纯净对象上会直接崩溃。最后分享一个我经常给团队讲的心法画原型链的时候不要从构造函数开始从实例开始顺着__proto__一步一步往上走每遇到一个节点就看看这个节点的constructor是谁。走一遍比看十篇文章都管用。把这个动作练成肌肉记忆以后不管是面试、排查 bug还是设计一个带继承关系的公共模块你都不会再觉得原型链是玄学。
返回列表