前端设计模式应用 | 青训营笔记

89 阅读10分钟

这是我参与「第四届青训营 」笔记创作活动的第5天

设计模式一共有二十三种,今天主要学习了在前端开发中常见的一些设计模式。

什么是设计模式?

软件设计中常见问题的解决方案模型。通过设计模式可以增加代码的可重用性,可扩展性,可维护性,最终使得我们的代码高内聚,低耦合。

设计模式的三大类

  • 创建型:工厂模式,抽象工厂模式,建造者模式,单例模式,原型模式
  • 结构型:适配器模式,装饰器模式,代理模式,外观模式,桥接模式,组合模式,享元模式
  • 行为型:策略模式,模板方法模式,发布订阅模式,迭代器模式,职责链模式,命令模式,备忘录模式,状态模式,访问者模式,中介者模式,解释器模式。

设计模式的六大原则

1、开闭原则(Open Close Principle)

开闭原则的意思是:对扩展开放,对修改关闭。在程序需要进行拓展的时候,不能去修改原有的代码,实现一个热插拔的效果。简言之,是为了使程序的扩展性好,易于维护和升级。想要达到这样的效果,我们需要使用接口和抽象类,后面的具体设计中我们会提到这点。

2、里氏代换原则(Liskov Substitution Principle)

里氏代换原则是面向对象设计的基本原则之一。 里氏代换原则中说,任何基类可以出现的地方,子类一定可以出现。LSP 是继承复用的基石,只有当派生类可以替换掉基类,且软件单位的功能不受到影响时,基类才能真正被复用,而派生类也能够在基类的基础上增加新的行为。里氏代换原则是对开闭原则的补充。实现开闭原则的关键步骤就是抽象化,而基类与子类的继承关系就是抽象化的具体实现,所以里氏代换原则是对实现抽象化的具体步骤的规范。

3、依赖倒转原则(Dependence Inversion Principle)

这个原则是开闭原则的基础,具体内容:针对接口编程,依赖于抽象而不依赖于具体。

4、接口隔离原则(Interface Segregation Principle)

这个原则的意思是:使用多个隔离的接口,比使用单个接口要好。它还有另外一个意思是:降低类之间的耦合度。由此可见,其实设计模式就是从大型软件架构出发、便于升级和维护的软件设计思想,它强调降低依赖,降低耦合。

5、迪米特法则,又称最少知道原则(Demeter Principle)

最少知道原则是指:一个实体应当尽量少地与其他实体之间发生相互作用,使得系统功能模块相对独立。

6、合成复用原则(Composite Reuse Principle)

合成复用原则是指:尽量使用合成/聚合的方式,而不是使用继承。


设计模式有这么多种,本文主要介绍一些在前端开发中比较常用到的一些设计模式。

浏览器中的设计模式

单例模式

定义:全局唯一访问对象;

单例模式的思路是:保证一个类只能被实例一次,每次获取的时候,如果该类已经创建过实例则直接返回该实例,否则创建一个实例保存并返回。

单例模式的核心就是创建一个唯一的对象;如JS中const

应用场景:缓存,全局状态管理等

优点

  1. 内存中只有一个实例,减少了内存的开销

  2.  避免了对资源多重的占用(比如写文件操作)

缺点:没有接口,不能继承,违反了单一职责,一个类应该只关心内部逻辑,而不用去关心外部的实现

例子(实现请求缓存)

import {api} from "./utils"
//实现请求缓存

//定义Request类,
export class Request {
    static instance: Request;
    private cache: Record<string, string>;
    //初始化一个空的缓存内容
    constructor(){
        this.cache = {};
    }
    //核心
    static getInstance(){
        // 通过静态方法获取静态属性instance上是否存在实例
        //如果没有创建一个并返回,反之直接返回已有的实例
        if (this.instance) {
            return this.instance;
        }
        
        this.instance = new Request();
        return this.instance;
    }
    //缓存的实现
    public async request(url: string) {
        if (this.cache[url]) {
            return this.cache[url];
        }
        const response = await api(url);
        this.cache[url] = response;

        return response;
    }
}

//有缓存场景下
test("should response quickly second time with class", async () => {
    const request1 = Request.getInstance();
    await request1.request("/user/1");

    const startTime = Date.now();
    const request2 = Request.getInstance();
    await request2.request("user/2");
    const endTime = Date.now();

    const constTime = endTime - startTime;

    expect(constTime).toBeGteaterThanOrEqual(50);
})

发布订阅模式(观察者模式)

定义:一种订阅机制,可在被订阅对象发生变化时通知订阅者

应用场景:从系统架构之间的解耦,到业务中一些实现模式,像邮件订阅、上线订阅等等,应用广泛。

优点:观察者和被观察者之间是抽象耦合的,并且建立了触发机制

缺点

  1. 当订阅者较多时,同时通知所有的订阅者可能会导致性能问题

  2. 在订阅者和订阅目标之间如果循环引用执行,会导致崩溃

  3. 发布订阅模式没有办法提供给订阅者所订阅的目标它时怎么变化的,只知道发生变化了。

例子(用户上线订阅)

type Notify = (user: User) => void;
//被订阅者
export class User {
    name: string;
    status: "offline" | "online";
    followers: {user: User; notify: Notify} [];//订阅user的数组

    constructor(name: string) {
        this.name = name;
        this.status = "offline";
        this.followers = [];
    }
    //订阅方法(第一个参数是被订阅者,第二个参数是上线后的通知函数)
    subscribe(user: User, notify: Notify) {
        user.followers.push({user, notify});
    }
    //上线方法,第一个作用是status改为上线,第二个作用是通知一下订阅自己的人调用订阅函数
    online(){
        this.status = "online";

        this.followers.forEach(({notify}) => {
            notify(this);
        })
    }

}


test("should notify followers when user is online for multiple users", () => {
    const user1 = new User("user1");
    const user2 = new User("user2");
    const user3 = new User("user3");

    //测试函数,这里目前是一个假的被订阅的函数
    const mockNotifyUser1 = jest.fn();//通知用户1
    const mockNotifyUser2 = jest.fn();//通知用户2

    user1.subscribe(user3, mockNotifyUser1);//用户1订阅了用户3,并传入一个通知用户1的函数
    user2.subscribe(user3, mockNotifyUser2);

    user3.online();

    expect(mockNotifyUser1).toBeCalledWith(user3);
    expect(mockNotifyUser2).toBeCalledWith(user3);
})


其实这里的online方法并不完美,因为我们希望一个方法只做一件事情。

JS中的设计模式

原型模式

定义:复制已有对象来创建对象。指原型实例只想创建对象的种类,通过拷贝这些原型来拆功能键新的对象。

应用场景:JS中对象创建的基本模式 优点

  • 不再依赖构造函数或者类创建对象,可以将这个对象作为一个模板生成更多的新对象。

缺点

  • 对于包含引用类型值的属性来说,所有实例在默认的情况下都会取得相同的属性值

  例子(创建上线订阅中的用户)

const baseUser: User = {
   name:"",
   status: "offline",
   followers:[],

   subscribe(user, notify) {
       user.followers.push({user, notify});
   },

   online(){
       this.status = "online";

       this.followers.forEach(({notify}) => {
           notify(this);
       })
   }
};

export const createUser = (name: string) => {
   const user: User = Object.create(baseUser);

   user.name = name;
   user.followers = [];

   return user;
}

test与前面发布订阅模式类似,new user 改为createUser即可,所以这里不再赘述。

代理模式

定义:可以自定义控制对元对象的访问方式,并且允许在更新前后做一些额外处理。

代理模式的关键是:当客户不方便直接访问一个对象或者不满足需要的时候,提供一个替身对象来控制对这个对象的访问,客户实际访问的是替身对象。替身对象对请求做出一些处理后,再把请求转交给本体对象。

应用场景:代理工具,前端框架实现等等

优点:职责清晰,高扩展化,智能化

缺点

  • 当对象和对象之间增加了代理可能会影响到处理的速度
  • 实现代理需要额外的工作,有些代理会非常的复杂  

例子(实现用户状态订阅)

import { User } from "./发布订阅模式"

export const createProxyUser = (name: string) => {
    const user = new User(name);
    //创建代理用户
    const proxyUser = new Proxy(user, {
        set: (target, prop : keyof User, value) => {
            target[prop] = value;
            if(prop === "status") {
                notifyStatusHandler(target, value);
            }
            return true;
        }
    })

    const notifyStatusHandler = (user: User, status: "online" | "offline") => {
        if (status === "online") {
            user.followers.forEach(({notify}) => {
                notify(user);
            })
        }
    }

    return proxyUser;
}

迭代器模式

定义:在不暴露数据类型的情况下访问集合中的数据

应用场景:数据结构中有多种数据类型。列表,树等,提供通用操作接口

优点

  • 支持以不同的方式遍历一个聚合对象
  • 迭代器简化了聚合类。在同一个聚合上可以有多个遍历。
  • 在迭代器模式中,增加新的聚合类和迭代器类都很方便,无须修改原有的代码。

缺点

  • 由于迭代器模式将存储数据饿遍历数据的职责分离,增加新的聚合类需要对应增加新的迭代器类,类的个数成对增加,在一定程度上增加了系统的复杂性。

例子(用for...of迭代所有组件)

class MyDomElement {
    tag: string;
    children: MyDomElement[];
    constructor(tag: string) {
        this.tag = tag;
        this.children = [];
    }

    addChildren(component: MyDomElement) {
        this.children.push(component);
    }

    [Symbol.iterator]() { 
        const list = [...this.children];
        let node;

        return {
            next: () => {
                while((node = list.shift())) {
                    node.children.length > 0 && list.push(...node.children);

                    return {value: node, done: false};
                }
                return {value: null, done: true};
            }
        }
    }
}


test("can iterate root element", () => {
    const body = new MyDomElement("body");

    const header = new MyDomElement("header");

    const main = new MyDomElement("main");
    const banner = new MyDomElement("banner");
    const content = new MyDomElement("content");
    const footer = new MyDomElement("footer");

    body.addChildren(header);
    body.addChildren(main);
    body.addChildren(footer);

    main.addChildren(banner);
    main.addChildren(content);

    const expectTags: string[] = [];
    for (const element of body) {
        if (element) {
            expectTags.push(element.tag);
        }
    }
    
    expect(expectTags.length).toBe(5);
})

这个例子中利用到了Iterator遍历器。我们知道任何数据结构只要部署 Iterator 接口,就可以完成遍历操作。一个数据结构只要部署了Symbol.iterator属性,就被视为具有 iterator 接口,就可以用for...of循环遍历它的成员。也就是说,for...of循环内部调用的是数据结构的Symbol.iterator方法。

前端框架中的设计模式

组合模式

定义:组合模式就是由一些小的子对象构建出更大的对象,而这些小的子对象本身可能也是由多个子孙对象组合而成的。可多个对象组合使用,也可单个对象独立使用。

组合模式将对象组合成树状结构,以表示“部分-整体”的层次结构。除了用来表示树状结构之外,组合模式的另一个好处就是通过对象的多态性表现,使得用户对单个对象和组合对象的使用具有一致性。

应用场景:DOM、前端组件、文件目录、部门

前端框架中对DOM操作的代理

image.png

优点

  • 高层模块调用简单,节点可以自由添加

  缺点:

  • 其叶对象和子对象声明都是实现类,而不是接口,这违反了依赖倒置原则

例子(文件夹的添加、扫描和删除)

// 文件夹类
class Folder {
  constructor(name) {
    this.name = name
    this.parent = null;
    this.files = []
  }
  // 添加文件
  add(file) {
    file.parent = this
    this.files.push(file)
    return this
  }
  // 扫描文件
  scan() {
    console.log(`开始扫描文件夹:${this.name}`)
    this.files.forEach(file => {
      file.scan()
    });
  }
  // 删除指定文件
  remove() {
    if (!this.parent) {
      return
    }
    for (let files = this.parent.files, i = files.length - 1; i >= 0; i--) {
      const file = files[i]
      if (file === this) {
        files.splice(i, 1)
        break
      }
    }
  }
}
// 文件类
class File {
  constructor(name) {
    this.name = name
    this.parent = null
  }
  add() {
    throw new Error('文件下面不能添加任何文件')
  }
  scan() {
    console.log(`开始扫描文件:${this.name}`)
  }
  remove() {
    if (!this.parent) {
      return
    }
    for (let files = this.parent.files, i = files.length - 1; i >= 0; i++) {
      const file = files[i]
      if (file === this) {
        files.splice(i, 1)
      }
    }
  }
}

const book = new Folder('电子书')
const js = new Folder('js')
const node = new Folder('node')
const vue = new Folder('vue')
const js_file1 = new File('javascript高级程序设计')
const js_file2 = new File('javascript忍者秘籍')
const node_file1 = new File('nodejs深入浅出')
const vue_file1 = new File('vue深入浅出')

const designMode = new File('javascript设计模式实战')

js.add(js_file1).add(js_file2)
node.add(node_file1)
vue.add(vue_file1)

book.add(js).add(node).add(vue).add(designMode)
book.remove()
book.scan()

如何理解设计模式

感受设计模式思想;总结出抽象的模式相对比较简单,但是想要将抽象的模式套用到场景中却非常困难;现代编程语言的多编程范式带来的更多可能性;真正优秀的开源项目学习设计模式并不断实践。

 

学习参考:

青训营前端设计模式应用

juejin.cn/post/707217…