对 JavaScript 进阶学习

前言

最近很长一段时间做项目比较轻松,导致进阶知识点大量丢失,虽然之前看过很多知识点但时间长还是忘了,趁最近有时间再次温习并且记录下来。

对象原型

对象原型在之前一直处于半懵半懂的状态,所以这次搜了大量资料把心中存在的疑问一次性全部解决掉,按照我理解来看原型的概念类似面向对象 class 类的扩展方法,只需要理解它的原型链工作原理就基本理解原型的概念。比如下面的代码:

const myObject = {
  city: "Madrid",
  greet() {
    console.log(`来自 ${this.city} 的问候`);
  },
};
myObject.greet(); // 来自 Madrid 的问候

如果在控制台打印输出 myObject 对象的时候,展开 Prototype 会发现有很多熟悉,这些属性可以直接调用并且执行输出内容,所以这些东西其实是所有对象的一个内置属性,也就是所谓的 prototype 原型,原型本身也是一个对象,既然是对象那肯定也有原型,一层一层走下去构成原型链,所有原型链的尽头都为 null。如果访问一个对象的属性时候,如果在对象本身找不到这个属性的时候,就会在它的原型中查找属性,如果仍然没有找到该属性,那么就搜索原型的原型,直到找到这个属性,这时候还没找到但已经到末端则会返回 undefined。比如调用 myObject.toString() 的时候它的过程是这样的:

  • myObject 中寻找 toString 属性
  • myObject 中找不到 toString 属性,故在 myObject 的原型对象中寻找 toString
  • 其原型对象拥有这个属性,然后调用它

如果查找一个对象的原型可以使用 Object.getPrototypeOf() 方法:

Object.getPrototypeOf(myObject); // Object { }

如果要重写一个对象的属性方法,可以这么做:

myObject.toString = function () {
    console.log('hello')
}

myObject.toString() // hello

除了重写对应的属性之外还可以在对象上添加新的原型对象:

const personPrototype = {
  greet() {
    console.log("hello!");
  },
};

const carl = Object.create(personPrototype);
carl.greet(); // hello!

所有的函数都有一个名为 prototype 的属性。调用一个函数作为构造函数时,这个属性被设置为新构造对象的原型(在名为 __proto__ 的属性中)。在设置一个构造函数的 prototype,可以保证所有用该构造函数创建的对象都被赋予该原型:

const personPrototype = {
  greet() {
    console.log(`你好,我的名字是 ${this.name}!`);
  },
};

function Person(name) {
  this.name = name;
}

Object.assign(Person.prototype, personPrototype);
// 或
// Person.prototype.greet = personPrototype.greet;

这里解释了:

  • 创建了一个 personPrototype 对象,它具有 greet() 方法
  • 创建了一个 Person() 构造函数,它初始化了要创建人物对象的名字

使用 Object.assignpersonPrototype 中定义的方法绑定到 Person 函数的 prototype 属性上:

const reuben = new Person("Reuben");
reuben.greet(); // 你好,我的名字是 Reuben!

使用上面的 Person 构造函数创建的对象有两个属性:

  • name 属性,在构造函数中设置,在 Person 对象中可以直接看到
  • greet() 方法,在原型中设置

在方法是在原型上定义的,但数据属性是在构造函数中定义的。这是因为方法通常对我们创建的每个对象都是一样的,有时候需要希望每个对象的数据属性都有自己的值(就像这里每个人都有不同的名字)。直接在对象中定义的属性,如这里的name,被称为自有属性,你可以使用静态方法 Object.hasOwn() 检查一个属性是否是自有属性:

const irma = new Person("Irma");

console.log(Object.hasOwn(irma, "name")); // true
console.log(Object.hasOwn(irma, "greet")); // false

面向对象编程

如果有后端 OOP 编程思想这一块应该很好理解的,比如可以做个老师的通用模板,然后根据这个模板继承扩展出不同的人种模板,例如语文老师、数学老师等:

class Professor
    properties
        name
        teaches
    methods
        grade(paper)
        introduceSelf()

Professor 类的定义包括如下内容:

  • 两个属性:姓名 name 和所教的课程 teaches
  • 两个方法:grade() 方法用于为学生的论文打分;introduceSelf() 方法用于介绍自己

定义构造函数后就可以创建出具体的老师了,编程语言通常使用 new 关键字来表示执行构造函数:

walsh = new Professor("沃尔什", "心理学");
lillian = new Professor("丽莲", "诗歌");

walsh.teaches; // '心理学'
walsh.introduceSelf(); // '我是沃尔什,我是你们的心理学老师。'

lillian.teaches; // '丽莲'
lillian.introduceSelf(); // '我是丽莲,我是你们的诗歌老师'

如果在上面的基础上新增学生的类,那么需要提出和老师的共同属性和方法,例如:

class Student
    properties
        name
        year
    constructor
        Student(name, year)
    methods
        introduceSelf()

在某种层级上,二者实际上是同种事物,他们能够具有相同的属性也是合理的。继承(Inheritance)可以完成这一操作:

class Person
    properties
        name
    constructor
        Person(name)
    methods
        introduceSelf()

class Professor : extends Person
    properties
        teaches
    constructor
        Professor(name, teaches)
    methods
        grade(paper)
        introduceSelf()

class Student : extends Person
    properties
        year
    constructor
        Student(name, year)
    methods
        introduceSelf()

更具体的操作过程如下:

walsh = new Professor("沃尔什", "心理学");
walsh.introduceSelf(); // '我是沃尔什,我是你们的心理学老师。'

summers = new Student("萨摩斯", 1);
summers.introduceSelf(); // '我是萨摩斯,我是一年级的学生。'

或者是普通人的调用:

pratt = new Person("普拉特");
pratt.introduceSelf(); // '我是普拉特。'

当其他部分的代码想要执行对象的某些操作时,可以借助对象向外部提供的接口完成操作,借此,对象保持了自身的内部状态不会被外部代码随意修改。也就是说,对象的内部状态保持了私有性,而外部代码只能通过对象所提供的接口访问和修改对象的内部状态,不能直接访问和修改对象的内部状态。保持对象内部状态的私有性、明确划分对象的公共接口和内部状态,这些特性称之为封装(encapsulation)。封装的好处在于,当程序员需要修改一个对象的某个操作时,程序员只需要修改对象对应方法的内部实现即可,而不需要在所有代码中找出该方法的所有实现,并逐一修改。某种意义上来说,封装在对象内部和对象外部设立了一种特别的“防火墙”。

例如学生只有在二年级以后才能学习弓箭课,我们可以将学生的 year 属性暴露给外部,从而外部代码可以通过检查学生的 year 属性来确认该学生是否可以选修该课程:

if (student.year > 1) {
  // 允许学生选修弓箭课
}

Student 类中添加一个 canStudyArchery() 方法(用于检查学生是否能够选修弓箭课),那么相应代码的实现逻辑就会集中在一个地方:

class Student : extends Person
    properties
       year
    constructor
        Student(name, year)
    methods
       introduceSelf()
       canStudyArchery() { return this.year > 1 }
if (student.canStudyArchery()) {
  // 允许学生选修弓箭课课
}

这样一来,如果要修改选修弓箭课的规则,只需要更新 Student 类中的相应方法即可,而其他地方的代码无需修改,整个系统仍旧可以正常工作。

在许多面向对象编程语言中,可以使用 private 关键字标记对象的私有部分,也就是外部代码无法直接访问的部分。如果一个属性在被标记为 private 的情况下,外部代码依旧尝试访问该属性,那么通常来说,计算机会抛出一个错误。

class Student : extends Person
    properties
       private year
    constructor
        Student(name, year)
    methods
       introduceSelf()
       canStudyArchery() { return this.year > 1 }

student = new Student('Weber', 1)
student.year // 错误:'year'是学生类的私有属性

在之前开发汽车小组件我使用 class 对象去编写业务,使用过程中也没有遇到太大的坑,绝大部分和后端编程语言差不多,根据上面的 Person 类如下:

class Person {
  name;

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

  introduceSelf() {
    console.log(`Hi! I'm ${this.name}`);
  }
}

在这个 Person 类的声明中,有:

  • 一个 name 属性。
  • 一个需要 name 参数的构造函数,这一参数用于初始化新的对象的 name 属性。
  • 一个 introduceSelf() 方法,使用 this 引用了对象的属性。

name; 这一声明是可选的:你可以省略它,因为在构造函数中的 this.name = name; 这行代码会在初始化 name 属性前自动创建它。但是,在类声明中明确列出属性可以方便阅读代码的人更容易确定哪些属性是这个类的一部分。

const giles = new Person("Giles");

giles.introduceSelf(); // Hi! I'm Giles

然后继承子类 Professor

class Professor extends Person {
  teaches;

  constructor(name, teaches) {
    super(name);
    this.teaches = teaches;
  }

  introduceSelf() {
    console.log(
      `My name is ${this.name}, and I will be your ${this.teaches} professor.`,
    );
  }

  grade(paper) {
    const grade = Math.floor(Math.random() * (5 - 1) + 1);
    console.log(grade);
  }
}

实例化此对象:

const walsh = new Professor("Walsh", "Psychology");
walsh.introduceSelf(); // 'My name is Walsh, and I will be your Psychology professor'

walsh.grade("my paper"); // some random grade

对于子类特殊情况进行封装,例如 Studentyear 属性变为私有的,我们可以在不破坏任何使用了 Student 类的代码的情况下,修改射箭课程的规则:

class Student extends Person {
  #year;

  constructor(name, year) {
    super(name);
    this.#year = year;
  }

  introduceSelf() {
    console.log(`Hi! I'm ${this.name}, and I'm in year ${this.#year}.`);
  }

  canStudyArchery() {
    return this.#year > 1;
  }
}

执行如下:

const summers = new Student("Summers", 2);

summers.introduceSelf(); // Hi! I'm Summers, and I'm in year 2.
summers.canStudyArchery(); // true

summers.#year; // SyntaxError