1. 先搞清楚:JS的面向对象跟Java不是一回事
1.1 从一道面试题说起
很多初学者学JavaScript的面向对象编程(OOP)时,脑子里装的是Java、C++那套"类"的模型:先定义一个类,然后new出对象,对象之间有严格的类型边界。结果一到JS就懵了——ES6的class看上去挺像那么回事,但一深挖就发现到处是prototype、__proto__、constructor,跟传统OOP完全对不上。
我当年就是这么被绕进去的。直到有一天,面试官问了一道题,我才真正把这块打通了:
javascript复制function Person(name) {
this.name = name;
}
Person.prototype.sayHi = function() {
console.log('Hi, I am ' + this.name);
};
const p1 = new Person('张三');
const p2 = new Person('李四');
console.log(p1.sayHi === p2.sayHi); // true
答案很简单:true。但为什么是true?因为sayHi这个方法并不是每个实例自己拷一份,而是挂在Person.prototype上,所有的实例共享同一个方法。这就是JS面向对象跟Java最大的区别——JS没有真正的"类",只有"对象"和"对象与对象之间的关联"。
这引出了一个核心问题:JS到底怎么实现面向对象的三大特性——封装、继承、多态?答案就藏在原型(Prototype)这套机制里。
1.2 封装、继承、多态在JS里长什么样
先别急着看代码,我们把三个概念放到JS的语境里重新理解一遍。
封装:在JS里,最常见的封装手段就是函数作用域。你可以用构造函数加闭包,把不想暴露的变量藏起来。比如:
javascript复制function createCounter() {
let count = 0; // 外部访问不到
return {
increment: function() { count++; },
getCount: function() { return count; }
};
}
ES6之后用class配合#私有字段就更直观了,但底层逻辑没变——JS通过作用域和闭包实现数据隐藏。
继承:这才是本文的重点。JS里继承的本质不是"子类复制父类的模板",而是子类对象通过原型链委托给父类对象。一个对象找不到某个属性时,会沿着__proto__一路往上找,直到找到或碰到null为止。
多态:JS的多态其实最灵活。同一方法名,不同对象可以有不同实现,运行时看的是"这个对象的原型链上谁先响应"。比如toString(),数组有数组的版本,对象有对象的版本,调用时各自找各自的。
理解了这三件事,你就会明白:JS的面向对象,核心不是class,而是"对象+原型链"这套动态机制。ES6的class只是这层本质的语法糖,好看,但糖纸底下还是原型那一套。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 原型:JS继承的底层密码
2.1 prototype、proto、constructor三角关系
很多教程一开始就丢出三个概念,直接把新手整懵。我换个讲法:你只需要记住两个规则。
规则一:函数都有prototype属性,这个属性是一个对象。
javascript复制function Foo() {}
console.log(typeof Foo.prototype); // 'object'
规则二:对象都有__proto__属性,指向构造该对象的那个函数的prototype。
javascript复制const f = new Foo();
console.log(f.__proto__ === Foo.prototype); // true
constructor是干什么的?它是prototype上的一个属性,反向指回构造函数:
javascript复制console.log(Foo.prototype.constructor === Foo); // true
所以三角关系是:构造函数Foo有prototype属性,Foo.prototype是一个对象,对象的constructor指回Foo;用new Foo()创建的实例f,它的__proto__指向Foo.prototype。
你不需要死记硬背,画一张图放在脑子里就行——实例通过__proto__找到原型,原型通过constructor找到构造函数。这个三角关系就是整个原型链的基石。
2.2 原型链到底怎么走
先说结论:原型链就是对象的__proto__链条。当你访问obj.abc时,JS引擎的顺序是:
- 先看
obj自身有没有abc属性; - 没有,就顺着
obj.__proto__去它的原型对象上找; - 原型对象上也没有,就顺着
原型的__proto__继续往上; - 直到某个对象的
__proto__是null,停止,返回undefined。
举个具体的例子:
javascript复制const arr = [1, 2, 3];
console.log(arr.map); // 这里能找到map
console.log(arr.__proto__ === Array.prototype); // true
console.log(Array.prototype.__proto__ === Object.prototype); // true
console.log(Object.prototype.__proto__); // null
数组对象arr自己没有map方法,但是Array.prototype上有,所以能访问到。Array.prototype本身又是个对象,它的__proto__指向Object.prototype,所以数组也能用Object.prototype上的方法,比如hasOwnProperty。
这条链走到头就是Object.prototype.__proto__ === null,相当于链的终点是null。
用文字来写一遍的话,访问arr.toString的过程是:arr本身 → Array.prototype → Object.prototype → null。这也就是为什么有人说"JS里的所有对象最终都继承自Object"——严格说不是继承,是共享同一条祖先链。
这个机制的巧妙之处在于:父级原型上新增方法,子级所有实例立刻能用,不用重新实例化。这是传统的基于类的继承做不到的——类一旦编译完,你再往父类模板里加东西,已经new出来的对象是不受影响的。而JS的这种"活的委托链"是运行时动态的,加了就生效。
3. 用原型实现继承:五种写法逐个拆解
3.1 原型链继承:最基础但也是最坑的
第一种写法,原型链继承。核心思想很简单:让子类的prototype指向父类的实例。
javascript复制function Animal(name) {
this.name = name;
this.features = ['run', 'eat'];
}
Animal.prototype.sayName = function() {
console.log('My name is ' + this.name);
};
function Dog(name) {
this.name = name;
}
Dog.prototype = new Animal();
Dog.prototype.constructor = Dog;
const dog1 = new Dog('旺财');
const dog2 = new Dog('来福');
dog1.features.push('bark');
console.log(dog2.features); // ['run', 'eat', 'bark'] —— 意外!
console.log(dog1.sayName()); // My name is 旺财
问题很明显:
第一,子类无法传参给父类构造函数。Dog构造函数里的this.name其实跟Animal没有半毛钱关系,如果你想让父类做更多初始化工作,做不到。
第二,父类的引用类型属性被所有实例共享。features这个数组存在Animal实例上,而Dog.prototype就指向这个实例,所有Dog的实例共享同一个features。一个狗往里push,所有狗都有了这个特征,明显不符合直觉。
这种写法我只能说"能跑",但它把原型链继承的所有缺点都暴露了。实战中几乎不会单独用它。
3.2 构造函数继承:解决引用共享,却丢了原型方法
既然问题出在父类构造函数没有被正确调用,那么直接在子类构造函数里"借调"父类构造函数不就行了?这就是构造函数继承,也叫经典继承:
javascript复制function Animal(name) {
this.name = name;
this.features = ['run', 'eat'];
}
Animal.prototype.sayName = function() {
console.log('My name is ' + this.name);
};
function Dog(name) {
Animal.call(this, name); // 关键:借用父构造函数
}
const dog1 = new Dog('旺财');
const dog2 = new Dog('来福');
dog1.features.push('bark');
console.log(dog2.features); // ['run', 'eat'] —— 各玩各的,没问题
console.log(dog1.sayName); // undefined —— 原型方法丢了
Animal.call(this, name)的作用是在Dog的实例上下文中调用Animal,把name、features都挂在Dog实例自身上,所以每个实例都有自己的features副本。
但问题又来了:Animal.prototype上的方法,Dog的实例根本拿不到。因为Dog.prototype还是自己的那个原型对象,跟Animal.prototype没有建立任何联系。
所以构造函数继承解决了"引用类型共享"和"传参",但牺牲了"方法的复用"。方法如果都写在构造函数里,那每创建一个实例就重新创建一遍函数,性能和内存上都不划算。
3.3 组合继承:把两种思路拼起来
聪明的人马上想到:那我把这两个方案拼起来不就行了?用原型链继承方法来复用原型上的函数,用构造函数继承来保证实例属性和传参。这就是组合继承:
javascript复制function Animal(name) {
this.name = name;
this.features = ['run', 'eat'];
}
Animal.prototype.sayName = function() {
console.log('My name is ' + this.name);
};
function Dog(name) {
Animal.call(this, name);
}
Dog.prototype = new Animal();
Dog.prototype.constructor = Dog;
const dog1 = new Dog('旺财');
dog1.features.push('bark');
const dog2 = new Dog('来福');
console.log(dog1.sayName()); // My name is 旺财
console.log(dog2.features); // ['run', 'eat'] —— 引用不共享
这个写法确实解决了前面的问题:方法在原型上,实例属性各自独立,传参也正常。
但它有一个隐性问题——Animal构造函数被调用了两次。一次在Dog.prototype = new Animal(),一次在Dog内部的Animal.call(this, name)。第一次调用创建了name和features,但这些属性挂在Dog.prototype上,实例自己的属性又把它们遮住了,白白创建了一份"废属性"。对性能不敏感的项目无所谓,但严格来讲不够优雅。
3.4 寄生组合式继承:目前最完美的方案
如何避免那一次多余的父类调用?答案是不用new Animal(),而是直接让Dog.prototype继承Animal.prototype。怎么做?用Object.create():
javascript复制function Animal(name) {
this.name = name;
this.features = ['run', 'eat'];
}
Animal.prototype.sayName = function() {
console.log('My name is ' + this.name);
};
function Dog(name) {
Animal.call(this, name);
}
Dog.prototype = Object.create(Animal.prototype);
Dog.prototype.constructor = Dog;
const dog1 = new Dog('旺财');
dog1.features.push('bark');
console.log(dog1.sayName()); // My name is 旺财
console.log(dog1 instanceof Animal); // true
Object.create(Animal.prototype)会返回一个以Animal.prototype为原型的空对象,把它赋给Dog.prototype,这样:
Dog.prototype和Animal.prototype建立了原型链关联;- 但是并没有真的调用
Animal构造函数,不会产生多余的实例属性; - 实例的
instanceof Animal依然是true,因为原型链上能找得到Animal.prototype。
这就是寄生组合式继承——目前在ES6 class出现之前,公认的最理想继承方案。你去看很多老牌库(比如Backbone、早期jQuery插件源码)的内部实现,基本都是这个套路。
3.5 ES6 class的本质:还是原型链
ES6的class语法糖把事情变得好看了:
javascript复制class Animal {
constructor(name) {
this.name = name;
this.features = ['run', 'eat'];
}
sayName() {
console.log('My name is ' + this.name);
}
}
class Dog extends Animal {
constructor(name) {
super(name);
}
bark() {
console.log('Woof!');
}
}
但你要知道,这段代码如果编译成ES5,底层还是寄生组合式继承。extends关键字被转译之后,核心逻辑就是Object.create(Parent.prototype)加上Child.prototype.constructor = Child。
所以我的建议是:项目代码用class写,但面试和排坑的时候,脑子里要能把它翻译回原型链。否则你遇到"为什么父类的静态方法子类调用不到"这种问题时,会一头雾水——因为class的static方法挂在哪?挂在构造函数自身上,而extends不会自动帮你拷贝静态属性,本质上是Object.setPrototypeOf(Child, Parent)才把静态方法也接上链的。
4. 实战:从零写一个基于原型继承的用户系统
4.1 需求与设计思路
理论讲了半天,来点实际的。假设我们要做一个用户系统,有三类角色:普通用户(User)、管理员(Admin)、游客(Guest)。用户有基本的name和email,管理员额外有permissions数组,游客没有email但有一个expireAt过期时间。所有角色都能login和logout,管理员能banUser,游客能viewOnly。
按照原型继承的设计思路:
- 基类
User:放通用属性和通用方法; Admin继承User,加自己的属性和方法;Guest继承User,覆盖掉login的逻辑;- 所有方法尽量放
prototype上,不要放构造函数里。
4.2 基类与子类的完整实现
javascript复制function User(name, email) {
this.name = name;
this.email = email;
this.loginStatus = false;
}
User.prototype.login = function() {
this.loginStatus = true;
console.log(this.name + ' 登录成功');
};
User.prototype.logout = function() {
this.loginStatus = false;
console.log(this.name + ' 已退出登录');
};
User.prototype.getInfo = function() {
return {
name: this.name,
email: this.email,
status: this.loginStatus ? 'online' : 'offline'
};
};
function Admin(name, email, permissions) {
User.call(this, name, email);
this.permissions = permissions || [];
this.role = 'admin';
}
// 寄生组合式继承
Admin.prototype = Object.create(User.prototype);
Admin.prototype.constructor = Admin;
Admin.prototype.banUser = function(targetUser) {
targetUser.loginStatus = false;
console.log(this.name + ' 封禁了用户 ' + targetUser.name);
};
Guest.prototype = Object.create(User.prototype);
Guest.prototype.constructor = Guest;
function Guest(name, expireAt) {
User.call(this, name, null);
this.expireAt = expireAt || new Date(Date.now() + 7 * 24 * 60 * 60 * 1000);
this.role = 'guest';
}
// 覆盖:游客的login逻辑不同
Guest.prototype.login = function() {
const now = new Date();
if (now > this.expireAt) {
console.log('抱歉,' + this.name + ' 的访问已过期');
this.loginStatus = false;
return;
}
this.loginStatus = true;
console.log('游客 ' + this.name + ' 临时登录成功');
};
const user = new User('张三', 'zhangsan@example.com');
const admin = new Admin('管理员李四', 'admin@example.com', ['ban_user', 'delete_post']);
const guest = new Guest('游客小明');
user.login(); // 张三 登录成功
admin.login(); // 管理员李四 登录成功
console.log(admin.getInfo());
// { name: '管理员李四', email: 'admin@example.com', status: 'online' }
guest.login(); // 游客 游客小明 临时登录成功
注意代码里Admin.prototype = Object.create(User.prototype)后面紧跟着Admin.prototype.constructor = Admin。这行不写的话,Admin.prototype.constructor会指向User,虽然大部分业务代码用不到constructor,但调试、序列化、某些依赖它的库会出问题。养成好习惯,改完prototype就顺手补回constructor。
4.3 用class重写,对比一下差别
同一个系统用ES6 class重写:
javascript复制class User {
constructor(name, email) {
this.name = name;
this.email = email;
this.loginStatus = false;
}
login() { this.loginStatus = true; console.log(this.name + ' 登录成功'); }
logout() { this.loginStatus = false; console.log(this.name + ' 已退出登录'); }
getInfo() { return { name: this.name, email: this.email, status: this.loginStatus ? 'online' : 'offline' }; }
}
class Admin extends User {
constructor(name, email, permissions) {
super(name, email);
this.permissions = permissions || [];
this.role = 'admin';
}
banUser(targetUser) { targetUser.loginStatus = false; console.log(this.name + ' 封禁了用户 ' + targetUser.name); }
}
class Guest extends User {
constructor(name, expireAt) {
super(name, null);
this.expireAt = expireAt || new Date(Date.now() + 7 * 24 * 60 * 60 * 1000);
this.role = 'guest';
}
login() {
if (new Date() > this.expireAt) {
console.log('抱歉,' + this.name + ' 的访问已过期');
this.loginStatus = false;
return;
}
this.loginStatus = true;
console.log('游客 ' + this.name + ' 临时登录成功');
}
}
差别主要在可读性上:class的extends和super看起来更直观,但底层跟寄生组合式继承完全等价。需要特别注意的是,class的继承有"必须先super()再访问this"的规则,而ES5原型链继承没有这个限制。这是JS引擎在class上强制加的约束,防止你在父类初始化之前访问this造成不可预期的状态。
4.4 一个实战中的细节:如何判断对象类型
继承做完之后,类型判断是个绕不开的话题。instanceof是检查原型链上有没有目标构造函数的prototype:
javascript复制console.log(admin instanceof Admin); // true
console.log(admin instanceof User); // true,因为原型链上有User.prototype
console.log(admin instanceof Object); // true,所有对象都能在这里为true
如果要做更精确的类型判断,可以结合constructor或者直接加一个类型标记(比如上面代码里的role字段)。实战中我更倾向于用role这种显式标记,因为constructor有可能被误改,而instanceof对于跨iframe的场景会失效(不同全局环境有各自的构造函数)。
5. 常见问题与排查技巧实录
5.1 不小心覆盖了prototype,导致整条链断裂
这是很经典的一个坑。很多人知道要改prototype来实现继承,但搞错了方式:
javascript复制function Dog() {}
Dog.prototype = { bark() { console.log('Woof'); } };
const d = new Dog();
console.log(d instanceof Dog); // true
console.log(d.constructor === Dog); // false,constructor现在是Object
为什么?因为你把Dog.prototype换成了一个普通对象字面量,这个对象的constructor指向的是Object,而不是Dog。
更隐蔽的问题是:如果你是在new之后才重置prototype,那已经创建的实例跟新原型之间没有任何关系。原型链是实例出生那一刻通过__proto__定下来的,之后再改prototype,已经创建的实例并不会跟着更新。
经验教训:改prototype要改成"添加属性"而不是"整体覆盖"。实在要覆盖,立刻把constructor补回来。
5.2 忘了调用父类构造函数,属性一片空白
用extends的时候忘了写super(),直接报错"Must call super constructor in derived class"。但用ES5那套写法,忘了User.call(this, name)并不会报错,只会让子类实例缺少父类属性,查起来特别费劲。
我排查过的最难搞的bug就是这种:一个Admin实例,name一直是undefined,但是方法都能正常调用。原因就是子类构造函数里漏了Parent.call(this)那行代码,方法从原型链上找到了,但属性压根没初始化。
排查技巧:遇到"实例有方法但属性缺失"的情况,第一反应去看子类构造函数的函数体,确认有没有正确调用父类构造函数。
5.3 instanceof 并不总是可靠
前面说了,跨iframe、跨窗口的情况,instanceof会失灵。此外,如果你手动改过Symbol.hasInstance,instanceof的行为也能被改写。虽然日常业务很少碰这个,但理解它不靠谱的原理有助于你写出更健壮的代码。
更实用的isPrototypeOf方法也值得一提:
javascript复制console.log(User.prototype.isPrototypeOf(admin)); // true
Admin.prototype.isPrototypeOf(admin); // true
它表达的意思是"某个对象是否出现在另一个对象的原型链上"。在刻意检测原型关系时,比instanceof更直观、更少歧义。
5.4 属性遮蔽带来的困惑
子类原型上有一个方法,实例自身又有一个同名属性,实例会把原型上的方法遮蔽掉:
javascript复制function A() {}
A.prototype.say = function() { console.log('A'); };
const a = new A();
a.say = function() { console.log('B'); };
a.say(); // B
delete a.say;
a.say(); // A,遮蔽解除后又走原型链
这其实正是多态的体现,但调试时容易懵:"我明明在prototype上定义了方法,怎么没有效果?"答案往往是某处代码给实例加了一个同名属性。排查时用hasOwnProperty看属性是不是在实例自身:
javascript复制console.log(a.hasOwnProperty('say')); // true,说明自身有遮蔽
避坑原则:不要在实例上动态添加跟原型方法同名的属性。如果需要覆盖,明确写在继承体系里,让意图清晰。
5.5 初始化顺序导致的坑:先改prototype还是先new
有一段经典的"先new后改"问题我需要再强调一遍:
javascript复制function Person() {}
const p = new Person();
Person.prototype.sayHi = function() { console.log('Hi'); };
p.sayHi(); // Hi —— 这个时候能访问到,因为p的原型链是动态向上查找的
Person.prototype = { sayHi() { console.log('Hi2'); } };
p.sayHi(); // Hi —— 还是旧原型上的方法,新原型和p已经没关系了
这也是为什么业界普遍建议:入口文件里先把所有继承关系建立好,再开始创建对象。不要边运行边改原型结构,否则代码的执行顺序会直接影响结果,非常难调。
6. 一点额外的建议
网上关于JS原型的讨论特别多,有讲"原型链补环境"的,有讲"JS逆向"的,有把原型链跟各种底层引擎挂钩的。说句实在话,你日常项目开发根本用不到那么底层的东西,原型链的核心就是一件事:对象找不到属性,就往父级找,一路找到null为止。把这句话理解透,再去看各种继承写法和源码,都会觉得很自然。
我个人在实际操作中的一个体会是:学原型链最好的方式不是看文章,而是动手改代码看输出。比如在这个用户系统的代码里,把Object.create(User.prototype)换成new User()试试,看看features那类数组属性的表现有什么不同;再试试不给Guest.prototype.constructor赋值,打印一下guest.constructor看是什么结果。这比我在这写一万字的"注意"都管用。
另外一个经验是,现代业务代码里,ES6的class已经足够好用了,完全没必要自己手写寄生组合式继承。但阅读老项目时你会遇到大量的prototype操作,所以这套知识不是"过时的旧技术",而是读懂整个JS生态的底层语言。你越早把它搞懂,后面看Vue源码、写公共组件、设计插件API,都会顺畅很多。工欲善其事,必先利其器,原型链就是那把最基础的工具。
