MK
摩柯社区 - 一个极简的技术知识社区
AI 面试

JavaScript中的类与面向对象编程

2023-06-101.3k 阅读

面向对象编程基础概念

在深入探讨JavaScript中的类与面向对象编程之前,让我们先回顾一下面向对象编程(OOP)的一些基本概念。

封装

封装是将数据和操作数据的方法绑定在一起,形成一个独立的单元。在面向对象编程中,类就是一种封装机制。通过封装,可以隐藏对象的内部实现细节,只暴露必要的接口给外部使用。这样做的好处是提高了代码的安全性和可维护性。例如,假设我们有一个表示银行账户的类:

function BankAccount(accountNumber, balance) {
    // 私有属性
    let _accountNumber = accountNumber;
    let _balance = balance;

    // 公有方法
    this.getAccountNumber = function() {
        return _accountNumber;
    };

    this.getBalance = function() {
        return _balance;
    };

    this.deposit = function(amount) {
        if (amount > 0) {
            _balance += amount;
        }
    };

    this.withdraw = function(amount) {
        if (amount > 0 && amount <= _balance) {
            _balance -= amount;
        }
    };
}

在这个例子中,_accountNumber_balance 是私有属性,外部代码不能直接访问。只能通过 getAccountNumbergetBalancedepositwithdraw 这些公有方法来操作这些属性,这就是封装的体现。

继承

继承允许一个类获取另一个类的属性和方法。被继承的类称为父类(或超类),继承的类称为子类(或派生类)。通过继承,可以实现代码的复用,减少重复代码。例如,我们有一个 Animal 类,然后有 DogCat 类继承自 Animal 类:

function Animal(name) {
    this.name = name;
    this.speak = function() {
        console.log(this.name + ' makes a sound.');
    };
}

function Dog(name, breed) {
    // 调用父类的构造函数
    Animal.call(this, name);
    this.breed = breed;
}
// 设置Dog的原型为Animal的实例,实现继承
Dog.prototype = Object.create(Animal.prototype);
Dog.prototype.constructor = Dog;

function Cat(name, color) {
    Animal.call(this, name);
    this.color = color;
}
Cat.prototype = Object.create(Animal.prototype);
Cat.prototype.constructor = Cat;

在这个例子中,DogCat 类继承了 Animal 类的 name 属性和 speak 方法。同时,它们各自又有自己独特的属性,如 DogbreedCatcolor

多态

多态是指同一个方法在不同的对象上有不同的表现形式。在JavaScript中,多态通常通过继承和方法重写来实现。继续上面的例子,我们可以在 DogCat 类中重写 speak 方法:

Dog.prototype.speak = function() {
    console.log(this.name + ' barks.');
};

Cat.prototype.speak = function() {
    console.log(this.name + ' meows.');
};

现在,当我们调用 DogCat 实例的 speak 方法时,会得到不同的输出,这就是多态的体现。

JavaScript中的类

在ES6之前,JavaScript并没有像其他面向对象语言那样的类的语法。开发者通常使用构造函数和原型链来模拟类和面向对象编程。然而,ES6引入了 class 关键字,提供了更简洁、更直观的类的语法。

类的定义

使用 class 关键字定义一个类非常简单。例如,我们定义一个 Person 类:

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

    greet() {
        console.log(`Hello, my name is ${this.name} and I'm ${this.age} years old.`);
    }
}

在这个例子中,constructor 是一个特殊的方法,用于初始化类的实例。this 关键字指向新创建的实例对象。greet 方法是一个实例方法,可以通过类的实例来调用。

创建类的实例

定义好类后,我们可以使用 new 关键字来创建类的实例:

let person1 = new Person('Alice', 30);
person1.greet();

这里,person1Person 类的一个实例,我们可以调用它的 greet 方法。

类的静态方法和属性

除了实例方法和属性,类还可以有静态方法和属性。静态方法和属性属于类本身,而不是类的实例。在类中定义静态方法和属性,使用 static 关键字。例如:

class MathUtils {
    static add(a, b) {
        return a + b;
    }

    static PI = 3.14159;
}

let result = MathUtils.add(2, 3);
console.log(result);
console.log(MathUtils.PI);

在这个例子中,add 是一个静态方法,PI 是一个静态属性。我们通过类名直接调用静态方法和访问静态属性,而不是通过类的实例。

类的继承

ES6的 class 语法也提供了更简洁的继承方式。使用 extends 关键字来实现继承。例如,我们定义一个 Student 类继承自 Person 类:

class Student extends Person {
    constructor(name, age, grade) {
        super(name, age);
        this.grade = grade;
    }

    study() {
        console.log(`${this.name} is studying in grade ${this.grade}.`);
    }
}

Student 类的构造函数中,我们使用 super 关键字调用父类 Person 的构造函数,以初始化继承自父类的属性。Student 类还定义了自己特有的 study 方法。

方法重写

当子类继承父类时,可以重写父类的方法。例如,我们在 Student 类中重写 greet 方法:

Student.prototype.greet = function() {
    console.log(`Hello, I'm ${this.name}, a student in grade ${this.grade}.`);
};

现在,当我们调用 Student 实例的 greet 方法时,会执行重写后的方法。

类的原型

在JavaScript中,类实际上是基于原型的。每个类都有一个原型对象,实例方法和属性都存储在原型对象上。理解原型对于深入掌握JavaScript的面向对象编程非常重要。

原型链

当我们访问一个对象的属性或方法时,JavaScript首先会在对象本身查找。如果找不到,它会沿着原型链向上查找,直到找到该属性或方法,或者到达原型链的顶端(null)。例如:

class A {
    methodA() {
        console.log('Method A');
    }
}

class B extends A {
    methodB() {
        console.log('Method B');
    }
}

let b = new B();
b.methodA(); // 可以调用,因为B的原型链上有A的方法
b.methodB();

在这个例子中,B 类继承自 A 类。B 的实例 b 可以调用 methodA,因为 B 的原型链上有 A 的原型对象,而 methodA 存储在 A 的原型对象上。

原型对象的修改

我们可以直接修改类的原型对象,添加新的方法或属性。例如:

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

Person.prototype.sayHello = function() {
    console.log(`Hello, ${this.name}`);
};

let person = new Person('Bob');
person.sayHello();

这里,我们在 Person 类的原型对象上添加了 sayHello 方法,然后 Person 的实例 person 就可以调用这个方法。

私有属性和方法

在传统的面向对象语言中,有明确的访问修饰符来定义私有属性和方法,如 privatepublic 等。然而,JavaScript并没有原生的访问修饰符。但是,我们可以通过一些技巧来模拟私有属性和方法。

使用闭包模拟私有属性

我们前面提到过通过闭包来模拟私有属性,就像 BankAccount 类的例子一样。通过在构造函数内部定义变量,这些变量只能通过构造函数内部定义的函数来访问,从而实现类似私有属性的效果。

使用WeakMap模拟私有属性

WeakMap 也可以用来模拟私有属性。WeakMap 是一种键值对的集合,其中的键必须是对象。我们可以将实例对象作为键,将私有属性的值作为值存储在 WeakMap 中。例如:

const privateData = new WeakMap();

class Person {
    constructor(name) {
        privateData.set(this, {
            _name: name
        });
    }

    get name() {
        return privateData.get(this)._name;
    }
}

在这个例子中,privateData 是一个 WeakMap,我们将 _name 作为私有属性存储在 WeakMap 中,通过 get 方法来访问这个私有属性。

使用 # 前缀表示私有属性(ES2020+)

从ES2020开始,JavaScript引入了一种新的语法来表示私有属性和方法,即使用 # 前缀。例如:

class Person {
    #name;

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

    get name() {
        return this.#name;
    }
}

这里,#name 就是一个私有属性,外部代码无法直接访问。只能通过类内部的方法来访问和操作。

面向对象设计模式

设计模式是在软件开发过程中针对反复出现的问题总结出来的通用解决方案。在面向对象编程中,有许多经典的设计模式,下面介绍几种常见的设计模式在JavaScript中的应用。

单例模式

单例模式确保一个类只有一个实例,并提供一个全局访问点。在JavaScript中,可以通过闭包和立即执行函数(IIFE)来实现单例模式。例如:

const Singleton = (function() {
    let instance;

    function createInstance() {
        let object = {
            data: 'This is singleton data'
        };
        return object;
    }

    return {
        getInstance: function() {
            if (!instance) {
                instance = createInstance();
            }
            return instance;
        }
    };
})();

let singleton1 = Singleton.getInstance();
let singleton2 = Singleton.getInstance();
console.log(singleton1 === singleton2);

在这个例子中,Singleton 是一个通过IIFE返回的对象,其中 getInstance 方法确保每次调用都返回同一个实例。

工厂模式

工厂模式是一种创建型设计模式,它提供了一种创建对象的方式,将对象的创建和使用分离。在JavaScript中,工厂函数可以用来创建对象。例如:

function createPerson(name, age) {
    return {
        name: name,
        age: age,
        greet: function() {
            console.log(`Hello, my name is ${this.name} and I'm ${this.age} years old.`);
        }
    };
}

let person1 = createPerson('Alice', 30);
let person2 = createPerson('Bob', 25);

这里,createPerson 是一个工厂函数,它根据传入的参数创建不同的 person 对象。

观察者模式

观察者模式定义了一种一对多的依赖关系,当一个对象的状态发生改变时,所有依赖于它的对象都会得到通知并自动更新。在JavaScript中,可以通过自定义事件和回调函数来实现观察者模式。例如:

class Subject {
    constructor() {
        this.observers = [];
    }

    subscribe(observer) {
        this.observers.push(observer);
    }

    unsubscribe(observer) {
        this.observers = this.observers.filter(obs => obs!== observer);
    }

    notify(data) {
        this.observers.forEach(observer => observer.update(data));
    }
}

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

    update(data) {
        console.log(`${this.name} received data: ${data}`);
    }
}

let subject = new Subject();
let observer1 = new Observer('Observer 1');
let observer2 = new Observer('Observer 2');

subject.subscribe(observer1);
subject.subscribe(observer2);

subject.notify('New data available');

在这个例子中,Subject 类维护了一个观察者列表,当调用 notify 方法时,会通知所有订阅的观察者。

最佳实践

在使用JavaScript进行面向对象编程时,有一些最佳实践可以帮助我们写出更清晰、更健壮的代码。

保持类的单一职责

每个类应该只负责一项主要功能。这样可以提高代码的可维护性和可测试性。例如,一个 User 类应该只负责与用户相关的操作,而不应该包含与数据库连接或文件操作相关的代码。

合理使用继承

继承虽然可以实现代码复用,但过度使用继承会导致代码的复杂性增加。在使用继承时,要确保子类和父类之间有合理的“is - a”关系。例如,Dog 类继承自 Animal 类是合理的,因为“狗是一种动物”。但如果一个类只是为了复用代码而继承,可能需要考虑其他方式,如组合。

注意原型链的性能

原型链的查找会带来一定的性能开销。如果频繁访问对象的属性和方法,尽量将常用的方法和属性直接定义在对象本身,而不是依赖原型链查找。同时,要避免原型链过长,因为过长的原型链会增加查找时间。

遵循命名规范

使用有意义的类名、方法名和属性名,遵循驼峰命名法。例如,类名使用大写字母开头的驼峰命名法(如 Person),方法名和属性名使用小写字母开头的驼峰命名法(如 getBalance)。这样可以提高代码的可读性。

与其他语言的对比

与一些传统的面向对象语言(如Java、C++)相比,JavaScript的面向对象编程有一些独特之处。

语法简洁性

JavaScript的ES6类语法相对简洁,与Java和C++相比,没有那么多繁琐的修饰符和声明。例如,在JavaScript中定义类和方法非常简洁,不需要像Java那样声明访问修饰符(如 publicprivate),除非使用ES2020的 # 前缀模拟私有属性。

动态类型

JavaScript是动态类型语言,而Java和C++是静态类型语言。这意味着在JavaScript中,变量的类型在运行时确定,而不是在编译时。在面向对象编程中,这使得对象的属性和方法可以在运行时动态添加和修改。例如:

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

let person = new Person('Alice');
person.age = 30;

在这个例子中,我们在运行时为 Person 实例动态添加了 age 属性。在Java和C++中,这种操作是不允许的,因为它们要求在编译时确定对象的结构。

基于原型

JavaScript是基于原型的面向对象语言,而Java和C++是基于类的。在基于类的语言中,类是对象的模板,对象是类的实例。而在JavaScript中,对象直接从原型对象继承属性和方法。虽然ES6的 class 语法在一定程度上模拟了基于类的编程,但底层仍然是基于原型的机制。

通过深入理解JavaScript中的类与面向对象编程,我们可以编写出更高效、更易于维护的代码,充分发挥JavaScript在前端和后端开发中的强大功能。无论是构建小型的Web应用还是大型的企业级项目,面向对象编程的理念和技术都起着至关重要的作用。希望通过本文的介绍,能帮助你更好地掌握JavaScript中的面向对象编程技巧。