我以前一直觉得,原型链是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.create:
Student.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(实例) - 从原型得到构造函数:
原型.constructor
constructor这个属性,主要作用是让一个对象“能找回自己的构造函数”。实际开发中我们用它做类型判断时不推荐,因为它是可变的(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.prototype Function.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.prototype:
Foo.__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是 false,toString输出[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,后端做数据校验直接报错。
排查了很久,最后定位到问题出在我“污染”了原型。从那以后我的原则就两条:
- 能不改原生原型就不改。
- 真要扩展,用
Symbol:
const 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,还是设计一个带继承关系的公共模块,你都不会再觉得原型链是玄学。