《JavaScript全栈教程》08:面向对象编程——从原型到 class 继承全解析
本篇核心收获
- 理解 JavaScript 基于原型的面向对象编程与 Java、C# 等传统基于类的语言的核心区别。
- 掌握原型链的工作原理,学会通过
__proto__和Object.create()操作对象原型。 - 深入理解构造函数、
new关键字以及prototype属性的作用,掌握创建对象的多种模式。 - 学会实现经典的原型继承,并掌握用于封装继承逻辑的
inherits函数。 - 掌握 ES6 的
class与extends语法,理解其作为“语法糖”如何简化原型链操作。
一、重新认识 JavaScript 的面向对象
JavaScript 的所有数据都可以看成对象,那是不是我们已经在使用面向对象编程了呢?当然不是。如果我们只使用 Number、Array、string 以及基本的 {...} 定义的对象,还无法发挥出面向对象编程的威力。
JavaScript 的面向对象编程和大多数其他语言如 Java、C# 的面向对象编程都不太一样。如果你熟悉 Java 或 C#,很好,你一定明白面向对象的两个基本概念:
- 类:类是对象的类型模板。例如,定义
Student类来表示学生,类本身是一种类型,Student表示学生类型,但不表示任何具体的某个学生。 - 实例:实例是根据类创建的对象。例如,根据
Student类可以创建出xiaoming、xiaohong、xiaojun等多个实例,每个实例表示一个具体的学生,他们全都属于Student类型。
所以,类和实例是大多数面向对象编程语言的基本概念。不过,在 JavaScript 中,这个概念需要改一改。JavaScript 不区分类和实例的概念,而是通过原型(prototype)来实现面向对象编程。
1.1 原型(Prototype)的概念
原型是指当我们想要创建 xiaoming 这个具体的学生时,我们并没有一个 Student 类型可用。那怎么办?恰好有这么一个现成的对象:
let robot = {
name: 'Robot',
height: 1.6,
run: function () {
console.log(this.name + ' is running...');
}
};我们看这个 robot 对象有名字,有身高,还会跑,有点像小明,干脆就根据它来“创建”小明得了!于是我们把它改名为 Student,然后创建出 xiaoming:
let Student = {
name: 'Robot',
height: 1.2,
run: function () {
console.log(this.name + ' is running...');
}
};
let xiaoming = {
name: '小明'
};
xiaoming.__proto__ = Student;
图1:将 xiaoming 的原型指向 Student 对象
注意最后一行代码把 xiaoming 的原型指向了对象 Student,看上去 xiaoming 仿佛是从 Student 继承下来的:
xiaoming.name; // '小明'
xiaoming.run(); // 小明 is running...xiaoming 有自己的 name 属性,但并没有定义 run() 方法。不过,由于小明是从 Student 继承而来,只要 Student 有 run() 方法,xiaoming 也可以调用。
JavaScript 的原型链和 Java 的 Class 区别就在,它没有“Class”的概念,所有对象都是实例,所谓继承关系不过是把一个对象的原型指向另一个对象而已。
如果你把 xiaoming 的原型指向其他对象:
let Bird = {
fly: function () {
console.log(this.name + ' is flying...');
}
};
xiaoming.__proto__ = Bird;现在 xiaoming 已经无法 run() 了,他已经变成了一只鸟:
xiaoming.fly(); // 小明 is flying...在 JavaScript 代码运行时期,你可以把 xiaoming 从 Student 变成 Bird,或者变成任何对象。
⚠️避坑指南:
上述代码仅用于演示目的。在编写 JavaScript 代码时,不要直接用obj.__proto__去改变一个对象的原型,并且,低版本的 IE 也无法使用__proto__。
1.2 使用 Object.create() 创建对象
Object.create() 方法可以传入一个原型对象,并创建一个基于该原型的新对象,但是新对象什么属性都没有。因此,我们可以编写一个函数来创建 xiaoming:
// 原型对象:
let Student = {
name: 'Robot',
height: 1.2,
run: function () {
console.log(this.name + ' is running...');
}
};
function createStudent(name) {
// 基于 Student 原型创建一个新对象:
let s = Object.create(Student);
// 初始化新对象:
s.name = name;
return s;
}
let xiaoming = createStudent('小明');
xiaoming.run(); // 小明 is running...这是创建原型继承的一种方法,JavaScript 还有其他方法来创建对象,我们在后面会一一讲到。
模块小结: 本模块我们了解到,JavaScript 的面向对象编程基于原型,而非传统的“类”。对象可以通过将其原型(
__proto__)指向另一个对象来实现属性和方法的继承。Object.create()是更规范的原型指定方式。
二、深入理解原型链
JavaScript 对每个创建的对象都会设置一个原型,指向它的原型对象。
当我们用 obj.xxx 访问一个对象的属性时,JavaScript 引擎先在当前对象上查找该属性,如果没有找到,就到其原型对象上找,如果还没有找到,就一直上溯到 Object.prototype 对象,最后,如果还没有找到,就只能返回 undefined。
2.1 数组的原型链
例如,创建一个 Array 对象:
let arr = [1, 2, 3];其原型链是:

图2:数组对象 arr 的原型链示意图
Array.prototype 定义了 indexOf()、shift() 等方法,因此你可以在所有的 Array 对象上直接调用这些方法。
2.2 函数的原型链
当我们创建一个函数时:
function foo() {
return 0;
}函数也是一个对象,它的原型链是:

图3:函数对象 foo 的原型链示意图
由于 Function.prototype 定义了 apply() 等方法,因此,所有函数都可以调用 apply() 方法。
很容易想到,如果原型链很长,那么访问一个对象的属性就会因为花更多的时间查找而变得更慢,因此要注意不要把原型链搞得太长。
模块小结: 原型链是 JavaScript 实现继承的底层机制。属性查找是沿着原型链向上进行的,直到
Object.prototype。理解这一查找过程是掌握 JavaScript 对象模型的关键。
三、构造函数:更规范的“类”定义
除了直接用 {...} 创建一个对象外,JavaScript 还可以用一种构造函数的方法来创建对象。
3.1 构造函数的基本使用
它的用法是,先定义一个构造函数:
function Student(name) {
this.name = name;
this.hello = function () {
alert('Hello, ' + this.name + '!');
};
}你会问,咦,这不是一个普通函数吗?这确实是一个普通函数,但是在 JavaScript 中,可以用关键字 new 来调用这个函数,并返回一个对象:
let xiaoming = new Student('小明');
xiaoming.name; // '小明'
xiaoming.hello(); // Hello, 小明!注意,如果不写 new,这就是一个普通函数,它返回 undefined。但是,如果写了 new,它就变成了一个构造函数,它绑定的 this 指向新创建的对象,并默认返回 this,也就是说,不需要在最后写 return this;。
新创建的 xiaoming 的原型链是:

图4:通过 new Student() 创建的 xiaoming 的原型链
也就是说,xiaoming 的原型指向函数 Student 的原型。如果你又创建了 xiaohong、xiaojun,那么这些对象的原型与 xiaoming 是一样的:

图5:多个实例共享同一个原型对象
3.2 constructor 属性与 prototype 关系梳理
要彻底理清这三者的关系,我们得先从 “构造函数的 prototype 从何而来” 讲起:在 JavaScript 中,只要你定义了一个函数(无论是普通函数还是构造函数),JavaScript 引擎就会自动为它生成一个配套的 “原型对象(prototype object)”,并通过函数的 prototype 属性指向这个对象。这个自动生成的原型对象并非空壳,它天生就带有一个 constructor 属性,用于 “认祖归宗”—— 精准指回创建它的那个函数本身。
当我们用 new Student() 创建出 xiaoming 这样的实例对象时,new 操作符会在内部把新对象的内部原型(可通过非标准的 proto 查看,或用标准方法 Object.getPrototypeOf() 获取)直接关联到 Student.prototype 上。这样,实例对象虽然自身没有 prototype 属性,也没有独立的 constructor,但能顺着原型链 “借用” 原型对象上的一切。
我们可以通过几组全等关系清晰验证这层逻辑:
xiaoming.constructor === Student.prototype.constructor; // true
Student.prototype.constructor === Student; // true
Object.getPrototypeOf(xiaoming) === Student.prototype; // true
xiaoming instanceof Student; // true看晕了吧?用一张图来表示这些乱七八糟的关系就是:

图6:构造函数、原型对象与实例之间的复杂关系
简单来说,整个闭环是:函数定义时自动获得 prototype 原型对象 → 原型对象自带 constructor 指回函数 → new 出的实例通过内部原型关联到该原型对象。因此我们可以认为,xiaoming、xiaohong 这些由 Student 创建的对象,都 “继承” 自 Student.prototype。
3.3 优化构造函数:共享方法
不过还有一个小问题,注意观察:
xiaoming.name; // '小明'
xiaohong.name; // '小红'
xiaoming.hello; // function: Student.hello()
xiaohong.hello; // function: Student.hello()
xiaoming.hello === xiaohong.hello; // falsexiaoming 和 xiaohong 各自的 name 不同,这是对的,否则我们无法区分谁是谁了。xiaoming 和 xiaohong 各自的 hello 是一个函数,但它们是两个不同的函数,虽然函数名称和代码都是相同的!
如果我们通过 new Student() 创建了很多对象,这些对象的 hello 函数实际上只需要共享同一个函数就可以了,这样可以节省很多内存。
要让创建的对象共享一个 hello 函数,根据对象的属性查找原则,我们只要把 hello 函数移动到 xiaoming、xiaohong 这些对象共同的原型上就可以了,也就是 Student.prototype:

图7:将 hello 方法移动到 Student.prototype 上
修改代码如下:
function Student(name) {
this.name = name;
}
Student.prototype.hello = function () {
alert('Hello, ' + this.name + '!');
};用 new 创建基于原型的 JavaScript 的对象就是这么简单!
3.4 忘记写 new 怎么办
如果一个函数被定义为用于创建对象的构造函数,但是调用时忘记了写 new 怎么办?
- 在 strict 模式下,
this.name = name将报错,因为this绑定为undefined。 - 在非 strict 模式下,
this.name = name不报错,因为this绑定为window,于是无意间创建了全局变量name,并且返回undefined,这个结果更糟糕。
所以,调用构造函数千万不要忘记写new。为了区分普通函数和构造函数,按照约定,构造函数首字母应当大写,而普通函数首字母应当小写,这样,一些语法检查工具如 jslint 将可以帮你检测到漏写的 new。
3.5 更灵活的封装模式
最后,我们还可以编写一个 createStudent() 函数,在内部封装所有的 new 操作。一个常用的编程模式像这样:
function Student(props) {
this.name = props.name || '匿名'; // 默认值为'匿名'
this.grade = props.grade || 1; // 默认值为1
}
Student.prototype.hello = function () {
alert('Hello, ' + this.name + '!');
};
function createStudent(props) {
return new Student(props || {});
}这个 createStudent() 函数有几个巨大的优点:
- 不需要
new来调用。 - 参数非常灵活,可以不传,也可以这么传:
let xiaoming = createStudent({ name: '小明' });
xiaoming.grade; // 1如果创建的对象有很多属性,我们只需要传递需要的某些属性,剩下的属性可以用默认值。由于参数是一个 Object,我们无需记忆参数的顺序。如果恰好从 JSON 拿到了一个对象,就可以直接创建出 xiaoming。
📝 练习
请利用构造函数定义 Cat,并让所有的 Cat 对象有一个 name 属性,并共享一个方法 say(),返回字符串 'Hello, xxx!':
function Cat(name) {
this.name = name;
}
Cat.prototype.say = function () {
return 'Hello, ' + this.name + '!';
};
// 测试:
let kitty = new Cat('Kitty');
let doraemon = new Cat('哆啦A梦');
if (kitty && kitty.name === 'Kitty' && kitty.say && typeof kitty.say === 'function' && kitty.say() === 'Hello, Kitty!' && kitty.say === doraemon.say) {
console.log('测试通过!');
} else {
console.log('测试失败!');
}模块小结: 构造函数配合
new关键字,是 JavaScript 中创建对象的标准模式。将共享方法定义在构造函数的prototype属性上,可以优化内存使用。为避免错误,构造函数名应首字母大写,并始终使用new调用。
四、实现经典的原型继承
在传统的基于 Class 的语言如 Java、C++ 中,继承的本质是扩展一个已有的 Class,并生成新的 Subclass。由于这类语言严格区分类和实例,继承实际上是类型的扩展。但是,JavaScript 由于采用原型继承,我们无法直接扩展一个 Class,因为根本不存在 Class 这种类型。但是办法还是有的。
4.1 回顾 Student 构造函数
我们先回顾 Student 构造函数:
function Student(props) {
this.name = props.name || 'Unnamed';
}
Student.prototype.hello = function () {
alert('Hello, ' + this.name + '!');
};以及 Student 的原型链:

图8:Student 构造函数及其原型链
4.2 扩展 PrimaryStudent
现在,我们要基于 Student 扩展出 PrimaryStudent,可以先定义出 PrimaryStudent:
function PrimaryStudent(props) {
// 调用 Student 构造函数,绑定 this 变量:
Student.call(this, props);
this.grade = props.grade || 1;
}但是,调用了 Student 构造函数不等于继承了 Student,PrimaryStudent 创建的对象的原型是:
new PrimaryStudent() --> PrimaryStudent.prototype --> Object.prototype --> null必须想办法把原型链修改为:
new PrimaryStudent() --> PrimaryStudent.prototype --> Student.prototype --> Object.prototype --> null这样,原型链对了,继承关系就对了。新的基于 PrimaryStudent 创建的对象不但能调用 PrimaryStudent.prototype 定义的方法,也可以调用 Student.prototype 定义的方法。
如果你想用最简单粗暴的方法这么干:
PrimaryStudent.prototype = Student.prototype;是不行的!如果这样的话,PrimaryStudent 和 Student 共享一个原型对象,那还要定义 PrimaryStudent 干啥?
4.3 使用中间函数实现继承
我们必须借助一个中间对象来实现正确的原型链,这个中间对象的原型要指向 Student.prototype。为了实现这一点,参考道爷(就是发明 JSON 的那个道格拉斯)的代码,中间对象可以用一个空函数 F 来实现:
// PrimaryStudent 构造函数:
function PrimaryStudent(props) {
Student.call(this, props);
this.grade = props.grade || 1;
}
// 空函数 F:
function F() {}
// 把 F 的原型指向 Student.prototype:
F.prototype = Student.prototype;
// 把 PrimaryStudent 的原型指向一个新的 F 对象,F 对象的原型正好指向 Student.prototype:
PrimaryStudent.prototype = new F();
// 把 PrimaryStudent 原型的构造函数修复为 PrimaryStudent:
PrimaryStudent.prototype.constructor = PrimaryStudent;
// 继续在 PrimaryStudent 原型(就是 new F() 对象)上定义方法:
PrimaryStudent.prototype.getGrade = function () {
return this.grade;
};
// 创建 xiaoming:
let xiaoming = new PrimaryStudent({ name: '小明', grade: 2 });
xiaoming.name; // '小明'
xiaoming.grade; // 2
// 验证原型:
xiaoming.__proto__ === PrimaryStudent.prototype; // true
xiaoming.__proto__.__proto__ === Student.prototype; // true
// 验证继承关系:
xiaoming instanceof PrimaryStudent; // true
xiaoming instanceof Student; // true用一张图来表示新的原型链:

图9:PrimaryStudent 完整原型链图示
注意,函数 F 仅用于桥接,我们仅创建了一个 new F() 实例,而且,没有改变原有的 Student 定义的原型链。
4.4 封装 inherits 函数
如果把继承这个动作用一个 inherits() 函数封装起来,还可以隐藏 F 的定义,并简化代码:
function inherits(Child, Parent) {
let F = function () {};
F.prototype = Parent.prototype;
Child.prototype = new F();
Child.prototype.constructor = Child;
}这个 inherits() 函数可以复用:
function Student(props) {
this.name = props.name || 'Unnamed';
}
Student.prototype.hello = function () {
alert('Hello, ' + this.name + '!');
};
function PrimaryStudent(props) {
Student.call(this, props);
this.grade = props.grade || 1;
}
// 实现原型继承链:
inherits(PrimaryStudent, Student);
// 绑定其他方法到 PrimaryStudent 原型:
PrimaryStudent.prototype.getGrade = function () {
return this.grade;
};模块小结: JavaScript 的原型继承实现方式就是:
- 定义新的构造函数,并在内部用
call()调用希望“继承”的构造函数,并绑定this。- 借助中间函数
F实现原型链继承,最好通过封装的inherits函数完成。- 继续在新的构造函数的原型上定义新方法。
五、ES6 的 class 继承
在上面的章节中我们看到了 JavaScript 的对象模型是基于原型实现的,特点是简单,缺点是理解起来比传统的类-实例模型要困难,最大的缺点是继承的实现需要编写大量代码,并且需要正确实现原型链。
有没有更简单的写法?有!新的关键字 class 从 ES6 开始正式被引入到 JavaScript 中。class 的目的就是让定义类更简单。
5.1 用 class 定义类
我们先回顾用函数实现 Student 的方法:
function Student(name) {
this.name = name;
}
Student.prototype.hello = function () {
alert('Hello, ' + this.name + '!');
};如果用新的 class 关键字来编写 Student,可以这样写:
class Student {
constructor(name) {
this.name = name;
}
hello() {
alert('Hello, ' + this.name + '!');
}
}比较一下就可以发现,class 的定义包含了构造函数 constructor 和定义在原型对象上的函数 hello()(注意没有 function 关键字),这样就避免了 Student.prototype.hello = function() {...} 这样分散的代码。
最后,创建一个 Student 对象代码和前面章节完全一样:
let xiaoming = new Student('小明');
xiaoming.hello();5.2 用 extends 实现继承
用 class 定义对象的另一个巨大的好处是继承更方便了。想一想我们从 Student 派生一个 PrimaryStudent 需要编写的代码量。现在,原型继承的中间对象,原型对象的构造函数等等都不需要考虑了,直接通过 extends 来实现:
class PrimaryStudent extends Student {
constructor(name, grade) {
super(name); // 记得用 super 调用父类的构造方法!
this.grade = grade;
}
myGrade() {
alert('I am at grade ' + this.grade);
}
}注意:
PrimaryStudent的定义也是class关键字实现的,而extends则表示原型链对象来自Student。- 子类的构造函数可能会与父类不太相同,例如,
PrimaryStudent需要name和grade两个参数,并且需要通过super(name)来调用父类的构造函数,否则父类的name属性无法正常初始化。 PrimaryStudent已经自动获得了父类Student的hello方法,我们又在子类中定义了新的myGrade方法。
ES6 引入的 class 和原有的 JavaScript 原型继承有什么区别呢?实际上它们没有任何区别,class的作用就是让 JavaScript 引擎去实现原来需要我们自己编写的原型链代码。简而言之,用 class 的好处就是极大地简化了原型链代码。
📝 练习
请利用 class 重新定义 Cat,并让它从已有的 Animal 继承,然后新增一个方法 say(),返回字符串 'Hello, xxx!':
class Animal {
constructor(name) {
this.name = name;
}
}
class Cat extends Animal {
constructor(name) {
super(name);
}
say() {
return 'Hello, ' + this.name + '!';
}
}
// 测试:
let kitty = new Cat('Kitty');
let doraemon = new Cat('哆啦A梦');
if ((new Cat('x') instanceof Animal) && kitty &&
kitty.name === 'Kitty' &&
kitty.say &&
typeof kitty.say === 'function' &&
kitty.say() === 'Hello, Kitty!' &&
kitty.say === doraemon.say) {
console.log('测试通过!');
} else {
console.log('测试失败!');
}⚠️浏览器兼容性说明:
这个练习需要浏览器支持 ES6 的class,如果遇到SyntaxError,则说明浏览器不支持class语法,请换一个最新的浏览器试试。
模块小结: ES6 引入的
class语法是原型继承的语法糖,它让定义类和实现继承变得更加清晰、简洁。通过constructor定义构造方法,通过extends实现继承,并通过super()调用父类构造函数。
本篇核心复盘
- 核心思想:JavaScript 的面向对象编程基于原型,对象可以通过指向另一个对象(其原型)来继承属性和方法,这与传统的类-实例模式有本质区别。
- 原型链:属性查找是沿着原型链向上进行的,所有原型链的顶端都是
Object.prototype。这是实现继承的底层机制。 - 构造函数模式:
- 通过
new FunctionName()创建对象。 - 构造函数内部的
this指向新创建的实例。 - 实例共享的方法应定义在构造函数的
prototype属性上,以节省内存。 - 约定构造函数名首字母大写,以防止遗漏
new关键字。
- 通过
- 原型继承实现:
- 核心是手动修改原型链,让子类的
prototype指向一个父类实例(或一个空函数的实例)。 - 需要使用
Student.call(this, props)来继承父类的实例属性。 - 通常会封装
inherits函数来简化这一过程。
- 核心是手动修改原型链,让子类的
- ES6
class语法:class是 ES6 引入的语法糖,使代码更接近传统语言。- 通过
extends关键字实现继承,通过super()调用父类构造函数,极大地简化了继承的编写复杂度,但底层依然是原型链。
