资讯动态

JavaScript原型链完全解析:从查找机制到继承与补环境实战

发布时间:2026/10/14 7:38:46 来源:尧图企业网站定制
前阵子在HoRain云上面调试一个服务端渲染服务日志里蹦出来一行奇怪的报错TypeError: xxx.querySelector is not a function。本地浏览器里明明跑得好好的怎么一到Node环境就翻车排查到最后发现是某个库在Node环境里访问了document对象而这个对象的原型链上压根没有挂对应的DOM方法。这个问题让我下了决心把JavaScript原型链彻底写透。你在浏览器控制台随手打印一个对象展开那个三角形的箭头十有八九会看到一行[[Prototype]]很多人的原型链之旅就是从这里开始犯迷糊的。再加上面试场景里高频出现的“new的时候到底发生了什么”“instanceof的原理是什么”原型链几乎成了前端进阶路上绕不开的第一道坎。这篇文章不打算按教科书式的定义开念而是从实际开发视角出发把原型链的查找机制、继承方案的演进、脱离浏览器环境时如何“补”原型链以及我踩过的各种坑完整地捋一遍。适合刚接触JavaScript不久、看过一些教程但没真正理解原型链的读者也适合那些写过不少代码、却始终没搞懂“为什么我给Array.prototype加个方法整个项目都变了”的老手。1. 原型链的地基三个绕不开的属性每个接触原型链的人首先撞上的就是三个属性prototype、__proto__、constructor。很多人被它们绕晕是因为没分清它们各司其职。我先把它们的分工说清楚后面所有的内容都建立在这三个属性之上。1.1 prototype只有函数才有的“模具车间”在JavaScript里函数和普通对象最大的一个区别就是函数身上天生挂着一个prototype属性。这个属性的值是一个对象而且这个对象的constructor会默认指回这个函数本身。听起来绕其实它做的事情很朴素当一个函数被当作构造函数、用new去调用时新创建出来的实例会被“安排”到这个prototype对象名下。我用一个实际场景来演示最基础的用法function Dog(name) { this.name name; } Dog.prototype.bark function() { console.log(this.name 在汪汪叫); }; const d1 new Dog(旺财); d1.bark(); // 旺财 在汪汪叫这里的Dog.prototype就是所有Dog实例共享的“模具车间”。你在Dog.prototype上放什么方法所有new Dog()出来的实例都能调用。这里有个关键准则方法放原型上属性放实例上。为什么如果每个实例都拷贝一份bark方法1000个实例就得存1000份相同的函数内存直接浪费。共享同一个原型对象相当于所有实例共用同一个方法引用这是JavaScript选择原型机制的根本动因——在“共享”和“隔离”之间找平衡。实例上的name属性必须隔离否则改一条狗的名字所有狗都跟着改那就乱了套。1.2proto每个对象都有的“血缘指针”prototype是函数才有的而__proto__是所有对象都有的。每个对象内部都藏着这个隐藏属性它指向“创建这个对象的那个构造函数的prototype”。用上面的例子来说d1.__proto__ Dog.prototype这个等式成立。这里要特别提醒一句__proto__其实是历史遗留的访问器属性真正的规范名称叫[[Prototype]]它在ES6之后有了标准的访问方式Object.getPrototypeOf()。日常调试用console.dir看结构没问题但代码里判断原型关系时建议用instanceof或Object.getPrototypeOf()不要直接依赖__proto__。它在老版本浏览器上的行为和性能都不可靠而且是一个可写属性随意修改容易埋下隐患。1.3 constructor原型对象回看构造函数的一扇窗constructor严格来说不是实例有的而是“原型对象”有的。Dog.prototype.constructor Dog它存在的意义在于你手里只有一个实例时想查出“这是谁造的”可以先找它的原型再通过原型的constructor找回来。但它也是最容易踩坑的属性。一旦你手动重写了构造函数的prototype对象constructor就会丢失。我见过好多次这样的代码function Dog() {} Dog.prototype { bark() { console.log(叫); } }; const d new Dog(); console.log(d.constructor); // Object而不是Dog原因很简单Dog.prototype被整个替换成了另一个普通对象字面量这个新对象身上没有constructor指向Dog。这时顺着d的__proto__找到新原型对象再找它的constructor就找到了Object。修复方式是在替换原型时手动补上Dog.prototype { constructor: Dog, bark() { console.log(叫); } };做类式封装时这个细节必须注意不然某些依赖constructor做反射或实例识别的库就会莫名其妙出错。2. 原型链的查找机制一次属性访问的完整旅程很多教程把原型链定义为“对象之间的隐式连接关系”听着抽象。我换个说法当你在代码里写obj.someProperty的时候JavaScript引擎做的事其实是一条固定路线的查找。2.1 实例到原型再到null的查找路径引擎会先看obj自身有没有这个属性没有就顺着obj.__proto__找到Dog.prototype这个对象看它有没有没有就再顺着Dog.prototype.__proto__继续往上。每一层找不到就向上走直到走到Object.prototype再往上就是null。到了null还没有属性访问就返回undefined。用一个最简单的例子验证const obj {}; console.log(obj.toString); // 一个函数来自Object.prototype console.log(obj.someRandomProp); // undefinedobj自己身上根本没有toString但能打印出来一个函数说明这就是沿着原型链从Object.prototype上找到的。这条链的每一环都由“创建对象时的构造函数”决定。你用什么方式创建对象基本就决定了它的原型链起点。2.2 为什么终点是nullObject.prototype.__proto__是null。这是一条人为设定的终止规则目的是防止查找演变成无限循环。我自己讲课时喜欢把原型链类比成“家族族谱”你想知道家族里有没有人会一门手艺先问当事人他不会就问父亲父亲不会问祖父……问到最高辈分的那位祖先还是没有这族谱就翻完了再往上没人了。null就是族谱翻到尽头的标记。这段设计还有一个实际意义当你用一个for...in遍历对象属性时它能保证遍历能正常结束而不会因为环状引用卡死。2.3 属性访问的性能账原型链查找不是直接查本地属性每向上走一层都是一次对象属性表的查找。现代JavaScript引擎做了很多内联缓存inline cache优化能在运行时记住属性所在的层数避免每次都从头找。但原型链层级一旦过深缓存命中率就会下降属性访问的开销会明显变大。所以我平常有个习惯业务代码里的原型链层级控制在三层以内实例 → 直接原型 → Object.prototype。不要为了“显得很会继承”把链拉出五六层。这不是玄学是我在团队项目里做性能排查时实测出来的结论。原型链是给代码提供复用能力的不是用来炫技的迷宫。3. 从原型链到继承几种写法的演进与取舍原型链最大的应用价值是继承。我刚学的时候在网上翻解决方案什么“原型链继承”“借用构造函数”“组合模式”“寄生组合式继承”看了一堆最后还是自己把每种写法的优缺点捋了一遍才彻底明白。这一节我把演进路径完整走一遍。3.1 原型链继承直观但有个大坑最原始的写法是让子类的prototype直接指向父类的一个实例function Animal() {} Animal.prototype.eat function() { console.log(吃东西); }; function Dog() {} Dog.prototype new Animal(); const d new Dog(); d.eat(); // 吃东西这个写法很好理解d自己没有eat方法但它的原型链上有Dog.prototype而Dog.prototype本身是Animal的一个实例顺着链再往上就能找到Animal.prototype.eat。问题也随之而来如果父类实例上有引用类型的属性所有子类实例会共享同一个引用。function Animal() { this.tags [animal]; } function Dog() {} Dog.prototype new Animal(); const d1 new Dog(); const d2 new Dog(); d1.tags.push(dog); console.log(d2.tags); // [animal, dog]你只是往d1的tags里加了一个字符串结果d2的tags也变了。因为d1和d2自己身上都没有tags属性访问时都顺着原型链找到了同一个数组。这种共享陷阱在生产环境很致命所以原型链继承几乎不会直接用。3.2 借用构造函数解决共享却丢了原型既然共享的根源是属性在原型上那就在子类构造函数里手动把父类属性初始化到自己身上function Animal() { this.tags [animal]; } function Dog() { Animal.call(this); } const d1 new Dog(); const d2 new Dog(); d1.tags.push(dog); console.log(d2.tags); // [animal]每个Dog实例构造时都执行了Animal.call(this)属性都长在自己身上互不干扰。但坏处也明显Animal.prototype上的方法Dog实例完全访问不到因为Dog.prototype没有和Animal.prototype建立联系。单独用这一招方法没继承到代码还显得很啰嗦。3.3 组合继承经典方案为何不是最优组合继承就是把上面两招合起来用构造函数继承属性用原型链继承方法function Animal(name) { this.name name; this.tags [animal]; } Animal.prototype.eat function() { console.log(this.name 吃饭); }; function Dog(name) { Animal.call(this, name); } Dog.prototype new Animal(); Dog.prototype.constructor Dog;新出来的Dog实例既有了自己的name和tags又能访问Animal.prototype.eat看起来完美。但细看有个浪费new Animal()执行了一次父类构造函数导致Dog实例的原型上平白多出一份name和tags。也就是说父类属性被初始化了两次一次在实例自身一次在原型上。功能上无伤大雅内存和性能上却有冗余。3.4 寄生组合继承业界公认的标准答案真正推荐的方案是用Object.create只继承原型对象不执行父类构造函数function inherit(Child, Parent) { const proto Object.create(Parent.prototype); proto.constructor Child; Child.prototype proto; } function Animal() {} Animal.prototype.eat function() {}; function Dog() {} inherit(Dog, Animal);核心在于Object.create(Parent.prototype)会创建一个以Parent.prototype为原型的新对象这个过程中不会调用Animal构造函数所以父类在构造函数里给实例加的那些属性不会跑到子类原型上。ES6的class extends底层逻辑就是这套寄生组合继承。你理解了这套机制再回头看class就不会觉得它是魔法了。4. 原型链补环境脱离浏览器也要有完整的“家”“补环境”这个词在前端圈子里经常和自动化测试、服务端渲染绑定在一起。其实它的本质就是把非浏览器环境里缺失的原型链一层一层接回去。4.1 补环境的本质是补齐原型链一个正经的前端项目在Node.js里跑服务端渲染或自动化测试时经常会碰到浏览器全局对象不存在的问题。document、window、Element这些在浏览器里随手可用的东西在Node原生环境里可能完全没有或者只有残缺的实现。jsdom、happy-dom这些库能跑起来核心就是它们在Node环境里实现了从EventTarget到Node再到Element、HTMLElement的一整条原型链。举个例子。浏览器里你写document.querySelector(video)在纯Node环境里直接写会报ReferenceError: document is not defined。所谓补环境要做的事情是先构造一个有监听事件能力的EventTarget让它当原型链的顶层再在EventTarget.prototype上补addEventListener、removeEventListener等方法然后让Node.prototype继承它让Element.prototype继承Node……一层层往下搭。最后document是Document的一个实例querySelector被定义在Document.prototype上。代码访问document.querySelector时走的依然是最标准的原型链查找流程。4.2 一个最小可行的原型链搭建示例我写一个最小的骨架演示这个思路实际项目里用的库会比这个复杂得多但原理完全一致// 顶层EventTarget function EventTarget() {} EventTarget.prototype.addEventListener function(type, cb) { this._listeners this._listeners || {}; (this._listeners[type] || []).push(cb); }; EventTarget.prototype.dispatchEvent function(event) { const cbs this._listeners?.[event.type] || []; cbs.forEach(cb cb.call(this, event)); }; // Node继承EventTarget function Node() {} Node.prototype Object.create(EventTarget.prototype); Node.prototype.constructor Node; // Element继承Node function Element() {} Element.prototype Object.create(Node.prototype); Element.prototype.constructor Element; Element.prototype.querySelector function(selector) { // 简化实现实际会走DOM选择器逻辑 return null; }; // Document继承Element function Document() {} Document.prototype Object.create(Element.prototype); Document.prototype.constructor Document; const document new Document(); const el document.querySelector(.video); console.log(el instanceof Element); // true这个骨架里能看到两个关键点。第一每一层继承都用Object.create加手动补constructor这正是寄生组合继承的实际应用。第二document虽然只是一个普通构造函数的实例但因为把原型链补齐了代码里对document.querySelector的访问就可以按照浏览器里相同的路径找到方法。这里也顺便回答一个常见的自动化需求在测试代码里想让一个video元素触发播放结束事件通常会构造一个Event对象再调用dispatchEvent分发。这个操作依赖的正是原型链顶层EventTarget.prototype上挂的dispatchEvent方法。方法之所以能找到靠的还是原型链逐层查找。4.3 补原型链时最容易漏的两个点第一别忘了constructor。每次用Object.create重新指定原型新原型对象上默认没有constructor会导致某些依赖constructor的代码失效排查起来很隐蔽。第二方法挂的位置要对。querySelector在Element.prototype上而getElementById在Document.prototype上。补错层级虽然调起来也能成功但用Object.getPrototypeOf检查时就会发现结构不干净逻辑上也会乱。第三Event对象必须正确设置type属性否则addEventListener监听的事件永远不会被触发——因为dispatchEvent内部就是拿event.type去匹配监听器数组的。这几个点我在实际调试中都踩过说出来都是泪。5. 常见错误与排查技巧实录这一节放的是我实际开发和带团队过程中反复踩过、也被问过几十遍的问题整理成速查式的内容方便针对性查阅。5.1 给内置原型加了方法项目全局出bug在应用代码里写Array.prototype.sum function() {}短期用着爽长期隐患很大。两个库同时改同一个内置原型后加载的会覆盖先加载的线上bug极难定位。而且用for...in遍历数组时挂在原型上的方法只要enumerable没设为false也会被遍历出来直接污染业务逻辑。我处理这类问题的排查顺序是先全局搜Array.prototype.xxx定位谁改了原型再用Object.getOwnPropertyDescriptor查看属性描述符最后判断是改用Symbol属性还是干脆把方法挪到局部工具函数里。Symbol可以避免命名冲突在扩展内置对象时是更稳妥的选择。5.2 hasOwnProperty和in到底有什么区别这个问题几乎每周都有人问。hasOwnProperty只检查对象自身属性in会顺着原型链把父辈属性也算进去。二者结果经常不一样const proto { inherited: 1 }; const obj Object.create(proto); obj.own 2; console.log(obj.hasOwnProperty(inherited)); // false console.log(inherited in obj); // true在写对象拷贝、字段清洗或者做属性白名单时这两个API直接决定结果。比如你要把一个对象转成JSON格式传给后端不小心用in做判断就会把原型链上继承来的属性也带进去造成数据污染。记住一条朴素规则只想处理“自己的字段”用hasOwnProperty想判断“能不能访问到”用in。5.3 instanceof内部原理与失效场景instanceof的底层机制是检查右边构造函数的prototype对象是否出现在左边对象的原型链上。它不是严格的类型检查所以只要原型关系变了结果立刻变function Dog() {} const d {}; console.log(d instanceof Dog); // false Object.setPrototypeOf(d, Dog.prototype); console.log(d instanceof Dog); // true还有一个特别反直觉的场景跨iframe或多个window环境创建的数组用arr instanceof windowA.Array可能会返回false因为原型链来自另一个全局环境。遇到这种跨环境类型判断稳妥的做法是用Array.isArray()或Object.prototype.toString.call()它们不看原型链直接返回内部的类型标签通用性更强。5.4 原型污染攻击一个容易忽略的安全隐患原型链查找机制是“找不到就往上找”这意味着如果有人能把属性写到Object.prototype上项目中所有对象都会被“继承”到那个属性。这是一种真实存在的攻击手法经典触发路径是这样的const obj JSON.parse({__proto__: {isAdmin: true}});当某个深拷贝或对象合并函数按key把用户可控的数据递归赋值给目标对象时遇到__proto__这个key就会直接修改目标对象的原型。结果可能是权限绕过也可能是数据异常。防御思路归纳成三条一是不对用户可控数据做无过滤的递归合并二是用Object.create(null)创建纯词典对象这类对象没有原型天然免疫污染三是在写入前用Object.prototype.hasOwnProperty.call(key, ...)做一次白名单过滤把__proto__、constructor、prototype这几个危险key挡在外面。这块内容在项目安全审查时我每次都会单独过一遍。结尾我自己最深的体会是把原型链理解成“共享 委托”的组合体。它不像class那样是静态的设计图纸更像一套动态的找人流程。年轻时我也想过绕开它后来发现它渗透在JavaScript的方方面面事件系统靠它、内置对象靠它、继承靠它、前端框架的响应式拦截也靠它。建议你专门花一个下午把常见的Array、String、Date、Function、RegExp、Error这几个内置对象的原型链逐一打印出来看一遍我保证你对JavaScript的理解会上一个台阶。最后再分享一个小技巧调试时多试console.dir加Object.getPrototypeOf的组合少直接打印整个大对象既能看清原型关系又不会把控制台刷爆。只要你把原型链这个底层机制吃透了以后再遇到那些稀奇古怪的报错你会发现自己不再慌张而是能顺着这条链一步一步找到问题真正的源头。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑