前端设计模式和设计原则

199 阅读14分钟

作为前端开发,在code时,或多或少地都会践行设计模式,但是你清楚自己用到的是何种设计模式吗?

为什么前端开发一定要懂设计模式呢?

code时不遵从设计模式,又能怎样呢?

上面的问题可以留作思考,这里首先介绍一下前端开发经常遇到的一些设计模式和设计原则。

1 前端常见的设计模式

1.1 单例模式

在整个应用程序中只允许创建一个实例的模式。 在前端开发中,它通常用于管理全局状态或资源,例如:

  • React 应用中,使用 Redux 库来管理应用的状态;
  • Vue 应用中,使用 Vuex 库来管理应用的状态;
  • Angular 应用中,使用 Service 来管理应用的状态;

angular 服务是可注入的类,用于提供共享的数据、功能或逻辑给整个应用程序的组件;由于服务是以单例形式存在的,每次注入服务都会返回同一个实例

使用@Injectable({ providedIn: 'root' })装饰器将MyService注册为根级提供商,这意味着整个应用程序都可以访问该服务的单一实例。

// my.service.ts

import { Injectable } from '@angular/core';

@Injectable({
  providedIn: 'root'
})

export class MyService {
  private count: number = 0;

  incrementCount() {
    this.count++;
  }

  getCount(): number {
    return this.count;
  }
}

在组件中,可以通过依赖注入的方式来使用该服务;

// my.component.ts

import { Component } from '@angular/core';
import { MyService } from './my.service';

@Component({
  selector: 'my-component',
  template: `
    <h2>Count: {{ myService.getCount() }}</h2>
    <button (click)="incrementCount()">Increment Count</button>
  `
})
export class MyComponent {
  constructor(private myService: MyService) {}

  incrementCount() {
    this.myService.incrementCount();
  }
}

拓展

如果在Angular中的@Injectable装饰器的providedIn配置中不使用"root",而是指定其他模块或组件,那么该服务将在该模块或组件的范围内成为单例

这意味着,服务将在指定的模块或组件及其子组件中共享同一个实例,而不是在整个应用程序中共享。这对于需要在特定范围内共享数据或功能的场景非常有用

例如,假设有两个组件ComponentAComponentB,它们都引用了同一个服务SharedService,并且将该服务作为提供程序配置在它们各自的模块中。

// shared.service.ts

import { Injectable } from '@angular/core';

@Injectable({
  providedIn: 'other-module' // 指定其他模块,而不是 'root'
})
export class SharedService {
  public sharedData: string = 'Shared data';
}

// component-a.component.ts

import { Component } from '@angular/core';
import { SharedService } from './shared.service';

@Component({
  selector: 'component-a',
  template: `
    <h2>{{ sharedService.sharedData }}</h2>
  `
})
export class ComponentA {
  constructor(public sharedService: SharedService) {}
}

// component-b.component.ts

import { Component } from '@angular/core';
import { SharedService } from './shared.service';

@Component({
  selector: 'component-b',
  template: `
    <h2>{{ sharedService.sharedData }}</h2>
  `
})
export class ComponentB {
  constructor(public sharedService: SharedService) {}
}

在这种情况下,SharedService将在ComponentAComponentB之间共享同一个实例,但其作用范围限定在这两个组件及其子组件中。

这种用法可以让开发者更细粒度地控制服务的共享范围,使得不同的模块或组件可以拥有各自独立的服务实例。

1.2 观察者模式

1.2.1 Vue.js

针对前端的观察者模式,一种常见的应用是使用Vue.js框架的响应式系统。Vue.js使用观察者模式来追踪数据的变化并更新视图。

<!-- index.html -->

<!DOCTYPE html>
<html>
<head>
    <title>Vue.js Observer Example</title>
    <script src="https://unpkg.com/vue@2.6.14/dist/vue.js"></script>
</head>
<body>
    <div id="app">
        <h2>{{ message }}</h2>
        <input v-model="message" type="text" placeholder="Type something...">
    </div>

    <script>
        // Initialize Vue
        new Vue({
            el: '#app',
            data: {
                message: 'Hello, Vue!'
            }
        });
    </script>
</body>
</html>

上述代码中,使用了Vue.js来创建一个具有双向数据绑定的简单应用。message属性的值将被显示在页面上的 h2 标签中,并且可以通过输入框进行编辑。

Vue.js的响应式系统会在message属性发生变化时自动更新页面中对应的内容。这是通过Vue.js在内部使用观察者模式来实现的。当message属性的值发生变化时,观察者会被通知,并执行相应的更新。

这种观察者模式的应用使得开发者无需显式地修改DOM来更新视图,而是只需关注数据的变化,Vue.js会自动处理更新过程。这大大简化了前端开发中处理视图更新的任务。

1.2.2 Angular

Angular中,观察者模式主要通过使用RxJS(响应式扩展)库来实现。RxJS是一个强大的事件处理库,它提供了多种操作符和观察者模式的实现,以处理异步事件流。

Angular中,通过使用Observables(可观察对象)和Subjects(主题),开发者可以实现观察者模式的效果,并在组件之间进行事件通信或数据共享

下面是一个简单的示例,展示了如何在Angular中使用观察者模式实现组件之间的通信:

// message.service.ts

import { Injectable } from '@angular/core';
import { Subject } from 'rxjs';

@Injectable()
export class MessageService {
  private messageSubject = new Subject<string>();

  message$ = this.messageSubject.asObservable();

  sendMessage(message: string) {
    this.messageSubject.next(message);
  }
}

上述代码创建了一个名为MessageService的服务,它使用Subject创建了一个消息主题。通过调用主题的next()方法,可以向订阅该主题的观察者发送消息。

// component-a.component.ts

import { Component } from '@angular/core';
import { MessageService } from './message.service';

@Component({
  selector: 'component-a',
  template: `
    <button (click)="sendMessage()">Send Message</button>
  `
})
export class ComponentA {
  constructor(private messageService: MessageService) {}

  sendMessage() {
    this.messageService.sendMessage('Hello from Component A!');
  }
}

上述代码创建了一个名为ComponentA的组件,它通过依赖注入的方式引用了MessageService。在按钮的点击事件中,我们调用sendMessage()方法来发送一条消息。

// component-b.component.ts

import { Component } from '@angular/core';
import { MessageService } from './message.service';

@Component({
  selector: 'component-b',
  template: `
    <h2>{{ receivedMessage }}</h2>
  `
})
export class ComponentB {
  receivedMessage: string = '';

  constructor(private messageService: MessageService) {}

  ngOnInit() {
    this.messageService.message$.subscribe(message => {
      this.receivedMessage = message;
    });
  }
}

上述代码创建了一个名为ComponentB的组件,并在ngOnInit()生命周期钩子中订阅了MessageServicemessage$可观察对象。一旦有新的消息发送,观察者将接收到该消息并更新receivedMessage属性。

使用上述代码,当ComponentA中的按钮被点击时,它将向ComponentB发送一条消息,并在ComponentB中更新显示的消息。这通过观察者模式实现组件之间的双向通信

1.2.3 React

React 中,观察者模式可以通过使用 Context API 和钩子函数来实现。下面是一个简单的示例:

首先,创建一个观察者上下文(ObserverContext):

import React, { createContext, useContext, useState } from 'react';

const ObserverContext = createContext();

export const ObserverProvider = ({ children }) => {
  const [observers, setObservers] = useState([]);

  const addObserver = (observer) => {
    setObservers((prevObservers) => [...prevObservers, observer]);
  };

  const removeObserver = (observer) => {
    setObservers((prevObservers) =>
      prevObservers.filter((o) => o !== observer)
    );
  };

  const notifyObservers = () => {
    observers.forEach((observer) => observer());
  };

  const contextValue = {
    addObserver,
    removeObserver,
    notifyObservers,
  };

  return (
    <ObserverContext.Provider value={contextValue}>
      {children}
    </ObserverContext.Provider>
  );
};

export const useObserver = (observer) => {
  const { addObserver, removeObserver } = useContext(ObserverContext);

  // 添加观察者
  useEffect(() => {
    addObserver(observer);

    // 组件卸载时移除观察者
    return () => removeObserver(observer);
  }, [observer, addObserver, removeObserver]);
};

然后,在需要进行观察的组件中使用 useObserver 钩子来订阅和响应变化。

import React, { useState } from 'react';
import { useObserver } from './ObserverContext';

const Counter = () => {
  const [count, setCount] = useState(0);

  const handleIncrement = () => {
    setCount((prevCount) => prevCount + 1);
  };

  // 添加观察者
  useObserver(() => {
    console.log('Count has changed:', count);
  });

  return (
    <div>
      <p>Count: {count}</p>
      <button onClick={handleIncrement}>Increment</button>
    </div>
  );
};

在上述示例中,ObserverProvider 提供了观察者的上下文,并定义了添加观察者、移除观察者和通知观察者的方法。使用 useObserver 钩子来订阅观察者模式中的变化。当状态(这里是 count)发生变化时,观察者将被通知并执行相应的操作。

使用这种方式,可以在 React 中实现简单的观察者模式,以便组件之间能够订阅和响应特定的事件或状态变化。

1.3 代码工厂模式

代码工厂模式是一种创建对象的设计模式,它通过使用工厂函数或类来封装对象的创建过程。在这种模式下,我们不直接调用对象的构造函数来创建对象,而是通过一个专门的工厂方法来统一管理对象的创建

代码工厂模式的主要目的是隐藏具体对象创建的细节,并提供一种可扩展和灵活的方式来创建对象。它将对象的实例化逻辑封装在一个独立的组件中,使得创建对象的过程可以进行集中管理,而不是分散在应用程序的各个地方。

1.3.1 Angular

下面是一个简单的示例,展示了如何在 Angular 中使用代码工厂模式:

import { Injectable } from '@angular/core';

@Injectable({
  providedIn: 'root',
})
export class UserService {
  private users: string[] = [];

  addUser(user: string): void {
    this.users.push(user);
  }

  getUsers(): string[] {
    return this.users;
  }
}

@Injectable({
  providedIn: 'root',
})
export class UserFactory {
  constructor(private userService: UserService) {}

  createUser(name: string): void {
    this.userService.addUser(name);
  }
}

在上述示例中,UserService 是一个服务类,它管理用户信息。UserFactory 是一个工厂类,负责创建用户并将其添加到 UserService 中。

Angular 中,通过使用 @Injectable() 装饰器和 providedIn: 'root' 选项,我们可以将 UserServiceUserFactory 注册为可注入的服务,并确保它们在整个应用程序中的任何组件中都可用。

然后,在其他组件中可以通过依赖注入的方式使用这些服务:

import { Component } from '@angular/core';
import { UserFactory } from './user.factory';

@Component({...})
export class AppComponent {
  constructor(private userFactory: UserFactory) {}

  createUser() {
    this.userFactory.createUser('John');
  }
}

在上述示例中,AppComponent 组件通过依赖注入 UserFactory 来使用工厂模式创建用户。

通过在 Angular 中使用代码工厂模式,我们可以将对象的创建和初始化逻辑封装到可注入的服务中,并在需要时利用依赖注入方便地使用这些服务。这样可以提高代码的可维护性、扩展性和测试性。

1.3.2 React

下面是一个简单的示例,展示了如何在 React 中使用代码工厂模式:

import React from 'react';

// 工厂函数
function createButton(type) {
  const Button = (props) => {
    let button;

    if (type === 'primary') {
      button = (
        <button className="primary-button" onClick={props.onClick}>
          {props.children}
        </button>
      );
    } else if (type === 'secondary') {
      button = (
        <button className="secondary-button" onClick={props.onClick}>
          {props.children}
        </button>
      );
    } else {
      throw new Error('Invalid button type');
    }

    return button;
  };

  return Button;
}

// 使用工厂函数创建按钮组件
const PrimaryButton = createButton('primary');
const SecondaryButton = createButton('secondary');

// 使用按钮组件
const App = () => (
  <div>
    <PrimaryButton onClick={() => console.log("Primary button clicked")}>
      Primary Button
    </PrimaryButton>
    <SecondaryButton onClick={() => console.log("Secondary button clicked")}>
      Secondary Button
    </SecondaryButton>
  </div>
);

在上述例子中,createButton 是一个工厂函数,根据传入的 type 参数返回一个特定类型的按钮组件。根据不同的类型,创建不同样式、行为或功能的按钮。

通过调用 createButton 工厂函数,可以轻松创建不同类型的按钮组件,并在应用程序中使用它们。这样,避免在多个地方重复编写相似的代码,而是通过工厂模式集中管理和创建组件。

使用工厂模式,可以根据需要快速创建并定制化多个组件,并轻松地进行修改或扩展。这种模式提供了一种更灵活、可维护和可重用的方式来创建和管理 React 组件。

1.4 策略模式

策略模式用于定义一系列算法,并将其封装成独立的对象,使它们可以相互替换。在前端开发中,策略模式可以用来处理多种算法或逻辑的情况,例如在表单验证中根据不同规则进行验证。

下面是一个简单的示例,用于根据不同的排序策略对数组进行排序:

// 排序策略对象
const sortingStrategies = {
  quickSort: (arr) => arr.sort((a, b) => a - b),
  mergeSort: (arr) => arr.sort((a, b) => b - a),
};

// 排序上下文对象
class SortContext {
  constructor(strategy) {
    this.strategy = strategy;
  }

  setStrategy(strategy) {
    this.strategy = strategy;
  }

  sort(arr) {
    return this.strategy(arr);
  }
}

// 使用策略模式进行排序
const arr = [5, 2, 8, 1, 4];
const context = new SortContext(sortingStrategies.quickSort);
console.log(context.sort(arr)); // 输出: [1, 2, 4, 5, 8]

context.setStrategy(sortingStrategies.mergeSort);
console.log(context.sort(arr)); // 输出: [8, 5, 4, 2, 1]

在上述示例中,sortingStrategies 是一个包含不同排序策略的对象,其中 quickSortmergeSort 是两种不同的排序算法。

SortContext 是排序上下文对象,它接收一个排序策略作为参数,并提供 setStrategy 方法来动态更改当前的排序策略。sort 方法使用当前的排序策略对给定的数组进行排序。

通过创建 SortContext 实例并设置不同的排序策略,我们可以根据需要选择特定的排序算法对数组进行排序。这样,我们可以在运行时根据需求灵活地切换算法,而不需要在每个地方都修改排序逻辑。

策略模式使得应用程序更具可扩展性和灵活性,因为我们可以轻松添加新的策略或修改现有的策略,而无需修改已有的代码。同时,它还能提高代码的可读性和可维护性,使算法选择与实际执行逻辑分离开来。

2 设计原则

2.1 开闭原则

该原则指出软件实体(类、模块、函数等)应该对扩展开放,对修改关闭。也就是说,在添加新功能时,应该通过扩展现有代码来实现,而不是直接修改已有的代码。这样可以确保现有代码的稳定性,并且减少对其他部分的影响。

// 开闭原则示例

// 原始功能实现类
class OriginalFunctionality {
  performAction() {
    console.log("Original functionality");
  }
}

// 扩展功能实现类
class ExtendedFunctionality extends OriginalFunctionality {
  performAction() {
    super.performAction();
    console.log("Extended functionality");
  }
}

// 使用示例
const functionality = new ExtendedFunctionality();
functionality.performAction();

在上述例子中,有一个原始功能实现类 OriginalFunctionality,它定义了一个 performAction 方法来执行某些功能。

根据开闭原则,需要通过扩展而不是修改原始功能类来添加新功能。因此,创建一个扩展功能实现类 ExtendedFunctionality,它继承自 OriginalFunctionality 并重写了 performAction 方法,在执行新功能之前先调用了原始功能。

通过应用开闭原则,可以减少对现有代码的修改,从而提高代码的稳定性和可维护性。同时,它也使得代码更易于扩展和重用,提供了更灵活的架构设计。

这里,再介绍一个常见的开闭原则示例

// 原始的 if-else 结构
function performAction(option) {
  if (option === 'A') {
    console.log("Performing action A");
  } else if (option === 'B') {
    console.log("Performing action B");
  } else if (option === 'C') {
    console.log("Performing action C");
  } else {
    console.log("Invalid option");
  }
}

// 使用 key-value 形式执行特定逻辑
const actions = {
  A: () => console.log("Performing action A"),
  B: () => console.log("Performing action B"),
  C: () => console.log("Performing action C"),
};

function performAction(option) {
  const action = actions[option];
  if (action) {
    action();
  } else {
    console.log("Invalid option");
  }
}

// 使用示例
performAction('A'); // 输出: Performing action A
performAction('B'); // 输出: Performing action B
performAction('C'); // 输出: Performing action C
performAction('D'); // 输出: Invalid option

if-else 结构改成 key-value 形式来执行特定逻辑,可以被视为一种应用开闭原则的方式。这样的改变允许通过添加新的键值对来扩展代码,而不需要修改原有的逻辑。

2.2 单一职责原则

该原则提倡每个类或模块应该只负责一个单一的功能或任务。这样可以提高代码的可读性、可维护性和重用性。当一个类具有多个职责时,建议将其拆分为多个独立的类,每个类专注于一个职责。

// 组件的单一职责示例

// 用户列表组件,负责渲染用户列表
class UserList {
  render(users) {
    // 渲染用户列表逻辑...
    console.log("User list rendered:", users);
  }
}

// 用户管理组件,负责处理用户的增删改操作
class UserManager {
  createUser(userData) {
    // 创建用户逻辑...
    console.log("User created:", userData);
  }

  updateUser(userId, userData) {
    // 更新用户逻辑...
    console.log("User updated:", userId, userData);
  }

  deleteUser(userId) {
    // 删除用户逻辑...
    console.log("User deleted:", userId);
  }
}

// 模块的单一职责示例

// 用户相关功能模块,仅负责用户相关的功能
const userModule = {
  createUser(userData) {
    // 创建用户逻辑...
    console.log("User created:", userData);
  },

  updateUser(userId, userData) {
    // 更新用户逻辑...
    console.log("User updated:", userId, userData);
  },

  deleteUser(userId) {
    // 删除用户逻辑...
    console.log("User deleted:", userId);
  }
};

// 函数的单一职责示例

// 检查用户名是否唯一
function checkUsernameUnique(username) {
  // 检查用户名唯一性逻辑...
  console.log("Checking username uniqueness:", username);
  return true;
}

// 验证密码
function validatePassword(password) {
  // 验证密码逻辑...
  console.log("Validating password:", password);
  return true;
}

// 使用示例
const userList = new UserList();
userList.render(["John", "Mike"]);

const userManager = new UserManager();
userManager.createUser({ name: "John", age: 25 });

userModule.updateUser(1, { name: "Mike" });

checkUsernameUnique("john123");

validatePassword("password123");

上述示例展示了组件、模块和函数单一职责原则应用。

  • 组件的单一职责:UserList 组件负责渲染用户列表,UserManager 组件负责处理用户的增删改操作。每个组件只负责一个特定的功能,使得代码更加清晰和易于维护。

  • 模块的单一职责:userModule 是一个用户相关功能的模块,其中包含了创建、更新和删除用户的功能。该模块只关注用户相关的功能,保持了模块的单一职责。

  • 函数的单一职责:checkUsernameUnique 函数用于检查用户名是否唯一,validatePassword 函数用于验证密码。每个函数负责一个具体的功能,使得函数的职责清晰可见。

通过应用单一职责原则,可以将不同的功能分别封装到不同的组件、模块和函数中,使代码更具可读性、可维护性和重用性。这种设计方式帮助我们遵循独立职责的原则,提高代码的可扩展性,并减少不必要的耦合。

2.3 依赖倒置原则

该原则提倡通过抽象来解耦代码之间的依赖关系。高层模块应该依赖于抽象接口,而不是具体实现细节。这样可以降低模块之间的耦合度,并且使得系统更容易扩展和修改,同时也更易于进行测试。

// 不符合依赖倒置原则

class UserService {
  getUser(userId) {
    // 获取用户逻辑...
  }
}

class UserController {
  constructor() {
    this.userService = new UserService();
  }

  getUser(userId) {
    const user = this.userService.getUser(userId);
    // 处理用户数据逻辑...
  }
}

// 符合依赖倒置原则

class UserService {
  getUser(userId) {
    // 获取用户逻辑...
  }
}

class UserController {
  constructor(userService) {
    this.userService = userService;
  }

  getUser(userId) {
    const user = this.userService.getUser(userId);
    // 处理用户数据逻辑...
  }
}

// 使用示例

const userService = new UserService();
const userController = new UserController(userService);

userController.getUser(123);

上述示例展示了一个不符合依赖倒置原则的情况和一个符合依赖倒置原则的情况。

在不符合原则的情况下,UserController 类内部直接创建了 UserService 的实例。这种硬编码的依赖关系导致了紧耦合,不利于代码的扩展和维护。

通过采用依赖注入的方式,可以将 UserService 作为参数传递给 UserController 的构造函数。这样,UserController 不再关心具体的 UserService 实现,而是依赖于抽象接口,降低了组件之间的耦合性。