九、面向对象

        面向对象编程(OOP,Object-Oriented Programming),首先涉及到的两个概念就是: 和 对象

        什么是类?
        类就是对现实事物的抽象设计。
        例如设计学生的类,可能包括属性:学号,姓名、年龄、性别等。
        设计狗的类,可能包括属性:名字、年龄、品种。
        类表示的是对一个事物的抽象,不是具体的某个事物。

        什么是对象?
        对象就是一个具体的实例,这个实例是从类派生出来的。
        我们将类的属性赋值,就产生了一个个实例,也就是对象。
        例如通过学生的类,我们赋值:学号=001,姓名=张三,年龄=16,性别=男,班级=三年二班,产生了就是一个名字叫张三的具体的学生,这样我们通过给类可以派生出一个个不同属性值的对象,李四、王五、赵六...。

        所以说面向对象编程,就是先设计类,然后根据类来生成一个个对象。

        9.1 类和对象

        JavaScript 和其他的面向对象的变成语言有一些区别,在一些其他的语言中,我们需要先创建类,然后根据类来创建对象。但是 在 JavaScript 中可以直接创建对象,就像我们前面讲解对象时一样。这主要是 JavaScript 面向对象实现的原理不同,是基于原型,而非基于类,class 只是语法糖(更简洁更易读的语法),底层仍是原型(ES6 未改变 JavaScript 的 OOP 本质),当然这个后面细说,有个印象。

        前面不通过类来创建对象的时候,存在如下问题:

  1. 无法区分对象的类型

    例如创建多个学生和多个老师对象,没办法区分哪个对象是什么类型的,所有的对象只是有一些属性和方法,没什么本质区别。

  2. 批量创建对象很不方便

    每次创建对象都需要使用字面量的形式创建,每次都要写各种属性和方法。

        下面我们先来定义类,然后通过类来创建对象,看看有什么不同。

        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中为什么需要原型呢?

  1. 将方法定义在原型上,所有对象可以共享同一份方法,而不是每个实例都创建独立的副本,这样可以节省内存。

        所以同一个类的多个对象的原型对象指向的是同一个对象,举个栗子:

class Person { 
  constructor(name) {
    this.name = name;
  }     
}

let p1 = new Person('Doubi');
let p2 = new Person('Niubi');

console.log(p1.__proto__ === p2.__proto__)  // true
  1. 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 执行如下操作:

  1. 在 student 对象本身查找 → ❌ 没有
  2. 查找 student.__proto__(即 Student.prototype) → ❌ 没有
  3. 查找 student.__proto__.__proto__(即 Person.prototype) → ✅ 找到
  4. 如果还没找到,继续找 Person.prototype.__proto__(即 Object.prototype)...
  5. 直到 __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
  • 通过上面的代码看,又不是那么回事。

        对于普通的构造函数,比如 PersonPerson 是一个函数,而所有函数都是 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. 函数作为参数传入
  2. 函数作为返回值返回

        高阶函数有什么作用呢?
        高阶函数能让你写出更简洁、可复用、模块化的代码,它是回调函数、事件处理、函数组合等场景的核心。
        下面慢慢讲。

        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 不是数组,所以无法直接使用如 mapfilterforEach 等方法;
  • 但是可以通过 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 指向。

更多推荐