原型和原型链,算是我面试前端候选人时几乎必问的一道基础题,也是我自己当初学 JavaScript 时第一个真正卡住的概念。你要是翻过几篇讲原型的文章,大概率见过那张经典的“三角关系图”——构造函数、prototype、实例三者之间互相引用的关系,看懂了觉得挺明白,一关上页面自己写的时候又容易绕进去。
这道题之所以高频,是因为它不光是面试题,更是 JS 面向对象机制的底层基石。你平时用 class 写继承、用 new 创建实例、给对象扩展公共方法、甚至排查一个属性“莫名其妙出现”的问题,背后全和原型链相关。可以说,搞清楚原型链,才算真正摸到了 JS 对象系统的门道。这篇文章我会从最基础的概念讲起,结合代码实例,把原型链查找规则、继承方案的演进、面试高频考察点以及日常开发中的坑都梳理一遍,争取让你看完之后不仅面试能讲清楚,写代码时也能用得明白。
1. prototype、__proto__与 constructor:先把三个角色分清楚
1.1 你需要先理解的两个“原型”
在 JavaScript 里,“原型”这个词其实对应了两样东西,初学者容易混。
- prototype:只存在于函数身上。它是函数自带的一个属性,指向一个对象,我们常说的“构造函数的原型对象”就是指它。
__proto__:几乎每个对象都有(除了用特殊方式创建的),它指向创建这个对象的那个构造函数的 prototype。它是对象内部[[Prototype]]的非标准访问方式,虽然在现代浏览器里基本都能用,但规范上更推荐Object.getPrototypeOf()。
拿经典的构造函数举例:
function Person(name) { this.name = name } Person.prototype.sayHello = function () { console.log(`Hello, I'm ${this.name}`) } const p1 = new Person('Alice') console.log(p1.__proto__ === Person.prototype) // true这里的关键逻辑是:p1 是一个实例对象,它自己没有 sayHello 方法,但它能调用到,靠的就是p1.__proto__找到了Person.prototype。也就是说,prototype是给“通过构造函数创建出来的实例对象们”共享用的公共空间。
怎么记最稳?一句话:函数有 prototype,实例有__proto__。你在代码里最常见的组合就是一个构造函数 + 它的 prototype 上挂方法,然后用 new 得到实例,实例顺着__proto__找到这些方法。
1.2 constructor 的指向:一个容易忽视的细节
prototype对象身上默认还有一个属性叫constructor,它指回构造函数本身。这个三角关系是完整闭合的:
console.log(Person.prototype.constructor === Person) // true这个属性平时不起眼,但在某些场景下它会出来“捣乱”。最典型的是你重写了 prototype 时:
function Dog() {} // 错误示范:整个替换 prototype Dog.prototype = { bark() { console.log('Woof!') } } const d = new Dog() console.log(d.__proto__.constructor === Dog) // false因为Dog.prototype被整体替换成了一个普通对象字面量,这个新对象上并没有constructor属性,于是顺着原型链往上找,找到了 Object 的 constructor,也就是Object。结果d.constructor会输出Object而不是Dog。
所以规范的做法是:如果你确实要重写 prototype,记得手动把 constructor 补回来:
Dog.prototype = { bark() { console.log('Woof!') } } Object.defineProperty(Dog.prototype, 'constructor', { value: Dog, writable: true, enumerable: false, configurable: true })这算是一个冷知识,但面试官偶尔会拿它考你对 constructor 的理解深度。
1.3 一个简单的记忆框架
我把这套关系压缩成一个可以默写的“公式”:
- 构造函数(函数对象)拥有
prototype prototype默认带constructor,指回构造函数new出来的实例,__proto__指向构造函数的prototype- 原型对象本身也是一个对象,它的
__proto__继续向上指,最终到达Object.prototype Object.prototype的__proto__是null,原型链到此结束
这几行就是整道面试题的骨架。后面的所有内容,都是在这个骨架上继续添肉。
2. 原型链的查找规则:JS 引擎到底怎么找到一个属性
2.1 向上追溯的机制
当你访问obj.xxx这个属性时,JS 引擎做的第一件事不是直接翻 obj 自己的内存,而是先看 obj 本身有没有这个属性(也就是“自有属性”)。如果没找到,就沿着obj.__proto__指向的原型对象继续找;再找不到,就继续往上找原型的原型……直到Object.prototype.__proto__是 null,查找才停下。
这个“逐级向上找”的过程,就是原型链。它和你在浏览器控制台里展开一个对象时看到的嵌套层级一模一样。
举个例子:
function Animal() {} Animal.prototype.eat = function () { console.log('eating...') } function Dog() {} Dog.prototype = new Animal() Dog.prototype.constructor = Dog const d = new Dog() d.eat() // eating...这里 d 找不到 eat,就去 Dog.prototype 找,Dog.prototype 本身是 Animal 的实例,它也没有 eat,就继续去 Animal.prototype 找,这次找到了。整个链路是:
d -> Dog.prototype(Animal 实例) -> Animal.prototype -> Object.prototype -> null你可以想象成一条“属性查找的传送带”,实例在最前端,构造函数们把各自的公共属性依次摆在后面的节点上。理解这条链路之后,再看很多“诡异”的现象就顺理成章了。
2.2 hasOwnProperty 与 in 操作符的差异
面试里经常接着问:怎么判断一个属性是实例自己的还是原型上的?
in操作符:会顺着原型链查,只要链上任意一环有这个属性,就返回 true。hasOwnProperty:只看实例自身,不查原型链。
const obj = { a: 1 } Object.prototype.b = 2 console.log('a' in obj) // true console.log('b' in obj) // true,虽然在原型链上 console.log(obj.hasOwnProperty('a')) // true console.log(obj.hasOwnProperty('b')) // false,b 来自原型我在实际开发中用过最多的场景是:遍历对象属性的时候,想区分是自身属性还是继承来的,或者在使用for...in时配合hasOwnProperty过滤掉原型链上扩展过来的属性。否则一旦有人动过内置原型(或者你引入了某个库做过这类操作),遍历结果会很“脏”。
2.3 为什么对象能调用 toString?
你可能想过一个问题:一个空对象{},为什么可以直接调用.toString()?它自己明明没有这个方法。
原因就在原型链的顶端。Object.prototype上挂了toString、hasOwnProperty、valueOf这些通用方法,任何普通对象顺着原型链都能摸到它们。这也是为什么“万物皆对象”这个概念在 JS 里如此形象。
面试时如果被问到“Object.create(null)创建的对象有什么特殊之处”,答案的核心也在这里:它没有原型链,Object.prototype上的方法一概访问不到。
const pure = Object.create(null) console.log(typeof pure.toString) // undefined这个知识点我在做数据存储、处理不可信对象时经常用到,能有效防止某些原型链上的意外干扰。
3. 基于原型链的继承方案:从 ES5 到 ES6
3.1 原型链继承:最原始的方式与它的缺陷
既然实例能顺着原型链找到构造函数 prototype 上的属性,那经典的“继承”思路就出来了:让子类的 prototype 等于父类的一个实例,这样子类实例就能沿着链找到父类 prototype 上的方法。
function Parent(name) { this.name = name this.hobbies = ['coding', 'reading'] } Parent.prototype.sayName = function () { console.log(this.name) } function Child(name) {} Child.prototype = new Parent() const c1 = new Child('Tom') c1.sayName() // Tom但这套方案有明显问题。最经典的就是:父类构造函数里的引用类型属性会被所有子类实例共享。你看,Child.prototype 是 Parent 的一个实例,它的hobbies也是这个实例上的数组。两个 Child 实例通过原型链访问到的其实是同一个数组,改了一个,另一个也变了:
const c1 = new Child() const c2 = new Child() c1.hobbies.push('swimming') console.log(c2.hobbies) // ['coding', 'reading', 'swimming']这个缺陷决定了纯原型链继承基本只适合用来演示原理,不适合直接用于实际业务代码。
3.2 组合继承:把构造函数的两个作用拆开用
既然问题出在“引用类型被共享”,那解决方案也很直接:每个实例自己创建一份引用属性,只有方法走原型链。
怎么实现呢?在子类构造函数里调用Parent.call(this),这样每个子类实例都会有自己独立的一份属性。方法照旧挂到Parent.prototype上,让子类实例通过原型链获取。
function Parent(name) { this.name = name this.hobbies = ['coding', 'reading'] } Parent.prototype.sayName = function () { console.log(this.name) } function Child(name) { Parent.call(this, name) // 每个实例独立属性 } // 让子类原型挂到父类原型链上 Child.prototype = Object.create(Parent.prototype) Child.prototype.constructor = Child Child.prototype.sayHobbies = function () { console.log(this.hobbies.join(', ')) } const c1 = new Child('Tom') const c2 = new Child('Jerry') c1.hobbies.push('swimming') console.log(c1.hobbies) // ['coding', 'reading', 'swimming'] console.log(c2.hobbies) // ['coding', 'reading'] c1.sayName() // Tom这段代码里有一个关键点:为什么用Object.create(Parent.prototype),而不是Child.prototype = new Parent()?
因为前者只让子类原型顺着父类原型链找方法,不会在子类原型上生成一份多余的父类实例属性。而且new Parent()这种写法的确也能建立原型关系,但多走了构造函数内部逻辑,不干净。Object.create是专门用来“以某个对象为原型创建新对象”的,语义更纯粹。这算是我从实践里总结出来的一个偏好:能用Object.create建原型关系,就别去new一个临时实例。
3.3 ES6 class:看起来像“类”,本质还是原型链
ES6 提供了class语法糖,写起来和 Java 这类语言很像:
class Animal { constructor(name) { this.name = name } eat() { console.log(`${this.name} is eating`) } } class Dog extends Animal { constructor(name, breed) { super(name) this.breed = breed } bark() { console.log('Woof!') } } const d = new Dog('Buddy', 'Golden') d.eat() // Buddy is eating d.bark() // Woof!但你打开这个 class 背后的实现,会发现它的原型关系图和用 ES5 手写组合继承几乎是一模一样的:Dog.prototype的原型是Animal.prototype,d的原型是Dog.prototype。super()做的事就是Parent.call(this)的封装。class 并没有引入全新的对象机制,只是让写法更清晰、更不容易犯错。
在 Babel 转译之后的代码里,你会看到类似_inherits(Dog, Animal)这样的辅助函数,里面核心逻辑也是Object.create(Object.getPrototypeOf(Animal.prototype))这种操作。所以不要误以为学了 class 就可以不搞懂原型链,实际上你越深入 class 的转译原理,越会发现原型链无处不在。
3.4Object.create的另一种用途:简洁的原型式继承
除了在组合继承里用来连接原型,Object.create本身也可以直接实现继承。给你一个原型对象,创建一个以其为原型的新对象:
const animal = { eat() { console.log('eating...') } } const dog = Object.create(animal) dog.bark = function () { console.log('Woof!') } const d1 = Object.create(dog) d1.eat() // eating... d1.bark() // Woof!这套写法的好处是直观、轻量,适合用简单的对象来组织公共行为。不过要留意一点:dog 和 animal 之间的连接是“对象到对象”的,而不是“构造函数到原型对象”那种经典关系。所以如果你后面需要用到instanceof,基于这套写法创建的实例需要调整判断方式。我在写一些配置类、策略类的小模块时倒挺喜欢这种写法,省去了一堆构造函数样板代码。
4. 高频面试题实战拆解:从 new 到手写 instanceof
4.1 new 关键字到底做了什么
new的过程本质上就是“创建实例并连接原型链”的完整封装。面试官考 new 时,最常见的问法是:“你能手写一个new吗?”
拆开来看,new 做了四件事:
- 创建一个全新的空对象
- 把这个空对象的
__proto__指向构造函数的prototype - 以这个空对象为上下文(this),执行构造函数
- 如果构造函数返回了对象类型的结果,返回那个结果;否则返回第一步创建的对象
手写版本大概长这样:
function myNew(Ctor, ...args) { // 第1、2步:创建空对象并连接原型 const obj = Object.create(Ctor.prototype) // 第3步:以 obj 为 this 执行构造 const result = Ctor.apply(obj, args) // 第4步:根据构造函数的返回值决定返回什么 return (typeof result === 'object' && result !== null) || typeof result === 'function' ? result : obj } function Person(name) { this.name = name } const p = myNew(Person, 'Alice') console.log(p.name) // Alice console.log(p instanceof Person) // true这个手写题考察的知识点非常密集。它能一次检验你对Object.create的理解、对函数 apply 的熟悉程度、对构造函数返回值的边界判断。我自己面试别人时,也爱用这道题从侧面判断对方是不是真的理解原型链,而不仅仅是背过概念。
4.2 关于 constructor 的那些坑
前面提到重写 prototype 会让 constructor 指向错乱。除了这个,还有一个高频判断点:
function Foo() {} const f = new Foo() console.log(f.constructor === Foo) // true这个看起来顺理成章,因为 f 本身没有 constructor,顺着原型链找到了 Foo.prototype 上的 constructor,而它指向 Foo。
但如果你把Foo.prototype换成一个普通对象,会让这个判断变成 false;如果你只是给Foo.prototype加属性,就没问题。所以面试里出现“console.log(f.constructor)”这类的输出题时,先看一眼Foo.prototype有没有被整体替换过。
还有一个小知识点:Foo.prototype.constructor是可枚举的吗?默认不是,它被定义成了不可枚举属性。所以用for...in遍历时看不到 constructor。这个细节如果面试官问起来,你能答上来说明你已经看得很细了。
4.3 手写 instanceof:理解右侧的 prototype
instanceof的语义是:判断左侧对象的原型链上,是否存在右侧构造函数.prototype。所以它和“对象是不是这个构造函数制造出来的”是两个概念。比如:
function Animal() {} const a = new Animal() console.log(a instanceof Animal) // true console.log(a instanceof Object) // true,因为顺着链可以到 Object.prototype手写版本:
function myInstanceof(left, right) { let proto = Object.getPrototypeOf(left) const prototype = right.prototype while (proto !== null) { if (proto === prototype) return true proto = Object.getPrototypeOf(proto) } return false }注意这里的关键点是Object.getPrototypeOf(left)的起点是 left 本身的原型,也就是 left 的__proto__。然后不断向上,直到 null。这其实就是上一节讲的“属性查找传送带”的代码化表达。
我在实际开发里很少直接写instanceof,因为跨多 frame 环境、或者对象是用Object.create(null)创建的场景下,它都会失效。更稳妥的做法是检查Object.prototype.toString.call(obj),它不会受原型链污染的影响。
4.4 一道综合输出题,试试你的熟练度
跑到这里,你可以停下来做个自测。下面这段代码,全猜对的说明你的原型链理解基本过关:
function A() {} A.prototype.n = 1 const b = new A() A.prototype = { n: 2 } const c = new A() console.log(b.n) // ? console.log(c.n) // ? console.log(b.__proto__ === A.prototype) // ? console.log(c.__proto__ === A.prototype) // ?答案是1、2、false、true。
关键在于:b创建时,A.prototype还是原来那个带n: 1的对象。后来玩“偷梁换柱”,把A.prototype整个换了,但b.__proto__依旧指向旧对象。c是换完之后再new的,所以指向新对象。
这道题几乎每轮面试都能碰到变体。它考察的本质是:__proto__在对象创建那一刻就确定下来了,后续改变构造函数的 prototype 并不会回溯修改已有实例的引用。
5. 实战中的注意事项与避坑经验
5.1 原型链污染:别随意改内置原型的“地雷”
这是我在团队 Code Review 里最常提醒的一个点。很多人喜欢图省事,直接在Array.prototype、Object.prototype上挂方法:
Object.prototype.polluted = function () { // 危险操作 }一旦这么做了,所有对象都会“继承”到这个属性。用for...in遍历对象时,它会毫无征兆地出现在枚举结果里;如果你写代码时没有做hasOwnProperty过滤,就会莫名多出一堆字段。更严重的情况是,在某些 JSON 解析或对象合并功能的实现里,恶意数据可以通过__proto__字段污染原型链,我见过有安全报告专门讨论这个。
我的经验是:能用普通函数或工具类解决的需求,绝对不要动内置原型。如果只是为了做兼容性补丁,也要确保属性是不可枚举的,减少对正常遍历的影响。
5.2 面试回答原型链问题时的表达框架
既然这是高频面试题,就顺带聊聊怎么答得“出彩”。面试官问“什么是原型链”时,我建议的回答步骤是:
- 先说清楚 prototype 和
__proto__分别是什么 - 画一条最简单的链路:实例对象的
__proto__指向构造函数的 prototype - 补充分界线:
Object.prototype.__proto__是 null,所有查找终结于此 - 再举一个属性查找的实际例子,说明引擎如何沿链向上找
- 如果有余力,再提一笔继承的实现方式、
instanceof的原理
这个顺序把“定义 -> 关系 -> 边界 -> 场景 -> 扩展”都覆盖到了。我自己面试别人时,最反感的是候选人只背出“实例通过__proto__找到 prototype”这一句话就停了。稍微追问一句“那Object.prototype在上面还是下面?null 在哪?”就能看出差距。
一道题背后能延伸出的知识点很多,比如new的实现、instanceof的原理、继承方案的演进、原型链污染风险。抓住其中两三个点深入,你的面试回答就有了层次感,也很容易让面试官记住你。
说起来,我自己教新同事时最常用的一个比喻是:把构造函数当成一台生产图纸的机器,prototype是它给每批产品附赠的“公共工具箱”,实例呢,人手一份“工具箱地址清单”,也就是__proto__。要用工具时,先翻自己口袋,没有就按清单去公共箱子里拿,再没有就按箱子上写的上一级地址继续找,直到找到总仓 Object,再往上就空了。
这个比喻不一定严谨,但对初学者很友好。等你用多了、写多了,这些概念自然会在脑子里形成一张图,比任何口诀都管用。