面向对象
1、概述
面向对象编程是把客观世界的实体转换为代码进行表示的过程,只关注实体必要的属性和行为,忽略不相关的部分,这样更符合人的思维逻辑,所编写的代码更清晰,理解起来也更加简单。例如一个员工管理系统,它面向的对象虽然是人,但是只关注人作为员工时的一些基本信息,如:姓名、出生年月、入职时间、薪水情况等,还有行为,如:上班打卡、请假、升职等,它不会关注学号、班级、上课等学生信息和行为。员工管理系统所关注的这些信息和行为是每个员工共有的属性,可以把员工称为类(Class),它是抽象的,即不单指具体的某一个员工,若说张三在这个公司就职,则张三这个员工是具体的,可以称为实例(Instance)或实例对象,这样它的员工信息和上班打卡行为等才有实际意义。
再例如一个抽象的例子,假设某个网页中有个按钮组件,它有标签(Label)、背景、大小属性,以及单击事件和渲染HTML标签的行为,这时可以定义一个Button类,代表该按钮,里边包括上边描述的所有属性和行为,通过这个按钮类可以创建出多个不同的按钮实例,它们都有相同的属性和行为,只不过每个按钮的背景、大小和单击处理方式可以相同也可以不同,这就是每个实例自己要管理的事情了。
面向对象编程有4个特征:封装、抽象、继承和多态:
- 封装(Encapsulation)指的是对象中的属性和行为对外都是不可见的,只能通过暴露给外部的公开的接口进行修改。
- 抽象(Abstraction)指的是对象中的行为、属性等的实现细节是不可见的,使用这个对象只需调用公开的方法,无须知道方法具体做了什么。
- 继承(Inheritance)指的是对象通过继承其他对象,可以获得继承下来的属性和行为,并且可以覆盖它们或者定义新的。
- 多态(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也都是以静态成员的方式呈现的(即使它们是以原型形式实现的)。