在一次代码评审中,同事指着我写的一段工具函数说:"你知道arr.map为什么能直接用吗?这个问题的答案,就是 JavaScript 原型链 的核心。"我当时一愣。写了几年JavaScript,天天用map、filter,却很少认真想过这些方法到底从哪里来。后来为了彻底讲清楚这个问题,我把原型链机制从前到后啃了一遍,期间还因为误挂原型属性引发过线上样式错乱的事故。这篇文章就是把那段时间的笔记和实战心得整理出来,从原型链的地基概念、属性查找的完整路径,到继承实现、原型链污染和调试技巧,一次性讲透。
如果你已经能熟练写业务代码,却对prototype、__proto__、constructor这三者关系模棱两可;或者你背过八股文,但真遇到"为什么这个类的实例能调用那个方法"时依旧心里没底,这篇文章应该能帮你补齐最后那块拼图。
1. 一次误挂原型属性引发的"灵异事件"
先分享一个真实的事故。它不是什么高深的框架问题,纯粹是原型链机制没吃透导致的。
1.1 事故现场还原
当时我们运营后台有个表格页,需要对列表里的日期做统一格式化。为了方便,我直接在项目入口文件里写了这么一段:
Date.prototype.format = function (pattern = 'YYYY-MM-DD') { const map = { YYYY: this.getFullYear(), MM: String(this.getMonth() + 1).padStart(2, '0'), DD: String(this.getDate()).padStart(2, '0') }; return pattern.replace(/YYYY|MM|DD/g, (key) => map[key]); };本意是让所有Date实例都能调用.format()。代码上线后业务确实正常,数据展示也符合预期。但大概过了两周,运营反馈:某个筛选组件的日期面板打不开了,控制台一片红色报错。
排查时发现,报错来自日期组件内部——它在自己的逻辑里遍历了某个对象的所有可枚举属性,结果把我挂在Date.prototype上的format方法也扫了出来。组件内部没有做hasOwnProperty过滤,于是把函数当数据处理,直接崩了。
1.2 事故背后牵出的核心机制
这个事故其实包含了两层原型链知识:
Date.prototype上新增的方法,会出现在所有 Date 实例的查找路径上,这是原型链"共享"特性的体现;- 遍历对象属性时,
for...in或Object.keys之外的工具方法会沿原型链枚举到继承来的属性,这是原型链"向上查找"的副作用。
从那天起,我对原型链的态度就是:它既是最强大的复用机制,也是最容易引发全局污染的地方。后面所有小技巧,都建立在"先理解它,再敬畏它"的基础上。
提示:修改内置对象原型(如
Array.prototype、Date.prototype)在多人协作项目里是高风险操作。即便要做,也要加前缀命名、用Object.defineProperty设为不可枚举,并且写入团队规范文档。
2. 原型链的地基:prototype、proto、constructor 三件套
要真正理解原型链,绕不开三个核心属性。很多人在这里卡住,是因为这三个属性名字太像、指向也容易绕晕。我用一组代码把它们的关系直接摊开。
2.1 先分清prototype和__proto__
prototype是构造函数才有的属性,指向一个对象,这个对象会成为由该构造函数创建的所有实例的"原型"。__proto__是每个对象都有的属性,指向构造这个对象的函数的prototype。
用代码验证:
function Person(name) { this.name = name; } Person.prototype.sayHi = function () { console.log(`Hi, I am ${this.name}`); }; const p1 = new Person('Tom'); console.log(p1.__proto__ === Person.prototype); // true console.log(p1.constructor === Person); // true,见下p1本身没有sayHi,但调用p1.sayHi()不报错,因为p1.__proto__指向Person.prototype,而Person.prototype上挂着sayHi。
new操作符做了三件事:创建一个新对象、把这个对象的__proto__绑定到构造函数的prototype、用构造函数体内的this初始化对象属性。第三条很多人知道,前两条才是原型链的起点。
2.2 constructor:对象的"出身证明"
每个prototype对象上默认带一个constructor属性,指回构造函数本身:
console.log(Person.prototype.constructor === Person); // true所以前面p1.constructor的查找路径是:p1自己没有constructor,沿原型链找到Person.prototype.constructor,再指向Person。
这个属性的作用不只是"查户口"。很多类型判断库会利用它来辅助识别对象类型,尤其在instanceof失效的场景下。
2.3 特殊情况:箭头函数与Object.create
箭头函数没有自己的prototype,所以不能作为构造函数:
const Fn = () => {}; console.log(Fn.prototype); // undefined而Object.create(proto)是另一种创建对象的方式,它直接指定新对象的原型:
const animal = { eat: true }; const dog = Object.create(animal); dog.bark = true; console.log(dog.eat); // true,来自原型 console.log(dog.__proto__); // animal理解Object.create对后面看继承实现很有帮助,许多框架内部就是用它来做原型链连接的。
2.4 三件套关系速查
| 属性 | 谁拥有 | 指向 |
|---|---|---|
prototype | 构造函数 | 实例的原型对象 |
__proto__ | 每个对象 | 构造自己函数的prototype |
constructor | 原型对象 | 构造函数本身 |
记住这张表,三件套不再容易混。还有一个经常被忽略的细节:__proto__是历史遗留的访问器属性,规范推荐用Object.getPrototypeOf(obj)来读取对象原型,后面调试部分会详细讲。
3. 一次属性查找的完整旅程
理解了三件套,接下来是原型链最核心的运行逻辑:当你访问一个属性时,JavaScript 引擎并不是只在对象本身上找。
3.1 查找优先级与逐层回溯
访问obj.prop的流程:
- 在
obj自身查找prop,找到即返回; - 找不到,沿着
obj.__proto__到它的原型对象上找; - 再找不到,继续沿着原型的
__proto__往上; - 直到某个原型的
__proto__为null,返回undefined。
用代码演示完整的链式结构:
const grandParent = { surname: 'Wang' }; const parent = Object.create(grandParent); parent.age = 40; const child = Object.create(parent); child.name = 'XiaoWang'; console.log(child.name); // 'XiaoWang' 自身属性 console.log(child.age); // 40,来自 parent console.log(child.surname); // 'Wang',来自 grandParent console.log(child.xxx); // undefined,整条链都没有每跳一步,就是一次"原型链上的属性查找"。这个过程和闭包作用域链的查找逻辑非常像,都是优先局部、逐层向外。
3.2 链的尽头是 null
几乎所有对象的原型链终点都是Object.prototype,而Object.prototype的__proto__是null:
console.log(Object.getPrototypeOf(Object.prototype)); // nulltoString、hasOwnProperty这些所有对象都能调用的方法,就挂在Object.prototype上,是整条链的公共"工具包"。
Object.create(null)可以创建没有原型的对象,它没有toString、没有hasOwnProperty,完全"裸奔":
const bare = Object.create(null); console.log(bare.toString); // undefined这种对象适合当纯字典(Map 出现前常用来做无污染映射表),但也因为没有原型方法,使用时一不小心就会报错,新人慎用。
3.3 hasOwnProperty 与 in 操作符的差别
这是实际开发中高频踩坑点。
"prop" in obj:判断属性在不在整条原型链上;obj.hasOwnProperty("prop"):只判断属性是不是对象自身的。
console.log('name' in child); // true,来自自身 console.log('age' in child); // true,来自原型 console.log(child.hasOwnProperty('name')); // true console.log(child.hasOwnProperty('age')); // false用Object.keys()只能拿到自身可枚举属性,拿不到原型上的。这也解释了第一节事故里的问题:有些遍历库扫到了原型属性,是因为它用的是for...in这类会走原型链的遍历。
推荐做法:遍历对象时,要么用Object.keys+hasOwnProperty配合,要么用Object.entries,避免把继承属性当数据处理。
4. 基于原型链的继承:从手工实现到 class 语法
ES6 的class只是语法糖,底层依然是原型链。搞懂手工继承,再看class会豁然开朗。
4.1 三种常见原型继承写法对照
写法一:设置 prototype
function Animal(name) { this.name = name; } Animal.prototype.move = function () { console.log(`${this.name} moving`); }; function Dog(name, breed) { Animal.call(this, name); // 借用构造函数,继承"实例属性" this.breed = breed; } Dog.prototype = Object.create(Animal.prototype); // 继承"方法" Dog.prototype.constructor = Dog; // 修正 constructor 指向 const d = new Dog('Bobby', 'Husky'); d.move(); // Bobby moving console.log(d instanceof Animal); // true关键点:
Animal.call(this, name)的作用是在Dog实例上执行父构造函数,把name变成子类实例的自身属性;Object.create(Animal.prototype)切断Dog.prototype原本对Object.prototype的直接继承,改为经由Animal.prototype,这样move方法才会被找到;- 手改
prototype后constructor会丢,要手动补回来。
写法二:直接赋值(不推荐)
Dog.prototype = Animal.prototype;这样做Dog.prototype和Animal.prototype是同一个对象,往Dog.prototype上加方法会同时污染父类,等于把两个类耦合在一起。
写法三:工具方法封装
很多库里的inherits函数,本质就是把Object.create和constructor修正打包起来:
function inherits(Child, Parent) { Child.prototype = Object.create(Parent.prototype); Child.prototype.constructor = Child; }4.2 class 语法背后做了什么
下面的 ES6 代码:
class Animal { constructor(name) { this.name = name; } move() { console.log(`${this.name} moving`); } } class Dog extends Animal { constructor(name, breed) { super(name); // 调用父类构造函数 this.breed = breed; } }它内部做的事,和上面的原型继承写法几乎一一对应:
move方法定义在Animal.prototype上;Dog.prototype的原型指向Animal.prototype;super(name)相当于先拿到父类构造函数并执行,用父类逻辑初始化子类实例。
super还有一个容易被忽略的作用:在子类构造函数中,必须先调用super才能使用this,这是由"实例初始化顺序"决定的——父类先把自己的属性挂在实例上,子类再往后叠加。
4.3 方法重写与 super 调用链
子类可以重写父类方法,同时通过super调用父类版本:
class Dog extends Animal { move() { console.log('Dog barks first'); super.move(); } }super.move()的查找路径是:从Dog.prototype的原型(即Animal.prototype)开始找move,而不是从Dog.prototype自身开始。这一点和普通属性查找不同,初学者容易误以为super只是this的别名,其实它绑定的是"当前方法所属对象的原型链上一层"。
在这个机制下,重写方法时尽量不要在子类方法里直接调用this.__proto__.move.call(this)之类的手工写法——一旦继承层级加深(孙子类),指向就会乱;super是更可靠的语法。
5. 原型链污染:被忽略的工程隐患
把原型链讲完,必须聊一个配套的风险点。前面的事故只是遍历误扫,真正危险的是原型链污染——往Object.prototype上挂属性,导致所有对象都"自带"这个属性。
5.1 污染是怎么产生的
最典型的场景是:在通用工具函数里给Object.prototype或Array.prototype添加自定义方法。
Object.prototype.safeGet = function (key) { return this[key]; }; const config = { theme: 'dark' }; console.log(config.safeGet('theme')); // 看起来挺好用问题在于:任何对象现在都能调用safeGet,包括第三方库内部创建的临时对象。当第三方库遍历对象键时,可能把safeGet当作业务属性处理,轻则数据多出无意义字段,重则直接报错。
另一个常见污染入口是复制对象时没有过滤原型链:
const target = {}; for (const key in source) { target[key] = source[key]; // source 上有原型属性时会被带过来 }如果source的原型链上带着不该有的属性,复制结果就会"传染"。
5.2 工程中的防线:冻结、白名单与工具选择
针对污染,有几个实用防御手段。
- 不要直接改内置原型。团队规范里应该明确禁止向
Object.prototype、Array.prototype、String.prototype等添加属性; - 必须扩展时使用
Object.defineProperty并设置enumerable: false,让它不出现在遍历里:
Object.defineProperty(Date.prototype, 'format', { value: function () { /* ... */ }, enumerable: false, configurable: false, writable: true });- 复制对象时使用
Object.assign,它只复制自身可枚举属性,不会带上原型链上的字段; - 接收第三方数据的边界对象,可以考虑
Object.create(null)创建"无原型字典",从根上隔离污染; - 如果项目确实有历史包袱,已经有人往原型上挂过属性,可以写一个白名单校验,在数据入口处过滤掉非白名单字段。
5.3 检查是否存在污染的调试方法
怀疑某个对象属性来自原型链时,快速判断:
console.log(Object.hasOwn(obj, 'safeGet')); // ES2022推荐写法 console.log(Object.getPrototypeOf(obj).hasOwnProperty('safeGet'));在全局层面,可以临时扫描常见内置原型的可枚举属性:
for (const key of Object.keys(Object.prototype)) { console.log('suspicious:', key); }顺带一提,市面上有些"原型链补环境"的操作,就是为了让执行环境里的对象表现得更接近真实浏览器。这种做法在自动化测试、模拟环境搭建中有一定价值,但本质上也是在动原型链,必须非常谨慎——一旦哪个原生方法被不当修改,后续排查难度会成倍上升。我个人的原则是:能用Object.defineProperty精确控制,绝不用直接赋值。更高一层的思路是尽量不动原生原型,改为在业务代码入口显式封装工具函数。
6. 原型链调试三板斧
最后分享几个我实际排查原型问题时的固定操作。
6.1 用 Object.getPrototypeOf 代替proto
__proto__虽然浏览器都支持,但它毕竟是历史遗留属性,在严格模式或某些特殊环境下行为可能不符合预期。规范推荐的做法:
const proto = Object.getPrototypeOf(new Person('Tom')); console.log(Object.getPrototypeOf(proto)); // 继续向上排查时我会配合console.dir的展开能力,一次看清某实例的完整原型链:
console.dir(new Person('Tom'), { showHidden: true });在控制台里逐层展开[[Prototype]],比在代码里一路getPrototypeOf快得多。
6.2 通过报错反推原型问题
常见场景:调用方法报is not a function。不要急着怀疑方法名拼写,先确认这个方法应该挂在哪一层原型上。
用一小段命令行验证某构造函数是否有这个能力:
console.log(typeof Array.prototype.map); // function console.log(typeof Date.prototype.format); // function 或 undefined console.log(Object.getPrototypeOf(new Date()).hasOwnProperty('format'));如果打印出来是undefined,说明方法没挂上去或层数不对。有时候方法其实存在,但调用时this绑定错了方向。比如把map从数组里解构出来单独调用,this就丢了:
const map = [].map; map.call([1, 2], (x) => x * 2); // 正确用法 map([1, 2], (x) => x * 2); // 报错,this 是 undefined这种错误排起来通常很隐蔽,好在报错信息会直接指向"map不是一个函数"或"thisis undefined",对照原型链就能马上定位。
6.3 给原型方法打补丁的正确姿势
如果确实需要临时修补一个原型方法(比如垫片),我的固定模板是:
if (!Array.prototype.flat) { Object.defineProperty(Array.prototype, 'flat', { value: function (depth = 1) { // 实现逻辑 }, enumerable: false, configurable: true, writable: true }); }要点有三个:
- 先判断方法是否存在,避免重复定义;
- 用
Object.defineProperty控制属性描述符,保证不可枚举; - 不随意覆盖可能已被其他库扩展过的方法,必要时记录原始实现再包一层。
在实际开发中,原型链相关的 bug 往往会伪装成"数据异常"或"函数不存在"等普通错误,但只要能从"属性从哪一层原型上来"这个角度提问,排查路径就会清晰很多。
如果你现在再回头看[].map这个方法:它不在数组实例上,而在Array.prototype上;{}.toString不在对象字面量上,而在Object.prototype上。整条链通着,方法就能被找到,这就是 JavaScript 原型链 的全部秘密。下次遇到方法调用诡异失败时,不妨先走一遍原型链查找流程,往往几分钟就能定位到问题。