设计模式
设计模式总共有 23 种,但在前端领域其实没必要全部都去学习,毕竟大部分的设计模式是在 JavaScript 中占的比重并不是那么大,本文会列举出一些 JavaScript 常见的、容易被忽视的设计模式,不过还是有必要先简单了解一下设计模式相关的概念。
设计模式是什么?
先举个形象的例子,比如现在正在考试而且恰好在考数学,实际上每道数学题目都对应着一种或多种解决公式(如和三角形相关的勾股定理),而这些解决公式是经过数学家研究、推导、总结好的,我们只需要把 题目 和 已有公式 对应上就很容易解决问题,而 设计模式 也是如此,只不过是它是相对于 软件设计领域 而言的。
设计模式(Design pattern) 是一套被反复使用、经过分类、代码设计经验的总结,简单来说设计模式就是为了解决 软件设计领域 不同场景下相应问题的 解决方案。
设计原则(SOLID)
SOLID 实际上指的是五个基本原则,但在前端领域涉及到最多的是仍然是前面两条:
- 单一功能原则(Single Responsibility Principle)
- 开放封闭原则(Opened Closed Principle)
- 里式替换原则(Liskov Substitution Principle)
- 接口隔离原则(Interface Segregation Principle)
- 依赖反转原则(Dependency Inversion Principle)
设计模式的类型
主要分三个类型。
创建型
- 主要用于解耦 对象的实例化 过程,即用于创建对象,如对象实例化
- 本文主要包含:简单工厂模式、抽象工厂模式、单例模式、原型模式
行为型
- 主要用于优化不同 类、对象、接口 间的结构关系,如把 类 或 对象 结合在一起形成一个更大的结构
- 本文主要包含:装饰器模式、适配器模式、代理模式
结构型
- 主要用于定义 类 和 对象 如何交互、划分责任、设计算法
- 本文主要包含:策略模式、状态模式、观察者模式、发布订阅模式、迭代器模式
创建型设计模式
设计模式的核心是区分逻辑中的 可变部分 和 不变部分,并使它们进行分离,从而达到使变化的部分易扩展、不变的部分稳定。
工厂模式
简单工厂模式
核心就是创建一个对象,这里的 可变部分 是 参数,不变部分 是 共有属性.
举例:通过不同职级的员工创建员工相关信息,需要包含 name、age、position、job 等信息。
实现方式一:
1 2 3 4 5 6 7
| function Staff(name, age, position, job) { this.name = name; this.age = age; this.position = position; this.job = job;} const developer = new Staff('zs', 18, 'develoment', ['写 bug', '改 bug', '摸鱼']); const productManager = new Staff('ls', 30, 'manager', ['提需求', '改需求', '面向 PPT 开发']);
|
实现方式二:
实际上在实现方式一中的 job 部分是和 position 是相互关联的,可以认为 job 部分是 不变的,因此可以根据 position 内容的内容来自动匹配 job
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24
| function Staff(name, age, position, job) { this.name = name; this.age = age; this.position = position; this.job = job; }
function StaffFactory(name, age, position){ let job = [] switch (position) { case 'develoment': job = ['写 bug', '改 bug', '摸鱼']; break; case 'manager': job = ['提需求', '改需求', '面向 PPT 开发']; break; ... }
return new Staff(name, age, position, job); }
const developer = StaffFactory('zs', 18, 'developer'); const productManager = StaffFactory('ls', 30, 'manager');
|
抽象工厂模式
这个模式最显眼的就是 抽象 两个字了,在如 Java 语言当中存在所谓的 抽象类,这个抽象类里面的所有属性和方法都没有具体实现,只有单纯的定义,而继承这个抽象类的子类必须要实现其对应的抽象属性和抽象方法.
在 JavaScript
中没有这样的直接定义,不过根据上面的描述其实我们可以把它映射到 typescript
中的 interface
接口,理解到这其实让我联想到了 vue.js
中的 自定义渲染器,预留的自定义渲染器的各个方法目的就是实现跨平台的渲染方式
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51
| export function createRenderer< HostNode = RendererNode, HostElement = RendererElement >(options: RendererOptions<HostNode, HostElement>) { return baseCreateRenderer<HostNode, HostElement>(options) }
export interface RendererOptions< HostNode = RendererNode, HostElement = RendererElement > { patchProp( el: HostElement, key: string, prevValue: any, nextValue: any, isSVG?: boolean, prevChildren?: VNode<HostNode, HostElement>[], parentComponent?: ComponentInternalInstance | null, parentSuspense?: SuspenseBoundary | null, unmountChildren?: UnmountChildrenFn ): void insert(el: HostNode, parent: HostElement, anchor?: HostNode | null): void remove(el: HostNode): void createElement( type: string, isSVG?: boolean, isCustomizedBuiltIn?: string, vnodeProps?: (VNodeProps & { [key: string]: any }) | null ): HostElement createText(text: string): HostNode createComment(text: string): HostNode setText(node: HostNode, text: string): void setElementText(node: HostElement, text: string): void parentNode(node: HostNode): HostElement | null nextSibling(node: HostNode): HostNode | null querySelector?(selector: string): HostElement | null setScopeId?(el: HostElement, id: string): void cloneNode?(node: HostNode): HostNode insertStaticContent?( content: string, parent: HostElement, anchor: HostNode | null, isSVG: boolean, start?: HostNode | null, end?: HostNode | null ): [HostNode, HostNode] }
|
接下来我们将以上的 typescript 的形式转变成 JavaScript 形式的抽象模式:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62
|
class Renderer { patchProp( el, key, prevValue, nextValue, isSVG, prevChildren, parentComponent, parentSuspense, unmountChildren ) { throw Error('抽象工厂方法不能直接使用,你需要将我重写!!!'); } insert(el, parent, anchor) { throw Error('抽象工厂方法不能直接使用,你需要将我重写!!!'); } remove(el) { throw Error('抽象工厂方法不能直接使用,你需要将我重写!!!'); } createElement(type, isSVG, isCustomizedBuiltIn, vnodeProps) { throw Error('抽象工厂方法不能直接使用,你需要将我重写!!!'); } createText(text) { throw Error('抽象工厂方法不能直接使用,你需要将我重写!!!'); } createComment(text) { throw Error('抽象工厂方法不能直接使用,你需要将我重写!!!'); } setText(node, text) { throw Error('抽象工厂方法不能直接使用,你需要将我重写!!!'); } setElementText(node, text) { throw Error('抽象工厂方法不能直接使用,你需要将我重写!!!'); } parentNode(node) { throw Error('抽象工厂方法不能直接使用,你需要将我重写!!!'); } nextSibling(node) { throw Error('抽象工厂方法不能直接使用,你需要将我重写!!!'); } querySelector(selector) { throw Error('抽象工厂方法不能直接使用,你需要将我重写!!!'); } setScopeId(el, id) { throw Error('抽象工厂方法不能直接使用,你需要将我重写!!!'); } cloneNode(node) { throw Error('抽象工厂方法不能直接使用,你需要将我重写!!!'); } insertStaticContent(content, parent, anchor, isSVG, start, end) { throw Error('抽象工厂方法不能直接使用,你需要将我重写!!!'); } }
class createRenderer extends Renderer{ ... }
|
单例模式
核心就是通过多次 new 操作进行实例化时,能够保证创建 实例对象 的 唯一性。
vuex 中的单例模式
其实,vuex
中就使用到了 单例模式,代码本身比较简单,当 install
方法被多次调用时,就会得到一个错误信息,并不会多次向 Vue
中混入 vuex
中自定义的内容。
实现一个单例模式
这里举个封装 localStorage
方法的例子,并提供给外部对应的创建方法,如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30
| let storageInstance = null;
class Storage { getItem(key) { let value = localStorage.getItem(key); try { return JSON.parse(value); } catch (error) { return value; } }
setItem(key, value) { try { localStorage.setItem(JSON.stringify(value)); } catch (error) { console.error(error); } } }
export default function createStorage(){ if(!storageInstance){ storageInstance = new Storage(); } return storageInstance; }
|
原型模式
在 JavaScript
中原型模式是很常见的,JavaScript
中实现的 继承 或者叫 委托 也许更合适,因为它不等同于如 Java
等语言中的继承,毕竟 JavaScript
的 继承 是基于原型(prototype
)来实现。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22
| class Person { say() { console.log(`hello, my name is ${this.name}!`); }
eat(foodName) { console.log(`eating ${foodName}`); } }
class Student extends Person { constructor(name) { super(); this.name = name; } }
const zs = new Student('zs'); const ls = new Student('ls');
console.log(zs.say === ls.say); console.log(zs.eat === ls.eat);
|
vue2.0使用了原型模式,在组件实例化的时候,vue2中的组件是通过构造函数创建的,每个组件都有自己的实例对象。当使用vue2创建组件实例的时候,Vue2 会将组件的选项对象和一些原型方法合并到该组件实例的原型中。
这样做的好处是可以在多个组件实例之间共享相同的原型方法,减少内存占用和提高性能。同时,原型方法也可以被子组件继承和覆盖,提供了更好的灵活性和扩展性。
需要注意的是,虽然 Vue2 中使用了原型模式,但是 Vue2 的组件实现并不是完全的原型继承,而是使用了一些技巧来实现组件的高效复用和扩展。例如,Vue2 中的组件选项会被合并成一个新的对象,而不是直接修改原型对象。
结构型设计模式
装饰器模式
核心是在不改变原 对象/方法 的基础上,通过对其进行包装拓展,使原有 对象/方法 可以满足更复杂的需求。
装饰器本质
装饰器模式本质上就是 函数的传参和调用,通过函数为已有 对象/方法 进行扩展,而不用修改原对象/方法,满足 开放封闭原则.
通过配置 babel
通过将 test.js
转为为 bable_test.js
用来查看装饰器的本质:
babel.config.json
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16
| { "presets": [ [ "@babel/preset-env", { "targets": { "node": "current" } } ] ], "plugins": [ ["@babel/plugin-proposal-decorators", { "legacy": true }], ["@babel/plugin-proposal-class-properties", { "loose": true }] ] }
|
test.js
1 2 3 4 5 6 7 8 9 10 11
| function decoratorTest(target) { console.log(target); }
@decoratorTest class Person { say() {} eat() {} }
|
执行 babel test.js –out-file babel_test.js 命令是生成 babel_test.js
1 2 3 4 5 6 7 8 9
| "use strict"; var _class; function decoratorTest(target) { console.log(target); } let Person = decoratorTest(_class = class Person { say() {} eat() {} }) || _class;
|
React 中的装饰器模式 —— HOC 高阶组件
高阶组件 是参数为 组件,返回值为新组件的 函数,在 React 中 HOC 通常用于复用组件公共逻辑。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
|
class TodoList extends React.Component {}
function WrapContainer(Comp) { return ( <div style={{ border: "1px solid red", padding: 10 }}> <Comp title="todo" /> </div> ); }
const newTodoList = WrapContainer(TodoList);
|
适配器模式
适配器模式本质就是 让原本不兼容的功能能够生效,避免大规模修改代码,对外提供统一使用。
Axios 中的适配器
通过观察 Axios 的目录结构,很容就发现其使用了适配器模式:
其实 Axios
中的 adapters
主要目的是根据当前运行时环境,向外返回对应的适配器 adapter
,而这个适配器要做的其实就是兼容 web
浏览器环境和 node
环境的 http
请求,保证对外暴露的仍然是统一的 API
接口。
代理模式
代理模式顾名思义就是 不能直接访问目标对象,需要通过代理器来实现访问,通常是为了提升性能、保证安全等。
事件代理
事件代理是很常见的性能优化手段之一,react
的事件机制也采用了事件代理的方式(篇幅有限可自行了解),这里演示简单的 JavaScript
事件代理:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
| <div id="container"> <p>this number is 1</p> <p>this number is 2</p> <p>this number is 3</p> <p>this number is 4</p> <p>this number is 5</p> </div>
<script> const container = document.querySelector("#container"); container.addEventListener("click", function (e) { alert(e.target.textContent); }); </script>
|
Vue 中的代理 Proxy
Vue.js 3.0中通过 Proxy
实现了对数据的代理,任何读取、设置的操作都会被代理对象的 handlers
拦截到,从而实现 Vue 中的 track
和 trigger
。
行为型设计模式
策略模式
策略模式实际上就是定义一系列的算法,将单个功能封装起来,并且对扩展开放.
举个例子
假如我们需要为某个游乐场的门票价格做差异化询价,主要人员类型分为 儿童、成年人、老年人 三种,其对应的门票折扣为 8折、9折、8.5折
if-else
代码一把梭
缺点:无论哪种人员类型的折扣变动,都需要修改 finalPrice
函数,不符合对 对修改封闭
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16
| function finalPrice(type, price) { if (type === "child") { return price * 0.8; }
if (type === "adult") { return price * 0.9; }
if (type === "aged") { return price * 0.85; } }
|
单一功能封装
缺点:若人员类型增加妇女类型,仍然需要修改 finalPrice
函数,且不符合 对扩展开放
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29
| function childPrice(price) { return price * 0.8; }
function adultPrice(price) { return price * 0.9; }
function agedPrice(price) { return price * 0.85; }
function finalPrice(type, price) { if (type === "child") { return childPrice(price); }
if (type === "adult") { return adultPrice(price); }
if (type === "aged") { return agedPrice(price); } }
|
创建映射关系
通过映射关系,很好的将 finalPrice
和 具体的计算逻辑进行分离,在需要扩展类型时,只需要修改 priceTypeMap
对象而不用修改对外暴露的 finalPrice
函数。