这是我参与「第四届青训营 」笔记创作活动的第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
应用场景:缓存,全局状态管理等
优点:
-
内存中只有一个实例,减少了内存的开销
-
避免了对资源多重的占用(比如写文件操作)
缺点:没有接口,不能继承,违反了单一职责,一个类应该只关心内部逻辑,而不用去关心外部的实现
例子(实现请求缓存)
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);
})
发布订阅模式(观察者模式)
定义:一种订阅机制,可在被订阅对象发生变化时通知订阅者
应用场景:从系统架构之间的解耦,到业务中一些实现模式,像邮件订阅、上线订阅等等,应用广泛。
优点:观察者和被观察者之间是抽象耦合的,并且建立了触发机制
缺点:
-
当订阅者较多时,同时通知所有的订阅者可能会导致性能问题
-
在订阅者和订阅目标之间如果循环引用执行,会导致崩溃
-
发布订阅模式没有办法提供给订阅者所订阅的目标它时怎么变化的,只知道发生变化了。
例子(用户上线订阅)
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操作的代理
优点:
- 高层模块调用简单,节点可以自由添加
缺点:
- 其叶对象和子对象声明都是实现类,而不是接口,这违反了依赖倒置原则
例子(文件夹的添加、扫描和删除)
// 文件夹类
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()
如何理解设计模式
感受设计模式思想;总结出抽象的模式相对比较简单,但是想要将抽象的模式套用到场景中却非常困难;现代编程语言的多编程范式带来的更多可能性;真正优秀的开源项目学习设计模式并不断实践。
学习参考:
青训营前端设计模式应用