前阵子在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 为什么终点是null
Object.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的组合,少直接打印整个大对象,既能看清原型关系,又不会把控制台刷爆。只要你把原型链这个底层机制吃透了,以后再遇到那些稀奇古怪的报错,你会发现自己不再慌张,而是能顺着这条链一步一步找到问题真正的源头。