五、JavaScript(二)
九、面向对象
面向对象编程(OOP,Object-Oriented Programming),首先涉及到的两个概念就是:类 和 对象。
什么是类?
类就是对现实事物的抽象设计。
例如设计学生的类,可能包括属性:学号,姓名、年龄、性别等。
设计狗的类,可能包括属性:名字、年龄、品种。
类表示的是对一个事物的抽象,不是具体的某个事物。
什么是对象?
对象就是一个具体的实例,这个实例是从类派生出来的。
我们将类的属性赋值,就产生了一个个实例,也就是对象。
例如通过学生的类,我们赋值:学号=001,姓名=张三,年龄=16,性别=男,班级=三年二班,产生了就是一个名字叫张三的具体的学生,这样我们通过给类可以派生出一个个不同属性值的对象,李四、王五、赵六...。
所以说面向对象编程,就是先设计类,然后根据类来生成一个个对象。
9.1 类和对象
JavaScript 和其他的面向对象的变成语言有一些区别,在一些其他的语言中,我们需要先创建类,然后根据类来创建对象。但是 在 JavaScript 中可以直接创建对象,就像我们前面讲解对象时一样。这主要是 JavaScript 面向对象实现的原理不同,是基于原型,而非基于类,class 只是语法糖(更简洁更易读的语法),底层仍是原型(ES6 未改变 JavaScript 的 OOP 本质),当然这个后面细说,有个印象。
前面不通过类来创建对象的时候,存在如下问题:
-
无法区分对象的类型
例如创建多个学生和多个老师对象,没办法区分哪个对象是什么类型的,所有的对象只是有一些属性和方法,没什么本质区别。
-
批量创建对象很不方便
每次创建对象都需要使用字面量的形式创建,每次都要写各种属性和方法。
下面我们先来定义类,然后通过类来创建对象,看看有什么不同。
1 类的定义
类中可以定义属性和行为,属性也就是成员变量,表示这个类有哪些数据信息,行为也叫方法,表示这个类能干什么。
例如,对于学生类而言,学号、姓名、年级就是属性,学习这个行为,可以定义为方法。
那么我们可以定义以下学生类:
/**
*定义类使用class关键字,后面跟类名
*/
class Student {
sid = '001';
name = 'Doubi';
age = 18;
study() {
console.log(`我是${this.name}, 我在学习`);
}
}
- 上面的类定义了三个成员变量(sid、name、age分别表示学号、姓名、年龄),并赋值了。还定义了一个成员方法
study,和之前定义对象很相似。
2 类的使用
上面我们定义好类了,现在可以使用类来创建对象了。
/**
*定义类使用class关键字,后面跟类名
*/
class Student {
sid = '001';
name = 'Doubi';
age = 18;
study() {
console.log(`我是${this.name}, 我在学习`);
}
}
// 使用类来创建对象
let s1 = new Student(); // 创建一个对象
let s2 = new Student(); // 再创建一个对象
console.log(s1);
console.log(s2);
s1.study();
s2.study();
- 通过
new 类名()可以创建对象,如果要创建多个对象,new多次就可以了,上面就创建了两个对象。 - 创建对象后,就像之前操作对象一样了,可以给属性赋值和调用方法。
执行结果:
Student {sid: '001', name: 'Doubi', age: 18}
Student {sid: '001', name: 'Doubi', age: 18}
我是Doubi, 我在学习
我是Doubi, 我在学习
但是上面两个对象的值都是一样的呀,但需要注意,这是两个不同的对象!
因为我们直接在类中给各个属性赋值,所以这些值各个对象都会会拥有,注意每次使用 new 创建一个对象,相当于复制了一份数据,我们可以单独再修改每个对象的属性值就可以了。
但是再修改每个对象的值也太麻烦了,能不能在创建对象的时候指定属性值呢?
这就用到构造函数。
3 构造函数
我们可以在创建对象的时候,直接传递参数,给属性赋值,这样每个对象就直接拥有不同的值了。
而构造函数会在创建对象的时候自动执行,通过传递的属性值,给属性进行初始化。
举个栗子:
/**
*定义类使用class关键字,后面跟类名
*/
class Student {
sid;
name;
age;
constructor(sid, name, age) {
console.log("构造方法: ", sid, name, age);
this.sid = sid;
this.name = name;
this.age = age;
}
study() {
console.log(`我是${this.name}, 我在学习`);
}
}
let s1 = new Student("001", "Doubi", 18);
let s2 = new Student("002", "Niubi", 13);
console.log(s1); // Student {sid: '001', name: 'Doubi', age: 18}
console.log(s2); // Student {sid: '002', name: 'Niubi', age: 13}
s1.study(); // 我是Doubi, 我在学习
s2.study(); // 我是Niubi, 我在学习
- 在上面的代码中,
constructor就是构造方法,名称是固定的,接收三个参数(sid, name, age),在函数方法中,分别将三个参数赋值给类中的成员变量;构造函数会在执行 new 创建对象的时候执行。 this.name = name;是将参数赋值给 当前被创建的对象 的 name 属性;- 在调用
new Student("001", "Doubi", 18)创建对象的时候,就是在调用构造方法,并将参数传递给构造方法;
执行结果:
构造方法: 001 Doubi 18
构造方法: 002 Niubi 13
Student {sid: '001', name: 'Doubi', age: 18}
Student {sid: '002', name: 'Niubi', age: 13}
我是Doubi, 我在学习
我是Niubi, 我在学习
在定义类的时候,可以不用在类上显式声明属性,可以直接在构造函数中创建并初始化属性:
/**
*定义类使用class关键字,后面跟类名
*/
class Student {
// 构造函数
constructor(sid, name, age) {
this.sid = sid;
this.name = name;
this.age = age;
}
// 标准类方法形式定义函数
study() {
console.log(`我是${this.name}, 我在学习`);
}
}
上面定义函数是标准类方法定义函数,推荐使用上面这种。
还可以使用匿名函数方式定义:
class Student {
// 构造函数
constructor(sid, name, age) {
this.sid = sid;
this.name = name;
this.age = age;
}
// 匿名函数
study = function () {
console.log(`我是${this.name}, 我在学习`);
}
}
- 当然上面两种定义函数的方式是有区别的,我们还可以使用箭头函数,这个后面再讲。
4 实例判断
上面讲的对象是通过类来创建的,也就是说通过一个类创建的对象,都是这个类的实例,通过类创建的对象可以等价于实例。
我们可以通过 instanceof 关键字来判断一个实例是否是一个类的实例。
举个栗子:
class Student { // 创建一个学生类
}
class Teacher { // 创建一个老师类
}
let s1 = new Student(); // 创建学生类的实例
let s2 = new Student();
let t1 = new Teacher(); // 创建老师类的实例
// 判断对象是否是类的实例
console.log(s1 instanceof Student); // true, s1是Student的实例
console.log(s2 instanceof Student); // true, s2也是Student的实例
console.log(s1 instanceof Teacher); // false, s1不是Teacher的实例
console.log(t1 instanceof Student); // false, t1也不是Student的实例
- 上面创建了两个类,通过这两个类来创建对象;
- 可以看到不同类的对象与类的关系。
5 静态属性和方法
上面我们定义的属性和方法,是实例变量和实例方法,也叫成员变量和成员方法。
我们还可以在类中定义静态属性和方法:
- 静态属性:属于类本身,而不是某个实例,所以只能通过类来访问。
- 静态方法:也是属于类本身的方法,不能通过实例调用,只能通过类来调用。
举个例子:
/**
*定义类使用class关键字,后面跟类名
*/
class Student {
// 定义静态属性,使用 static 关键字修饰
static planet = "Earth";
constructor(sid, name, age) {
this.sid = sid;
this.name = name;
this.age = age;
}
// 普通方法
study() {
console.log(`我是${this.name}, 我在学习`);
}
// 静态方法,使用 static 关键字修饰
static species() {
console.log("Homo sapiens");
}
}
let stu = new Student("001", "张三", 18); // 创建张三对象
console.log(Student.planet); // 输出:Earth
// 错误:实例不能直接访问静态属性
console.log(stu.planet); // undefined
// 调用实例方法
stu.study(); // 输出:我是张三, 我在学习
// 调用静态方法
Student.species(); // 输出:Homo sapiens
// 错误:实例不能调用静态方法
// stu.species(); // 会报错:TypeError: stu.species is not a function
- 上面使用
static修饰,分别定义静态属性和静态方法; - 静态属性和静态方法不能通过实例对象来访问,只能通过类来访问;
为什么需要用到静态属性和静态方法?
对于类的所有实例共享的数据,我们可以定义为静态属性。
如果一个方法不依赖实例的属性,也就是说在方法中不会访问到实例属性,那么就可以定义为静态方法,一般情况下,对于一些工具类,可以用静态方法来写。
需要注意:在实例方法中,可以访问静态属性,这没问题,在哪里通过类都可以访问。在静态方法中 可以访问其他静态属性/静态方法。但是不能直接访问实例属性,因为实例属性值都是属于某个具体的对象的,而静态方法是属于类的不属于某个对象,所以如果在静态方法中访问实例属性,根本不知道哪个实例。
下面演示一下静态属性的使用。
我们定义了一个 Student 类,然后通过 Student 类来创建对象,我们想记录一共创建了多少个 Student 对象,应该如何操作呢?
通过定义 静态属性 来实现,代码如下:
/**
*定义类使用class关键字,后面跟类名
*/
class Student {
// 定义一个静态属性,用于记录创建的对象个数
static stuCount = 0;
constructor(sid, name, age) {
Student.stuCount++; // 创建对象会调用构造方法,调用一次就+1
this.sid = sid;
this.name = name;
this.age = age;
}
}
let stu1 = new Student("001", "张三", 18);
let stu2 = new Student("002", "李四", 19);
let stu3 = new Student("003", "王五", 20);
// 通过类名访问
console.log(Student.stuCount); // 输出: 3
- 在上面的代码中,我们定义了一个类,然后在类中定义了一个
stuCount静态变量。当创建对象的时候,会调用构造方法,我们在构造方法中将stuCount++,这样就可以记录调用构造方法的次数。
面向对象编程,是一种编程思想,简单的理解就是:先有模板,也就是类,根据模板去创建实例,也就是对象,然后使用对象来完成功能开发。
我们经常说面向过程编程和面向对象编程,面向过程编程关注的是实现功能的步骤,面向对象编程更关注的是谁来实现功能。
面向对象编程有3大特性:
- 封装
- 继承
- 多态
下面依次开始讲起。
9.2 封装
在前面我们创建类,在类中定义了属性和方法,通过属性和方法来对现实世界的事物进行抽象的描述。
一个事物有很多的属性和方法,但是并不是所有属性和方法都需要开放出来。例如我们定义了一个手机类,我们可以使用手机打电话、拍照等,但是我们并不关心手机电压,驱动信息,也不关心内存的分配,CPU的调度等,虽然这些都属于手机的属性和行为。
我们可以将用户不关心的属性和方法封装并隐藏起来,只给类内部的方法调用,例如上网会用到4G模块,但是不是由用户来使用4G模块,而是由手机上网的功能来调用4G模块,只开放用户直接使用的信息和功能。
那么回过头来看,什么是封装?
它指的是将对象的状态信息隐藏在对象内部,不允许外部程序直接访问对象的内部信息。如果要访问这些信息,需要通过该类所提供的方法来实现对内部信息的操作和访问。
1 私有属性和方法
那么怎么将不想暴露的属性和方法隐藏起来呢,就需要用到私有属性和私有方法。
定义私有属性和方法,使用 # 开头来定义。
举个栗子:
class Phone {
#voltage; // 私有属性需要显式声明
constructor(producer, voltage) {
this.producer = producer; // 生产商
this.#voltage = voltage; // 电压
}
// 定义公共方法
call() {
console.log("打电话");
this.#getRunVoltage(); // 在类内部可以调用私有方法
}
// 定义一个私有方法
#getRunVoltage() {
console.log("当前电压:" + this.#voltage);
}
}
let phone = new Phone("小米", 12);
console.log(phone.producer); // 小米,调用公有属性
// console.log(phone.#voltage); // 报错:私有变量,类外部无法访问
phone.call(); // 调用公有方法
// phone.#getRunVoltage(); // 报错,私有方法,类外部无法访问
- 上面定义了私有属性
#voltage和私有方法#getRunVoltage(),私有属性和私有方法只能在类内部的方法访问,在类的外部不能通过对象来调用。 - 需要注意,私有方法需要显式声明。
- 静态属性和方法,同样在属性和方法前添加
#即可。
如果不想暴露的属性和方法可以定义为私有成员。私有属性只能在类内部的方法中调用,不能通过对象来调用。
2 getter和setter
我们前面说到,封装是将对象的状态信息隐藏在对象内部,不允许外部程序直接访问对象的内部信息。如果要访问这些信息,需要通过该类所提供的方法来实现对内部信息的操作和访问。
我们一般会针对私有属性提供两个对应的方法,分别用来获取和设置对应的属性,这两个方法称为 getter 和 setter 方法。
举个栗子:
class Phone {
#voltage; // 私有属性需要显式声明
constructor(producer, voltage) {
this.producer = producer;
this.#voltage = voltage;
}
// voltage属性的getter
getVoltage() {
return this.#voltage;
}
// voltage属性的setter
setVoltage(voltage) {
// 还可以在设置的时候进行判断
if (voltage > 36 || voltage < 0) {
return;
}
this.#voltage = voltage;
}
}
let phone = new Phone('小米', 12);
phone.setVoltage(20); // 调用 setter 设置值
console.log(phone.getVoltage()); // 调用 getter获取值
- 在上面为私有属性
voltage定义了 get 和 set 方法,get方法以get开头,后跟属性名,遵循方法命名规则。set方法类似,get 方法有返回值,没有参数,set 方法接收参数,没有返回值。 - 我们还可以根据需求,在设置或获取属性值的时候,对属性值进行额外的处理,例如格式验证。
- 如果有多个私有属性,根据需要,可以为每个私有属性添加 getter 和 setter 。
通过 getter 和 setter 来访问私有属性和为私有属性赋值。这样做的话,严格控制了属性获取和设置的入口,如果通过 对象.属性 来修改,代码很多的时候完全会不知道在哪里修改了属性导致出现了问题。
上面的 getter 和 setter 在进行属性访问的时候,需要调用方法,有点麻烦。JavaScript 还提供了访问器属性方式的 getter 和 setter。
举个栗子:
class Phone {
#voltage; // 私有属性需要显式声明
constructor(producer, voltage) {
this.producer = producer;
this.#voltage = voltage;
}
// voltage属性的getter
get voltage() {
console.log('调用了getter');
return this.#voltage;
}
// voltage属性的setter
set voltage(voltage) {
console.log('调用了setter', voltage);
this.#voltage = voltage;
}
}
let phone = new Phone('小米', 12);
phone.voltage = 20; // 以属性的方式访问,其实调用的是voltage的setter方法
console.log(phone.voltage); // 调用voltage的getter方法
- 在上面的代码中,通过
get或set 关键字 + 属性名构成的 getter 和 setter,可以以属性访问的方式来调用方法。 phone.voltage = 20;调用的是set voltage()方法。
9.3 继承
在现实世界,有麻雀和鸽子,它们都属于鸟类,麻雀、鸽子和鸟类的关系是父类和子类的关系。
麻雀和鸽子都会飞,我们可以在麻雀类中定义一个飞的方法,在鸽子类中定义一个飞的方法,但是这两个飞的方法是一样的,都是用翅膀飞。那么我们可以直接在鸟类中定义一个飞的方法,让麻雀和鸽子类都继承这个鸟类,那么它们就拥有了飞的方法,就不用重复再定义了。
1 继承的语法
在 JS 中,使用extends关键字实现继承。
class 子类名 extends 父类名 {
类内容
}
举个栗子:
麻雀继承鸟类,鸽子继承鸟类:
class Bird {
constructor(age) {
this.age = age;
}
fly() {
// 定义了一个飞的方法
console.log(`我${this.age}岁了,我会飞`);
}
tweet() {
// 定义了一个叫的方法
console.log("我会叫");
}
}
class Sparrow extends Bird {
// 定义一个麻雀类,继承自鸟类
}
class Pigeon extends Bird {
// 定义一个鸽子类,继承自鸟类
}
let sparrow = new Sparrow(1); // 创建一个麻雀对象
sparrow.fly();
sparrow.tweet();
let pigeon = new Pigeon(2); // 创建一个鸽子对象
pigeon.fly();
pigeon.tweet();
-
在上面的代码中,我们先定义了一个鸟类,然后在鸟类中定义了
age属性 ,并定义了两个方法fly方法和tweet方法; -
然后定义类麻雀类,类中没有代码,但是继承鸟类,那么就拥有了鸟类的属性和方法。鸽子类也同样。
执行结果:
我1岁了,我会飞 我会叫 我2岁了,我会飞 我会叫
子类继承父类后,就拥有了父类中声明的属性和方法。但是注意,继承不能继承父类私有的成员变量和方法。如果父类的成员变量和属性不想被子类继承,可以设置为私有成员。
子类也会继承父类的静态属性和方法,父类的静态属性和方法
2 重写父类方法
所以继承父类就拥有了父类非私有的成员变量和方法,如果父类不是我想要的方法,我还可以进行覆盖。
例如,鸟类提供了叫的方法,但是我麻雀有自己的叫法,我要啾啾叫,那么我们可以重写父类的方法,实现自己的个性化。
class Bird {
constructor(age) {
this.age = age; // 鸟都有年龄
}
fly() {
console.log(`我${this.age}岁了,我会飞`);
}
tweet() {
console.log("我会叫");
}
}
// 麻雀类
class Sparrow extends Bird {
tweet() {
console.log("我会啾啾叫");
}
}
// 鸽子类
class Pigeon extends Bird {
// 不重写父类方法,继承原样即可
}
// 测试代码
const sparrow = new Sparrow(1);
sparrow.fly(); // 输出: 我1岁了,我会飞
sparrow.tweet(); // 输出: 我会啾啾叫
const pigeon = new Pigeon(2);
pigeon.fly(); // 输出: 我2岁了,我会飞
pigeon.tweet(); // 输出: 我会叫
- 在上面的 Sparrow 类中重写了
tweet()方法(需要和父类中的方法同名),那么子类就有了自己定义的方法,可以实现个性化的功能。
那么如果我在父类的 fly() 方法中调用 tweet() 方法,然后子类对象调用 fly() 方法,那么会如何执行呢?
class Bird {
fly() {
console.log("我会飞");
this.tweet(); // 这里调用方法
}
tweet() {
console.log("我会叫");
}
}
class Sparrow extends Bird {
tweet() {
console.log("我会啾啾叫");
}
}
let sparrow = new Sparrow(); // 创建一个麻雀对象
sparrow.fly(); // 这里调用fly()
sparrow.fly()调用的是继承自父类的方法,在fly()方法中调用了tweet()方法,那么调用的是父类的tweet()方法还是子类的tweet()方法呢?
是调用子类的方法,因为子类继承父类,子类拥有了父类的方法,所以是子类的 fly() 调用了子类的 tweet() 。
执行结果:
我会飞 我会啾啾叫
3 重写构造方法
如果一个类没有写构造方法,那么其实它是有一个默认的隐式构造方法的。
constructor() { }
当你显式的写了构造方法以后,默认的隐式构造方法就不会添加了。
另外,子类的隐式构造方法会调用父类的隐式的构造方法,但是如果重写了子类的构造方法,那子类构造方法的第一句代码必须手动调用父类的构造方法,否则会报错,调用父类的构造方法使用 super() 来调用。
举个栗子:
class Bird {
constructor(age) {
this.age = age;
}
}
class Pigeon extends Bird {
constructor(name, age) {
super(age) // 重写构造方法,第一句代码必须调用父类的构造方法,可以传递参数
this.name = name;
}
}
const p = new Pigeon('Niubi', 5);
console.log(p); // 输出:Pigeon {age: 5, name: 'Niubi'}
- 在上面的代码中,子类重写了构造方法,第一句代码必须通过 super 来调用父类的构造方法;在调用父类构造方法的时候,可以传递参数。
需要注意,父类没有构造方法,子类第一句也是要调用父类的隐式构造方法的:
class Bird {
}
class Pigeon extends Bird {
constructor(name) {
super() // 重写构造方法,需要调用父类的构造方法
this.name = name;
}
}
const p = new Pigeon('Niubi', 5);
console.log(p); // 输出:Pigeon {name: 'Niubi'}
- 在上面的代码中,子类定义了构造方法,那构造方法的第一句就是要调用父类的构造方法。
4 调用父类方法
我们在重写父类方法的时候,应该尽量做到在父类方法的基础上进行扩展。所以很有可能需要调用父类被重写的方法,然后在父类原来的方法上进行扩展。
可以使用 super 关键字来调用父类的方法。
举个栗子:
class Bird {
tweet() {
console.log("我会叫");
}
}
class Pigeon extends Bird {
tweet() {
super.tweet(); // 调用父类的方法
console.log("我会咕咕叫");
}
}
let pigeon = new Pigeon();
pigeon.tweet();
- 如果要在子类中调用父类的方法,可以通过
super.父类方法()来调用。
执行结果:
我会叫 我会咕咕叫
9.4 多态
什么是多态?
多态就是多种状态,相同的类型或方法,因为指向的是不同的对象,而表现出的不同的状态。
举个栗子:
class Bird {
tweet() {
console.log("我会叫");
}
}
class Sparrow extends Bird {
tweet() {
console.log("我会啾啾叫");
}
}
class Pigeon extends Bird {
tweet() {
console.log("我会咕咕叫");
}
}
function makeItTweet(bird) {
bird.tweet(); // 不关心具体类型,统一调用接口
}
let bird1 = new Sparrow();
let bird2 = new Pigeon();
makeItTweet(bird1); // 我会啾啾叫
makeItTweet(bird2); // 我会咕咕叫
- 上面麻雀类和鸽子类都继承自鸟类,然后创建了一个麻雀对象和鸽子对象,通过
makeItTweet函数调用变量的tweet()方法 - 虽然两个都是鸟类的变量,执行的都是
tweet()方法,但是因为是不同的子类对象,却得到不同的结果。
变量因为只想不同的对象,执行相同的方法,却表现不同的行为,这就是多态。
多态的作用:
- 提高代码的维护性
- 提高代码的扩展性
- 把不同的子类对象当做父类来看待,可以屏蔽不同子类对象之间的差异,写出通用的代码,以适应需求的不断变化。
在其他语言中,一般通过继承或接口实现来实现多态,但是 JavaScript 是弱类型语言,没有类型检查,只要对象“看起来像”就可以用。
所以不一定要通过继承来实现,只要有相同的方法就可以实现多态。
举个栗子:
const bird = {
speak() {
console.log("鸟在叫");
}
};
const robot = {
speak() {
console.log("机器人在说话");
}
};
function letItSpeak(entity) {
entity.speak();
}
letItSpeak(bird); // 鸟在叫
letItSpeak(robot); // 机器人在说话
- 在上面的代码中,两个对象没有任何关系,只是有相同的方法,就可以实现多态。
- 所以如果它走起来像鸭子,叫起来像鸭子,那它就是鸭子。
9.5 原型简介
讲解原型之前,先看一个栗子。
class Person {
name = 'Doubi';
sayHello() {
console.log('Hello');
}
sleep = function() {
console.log('sleep');
}
}
let person = new Person();
person.age = 13; // 添加属性
person.eat = function() { // 添加方法
console.log('eat');
}
console.log(person); // Person {name: 'Doubi', age: 13, sleep: ƒ, eat: ƒ}
- 在上面的代码中,通过 Person 类创建了 person 对象,并给 person 对象添加了属性和方法。
但是在打印 person 对象的时候,发现并没有 sayHello() 方法,这是为什么呢?
因为对象中存储属性的区域实际有两个:
-
对象自身
在类中通过赋值的方式(
x = value)添加的属性,位于对象自身中;
直接通过对象.属性 = value的方式添加的属性,位于对象自身中。 -
原型对象
对象中还有一些内容,会存储在别的对象中,也就是原型对象,也就是说原型对象中也会存储对象的属性和方法,当我们通过对象来访问属性或方法的时候,会先在对象自身中寻找,如果找不到,就会去原型对象中寻找。
所以上面通过sayHello(){}添加的方法,实际是添加到原型对象中了。
在对象中,会有一个属性存储原型对象的引用,这个属性叫 __proto__ :

我们可以通过如下方式访问原型对象:
let person = new Person();
console.log(person.__proto__);
console.log(Object.getPrototypeOf(person));
- 可以通过
对象.__proto__获取到原型对象,但是千万不要通过对象.__proto__ = value来修改原型对象,会导致问题; - 还可以通过
Object.getPrototypeOf(obj)的方式来获取原型对象。
JavaScript中为什么需要原型呢?
- 将方法定义在原型上,所有对象可以共享同一份方法,而不是每个实例都创建独立的副本,这样可以节省内存。
所以同一个类的多个对象的原型对象指向的是同一个对象,举个栗子:
class Person {
constructor(name) {
this.name = name;
}
}
let p1 = new Person('Doubi');
let p2 = new Person('Niubi');
console.log(p1.__proto__ === p2.__proto__) // true
- JavsScript的继承是使用原型实现的,这个下面细讲。
9.6 原型链与继承
1 构造函数
在前面我们讲创建对象的方式,有以下三种方式:
// --方式1
let person1 = {
name: "Doubi",
age: 13,
};
// --方式2
let person2 = new Object();
person2.name = "Doubi";
person2.age = 13;
// --方式3
class Person {
constructor(name, age) {
this.name = name;
this.age = age;
}
}
let person3 = new Person("Doubi", 13);
在 ES6 之前,创建对象是通过构造函数来创建的,class 是 ES6 中引入的,class 其实是构造函数的语法糖(更简洁更易读的语法),而 class 实现的继承底层仍是原型,新的 class 写法只是让原型的写法更加的清晰、更像面向对象编程的语法而已。
所以既然 class 是语法糖,那就先从构造函数讲起。
创建一个 Person 的构造函数,用来创建 Person 对象:
// 函数
function Person() {
}
let person = new Person(); // 通过 new 使用
console.log(person); // Person {}
- 上面的
Person()函数就是一个构造函数,构造函数和普通的函数没有本质区别,只是调用方式不同。当我们使用new来调用一个函数,那么它就是被当做构造函数,返回的就是一个对象。
当然我们想把一个函数作为构造函数来创建对象,那么会在对象中添加属性、方法,是我们使用函数的方式不同,才区分了构造函数和普通函数:
// 构造函数
function Person(name, age) {
this.name = name;
this.age = age;
}
let person = new Person('Doubi', 13);
console.log(person); // Person {name: 'Doubi', age: 13}
使用构造函数创建的实例,都有一个 constructor 属性指向构造函数:
// 构造函数
function Person(name, age) {
this.name = name;
this.age = age;
}
let p1 = new Person("Doubi", 13);
let p2 = new Person("Niubi", 18);
console.log(p1.constructor === Person); // true
console.log(p2.constructor === Person); // true
console.log(p1.constructor === p2.constructor); // true
class 是构造函数的语法糖,那么使用类也是一样的:
// 构造函数
class Person {
constructor(name, age) {
this.name = name;
this.age = age;
}
}
let p1 = new Person("Doubi", 13);
let p2 = new Person("Niubi", 18);
console.log(p1.constructor === Person); // true
console.log(p2.constructor === Person); // true
console.log(p1.constructor === p2.constructor); // true
console.log(p1.hasOwnProperty("constructor")); // false
但是上面 person1.hasOwnProperty("constructor") 判断对象是否有 constructor 返回的是 false,也就是说实例本身是没有 constructor 属性的,这个 constructor 属性是继承自实例的原型对象。
也就是:p1.constructor -> p1.__proto__.constructor -> Person 。
下面来仔细说一下原型链。
2 原型链
在前面说到,一个对象是有一个 __proto__ 属性,指向的是对象的原型对象。
但其实是构造函数通过 .prototype 属性指向了原型对象,在用 new 创建实例时,会自动把这个原型对象赋值给实例的 __proto__,从而建立原型关系。
什么意思呢?
构造函数对象(普通函数也有)都会有一个属性 prototype ,它指向的就是原型对象,原型对象保存了该构造函数,以及定义的方法,而通过该构造函数生成的对象的 __proto__ 属性,也指向了该原型对象。
举个栗子:
// 构造函数
class Person {
constructor(name, age) {
this.name = name;
this.age = age;
}
sayHello() {
console.log("Hello");
}
}
let person = new Person("Doubi", 13);
console.log(person.__proto__ === Person.prototype); // true
console.log(person.__proto__.constructor === Person); // true
Person.prototype和person.__proto__指向的都是原型对象。

每个对象都有一个 __proto__ 属性,那么原型对象的 __proto__ 属性又指向谁呢?
Person 类没有父类,所以默认是继承 Object 类,所以 Person 构造函数的原型对象指向的是 Object 类的原型对象。
// 构造函数
class Person {
constructor(name, age) {
this.name = name;
this.age = age;
}
sayHello() {
console.log("Hello");
}
}
let person = new Person("Doubi", 13);
console.log(person.__proto__ === Person.prototype); // true
console.log(person.__proto__.__proto__ === Object.prototype); // true
console.log(Object.prototype.__proto__); // null
关系如下:

通过上面的原型关系形成的链条,就是原型链:
person --> Person.prototype --> Object.prototype --> null
Object 构造函数的原型对象( Object.prototype )的 __proto__ 属性值为 null ,也就是原型链的终点。
如果是 Student 类继承 Person 类:
// 人类
class Person {
constructor(name, age) {
this.name = name;
this.age = age;
}
sayHello() {
console.log("Hello");
}
}
// 学生类
class Student extends Person {
constructor(name, age) {
super(name, age)
}
study() {
console.log("Study");
}
}
let student = new Student("Doubi", 13);
student.sayHello(); // Hello
console.log(student.__proto__.__proto__ === Person.prototype); // true
形成的原型链如下:
student --> Student.prototype --> Person.prototype --> Object.prototype --> null
结构如下图:

当在访问对象属性或方法的时候,如果对象自身没有,会去原型对象查找,如果原型对象没有,那么回去原型对象的原型对象查找。也就是会沿着原型链一直查找,知道找到 Object 的原型对象,Object 原型对象为 null,停止查找。
所以 Student 类继承自 Person 类,当你访问 student.sayHello() 时,JavaScript 执行如下操作:
- 在
student对象本身查找 → ❌ 没有 - 查找
student.__proto__(即Student.prototype) → ❌ 没有 - 查找
student.__proto__.__proto__(即Person.prototype) → ✅ 找到 - 如果还没找到,继续找
Person.prototype.__proto__(即Object.prototype)... - 直到
__proto__ === null,查找结束。
所以说 JavaScript 使用原型链来实现继承,__proto__存在的意义在于为原型链查找提供方向。
在以前,我们经常在原型上添加方法,这样实例对象就拥有这个方法了。
例如给字符串扩展一个方法,将字符串首字母大写,其他字母小写的方法,就可以在 String.prototype 原型对象上进行扩展:
// 给字符串原型扩展一个方法
String.prototype.toCapitalize = function () {
if (this.length === 0) {
return "";
}
else {
return this[0].toUpperCase() + this.slice(1).toLowerCase(); // slice(1)表示从index为1的位置截取字符串
}
};
console.log("hEllO".toCapitalize()); // Hello
- 给
String.prototype原型扩展方法后,字符串实例就有这个方法了,就可以直接使用了
当然这种方式现在不太推荐,你添加我添加,很容易覆盖或冲突,推荐使用工具类的方式。
9.7 函数原型
上面说了构造函数(类)会有 prototype 属性,其实普通函数也有这个属性。
但是只有当这个函数被当作构造函数使用(用 new 调用)时,prototype 才真正起作用。如果函数从来不用于 new,那么它的 prototype 没有什么实际意义。
在 JavaScript 中,除了基础数据类型,其他的类型都是对象,所以函数也对象。是对象自然有 __proto__ 属性指向原型对象。
那么问题来了,函数的 __proto__ 属性指向谁呢?其实这个问题的关键就是函数是谁的实例?
所有函数本身都是 Function 的实例,所以看一下下面的代码:
// 函数
function Test() {
console.log('test');
}
console.log(typeof Test); // function
console.log(Test instanceof Function); // true
console.log(Test.__proto__ === Function.prototype); // true
- 通过上面的例子可以看出,函数的类型是
function其实还是对象,只是为了区分普通对象。 - 函数本身是
Function的实例,所以自然Function肯定是一个构造函数,那么Function的实例(也就是所有函数)的__proto__属性都指向Function构造函数的prototype属性所指向的原型,所以Test.__proto__ === Function.prototype。 - 类就是构造函数,就是函数,所以类也是一样的。
关系图如下:

问题还没完!
普通函数是对象,有 __proto__ 属性,Function 也是构造函数,也是对象,也有 __proto__ 属性,那么 Function 函数的 __proto__ 属性又指向谁呢?
看一下下面的代码:
console.log(Function.__proto__ === Function.prototype); // true
- 结果为
true,也就是说:**Function 自己是 Function 的实例!**而在 JavaScript 中,Function函数本身是唯一一个自己是自己创建的函数。
下面的代码执行,也确实为 true:
console.log(Function instanceof Function); // true
这样就形成了一个逻辑自洽,也就是所有函数(包括 Function)的 __proto__ 都指向 Function.prototype,保持原型链的统一性。
这里还有一个点需要注意,Object 也是构造函数,所以 Object.__proto__ 也是指向的 Function.prototype 。
console.log(Object.__proto__ === Function.prototype); // true
- 注意,不是
Object.prototype,Object.prototype指向null。
不好意思,问题还没完!
上面说了函数的 __proto__ 属性都指向 Function 构造函数的 prototype 属性。
举个栗子:
class Person {
}
console.log(Person.__proto__ === Function.prototype); // true
- 通过上面的代码看,确实那么回事。
让 Student 类继承 Person 类呢?
class Person {
}
class Student extends Person {
}
console.log(Object.__proto__ === Function.prototype); // true
console.log(Person.__proto__ === Function.prototype); // true
console.log(Student.__proto__ === Function.prototype); // false
console.log(Student.__proto__ === Person); // true
- 通过上面的代码看,又不是那么回事。
对于普通的构造函数,比如 Person,Person 是一个函数,而所有函数都是 Function 的实例,所以 Person.__proto__ 指向 Function.prototype。但让 class Student extends Person 时,Student 的 __proto__ 不再指向 Function.prototype,而是指向它的父类 Person 。
所以其实这里有两条原型链:
- 对象实例原型链:
student → Student.prototype → Person.prototype → Object.prototype → null - 构造函数原型链:
Student → Person → Function.prototype → Object.prototype → null
但这里还有一个让人迷惑的地方:
看一下下面的代码:
class Person {
}
console.log(Object.__proto__ === Function.prototype); // true
console.log(Person.__proto__ === Function.prototype); // true
和下面的代码比较一下:
class Person extends Object {
}
console.log(Object.__proto__ === Function.prototype); // true
console.log(Person.__proto__ === Function.prototype); // false
console.log(Person.__proto__ === Object); // true
- 将 Person 继承 Object 和不继承 Object 是有区别的。
在其他的面向对象语言中,经常有这样的规则:一个类如果没有写明继承哪个类,那么默认都是继承自Object类,所以最终的结果就是Object类是所有类的父类!
但是这句话在 JavaScript 只能对一半,为什么?
因为 JavaScript 中的继承是使用原型链实现的,不是基于类。从对象实例的原型上讲,Object.prototype 确实是所有对象实例的终极原型。所以从这个角度讲,一个类如果没有写明继承哪个类,那么默认都是继承自 Object 类,从对象实例的角度上讲,也算正确。
但 Object(构造函数)不是所有类的构造器的父类。JavaScript 中所有函数(包括类)默认都是 Function 的实例,Function 才是**所有函数(包括类)**的“构造函数之父”。所以 Person.__proto__ 指向 Function.prototype。
当使用 Person extends Object 时,JavaScript 引擎才会把类的构造器的原型指向 Object,实现“构造器的继承” 。
不过一般情况下,我们的类也不需要 extends Object ,除非你想要使用 Object 中的静态方法,因为继承 Object 才能拥有,这种情况很少。
9.8 原型总结
差不多了,通过上面的讲解应该对原型有了解了,确实有点绕。
下面总结一下,画个关系图:

最后再提醒一下,类就是构造函数的语法糖,也就是构造函数。
你说上面讲这些,鸡毛用?
说实话对写代码用处不大,只是搞清楚了底层的原理,面试会用!呵呵~
9.9 旧类
上面讲到,类就是构造函数,构造函数和普通函数本质上是一样的,只是调用的方式不同。如果使用 new 调用就是构造函数,以普通函数方式调用,就是普通函数。
下面再来说一下,在没有 class 的时候,如何实现定义类,并在类中定义属性、方法,以及实现类的继承。
举个栗子,Student 类继承 Person 类,代码如下:
9.10 实例判断
前面讲判断一个对象是否是一个类的实例,使用 instanceof 操作符。
但是 instanceof 是使用原型链来判断的,a instanceof B 会判断,在对象 a 的原型链上,是否能找到 B.prototype,则 a 是 B 类的实例。
这样如果 A 类是 B 类的子类,那么 A 类的对象就是 B 类的实例。
举个栗子:
class Animal {}
class Dog extends Animal {}
const dog = new Dog();
console.log(dog instanceof Dog); // true
console.log(dog instanceof Animal); // true
- 上面 Dog 类继承 Animal 类,所以 dog 对象也是 Animal 的实例。
可不可以判断一个对象是一个类的实例,但不是父类的实例呢?
很简答,直接使用 dog.__proto__ === Dog.prototype 判断即可。
class Animal {}
class Dog extends Animal {}
const dog = new Dog();
console.log(dog.__proto__ === Dog.prototype); // true
console.log(dog.__proto__ === Animal.prototype); // true
9.11 属性判断
在对象章节,可以通过 in 操作符判断一个对象中是否有某个属性。但是 in 操作符检查的时候,无论是对象自身还是在原型中,都会返回true。
举个栗子:
class Person {
name = "Doubi";
sayHello() {
console.log("Hello");
}
}
let person = new Person();
console.log('name' in person);
console.log('sayHello' in person);
- 在上面的代码中,sayHello() 方法是在原型对象中的,但是
in操作符也会返回true。
如果指向判断是否是对象自身的属性,可以使用如下方式:
// 新版,推荐,Object提供的静态方法
console.log(Object.hasOwn(person, 'sayHello'));
// 旧版,不推荐,Object原型提供的方法
console.log(person.hasOwnProperty('sayHello'));
十、数组
前面我们保存数据的时候,是将数据保存在变量中,如果要保存2个数据,就声明2个变量。或者保存对象的信息,那就需要定义2个属性。
如果要保存100个数据呢,1000个数据......呢?
所以就需要保存数据的集合来保存批量的数据,下面介绍一下数组。
数组就是用来存储多个数据的集合,数组中的数据按照顺序排列的,可以通过索引来访问数组中的元素,索引从0开始。

10.1 数组的基础使用
10.1.1 创建数组
同样,数组和变量一样,在使用之前也要进行声明和初始化,数组变量是引用类型的。
// 创建一个空数组
let array = new Array();
// 创建一个空数组
let numbers = [];
// 创建一个数组,数组的长度为5
let array = new Array(5);
// 创建一个数组,包含一个元素,值为5
let numbers = [5];
// 创建一个数组,其中包含一个元素,值为'5'
let array = new Array('5');
// 创建一个数组,并添加了5个元素
let arra2 = new Array(1, 3, 5, 7, 9);
// 创建了一个数组,并添加了5个元素
let fruits = ['Apple', 'Banana', 'Orange', 2, 5];
- 在创建数组,可以使用
new Array()或者[]的方式来定义。可以创建空数组,也可以创建指定长度的数组,还可以在创建的时候初始化数组中的元素。 - 数组中的元素,数据类型可以是任意的,包括对象和函数,但是尽量让各个元素的数据类型一样,好操作,免得出错。
10.1.2 基础操作
1 获取长度
使用 .length 属性可以获取到数组的长度。
let numbers = [1, 2, 3, 4, 5];
console.log(numbers); // [1, 2, 3, 4, 5]
console.log("数组长度:" + numbers.length); // 输出:5
2 访问元素
访问数组元素是使用下标 index 来访问的,从0开始,所以最后一个元素的索引为 数组的长度 - 1。
let numbers = [1, 2, 3, 4, 5];
let firstNum = numbers[0]; // 获取第一个元素
console.log(firstNum); // 输出:1
let lastNum = numbers[numbers.length - 1]; // 获取最后一个元素
console.log(lastNum); // 输出:5
// 修改元素
numbers[0] = 100; // 修改第一个元素的值为100
console.log(numbers[0]); // 100
numbers[2] = 50; // 修改第三个元素的值为50
console.log(numbers[2]); // 50
numbers[numbers.length] = 'Niubi'; // 此时已经越界了,相当于添加数据
console.log(numbers.length); // 此时数组长度为6了
console.log(numbers); // [100, 2, 50, 4, 5, 'Niubi']
numbers[100] = 'Doubi'; // 修改下标为100的元素
console.log(numbers.length); // 此时数组长度为101了
console.log(numbers); // [100, 2, 50, 4, 5, 'Niubi', 空属性 × 94, 'Doubi']
- 和其他语言不通, JS 中访问数组中的元素不会存在下标越界的问题,如果访问的下标大于等于数组长度,返回的元素数据为
undefined。 - 如果按照下标修改元素,如果下标大于等于数组长度,修改后,数组长度也会发生变化,会变为
下标 + 1,新增出来的元素为空,如果按照索引来获取这些元素,返回的值为undefined。 - 通过
numbers[numbers.length]这种方式赋值,其实就相当于在数组最后面添加元素。
在 JS 中,还有更变态的,还可以直接修改数组的长度:
let numbers = [1, 2, 3, 4, 5];
numbers.length = 10;
console.log(numbers.length); // 10
console.log(numbers); // [1, 2, 3, 4, 5, 空属性 × 5]
console.log(numbers[7]); // undefined
numbers.length = 3;
console.log(numbers); // [1, 2, 3]
- 如果是扩大数组的长度,那么后面的元素为空,获取就是
undefined,虽然获取元素的值为 undefined,但是和直接给元素赋值为 undefined 还是有区别的,遍历数组的时候再讲; - 如果是缩小数组的长度, 相当于把后面的元素删掉。
10.1.3 遍历数组
遍历就是依次访问数组中的每一个元素。
使用 for 循环遍历
let numbers = new Array(10); // 创建一个长度为10的数组
for (let i = 0; i < numbers.length; i++) {
numbers[i] = i; // 为每个元素赋值
}
console.log(numbers); // [0, 1, 2, 3, 4, 5, 6, 7, 8, 9]
// 遍历进行输出
for (let i = 0; i < numbers.length; i++) {
console.log(numbers[i]); // 依次输出: 0 1 2 3 4 5 6 7 8 9
}
- 使用 for 循环,可以对数据进行遍历赋值和访问,注意判断结束的条件是:
i < numbers.length。
使用 forEach 循环遍历
let numbers = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9];
console.log(numbers); [0, 1, 2, 3, 4, 5, 6, 7, 8, 9]
numbers.forEach((item, index) => {
console.log(`索引 ${index}:`, item); // 索引 0: 0
});
// 索引 0: 0
// 索引 1: 1
// ...
// 索引 9: 9
- 需要注意,
forEach循环遍历是没有办法通过break和return终止循环的。
使用 for...of 循环
let numbers = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9];
for (let item of numbers) {
console.log(item);
}
for (let item of numbers)循环在变量的时候,会依次取出数组中的值赋值给 item(临时变量),for...of不支持获取 index,如果要获取 index,需要自己在循环外部定义并自己进行处理;for...of循环支持break和return中断循环。
10.1.4 数组元素的默认值
动态初始化方式定义的数组,例如 new int[5] ,没有设置数组中元素的值,元素是有默认值的。对于基本数据类型,byte、short、int、long、char 类型的数组,默认值为 0,float、double 类型的数组,默认值为 0.0,boolean 类型数组,默认值为 false。对于引用类型的数组,元素的默认值都是 null。
举个栗子:
int [] numbers = new int[3];
console.log(Arrays.toString(numbers)); // [0, 0, 0]
boolean [] bools = new boolean[3];
console.log(Arrays.toString(bools)); // [false, false, false]
String [] strs = new String[3];
console.log(Arrays.toString(strs)); // [null, null, null]
Arrays.toString() 方法可以以字符串的形式打印数组中的内容。
10.2 数组常用的方法
下面介绍一下数组常用的方法,了解一下,在实际时候的时候,可以问一下大模型要实现的功能,看怎么实现。
10.2.1 修改数组内容的方法
下面的方法会修改数组的数据内容。
1 push()
.push() :在数组末尾添加元素。
举个栗子:
let arr = [1, 2];
let length = arr.push(3);
console.log(length); // 返回新长度 → 3
console.log(arr); // [1, 2, 3]
2 pop()
.pop() :删除并返回最后一个元素。
举个栗子:
let arr = [1, 2, 3];
let last = arr.pop(); // 返回 3
console.log(last); // 3
console.log(arr); // [1, 2]
3 unshift()
.unshift() :在开头添加元素。
举个栗子:
let arr = [1, 2];
let length = arr.unshift(0); // 在开头添加0元素
console.log(length); // 返回新长度 → 3
console.log(arr); // [0, 1, 2]
4 shift()
.shift() :删除并返回第一个元素。
举个栗子:
let arr = [1, 2, 3];
let first = arr.shift(); // 删除并返回第一个元素
console.log(first); // 1
console.log(arr); // [2, 3]
5 splice()
.splice(start, deleteCount, item1, item2, ...) :我们也可以使用这个方法删除元素,也可以删除后并插入新元素。
举个栗子:
let arr = [1, 2, 3, 4];
let deleteArray = arr.splice(1, 2, "a", "b"); // 从索引1删除2个元素,然后插入 "a", "b",返回的是被删除的元素
console.log(deleteArray); // 被删除的元素[2, 3]
console.log(arr); // [1, 'a', 'b', 4]
// 单纯的删除元素
arr.splice(1, 2); // 从索引1删除2个元素
console.log(arr); // [1, 4]
10.2.2 不改变原数组的方法
下面的方法不会改变原有数组,方法会返回一个新的数组。
1 slice()
.slice(start, end) :从 start 索引(包括)开始到 end 索引(不包括)结束,返回一个子数组。
别和splice()搞混了!
举个栗子:
let arr = [1, 2, 3, 4];
let sub = arr.slice(1, 3);
console.log(arr); // 没变,还是[1, 2, 3, 4]
console.log(sub); // [2, 3]
如果不指定参数,就相当于使用所有元素返回一个新的数组,也就是复制一个数组:
let arr = [1, 2, 3, 4];
let sub = arr.slice();
console.log(sub); // [1, 2, 3, 4]
- 但是需要注意,
slice()是浅拷贝,关于浅拷贝和深拷贝,查看下面的内容。
2 concat()
.concat() :合并两个数组,得到一个新的数组。
举个栗子:
let a = [1, 2];
let b = [3, 4];
let c = a.concat(b);
console.log(c); // [1, 2, 3, 4]
3 join()
.join(separator) :将数组转换为字符串,使用 separator 连接。
举个栗子:
let arr = [1, 2, 3, 4];
let str = arr.join("-");
console.log(str); // "1-2-3-4"
4 indexOf()
.indexOf() :从数组开头向末尾搜索,返回第一个匹配元素的索引,找不到则返回 -1。
举个栗子:
let arr = [1, 2, 3, 4, 4, 3, 2, 1];
let index = arr.indexOf(2);
console.log(index); // 找到第一个2的位置为:1
还可以指定查询的其实位置:
let arr = [1, 2, 3, 4, 4, 3, 2, 1];
let index = arr.indexOf(2, 3); // 从index为3的位置开始查找
console.log(index); // 找到2的位置为:6
5 lastIndexOf()
.lastIndexOf() :从数组末尾向开头搜索,返回最后一个匹配元素的索引,找不到则返回 -1。
举个栗子:
let arr = [1, 2, 3, 4, 4, 3, 2, 1];
let index = arr.lastIndexOf(2);
console.log(index); // 找到最后个2的位置为:6
6 includes()
.includes() :可以判断数组是否包含某个元素,返回布尔值。
举个栗子:
const fruits = ["apple", "banana", "orange", "mango"];
console.log(fruits.includes("banana")); // true
console.log(fruits.includes("grape")); // false
还可以指定查找的其实位置:
const fruits = ["apple", "banana", "orange", "mango"];
console.log(fruits.includes("banana", 2)); // false,指定从index为2的位置查找
- 注意查找的时候是按照引用来查找的,两个对象属性相同,也是不同的。
includes() 比 indexOf() 牛比的是,它能识别 NaN 。
举个栗子:
const arr = [1, NaN, 3];
console.log(arr.includes(NaN)); // true
console.log(arr.indexOf(NaN)); // -1 (无法找到)
10.2.3 其他常用方法
1 sort()
.sort() :对数组进行排序,默认按字符串排序。
举个栗子:
let arr = [10, 2, 30];
arr.sort(); // 排序
console.log(arr); // [10, 2, 30] → ["10", "2", "30"] → 按字符排序
arr.sort((a, b) => a - b); // 排序,可以传递一个函数,按照数字比较两个元素的大小,进行排序,实现升序或者降序
console.log(arr);
2 reverse()
.reverse() :可以将数组中的元素顺序进行反转。
举个栗子:
let arr = [1, 2, 3, 4];
arr.reverse(); // 倒序
console.log(arr); // [4, 3, 2, 1]
10.3 数组的其他操作
10.3.1 展开数组
JavaScript的展开运算符是ES6引入的语法糖,使用三个连续的点(...)表示,主要用于展开可迭代对象(如数组或对象)中的元素或属性。
1 展开数组
可以将数组展开成单独的元素,用于创建新数组,也可以传递给函数作为参数。
let arr1 = [1, 2, 3];
let arr2 = [...arr1, 4, 5];
console.log(arr2); // [1, 2, 3, 4, 5]
...会展开数组的元素,可以作为元素创建一个新数组。- 需要注意,展开数组是浅拷贝。
2 展开对象
const original = { a: 1, b: { c: 2 } };
const copy = { ...original };
console.log(copy); // { a: 1, b: { c: 2 } }
copy.a = 10; // 修改基本类型,不影响原对象
copy.b.c = 20; // 修改嵌套对象,会影响原对象!
console.log(original); // { a: 1, b: { c: 20 } }
...展开对象可以用来复制对象,注意是浅拷贝。展开运算符(...)和Object.assign()都不会复制原型链上的属性,它们仅复制对象自身的可枚举属性(Own Properties)。
十一、函数进阶
11.1 高阶函数
什么是高阶函数?
满足以下两种情况之一的函数就是高阶函数:
- 函数作为参数传入
- 函数作为返回值返回
高阶函数有什么作用呢?
高阶函数能让你写出更简洁、可复用、模块化的代码,它是回调函数、事件处理、函数组合等场景的核心。
下面慢慢讲。
1 函数作为参数
例如,我们现在有一个学生列表:
class Student {
constructor(name, chinese, math, english) {
this.name = name;
this.chinese = chinese;
this.math = math;
this.english = english;
}
}
// 学生列表
const allStuList = [
new Student("zhangsan", 87, 48, 92),
new Student("lisi", 47, 92, 71),
new Student("wangwu", 58, 46, 38),
new Student("zhangliu", 95, 91, 99),
];
现在分别想查询列表中:语文成绩不及格的同学、所有课程不及格的同学、所有课程都是优秀的学生,那么我们需要定义三个函数,如下:
// 学生列表
const allStuList = [
new Student("zhangsan", 87, 48, 92),
new Student("lisi", 47, 92, 71),
new Student("wangwu", 58, 46, 38),
new Student("zhangliu", 95, 91, 99),
];
// 获取语文成绩不及格的同学
function getChineseFlunkList() {
const stuList = [];
for (let stu of allStuList) {
if (stu.chinese < 60) {
stuList.push(stu);
}
}
return stuList;
}
// 获取所有课程不及格的同学
function getFlunkList() {
const stuList = [];
for (let stu of allStuList) {
if (stu.chinese < 60 && stu.math < 60 && stu.english < 60) {
stuList.push(stu);
}
}
return stuList;
}
// 获取所有课程都是优秀的学生
function getExcellentList() {
const stuList = [];
for (let stu of allStuList) {
if (stu.chinese >= 90 && stu.math >= 90 && stu.english >= 90) {
stuList.push(stu);
}
}
return stuList;
}
// -------------方法调用---------------------
console.log("语文不及格的同学:");
const chineseFlunkList = getChineseFlunkList();
for (let stu of chineseFlunkList) {
console.log(stu);
}
console.log("所有不及格的同学:");
const flunkList = getFlunkList();
for (let stu of flunkList) {
console.log(stu);
}
console.log("所有成绩都是优秀的同学:");
const excellentList = getExcellentList();
for (let stu of excellentList) {
console.log(stu);
}
- 在上面的代码中,定义了3个函数
getChineseFlunkList()、getFlunkList()、getExcellentList(),但是三个函数中只有其中的判断条件不同,代码比较冗余, 能否抽出判断条件,将判断条件作为参数传递呢?
我们先抽出一个公共的函数,根据传递的参数条件获取学生列表的函数:
// 根据条件获取学生列表
function filter(funCondition) {
const stuList = [];
for (let stu of allStuList) {
if (funCondition(stu)) {
stuList.push(stu);
}
}
return stuList;
}
- 上面的函数的参数也是一个函数,在函数中,调用传递的函数,根据函数的结果判断
stu是否满足条件。 - 这样就可以传递一个函数,具体判断的条件,在传递的函数中实现就可以了,返回
true就是满足条件。
下面来调用上面的函数:
// 使用示例
console.log("语文不及格的同学:");
const chineseFlunkList = filter((stu) => {return stu.chinese < 60});
for (let stu of chineseFlunkList) {
console.log(stu);
}
-
调用函数,并传递一个函数作为参数,为了简单,直接传递一个箭头函数就可以了。
-
stu.chinese < 60表示如果学生的中文成绩大于60就返回true,那么在filter()函数中调用传递的函数,就可以根据结果筛选学生了。
箭头函数还可以再简化一下,获取三个学习列表,可以这样写:
// 使用示例
console.log("语文不及格的同学:");
const chineseFlunkList = filter((stu) => stu.chinese < 60);
for (let stu of chineseFlunkList) {
console.log(stu);
}
console.log("所有不及格的同学:");
const flunkList = filter(
(stu) => stu.chinese < 60 && stu.math < 60 && stu.english < 60
);
for (let stu of flunkList) {
console.log(stu);
}
console.log("所有成绩都是优秀的同学:");
const excellentList = filter(
(stu) => stu.chinese >= 90 && stu.math >= 90 && stu.english >= 90
);
for (let stu of excellentList) {
console.log(stu);
}
通过函数式编程,代码的冗余度降低了,代码更简洁了。
这种方式更符合编程的 OCP (Open-Closed Principle)原则,对扩展开放,对修改关闭。如果有其他的查询学生的条件,直接扩展即可,不用修改 filter() 函数。
2 函数作为返回值
函数作为返回值也经常被用到。
举个栗子:
你开了一家咖啡店,需要根据顾客的不同身份生成问候语:
// 高阶函数 - 返回定制化的问候函数
function createGreeter(role) {
return function (name) {
if (role === "VIP") {
return `尊贵的${name}会员,欢迎光临!`;
} else if (role === "regular") {
return `${name},您好!今天想喝点什么?`;
} else {
return `欢迎${name}~`;
}
};
}
// 生成特定角色的问候函数
const greetVIP = createGreeter("VIP");
const greetRegular = createGreeter("regular");
// 使用示例
console.log(greetVIP("张先生")); // "尊贵的张先生会员,欢迎光临!"
console.log(greetRegular("李女士")); // "李女士,您好!今天想喝点什么?"
- 在上面的代码中,首先调用
createGreeter()函数,函数也返回一个函数。 - 通过调用返回的函数,就可以实现不同的结果了。
函数作为返回值,经常是创建闭包最常见的方式,待会讲一下闭包。
11.2 内置高阶函数
JavaScript 中的内置高阶函数主要是 数组方法,它们都支持 传入函数作为参数。下面介绍一下常用的高阶函数,可以参考适用场景进行使用。
常用内置高阶函数:
| 方法 | 作用 | 是否返回新数组 | 是否常用 |
|---|---|---|---|
forEach() | 遍历数组,执行副作用 | 否 | ✅ |
map() | 映射数组,返回新数组 | ✅ | ✅✅ |
filter() | 过滤数组,返回符合条件的项 | ✅ | ✅✅ |
reduce() | 聚合数组成单个值 | 否(返回值) | ✅✅✅ |
find() | 查找第一个符合条件的元素 | 否(返回元素) | ✅ |
some() | 至少一个符合条件返回 true | 否 | ✅ |
every() | 所有都符合条件返回 true | 否 | ✅ |
sort() | 排序,接收比较函数 | 原地修改 | ✅ |
flatMap() | 类似 map 后 flat 一层 | ✅ | ⚠️ |
1 forEach
作用:一般就是用来遍历数组的。
前面遍历数组的时候已经用到了,举个栗子:
const fruits = ["apple", "banana", "cherry"];
fruits.forEach((item, index) => {
console.log(`${index + 1}. ${item}`);
});
// 执行结果:
1. apple
2. banana
3. cherry
2 map
作用:对每一项进行转换处理,返回一个新数组(不会改变原数组)。
举个栗子,将数组中的数据乘以2倍,返回一个新的数组:
const nums = [1, 2, 3];
const newArray = nums.map((item) => item * 2);
console.log(newArray); // [2, 4, 6]
- 给 map 传递一个函数,函数的参数
item是每个元素,可以对每个元素进行处理,回调函数的返回值就是新数组的元素。 - 适用于对数组中的数据进行转换;
- 回调函数还有两个参数,第二个是index,第三个是被遍历的数组本身。
3 filter
作用:筛选符合条件的元素,返回新数组。
举个栗子,筛选数组中的偶数,组成一个新的数组。
const nums = [1, 2, 3, 4, 5];
const evens = nums.filter(item => item % 2 === 0); // 判断item是否能被2整除
console.log(evens); // [2, 4]
- 回调函数的返回值为 true,就表示将该元素添加到新数组中;
- 可以用来根据条件筛选元素,和前面我们编写的
filter函数类似。 - 回调函数还有两个参数,第二个是index,第三个是被遍历的数组本身。
4 reduce
作用:将数组归纳为一个值(例如求和等)。
举个栗子:求和
const nums = [1, 2, 3, 4];
const sum = nums.reduce((acc, cur) => acc + cur, 0);
console.log(sum); // 10
- 在上面的代码中,
reduce函数有两个参数,第一个是回调函数,第二个是初始值,也就是第一次调用回调函数时候的acc的值。acc表示累计的值,cur表示当前正在处理的元素。 - 上面是将数组的各个元素求和。
其实回调函数有四个值,第三个为当前元素的索引,第四个为调用 reduce 函数的数组本身。
再举个栗子,将数组中的元素变为一个对象的属性:
const arr = ['a', 'b', 'c'];
const obj = arr.reduce((acc, cur, i) => {
acc[cur] = i;
return acc;
}, {});
console.log(obj); // { a:0, b:1, c:2 }
- 在上面的代码中,初始值就是一个空对象
{},每次调用就给这个空对象添加属性和赋值acc[cur] = i。 - 注意,需要返回归纳的结果值
acc。
reduce 函数适合将数组变为单个值。
5 find
作用:返回第一个符合条件的元素(不是数组)。
举个栗子:
const users = [
{ id: 1, name: "Tom" },
{ id: 2, name: "Jerry" }
];
const jerry = users.find(user => user.name === "Jerry");
console.log(jerry); // { id: 2, name: "Jerry" }
- 就是查找数组中满足条件的第一个元素。
6 some
作用:判断是否至少有一个元素符合条件,返回布尔值。
举个栗子:
const nums = [1, 3, 5];
let hasEle = nums.some((x) => x % 2 === 0); // 判断数组中是否有偶数
console.log(hasEle); // false
- 用来判断数组中是否有一个元素满足条件。
7 every
作用:判断是否所有元素都符合条件。
举个栗子:
const nums = [2, 4, 6];
console.log(nums.every(x => x % 2 === 0)); // true,判断数组中是否都是偶数
8 sort
作用:对数组排序,将修改原数组。
讲解数组的时候,也举过例子了:
let arr = [10, 2, 30];
arr.sort(); // 排序
console.log(arr); // [10, 2, 30] → ["10", "2", "30"] → 按字符排序
arr.sort((a, b) => a - b); // 排序,可以传递一个函数,按照数字比较两个元素的大小,进行排序,实现升序或者降序
console.log(arr);
- 注意:如果不传递参数,默认是按字符排序。
- 可以传递参数,实现按照元素大小排序,返回
a-b结果是升序,返回b-a结果是降序。 - 注意:会修改原始数组!
9 flat
作用:对数组进行扁平化处理,减少数组的嵌套层级。
前面讲解数组的时候,就是数组中放元素,但是元素其实还可以是数组,也就是进行嵌套,而 flat() 函数就是对数组进行扁平化处理。
举个栗子:
const arr1 = [1, 2, [3, 4]];
console.log(arr1.flat()); // 对数组进行扁平化处理,默认只会扁平化一层
// [1, 2, 3, 4] (默认 depth=1)
const arr2 = [1, 2, [3, 4, [5, 6]]];
console.log(arr2.flat());
// [1, 2, 3, 4, [5, 6]] (只扁平化一层)
console.log(arr2.flat(2));
// [1, 2, 3, 4, 5, 6] (扁平化两层)
- 对数组进行扁平化处理,如果不指定参数,默认只会扁平化一层。
10 flatMap
作用:先使用 map 映射,然后再 flat 一层。
举个栗子:
const sentences = ["Hello world", "Goodbye universe"];
const words = sentences.flatMap((sentence) => sentence.split(" "));
console.log(words); // ["Hello", "world", "Goodbye", "universe"]
- 在上面的代码中,
sentence.split(" ")表示将每个元素,使用空格" "进行分割,那么数组变为:[["Hello", "world"], ["Goodbye", "universe"]],每个元素被被变为数组了,那么数组就变为两层了。 - 然后在使用
flat()函数进行扁平化处理。
11.3 闭包
1 闭包简介
如果要实现一个计数器的功能,每调用一次函数,打印的数值加1,那么该如何实现呢?
let count = 0;
function counter() {
count++;
console.log(count);
}
counter(); // 1
counter(); // 2
counter(); // 3
你可能像上面这样实现,但是这样实现会有一个问题,计数的变量是全局变量,谁都可以修改,那么就会出问题。
修改一下代码,如下:
function outer() {
let count = 0; // 外部函数的变量
function inner() {
count++; // 内部访问外部函数变量
console.log(count);
}
return inner; // 返回内部函数
}
let counter = outer(); // 调用外部函数,返回内部函数
counter(); // 1
counter(); // 2
counter(); // 3
- 既然全局变量大家都能修改,那么就把变量放在函数中,内部函数引用了外部函数的变量,所以这个变量在全局是无法访问和修改的;
- 然后将内部函数作为外部函数的返回值,进行返回;
上面的实现就形成了一个闭包,所以说**什么是闭包?**闭包必须满足三个条件:
- 在一个外部函数中有一个内部函数;
- 内部函数必须引用外部函数中的变量;
- 外部函数的返回值必须是内部函数;
当我们想要隐藏一些数据,不希望被别人访问时,就可以使用闭包。
2 闭包的本质
先看一下下面的代码:
let count = 2;
function func1() {
console.log(count);
}
function func2() {
let count = 3; // 外部函数的变量
func1();
}
func1(); // 2
func2(); // 2
- 在上面的代码中,调用
func1()和func2()都打印 2,也就是说在func1()函数中访问的 count 变量就是全局的 count,不会因为在不同的调用调用结果就不同。
也就是 JavaScript 中的作用域是词法作用域,在函数定义的时候就决定了能访问哪些变量。
3 闭包注意事项
在上面使用闭包的时候,每调用一次外部的函数就会产生一个闭包。而闭包会使函数的外部变量始终保存内存中,不会被作为垃圾回收,容易造成内存泄漏。
闭包中的变量只要它们“能被访问”,就不会被回收;一旦所有对闭包的引用都消失了,闭包和它的变量也就会被垃圾回收器清理。
11.4 函数的递归
什么是函数的递归?
函数的递归,就是在一个函数内部,又调用了这个函数自己,这个就是函数的递归。
举个例子:
function funA() {
console.log("----");
funA();
}
funA();
上面的函数,执行 funA() ,然后在 funA() 内部又调用了自己,那么会重新又调用了 funA() 函数,然后又调用了自己,这样就会一直无限调用,变成了无限循环调用,执行的时候很快就报Stack Overflow的错误,栈溢出。
所以函数的递归有时候是很危险的,很容易无限调用,造成栈溢出,程序崩溃。所以函数的递归调用一定要注意 结束或跳出递归 的条件。
例如我们写一个用递归求阶乘的函数:
/**
* 求阶乘
*/
function factorial(num) {
if (num <= 1) {
return 1;
}
return num * factorial(num - 1);
}
let num = 5;
let result = factorial(num);
console.log(num + "的阶乘为:" + result); // 120
以 5 举例,求 5 的阶乘,调用了 factorial() 函数 ,则计算 5 乘以 4 的阶乘,然后求 4 的阶乘,重新调用了 factorial() 函数,然后计算 4 乘以 3 的阶乘,依次类推,一直得到1的阶乘,然后向上返回。

递归函数一定得有结束的条件,否则就会无限递归导致栈溢出错误。
这里暂时理解不了,可以不纠结,慢慢领悟,平时也很少用到。
11.5 可变参数
“可变参数”是指函数能够接收 不定数量的参数。可以根据需要传入任意数量的参数值。
可变参数主要有两种方式:
- 传统方式:
arguments对象; - 现代推荐方式:
...rest参数。
1 arguments对象
arguments是一个 类数组对象(和数组的操作很像,但是不是数组),包含了传入函数的所有参数;- 它只在非箭头函数中有效;
- 它是函数中的隐藏参数,没有显式声明,但可用,名称是固定的,就是arguments。
举个栗子:
function sum() {
let sum = 0;
for (let i = 0; i < arguments.length; i++) { // 可以通过arguments获取到传入的所有参数
sum += arguments[i]; // 可以通过数组一样访问到传入的参数
}
return sum;
}
console.log(sum(1, 2)); // 3
console.log(sum(1, 2, 3, 4)); // 10
需要注意:
- arguments 不是数组,所以无法直接使用如
map,filter,forEach等方法; - 但是可以通过
Array.from(arguments)或[...arguments]转换为数组;
arguments 对象总是接收到函数的所有参数,不管函数有没有其他的参数:
function sum(a, b) {
console.log(a); // 1
console.log(b); // 2
console.log(arguments); // [1, 2, 3, 4]
}
console.log(sum(1, 2, 3, 4)); // 10
2 ...方式
而 ...rest 参数方式的参数就真的是一个数组了。
举个栗子:
function sum(...numbers) {
return numbers.reduce((acc, val) => acc + val, 0); // 使用 reduce() 方法进行求和
}
console.log(sum(1, 2)); // 输出 3
console.log(sum(1, 2, 3, 4)); // 输出 10
- 上面的
numbers就真的是一个数组,可以使用数组的方法; - 参数名称是可以自定义的;
...rest 可变参数参数可以和固定参数一起使用,但是需要注意,可变参数最多只能有一个,而且必须作为最后一个参数。
function sum(a, b, ...numbers) {
return a + b + numbers.reduce((acc, val) => acc + val, 0); // 使用 reduce() 方法进行求和
}
console.log(sum(1, 2)); // 输出 3
console.log(sum(1, 2, 3, 4)); // 输出 10
- 在上面的代码中,优先传递参数给前面的固定参数,剩余参数才会传递给可变参数。
为什么更推荐使用 ...rest 参数?
arguments 不是数组,...rest 是数组 ;
arguments 不支持箭头函数,...rest 支持 ;
arguments 在严格模式下行为有陷阱;
举个栗子,在非严格模式下,arguments 和命名参数是绑定的:
function test(a) {
arguments[0] = 99; // 传递的a的值为1,这里修改了第一个参数的值为99
console.log(a); // a的值也发生了变化,为99
}
test(1);
在严格模式下不再绑定:
"use strict";
function test(a) {
arguments[0] = 99; // 修改arguments的值
console.log(a); // 输出 1(未变)
}
test(1)
这种不一致性会导致潜在 bug,不如 ...rest 安全可靠。
11.6 call和apply
函数除了可以使用 () 进行调用,还可以使用 call() 和 apply() 方法进行调用。
举个栗子:
function test() {
console.log('Hello')
}
test.call();
- 对象会有属性和方法,而函数也是对象,而
call()和apply()其实是函数对象的方法。
上面这样调用函数,有点脱裤子放屁的感觉!
前面在讲函数中的 this 指向谁,基本可以归纳为以下几种:
- 以函数方式调用,this指向window对象;
- 以对象方式调用,指向当前调用方法的对象;
- 构造函数中的this,指向的是当前创建的对象;
- 箭头函数中没有自己的this,this 指向的是外层作用域。
其实 call() 和 apply() 的主要作用是改变函数内部的 this 指向。
举个栗子:
function test(a, b, c) {
console.log(this, a, b, c)
}
let person = {name:'Doubi'}
test.call(person, 1, 2, 3); // {name: 'Doubi'} 1 2 3
call的第一个参数,会成为函数中的 this,后面的参数会成为函数的参数。
apply() 函数也是一样的,只是 call() 和 apply() 传递参数的方式不同。call() 函数传递参数的方式是一个一个传递,apply() 函数是通过数组传递。
举个栗子:
function test(a, b, c) {
console.log(this, a, b, c)
}
let person = {name:'Doubi'}
test.call(person, 1, 2, 3); // {name: 'Doubi'} 1 2 3
test.apply(person, [1, 2, 3]); // {name: 'Doubi'} 1 2 3
11.7 bind
bind() 方法的作用,也是可以改变函数中 this 的指向,但是和 call 和 apply 的不同, 它不会立即执行函数,而是返回一个新的函数,将这个新函数中的 this 绑定。
举个栗子:
function test(a, b, c) {
console.log(this, a, b, c)
}
let person = {name:'Doubi'}
let newFunc = test.bind(person, 1, 2);
newFunc(4, 5, 6);
test.bind(person, 1, 2)会返回一个新的函数,函数中的this指向bind()方法的第一个参数,也就是person。bind()方法后面的参数是对参数进行绑定,也就是这个新函数的第一个参数被绑定为1,第二个参数被绑定为2了,调用新函数时传的参数将无效。
执行结果:
{name: 'Doubi'} 1 2 4
- 新函数的前两个参数被绑定为1和2 了,调用的时候传递的参数无效。
所以 bind() 可以创建一个新函数,为新函数绑定 this 和参数。
需要注意,箭头函数中没有this,所以不能使用 call、apply、bind 修改它的 this 指向。
更多推荐
所有评论(0)