JavaScript——面向对象

面向对象

1、概述

面向对象编程是把客观世界的实体转换为代码进行表示的过程,只关注实体必要的属性和行为,忽略不相关的部分,这样更符合人的思维逻辑,所编写的代码更清晰,理解起来也更加简单。例如一个员工管理系统,它面向的对象虽然是人,但是只关注人作为员工时的一些基本信息,如:姓名、出生年月、入职时间、薪水情况等,还有行为,如:上班打卡、请假、升职等,它不会关注学号、班级、上课等学生信息和行为。员工管理系统所关注的这些信息和行为是每个员工共有的属性,可以把员工称为类(Class),它是抽象的,即不单指具体的某一个员工,若说张三在这个公司就职,则张三这个员工是具体的,可以称为实例(Instance)或实例对象,这样它的员工信息和上班打卡行为等才有实际意义。

再例如一个抽象的例子,假设某个网页中有个按钮组件,它有标签(Label)、背景、大小属性,以及单击事件和渲染HTML标签的行为,这时可以定义一个Button类,代表该按钮,里边包括上边描述的所有属性和行为,通过这个按钮类可以创建出多个不同的按钮实例,它们都有相同的属性和行为,只不过每个按钮的背景、大小和单击处理方式可以相同也可以不同,这就是每个实例自己要管理的事情了。

面向对象编程有4个特征:封装、抽象、继承和多态:

  1. 封装(Encapsulation)指的是对象中的属性和行为对外都是不可见的,只能通过暴露给外部的公开的接口进行修改。
  2. 抽象(Abstraction)指的是对象中的行为、属性等的实现细节是不可见的,使用这个对象只需调用公开的方法,无须知道方法具体做了什么。
  3. 继承(Inheritance)指的是对象通过继承其他对象,可以获得继承下来的属性和行为,并且可以覆盖它们或者定义新的。
  4. 多态(Polymorphism)本质指一个事物有多种状态,在面向对象编程中,多态指在创建对象的时候,无须知道具体由哪个子类创建的,通过继承关系可以明确知道它有哪些属性和方法,即使它们具体的实现各不相同。

2、创建类

在JavaScript中,创建类使用class关键字,后面加上自定义的类名,类名需要遵守标识符命名规范,首字母按照类名的命名原则需要大写,类名后面使用一对大括号,在里边编写类中的代码。例如定义一个员工(Employee)类,代码如下:

js 复制代码
class Employ{}

类的定义也可以是表达式形式,所以可以作为变量、函数参数的值及函数的返回值。例如使用变量保存类的表达式,代码如下:

js 复制代码
     const Employee=class{}

2.1、定义构造函数

在类中定义构造函数与普通构造函数有所区别,类中的构造函数没有名字,而是使用固定的constructor关键字,例如定义Employee类的构造函数,代码如下:

js 复制代码
     class Employee{
         constructor(name,dept){
              this.name=name;
              this.dept=dept
         }
     }

Employee类的构造函数接收两个参数,name姓名和dept所在部门,之后在函数体中,使用this给员工类添加了属性,并使用参数中的值进行初始化,这些属性在使用该类创建的员工对象中都可以访问和修改。如果在定义class的时候没有提供构造函数,则JavaScript会自动生成一个没有参数的构造函数作为默认的构造函数,虽然可以保证能够创建对象,但是里边就没有任何属性了。

对应原型的实现方式,上边的写法就相当于JavaScript的普通构造函数,代码如下:

js 复制代码
     function Employee(name,dept){
         this.name=name;
         this.dept=dept
     }

2.2、实例化对象

实例化的过程与使用普通构造函数创建对象的方法类似,使用new关键字,后面加上类名,后边的类名实际上调用了类中的constructor()构造函数,在里边同样需要传递构造函数所需要的参数,例如实例化一个员工对象,并且访问它的实例变量,代码如下:

js 复制代码
     const emp=new Employee("张三","软件开发部");
     emp.name;                //"张三"
     emp.dept;                //"软件开发部"

这样,就创建出来了一个具体的员工对象,可以明确知道该员工的名字为张三,所在部门为软件开发部。通过Employee类创建出来的实例自动包含了构造函数中定义的属性,并且在调用构造函数时将传递的参数也分别赋值到对应的属性中了。如果要修改实例变量的值,也跟修改普通对象中的一样,使用=加上要赋的新值即可,例如emp.name="李四"。

这里的emp对象,跟使用原型方式定义的普通构造函数所创建出来的对象没什么区别,使用方式也一样。同样地,使用类实例化的对象也会把prototype设置为Employee类的prototype,因为本质上Employee类也是构造函数,所以使用代码Object.getPrototypeOf(emp)===Employee.prototype的判断结果为true。

2.3、添加行为

面向对象中的行为在代码中是以方法的形式呈现的,代表着对象中有哪些可以执行的操作。在类中定义方法与在使用字面值创建的对象中定义的方式一样,使用简写的形式,即方法名+参数列表+大括号,不过方法之间不使用逗号进行分隔,例如给Employee类添加打卡和请假两种方法(省略构造函数的定义)​,代码如下:

js 复制代码
     class Employee{
       //省略constructor
       signIn(){
         console.log("打卡上班");
       }
       askForLeave(){
         console.log("请假");
       }
     }

在类中定义方法,类似于在原型方式中给构造函数的prototype添加方法,代码如下:

js 复制代码
     function Employee(){/*省略代码*/}
     Employee.prototype.signIn=function(){/*...*/}
     Employee.prototype.askForLeave=function(){/*...*/}

另外,也可以在类中定义getters和setters。getters和setters在面向对象编程中经常用于结合私有属性实现封装特性,即对象的所有属性都是不可直接修改和读取的,而是通过对应的getters和setters方法来读写,这样可以在其中编写校验逻辑,以保护属性不被篡改。

定义getters和setters的方式和在对象字面值中定义的方式一样,分别使用get或set关键字加上函数名作为属性名,在函数体中编写访问或写入的逻辑,例如定义用于获取员工全部信息的get info()方法,还有set info()方法,接收包含name和dept数据的对象作为参数,并设置员工的name和dept属性,代码如下:

js 复制代码
     class Employee{
       //省略其他代码
       get info(){
            return`员工姓名:${this.name},所在部门:${this.dept}`;
       }
       set info(value){
         this.name=value.name;
         this.dept=value.dept;
       }
     }
     const emp=new Employee("张三","软件开发部");
     emp.info;          //员工姓名:张三,所在部门:软件开发部
     emp.info={name:"李四",dept:"软件开发II部"};
     emp.info;          //员工姓名:李四,所在部门:软件开发     II部

     console.log(emp.info)

2.4、注意事项

使用class关键字定义的类不会被提升,因此不能在定义类之前创建实例,代码如下:

js 复制代码
     let emp=new Employee();//不能在Employee初始化之前访问它
     class Employee{}

但是这里JavaScript知道有Employee的声明,所以错误中会提示Employee未初始化,而不是未定义,它只有在执行到classEmployee{}这行代码时,Employee才最终完成初始化。

3、实现继承

在JavaScript中,实现继承使用extends关键字

js 复制代码
     class Manager extends Employee{}
     var mgr=new Manager("李经理","信息技术部");
     mgr.signIn();                 //打卡上班
     mgr.askForLeave();            //请假
     mgr.info;                     //员工姓名:李经理,所在部门:信息技术部

Manager类本身没有定义任何属性和方法,但是仍然可以调用Employee类中的构造函数及打卡、请假和获取信息的方法,同时也拥有name和dept属性,这些都是从Employee类中自动继承下来的。对于继承下来的子类,它和父类之间是is-a(是一个)的关系,例如经理是一名员工。要证明或者判断这种关系,JavaScript中提供了instanceof关键字,左边是要判断的对象,右边是类名。例如判断mgr对象和Manager及Employee是不是is-a的关系,代码如下:

js 复制代码
     console.log(mgr instanceof Manager); //true
     console.log(mgr instanceof Employee);//true

如果Employee还继承了其他类,则mrg与这些上层的父类都是is-a的关系。因为class本质上是prototype原型机制的语法糖,因此使用class创建出来的对象最后也都继承到了Object这个最顶层的类,所以如果运行mgr instanceof Object这段代码,它的返回结果同样也是true。

在上边的例子中,Manager没有定义自己的属性和方法,并且signIn()和askLeave()方法的代码也没有进行任何改动,如果想让经理有不同的打卡和请假行为,则可以在Manager类中定义同名的方法,然后编写Manager类专属的业务逻辑,这样再调用这些方法时,就是直接使用的Manager类中的方法,这种机制叫作覆盖(Override)。同时,Manager类也可以增加自己特有的方法,例如审批行为。下方示例中覆盖了Manager的signIn()方法,并添加了approval()审批方法,代码如下:

js 复制代码
     class Manager extends Employee{
       signIn(){
         console.log("经理打卡上班");
       }
       approval(approved){
         console.log(approved?"审批通过":"审批不通过");
       }
     }
     var mgr=new Manager("李经理","信息技术部");
     mgr.signIn();                 //经理打卡上班
     mgr.approval(true);           //审批通过

可以看到signIn()和approval()都执行了Manager类中所定义的代码。

有时候可能需要在父类代码的基础上添加一些新的逻辑,而不是完全覆盖父类中的方法,这种情况下可以使用super()关键字调用父类的方法。super既可以用在构造函数中,也可以用在成员方法中,不过稍微有一些区别,先看一下调用父类的构造函数。

假设有一个软件工程师类SoftwareEngineer继承自员工类,它需要额外提供软件工程师所掌握的技能信息,那么在它的构造函数中除了需要姓名和部门属性外,还需要一个skill技能属性,但是对于姓名和部位属性的赋值操作和父类中的相同,如果要想只给skill进行赋值,则可以直接在构造函数中使用super调用父类的构造函数,即用员工类的构造函数来初始化姓名和部门属性,之后再在自己的构造函数中初始化skill技能属性。super的调用方式是直接在super关键字后边跟一对小括号,里边写上父类构造函数所需要的参数就可以了。需要注意super调用父类的构造函数需放在第1条使用this的代码之前。super用法的代码如下:

js 复制代码
     class SoftwareEngineer extends Employee{
       constructor(name,dept,skill){
          super(name,dept);
         this.skill=skill;
       }
     }
     var se=new SoftwareEngineer("王五","信息技术部","JavaScript");
     console.log(se.name,se.dept,se.skill);             //王五信息技术部JavaScript

可以看到name、dept和skill属性都初始化成功了。在创建SoftwareEngineer类的实例时,会首先调用父类的构造函数,初始化name和dept,然后会执行自身构造函数中的this.skill=skill来初始化skill属性。

如果要调用父类的普通方法,则可以使用super加上"."再加上父类中的方法名进行调用。假如工程师在打卡时,还需要显示一下职位信息,例如软件工程师,那么可以在SoftwareEngineer类中调用Employee的signIn()方法,然后额外打印出软件工程师这个字符串,代码如下:

js 复制代码
     class SoftwareEngineer extends Employee{
       signIn(){
          super.signIn();
         console.log("软件工程师");
       }
     }
     se.signIn();//打卡上班
                  //软件工程师

在上边的代码中同时执行了Employee和SoftwareEngineer类中的signIn()方法,这里在使用super调用父类普通方法时,可以放在任何位置,也可以在任何方法中调用,不必在与父类同名的方法中调用。

4、抽象类

抽象类(Abstract Class)是一种特殊的类:在它的类中有一系列没有具体功能实现的方法------抽象方法,需要子类通过继承去实现它们。就好比是一系列的规范或模板,子类必须严格按照这个规范去定义自身的方法,这样能保证所有基于此抽象类创建的子类都含有相同的方法。抽象类本身不能创建对象,因为它里边的方法都没实现,所以创建对象并没有意义。

在JavaScript中没有与抽象类相关的关键字和定义方式,也没有接口的概念,因为它是动态的类型语言,并不需要这种形式,只要某些对象中含有相同的属性和方法,就可以说它实现了包含这些方法和属性规范的接口。不过完全可以在现有语法基础上实现抽象类或接口的概念。

假设有矩形Rectangle和圆形Circle类用于绘图,它们都需要实现draw()方法来绘制对应的图形,那么为了保证它们都有draw()方法,可以定义一个抽象形状类AbstractShape,在里边定义一个抽象方法draw(),然后让Rectangle和Circle类分别继承AbstractShape类,从而实现各自的绘制逻辑,代码如下:

js 复制代码
     class AbstractShape{
       constructor(){
          if(new.target===AbstractShape){
            throw"不能直接初始化抽象类";
         }
       }
       draw(){
         throw"未定义";
       }
     }
     class Rectangle extends AbstractShape{
       draw(){
         console.log("绘制矩形");
       }
     }
     class Circle extends AbstractShape{
       draw(){
         console.log("绘制圆形");
       }
     }
     //const shape=new AbstractShape();    //不能直接初始化抽象类
     const rect=new Rectangle();
     const circle=new Circle();
     rect.draw();                          //绘制矩形
     circle.draw();                        //绘制圆形

如果一个抽象类全部都是抽象方法,则它可以称为接口。使用接口或者继承都可以实现面向对象中的多态(Polymorphism)特性,即使用父类或接口类型创建对象,但是对于方法的执行是在运行的时候通过相应的子类实现的。

由于JavaScript是动态类型语言,所以本身就是多态的,只要对象中包含相同的方法,不管是什么类型都可以正常调用。在上例中,如果随意定义了一个对象,它也有一个draw()方法,则在作为参数传递给函数时,代码同样可以执行成功,代码如下:

js 复制代码
     function drawShape(shape){
       shape.draw();
     }
     drawShape(new Rectangle());      //绘制矩形
     const someObj={
       draw(){
         console.log("随意对象...");
       },
     };
     drawShape(someObj);              //随意对象...

这样代码虽然可以正常执行,但是非常奇怪,并且难以阅读和维护,为了使其他开发者清楚shape这个参数到底有什么样的要求,就可以通过定义抽象类来定义一个规范。不过,上边的shape参数则解释了多态的概念,给它传递不同的对象进去,draw()中执行的代码也不一样。

5、成员变量

定义成员变量可以比构造函数更清楚地展示该类中有哪些属性,它的语法与定义变量类似,但是不需要使用var、let或const关键字,然后使用=赋初始值,如果没有赋值,成员变量的默认值则为undefined。例如,假设有一按钮类,它有label属性,用于设置按钮的文本标签,使用constructor形式的代码如下:

js 复制代码
     class Button{
       constructor(){
         this.label="按钮";
       }
     }

使用成员变量形式的代码如下:

js 复制代码
     //chapter8/public_fields1.js
     class Button{
       label="按钮";
     }

可以看到使用成员变量的形式比使用构造函数的形式更清晰简洁,后面创建对象和改变对象中的label属性值的代码与之前所介绍的语法并没有区别,代码如下:

js 复制代码
     const btn=new Button();
     btn.label;//按钮(默认值)
     btn.label="跳转";
     btn.label;              //跳转

成员变量可以和构造函数结合使用,如果成员变量的初始值计算过程复杂,则仍然可以使用构造函数的形式。如果成员变量之间需要互相引用,则可以使用this访问其他成员变量,另外在构造函数中也可以使用this访问或修改成员变量,但是不能在成员变量中使用this访问构造函数中的变量。

假设Button类中有成员变量保存了按钮最终要生成的html代码,默认为使用button标签,而Button类中也定义了一个构造函数,用于接收一个type参数,如果值为link则可使用a标签渲染按钮,代码如下:

js 复制代码
     class Button{
       label="按钮";
       html=`<button>${this.label}</button>`;
       constructor(type){
         //如果是链接形式的按钮,则可使用<a/>标签渲染
          if(type==="link"){
            this.html=`<a>${this.label}</a>`;     //修改html成员变量的值
         }
       }
     }
     const btn=new Button();
     console.log(btn.html);                              //<button>  按钮  </
     button>
     const linkBtn=new Button("link");
     console.log(linkBtn.html);                       //<a>按钮</a>

成员变量的原理是在创建对象的时候,使用Object.defineProperty()给对象添加属性。

上述定义的成员变量是公开的(Public),即在创建对象后,可以随意对成员变量的值进行更改,这样可能导致一些只想在内部使用的成员变量被修改而导致错误,对于这种情况可以把成员变量或方法定义成私有的(Private),这样就不能在外部通过对象访问这些成员了。要定义私有的成员变量或方法,只需要在它们的名字前加上#。

例如把按钮的html成员变量改为私有的,这样便不能人为地修改它的值,然后定义一个公开的方法用于获取它的值,还可以再添加一个私有的组装html代码的工具方法,只限于在对象内部使用,代码如下:

js 复制代码
     class Button{
       label="按钮";
       #html=this.#generateHtml("button");            //使用私有成员方法生成html
       constructor(type){
         //如果是链接形式的按钮,则可使用<a/>标签渲染
          if(type==="link"){
            this.#html=this.#generateHtml("a");       //使用私有成员方法生成html
         }
       }
       render(){
         console.log(this.#html);   //可以在类中访问私有成员变量,注意变量前的#
       }
       #generateHtml(tag){
         return`<${tag}>${this.label}</${tag}>`;
       }
     }
     const btn=new Button();
     btn.render();//<button>按钮</button>
     //SyntaxError:Private field   '#html'must be declared in an enclosing class
     //语法错误:私有成员变量   '#html'必须在类中声明
     btn.#html;
     //SyntaxError:Private field   '#generateHtml'must be declared in an enclosing class
     //语法错误:私有成员变量   '#generateHtml'必须在类中声明
     btn.#generateHtml("button");
     const linkBtn=new Button("link");
     linkBtn.render();//<a>按钮</a>

可以看到在试图访问私有成员变量和方法时会提示错误。使用私有成员可以实现面向对象中的封装特性,防止修改对象内部的属性和行为,起到保护的作用。常见的封装方式是把所有成员变量设置为私有的,然后通过getters和setters访问和修改成员变量的值,因为getters和setters是函数,可以编写复杂的业务逻辑,例如在访问成员变量时增加处理逻辑,而在修改成员变量时增加校验逻辑等。

6、静态成员

静态成员分为静态成员变量(Static Fields)和静态成员方法(StaticMethods),它们属于类中的变量和方法,而不属于具体某个对象。所有的实例对象都会共享静态成员,改变静态成员的内容会影响所有使用到它们的对象。

定义静态成员使用static关键字,写在成员变量名和方法名的前边,在访问静态成员时只能通过类名访问,而不能通过对象访问。在类中,只有静态成员方法才能够使用this访问静态成员变量,在实例方法中则只能使用类名的方式进行访问,因为静态成员不属于具体的对象。

下方的例子展示了一个Page页面类,它有一个静态的成员变量viewCount用于统计页面的单击次数,并且有一个增加页面单击次 数的静态成员方法increase(),代码如下:

js 复制代码
     class Page{
       static viewCount=0;
       static increase(){
         this.viewCount++;           //或Page.viewCount++;
       }
     }
     Page.increase();                //使用类名访问静态成员方法
     Page.increase();
     Page.viewCount;                 //2
     new Page().viewCount;//undefined

increase()和viewCount只能通过Page类名进行访问,如果使用对象访问则会返回undefined。

静态成员变量在原型方式中,就相当于给构造函数直接添加属性,使用Object.definedProperty(),并把value设置为初始值、把writable设置为true、把enumerable设置为true、把configurable设置为true,代码如下:

js 复制代码
     function Page(){}
     Object.definedProperty(Page,"viewCount",{
       value:0,
       writable:true,
       enumerable:true,
       configurable:true
     })

对于静态成员方法也是如此,只是value值是函数。静态成员也可以被继承,但是继承下来的静态成员变量,它的值仍然会和父类中的共享,并不会重新定义或初始化,代码如下:

js 复制代码
     class SubPage extends Page{}
     Page.increase();
     Page.increase();
     console.log(Page.viewCount);   //2
     console.log(SubPage.viewCount);//2

静态成员也可以使用#定义为私有的,这样静态成员只能在类中的实例变量和实例方法中使用,而不能再在外边使用类名访问它们了。私有的成员变量不能被继承,因此不能在公开的静态方法中使用this访问私有的静态成员,例如把Page中的viewCount改为私有的,这样SubPage在调用increase()的时候就会出错,代码如下:

js 复制代码
     class Page{
       static#viewCount=0;
       static increase(){
         this.#viewCount++;
       }
     }
     class SubPage extends Page{}
     //TypeError:Cannot read private member # viewCount from an object whose class did  not
     declare it
     //类型错误:不能访问对象中的私有成员#viewCount,它在类中没有定义
     SubPage.increase();

但是如果把this改为Page就不会出现错误了,为了方便测试,可以在Page类中添加一个静态的getViewCount用于获取viewCount的值,代码如下:

js 复制代码
     class Page{
       static#viewCount=0;
       static increase(){
         Page.#viewCount++;
       }
       static getViewCount(){
         return Page.#viewCount;
       }
     }
     class SubPage extends Page{}
     SubPage.increase();
     console.log(SubPage.getViewCount());//1

静态成员一般适合用于工具类中,使用类名访问静态成员的形式可以给方法和变量增加命名空间,这样便能减少因为同名而导致的冲突。静态方法也可以用于创建对象,例如Array中的from()和of(),而一些需要共享的属性,或者通用的常量,例如Number.MAX_VALUE也都是以静态成员的方式呈现的(即使它们是以原型形式实现的)​。

相关推荐
MarkHD2 分钟前
Stable Diffusion入门第19-20天:保存与分享你的工作流——第一阶段收官,从“能用”到“可复用”
java·开发语言·stable diffusion
爱敲键盘的猴子5 分钟前
Spring MVC 详解(一):全注解开发与请求响应处理
java·spring·mvc
云水初10 分钟前
【agent篇】RAG 知识库构建避坑指南
开发语言·python·学习·agent·rag
2019一路前行12 分钟前
Python 函数、循环语句
开发语言·python
counting money14 分钟前
SpringBoot 项目创建(IDEA不全,还需要修改)
java·spring boot·spring·maven
Dr.kangder15 分钟前
嵌入式面试总结(二十二)——指针
java·面试·职场和发展·架构·嵌入式
MacroZheng19 分钟前
几行代码给项目集成AI功能,Spring AI 2.0太香了!
java·人工智能·spring boot
Devin~Y1 小时前
从内容社区到AI智能客服:Spring Boot + Spring Cloud + Spring AI 全栈实战面试拆解
java·spring boot·redis·elasticsearch·spring cloud·kafka·mybatis
未秃头的程序猿1 小时前
从写CRUD到做AI Agent:我花了6个月转型,这是我的完整路线图
java·后端·ai编程
Java内核笔记1 小时前
万字长文剖析 Spring Boot 4.1.0 启动流程源码:从 main 到就绪
java·后端