原型和原型链一直是JavaScript里绕不开的一座山。很多人在初学阶段被prototype、__proto__、constructor这三兄弟绕晕,后面看继承、看new、看instanceof又是一脸懵。更麻烦的是,面试里几乎必考,你在实际写业务代码时虽然不一定天天直接操作原型,但理解不清楚,遇到跨页面组件继承、插件二次封装、框架源码阅读这些场景,马上就会露馅。
这篇文章我打算用最直白的方式,把原型链从底层原理讲到继承的演进过程,再补充一堆实际开发中容易踩的坑和排查技巧。不管你是刚入门的小白,还是写了几年业务想系统捋一遍的开发者,看完应该都能把这块彻底吃透。
1. 先把三个最容易混淆的概念拆清楚
1.1 prototype 到底是挂在谁身上的
很多人一开始记不住prototype和__proto__的区别,我提供一个非常朴素的理解方式。
函数在 JavaScript 里是一种特殊对象,每个函数天生自带一个属性叫prototype。这个属性的值是一个普通对象,我们把它称为“原型对象”。当你把这个函数当作构造函数去new的时候,生成出来的新实例会自动关联到这个原型对象。
function Person(name) { this.name = name; } // 函数身上自带 prototype 属性,值为一个对象 console.log(Person.prototype); // { constructor: Person } const p1 = new Person('张三'); // 实例身上并没有 prototype,它只有 __proto__ console.log(p1.prototype); // undefined console.log(p1.__proto__ === Person.prototype); // true这里有个关键点,prototype是函数这个“构造函数”独有的东西,而__proto__是每个对象都有的隐式属性,指向创建它的那个构造函数的prototype。也就是实例.__proto__ === 构造函数.prototype。
1.2proto就是实例找“爹”的路线图
__proto__在规范里其实是[[Prototype]]的浏览器实现方式,也是对象与对象之间的隐藏关联线。它的作用就是让实例可以沿着这条线往上找属性和方法。
const obj = {}; console.log(obj.__proto__ === Object.prototype); // true const arr = []; console.log(arr.__proto__ === Array.prototype); // true console.log(arr.__proto__.__proto__ === Object.prototype); // true可以这样记,__proto__是实例到构造函数原型对象之间的“引用关系”。它描述了对象从哪来、它的原型是谁,而这条链一层一层往上串,就形成了我们说的原型链。
1.3 constructor 是用来“认亲”的
原型对象里面默认会有一个constructor属性,它回指构造函数本身。
function Dog(name) { this.name = name; } console.log(Dog.prototype.constructor === Dog); // true const d1 = new Dog('旺财'); console.log(d1.constructor === Dog); // trueconstructor本身的实际用途不算大,但因为原型对象一旦被整体重新赋值,constructor就会丢失掉,所以很多源码里会手动把它补回去。这也是我们后文要讲的坑之一。
为了更直观,可以看下面这个简单的对照表:
| 角色 | 谁身上有 | 指向谁 |
|---|---|---|
prototype | 函数 | 函数的原型对象 |
__proto__ | 所有对象 | 构造函数的原型对象 |
constructor | 原型对象 | 构造函数 |
2. new 到底做了什么,从手写实现理解原型链建立的过程
2.1 手写一个 new,你才知道实例怎么来的
new不是一个魔法,它做的事情可以拆成四步:
- 创建一个全新的空白对象。
- 将这个对象的
__proto__指向构造函数的prototype。 - 执行构造函数,将
this绑定到这个新对象上,并传入参数。 - 如果构造函数返回了对象,则返回这个对象,否则返回第一步创建的新对象。
用代码实现的话,大概长这样:
function myNew(Ctor, ...args) { // 1. 创建一个新对象,并让它的原型指向构造函数的 prototype const obj = Object.create(Ctor.prototype); // 2. 执行构造函数,绑定 this const result = Ctor.apply(obj, args); // 3. 如果构造函数明确返回了对象,则返回这个对象 return (typeof result === 'object' && result !== null) || typeof result === 'function' ? result : obj; } function Person(name, age) { this.name = name; this.age = age; } const p = myNew(Person, '李四', 18); console.log(p.name); // 李四 console.log(p instanceof Person); // true console.log(p.__proto__ === Person.prototype); // true我自己在带前端新人时,一直强调手写一次new比背十遍概念都管用,因为整个原型链的建立过程在这里看得清清楚楚:实例没创建之前,它和原型之间没有任何关系,是new在工作时把__proto__给接了上去。
2.2 原型链的属性查找规则
当我们访问obj.name时,JavaScript 引擎做的事情是沿着__proto__链条逐层往上找,直到找到Object.prototype.__proto__为null为止。
function Animal() {} Animal.prototype.say = function () { console.log('动物发出声音'); }; function Cat() {} // 让 Cat 的原型对象继承 Animal 的实例 Cat.prototype = new Animal(); Cat.prototype.constructor = Cat; const cat = new Cat(); cat.say(); // 动物发出声音上面这种旧式继承写法里,cat自身没有say,它的原型Cat.prototype也没有say,但Cat.prototype是Animal的实例,而Animal.prototype上有say。于是查找链就变成了:
cat -> Cat.prototype -> Animal.prototype -> Object.prototype -> null一旦在某层找到了属性,就立即停止查找。如果所有层都没有,就返回undefined。这就是为什么所有对象都能调用toString、hasOwnProperty等方法,因为它们最后都会追溯到Object.prototype。
2.3 instanceof 的原理其实就是遍历原型链
instanceof不是靠检查类型字符串判断的,它检查的是右侧构造函数的 prototype是否出现在左侧对象的原型链上。
function myInstanceof(left, right) { let proto = Object.getPrototypeOf(left); const prototype = right.prototype; while (true) { if (proto === null) return false; if (proto === prototype) return true; proto = Object.getPrototypeOf(proto); } } console.log(myInstanceof(cat, Cat)); // true console.log(myInstanceof(cat, Animal)); // true console.log(myInstanceof(cat, Object)); // true理解了这一节,你对new、原型链查找、instanceof三者的关系就已经有了一个完整的画面。接下来讨论的继承,只是在这个基础上玩花样。
3. 从原型链继承到 class extends,一步步看继承演进的逻辑
3.1 最简单也最原始的原型链继承
原型链继承的核心操作是:把子类构造函数的prototype设置为父类构造函数的实例。
function Parent() { this.hobbies = ['读书', '跑步']; } Parent.prototype.showHobbies = function () { console.log(this.hobbies.join(', ')); }; function Child() {} // 核心思路:子类原型 = 父类实例 Child.prototype = new Parent(); const c1 = new Child(); const c2 = new Child(); c1.hobbies.push('打篮球'); console.log(c1.hobbies); // ['读书', '跑步', '打篮球'] console.log(c2.hobbies); // ['读书', '跑步', '打篮球'] // 注意这里也变了这种写法最大的问题就是引用类型属性会被所有实例共享,用户A改了hobbies,用户B也受影响。这在实际项目中几乎是不可接受的,所以它只适合用来理解原型链,不适合直接用于业务继承。
3.2 盗用构造函数,解决引用共享问题
盗用构造函数的核心思路是在子类构造函数内部手动调用父类构造函数,用call或apply把this绑定到子类实例上。
function Parent(name) { this.name = name; this.hobbies = ['读书', '跑步']; } Parent.prototype.showName = function () { console.log(this.name); }; function Child(name) { // 盗用父类构造函数 Parent.call(this, name); } const c1 = new Child('小明'); c1.hobbies.push('画画'); const c2 = new Child('小红'); console.log(c1.hobbies); // ['读书', '跑步', '画画'] console.log(c2.hobbies); // ['读书', '跑步']这样每个实例都拥有自己独立的hobbies数组,解决了上一个方案的引用共享问题。但它带来了新问题:子类无法访问父类原型上定义的方法,因为子类的原型还是默认的Child.prototype,并没有和父类原型建立连接。也就是说,c1.showName是undefined。
3.3 组合继承:把上面两种方案结合起来
组合继承就是既用原型链继承方法,又用盗用构造函数复制实例属性,这也是 ES5 时代最常用的继承方案。
function Parent(name) { this.name = name; this.hobbies = ['读书']; } Parent.prototype.showName = function () { console.log(this.name); }; function Child(name, age) { // 第一次调用 Parent,给实例添加属性 Parent.call(this, name); this.age = age; } // 第二次调用 Parent,给 Child.prototype 添加属性 Child.prototype = new Parent(); Child.prototype.constructor = Child; Child.prototype.showAge = function () { console.log(this.age); }; const c1 = new Child('张三', 20); c1.hobbies.push('游泳'); console.log(c1.hobbies); // ['读书', '游泳'] c1.showName(); // 张三 const c2 = new Child('李四', 22); console.log(c2.hobbies); // ['读书']组合继承的缺点是父类构造函数被调用了两次,Child.prototype上多了一份不必要的name和hobbies属性,虽然不影响功能,但确实不够优雅。其实也没多大事,但在追求极致的场景下,我们有了更好的方案。
3.4 寄生组合继承:ES5 下的最优解
寄生组合继承的核心优化是:不再通过调用父类构造函数来生成父类实例,而是直接用Object.create复制一份父类原型的副本。
function Parent(name) { this.name = name; } Parent.prototype.sayHi = function () { console.log('你好,我是' + this.name); }; function Child(name, age) { Parent.call(this, name); this.age = age; } // 关键步骤 Child.prototype = Object.create(Parent.prototype); Child.prototype.constructor = Child; Child.prototype.sayAge = function () { console.log(this.age); }; const child = new Child('王五', 25); child.sayHi(); // 你好,我是王五 child.sayAge(); // 25使用Object.create(Parent.prototype)创建的子类原型,它的__proto__指向父类原型,因此子类实例既能继承父类原型的方法,又不会在子类原型上保留多余的父类实例属性。这是 ES5 时代比较干净、性能相对较优的继承方式,网上很多“ES5 最优继承”指的就是它。
3.5 class 和 extends 的底层真相
ES6 的class本质上是语法糖,它的底层机制其实就是寄生组合继承。
class Parent { constructor(name) { this.name = name; } sayHi() { console.log('你好,我是' + this.name); } } class Child extends Parent { constructor(name, age) { super(name); // 相当于 Parent.call(this, name) this.age = age; } }super的语义可以理解成把父类的构造函数绑定到子类实例上执行,而extends会在内部建立子类原型和父类原型之间的连接。有一点要注意,class和 ES5 构造函数有个显著区别:class只能通过new调用,不能直接当函数执行,所以在设计工具类时,如果用class定义,就需要注意这个限制。
另外,子类构造函数里必须先调用super才能使用this,因为 JavaScript 的继承机制要求子类必须先完成父类实例部分的初始化,再赋值子类自己的属性。这一步如果你漏了,浏览器会直接抛错,这也是新手刚上手class时最常见的报错。
四种继承方案的对比可以看下面的表:
| 继承方式 | 引用共享问题 | 能访问父类原型方法 | 调用父类构造函数次数 | 推荐度 |
|---|---|---|---|---|
| 原型链继承 | 有 | 能 | 1 次 | 不推荐 |
| 盗用构造函数 | 无 | 不能 | 1 次 | 部分场景 |
| 组合继承 | 无 | 能 | 2 次 | 旧项目常见 |
| 寄生组合继承 | 无 | 能 | 1 次 | 推荐 |
4. 日常开发中真正容易踩的坑,附带排查技巧
4.1 for...in 会遍历到原型上的属性
如果你用for...in遍历对象,它会同时遍历实例属性和原型链上能被枚举的属性,这点在接手历史代码时特别容易被坑到。
function Person(name) { this.name = name; } Person.prototype.say = function () {}; const p = new Person('张三'); for (const key in p) { console.log(key); // name 和 say 都会被输出 }如果你的本意是只遍历实例自身属性,一定要配合hasOwnProperty做过滤。
for (const key in p) { if (Object.prototype.hasOwnProperty.call(p, key)) { console.log(key); // 只输出 name } }使用Object.prototype.hasOwnProperty.call而不是直接p.hasOwnProperty(key),原因很微妙:如果对象自身的hasOwnProperty被某个库重写过了,或者对象原型链上恰巧没有这个方法,调用就会直接报错。倒也不是每次都出问题,但养成这样的防御式写法,可以避免很多莫名奇妙的兼容性困扰。
4.2 重写原型对象会让 constructor 丢失
直接给函数的prototype赋值一个新对象,是很多封装时做的事,但如果不手动补回constructor,后续代码可能会判断出错。
function Person(name) { this.name = name; } Person.prototype = { say() { console.log('说话'); }, }; const p = new Person('张三'); console.log(p.constructor === Person); // false console.log(p.constructor === Object); // true原因很简单,原文原型对象携带的constructor被整体替换掉了。所以以后凡是重写整个原型对象,请养成顺手补回constructor的习惯:
Person.prototype.say = function () { console.log('说话'); }; Object.defineProperty(Person.prototype, 'constructor', { value: Person, enumerable: false, writable: true, configurable: true, });这里为什么用defineProperty而不是直接Person.prototype.constructor = Person?因为直接赋值会把constructor变成可枚举属性,可能导致for...in遍历时多出一个属性。用defineProperty设置enumerable: false,可以保持和原生的一致。
4.3 修改原型的影响范围比你想的大
原型上的属性和方法被所有实例共享,这也意味着只要你改了原型,所有已经创建的实例都会立即受到影响,不管这些实例是在修改之前还是之后创建的。
function Person(name) { this.name = name; } const p1 = new Person('张三'); Person.prototype.greet = function () { console.log('我是新加的方法'); }; p1.greet(); // 我是新加的方法这种特性在某些场景下很有用,比如给旧实例统一打补丁。但反过来也需要注意,如果你在设计组件基类时在原型上挂了引用类型的数据结构,所有实例会共享同一份数据结构,操作不当就是灾难。我见过一个真实案例,某个团队在一个公共插件里给原型挂了个数组配置项,结果一个页面多个实例互相污染,排查了很久才发现是原型共享问题。
4.4 优先用 Object.create 做原型复制,而不是 new
很多人写继承时习惯用Child.prototype = new Parent(),这会把父类构造函数里的实例属性也复制到子类原型上,容易造成原型上有冗余属性。正确做法是只复制原型对象:
Child.prototype = Object.create(Parent.prototype); Child.prototype.constructor = Child;Object.create传入的参数会作为新对象的__proto__,所以这里创建出来的Child.prototype天然连接到Parent.prototype,而不会多出父类实例的属性。如果担心兼容性,也可以用现代的 APIObject.setPrototypeOf,但总体性能不如Object.create,能不用就别用。
5. 进阶话题:Symbol、箭头函数、原型污染这些边角料
5.1 为什么箭头函数没有 prototype
箭头函数没有自己的prototype,因为它不能被当作构造函数使用。这一点其实反过来理解更通透:箭头函数不绑定this,也没有自己的arguments,自然也就没有作为“构造模板”的资格。
const fn = () => {}; console.log(fn.prototype); // undefined console.log(new fn()); // 直接报错,fn 不是构造函数这个限制我觉得反而是好事,它让代码设计意图更明确,不会有人错把箭头函数当构造函数用。
5.2 new.target 可以判断函数是否被 new 调用
new.target是一个很有意思的元属性,在普通函数中,如果函数是通过new调用的,new.target就是该函数本身,否则为undefined。利用这个特性,可以写一个防御式构造函数:
function Person(name) { if (!new.target) { return new Person(name); } this.name = name; } const p = Person('张三'); // 无需 new 也可以正确创建 console.log(p.name); // 张三在class里,new.target还可以用来限制基类不能被直接实例化,只能被继承。这种模式在一些框架的抽象基类里很常见。
5.3 原型污染是个安全话题
原型污染是指攻击者通过操作对象的__proto__,往Object.prototype上挂属性,从而影响所有对象。常见的触发入口是JSON.parse配合递归合并配置。
const userInput = JSON.parse('{"__proto__": {"isAdmin": true}}'); const config = {}; merge(config, userInput); // 如果 merge 实现不当,就会污染原型 console.log({}.isAdmin); // true 危险!防范原型污染的核心原则是:处理外部输入时,不允许直接操作__proto__、constructor.prototype这类关键属性。现在很多现代库会使用Object.create(null)创建无原型对象来承载外部输入,这也是一个有效的防护手段。
5.4 Symbol.iterator 也定义在原型上
这一点你平时写业务可能没注意,但数组的for...of之所以能用,是因为数组实例沿着原型链找到了Array.prototype[Symbol.iterator]。
const arr = [1, 2, 3]; console.log(arr[Symbol.iterator]); // ƒ values() { [native code] } console.log(Array.prototype[Symbol.iterator] === arr[Symbol.iterator]); // true自定义对象想支持for...of,就得在原型上实现Symbol.iterator。如果你在阅读第三方库源码时看到[Symbol.iterator]这样的键,也别觉得奇怪,它就是给对象定制迭代行为的原生通道。
6. 高频面试自查清单与最后一公里的活用经验
6.1 几个常见问题的回答思路
面试里围绕原型链的问题,翻来覆去也就这几道,我按回答逻辑给你列一下:
“如何实现继承?”不要只回答class extends,最好把 ES5 组合继承和寄生组合继承也提一下,再说明class底层就是寄生组合继承的语法糖。这样既有深度又显得有实践总结。
“instanceof的原理是什么?”回答时可以提,它的本质是检查构造函数的prototype是否在目标对象的原型链上,并注意跨 iframe 场景会失效,因为不同全局环境有各自的Object.prototype。
“如何判断一个属性是实例属性还是原型属性?”用hasOwnProperty判断前者,用in运算符判断是否能在原型链上查到,两者一对比就区分出来了。
“Function.prototype和Object.prototype谁在链的末端?”实际上Function.prototype.__proto__指向Object.prototype,而Object.prototype.__proto__是null。这两个不要弄混,画一条链就明白了。
6.2 一张原型链查找路径的文字版记录
以普通的对象、数组、函数三类为例,它们的查找路径分别如下:
const obj = {}; obj -> Object.prototype -> null const arr = []; arr -> Array.prototype -> Object.prototype -> null function Foo() {} Foo -> Function.prototype -> Object.prototype -> null为什么会这样?因为函数本质上是Function构造函数的实例,所以函数的__proto__是Function.prototype。记住这个规律后,不管多复杂的框架继承关系,你都能顺着__proto__一层一层推导出来。
6.3 我推荐的学习路径和实际活用方式
如果让我给新人一个实用的路径,我会建议用下面这个顺序走:
先写十个纯对象,打印它们的__proto__,观察它们和Object.prototype的关系。再手写三次new,确保能默写出来。然后用 ES5 的方式分别实现原型链继承、组合继承、寄生组合继承,在控制台里打印每个实例的原型链和属性,逐一对比差异。最后再去用class extends,你会发现一切都顺理成章。
这里说一个我自己的体会,单纯看原型概念的效率远不如直接啃一段小型框架的源码来得高。比如去读某个轻量级事件系统的实现,看它如何设计基类、子类如何继承、插件如何通过给原型挂方法实现扩展。读完之后,原型链就不再是一张抽象的图,而是你手里实实在在的工具。
原型链这块内容,难不在于语法本身,而在于它要同时理解“对象创建”“属性查找”“函数特性”三件事。当你把这三件事在脑子里串成一个完整的查找链,你就可以做到真正看到一段代码就能预判它的执行结果。希望这篇内容能帮你在 JavaScript 这条路上少走几个弯路。