本文旨在介绍前端框架中的一部分设计模式,并做简要分析以及介绍一些实际的使用案例。
什么是设计模式?
设计模式是对软件设计开发过程中反复出现的某类问题的通用解决方案。
设计模式更多的是指导思想和方法论,而不是现成的代码,当然每种设计模式都有每种语言中的具体实现方式。
学习设计模式更多的是理解各种模式的内在思想和解决的问题,毕竟这是前人无数经验总结成的最佳实践,而代码实现则是对加深理解的辅助。
通俗来讲,就是日常使用设计的一种惯性思维。因为对应的这种思维,以及对具体的业务或者代码场景,
有着具体的优势,而后成为行业中的一种设计模式。
设计模式的基本准则
- 优化代码第一步:单一职责原则(Single Responsibility Principle)
- 让程序更稳定更灵活:开闭原则(Open-Closed Principle)
- 构建扩展性更好的系统:里式替换原则(Liskov Substitution Principle)
- 让项目拥有变化的能力:依赖倒置原则(Dependence Inversion Principle)
- 系统有更高的灵活性:接口隔离原则(Interface Segregation Principle)
- 更好地扩展性:迪米特原则(Law Of Demeter)
设计模式的类型
设计模式可以分为三大类:
-
结构型模式(Structural Patterns): 通过识别系统中组件间的简单关系来简化系统的设计。
-
创建型模式(Creational Patterns): 处理对象的创建,根据实际情况使用合适的方式创建对象。
常规的对象创建方式可能会导致设计上的问题,或增加设计的复杂度。
创建型模式通过以某种方式控制对象的创建来解决问题。
-
行为型模式(Behavioral Patterns): 用于识别对象之间常见的交互模式并加以实现,如此,增加了这些交互的灵活性。
要了解这些设计模式真正的作用和价值还是需要通过实践去加以理解。这三大类设计模式又可以分成更多的小类,如下图:
单例模式
定义: 保证一个类只有一个实例。
一般先判断实例是否存在:如果存在直接返回;不存在则先创建再返回,
这样就可以保证一个类只有一个实例对象。
作用:
1.模块间通信;
2.保证某个类的对象的唯一性;
3.防止变量污染。
单例模式适用于全局只能有一个实例对象的场景,单例模式的一般结构如下:
let CreateSinglePattern = (function(){
let instance;
return function(age) {
if (instance) {
return instance;
}
this.age = age;
return instance = this;
}
})();
CreateSinglePattern.prototype.getAge = function() {
return this.age
}
let young = new CreateSinglePattern('18');
let old = new CreateSinglePattern('108');
console.log(young === old); // true
console.log(young.getAge()); // '18'
console.log(old.getAge()); // '18'
单例模式在vuex中的应用
看一下vuex的源码处理,在vue.use(vuex) 注册vuex的时候,会去调用vuex的install方法,如下:
var Vue; // bind on install
function install (_Vue) {
if (Vue && _Vue === Vue) {
{
console.error(
'[vuex] already installed. Vue.use(Vuex) should be called only once.'
);
}
return
}
Vue = _Vue;
applyMixin(Vue);
}
可以看出,如果重复注册的话会进行报错处理,通过这种方式,可以保证一个 Vue 实例(即一个 Vue 应用)只会被install一次Vuex插件,所以每个 Vue 实例只会拥有一个全局的 Store。
组合模式
组合模式适合一些容器组件场景,通过外层组件包裹内层组件,这种方式在 Vue 中称为 slot 插槽,外层组件可以轻松的获取内层组件的 props 状态,还可以控制内层组件的渲染,
组合模式能够直观反映出 父 -> 子组件的包含关系,
首先举个最简单的组合模式例子:
<Tabs onChange={ (type)=> console.log(type) } >
<TabItem name="react" label="react" >React</TabItem>
<TabItem name="vue" label="vue" >Vue</TabItem>
<TabItem name="angular" label="angular" >Angular</TabItem>
</Tabs>
如上 Tabs 和 TabItem 组合,构成切换 tab 功能,那么 Tabs 和 TabItem 的分工如下:
Tabs负责展示和控制对应的TabItem。绑定切换tab回调方法onChange。当tab切换的时候,执行回调。TabItem负责展示对应的tab项,向Tabs传递props相关信息。
我们直观上看到 Tabs 和 TabItem 并没有做某种关联,但是却无形的联系起来。这种就是组合模式的精髓所在,这种组合模式的组件,给使用者感觉很舒服,因为大部分工作,都在开发组合组件的时候处理了。所以编写组合模式的嵌套组件,对锻炼开发者的 React 组件封装能力是很有帮助的。
组合模式的原理
- 在容器组件中通过
props.children获取子组件的children,做进一步处理, - 可以对子组件做类型判断、容器的
props混入子组件、子组件传入props的判断控制渲染, - 既然可以混入容器组件的
props,进而可以传递方法参数组件通信。
工厂模式
工厂模式就是根据不同的输入返回不同的实例,一般用来创建同一类对象,它的主要思想就是将对象的创建与对象的实现分离。
在创建对象时,不暴露具体的逻辑,而是将逻辑封装在函数中,那么这个函数就可以被视为一个工厂。工厂模式根据抽象程度的不同可以分为:简单工厂、工厂方法、抽象工厂。
Vue中的工厂模式
(1)VNode
和原生的 document.createElement 类似,Vue 这种具有虚拟 DOM 树(Virtual Dom Tree)机制的框架在生成虚拟 DOM 的时候,提供了 createElement 方法用来生成 VNode,用来作为真实 DOM 节点的映射:
createElement('h3', { class: 'main-title' }, [
createElement('img', { class: 'avatar', attrs: { src: '../avatar.jpg' } }),
createElement('p', { class: 'user-desc' }, 'hello world')
])
createElement 函数结构大概如下:
class Vnode (tag, data, children) { ... }
function createElement(tag, data, children) {
return new Vnode(tag, data, children)
}
(2)vue-router
在Vue在路由创建模式中,也多次用到了工厂模式:
export default class VueRouter {
constructor(options) {
this.mode = mode // 路由模式
switch (mode) { // 简单工厂
case 'history': // history 方式
this.history = new HTML5History(this, options.base)
break
case 'hash': // hash 方式
this.history = new HashHistory(this, options.base, this.fallback)
break
case 'abstract': // abstract 方式
this.history = new AbstractHistory(this, options.base)
break
default:
// ... 初始化失败报错
}
}
}
mode 是路由创建的模式,这里有三种 History、Hash、Abstract,其中,History 是 H5 的路由方式,Hash 是路由中带 # 的路由方式,Abstract 代表非浏览器环境中路由方式,比如 Node、weex 等;this.history 用来保存路由实例,vue-router 中使用了工厂模式的思想来获得响应路由控制类的实例。
观察者模式
定义了一种一对多的关系, 所有观察对象同时监听某一主题对象,当主题对象状态发生变化时就会通知所有观察者对象,使得他们能够自动更新自己。
观察者模式是非常经典的设计模式, vue/react等各种框架,类库中均有使用。
观察者模式是由具体目标调度的,而发布-订阅模式是统一由调度中心调的。
class Subject {
constructor() {
this.subs = {}
}
addSub(key, fn) {
const subArr = this.subs[key]
if (!subArr) {
this.subs[key] = []
}
this.subs[key].push(fn)
}
trigger(key, message) {
const subArr = this.subs[key]
if (!subArr || subArr.length === 0) {
return false
}
for(let i = 0, len = subArr.length; i < len; i++) {
const fn = subArr[i]
fn(message)
}
}
unSub(key, fn) {
const subArr = this.subs[key]
if (!subArr) {
return false
}
if (!fn) {
this.subs[key] = []
} else {
for (let i = 0, len = subArr.length; i < len; i++) {
const _fn = subArr[i]
if (_fn === fn) {
subArr.splice(i, 1)
}
}
}
}
}
// 测试
// 订阅
let subA = new Subject()
let A = (message) => {
console.log('订阅者收到信息: ' + message)
}
subA.addSub('A', A)
// 发布
subA.trigger('A', '我是xxx') // A收到信息: --> 我是xxx
观察者模式在redux中的应用
redux推崇单一数据源,即一个web应用状态只有一个数据来源。以一种比较清晰的方式去维护应用的状态。这其实也和react的单向数据流吻合。
-
store提供数据的get钩子(store.getState),不直接提供数据的set,所以必须通过dispatch(action)来set数据。 -
利用观察者模式(
sub/ pub)连接model和view的中间对象。
model层通过调用store.subscribe订阅视图更新事件(setstate),该事件会在数据改变之后被调用。对应sub。
view层通过调用store.dispatch方法进行发布,触发reducer改变model。对应pub。
function createStore(reducers) {
const subList = []; // 注册事件列表
let currentState = initState = reducers(init); // 初始化initState
function subscribe(fun) { // sub 订阅
subList.push(fun);
}
function dispatch(action) { // pub 发布
currentState = reducers(initState, action);
subList.forEach(fn => fn()); // 将事先订阅注册的事件遍历执行。
}
function getState() { // state的get钩子,返回目前的state
return currentState;
}
const store = { dispatch, suscribe, getState };
return store;
}
Vue中的发布-订阅模式
(1)EventBus
在Vue中有一套事件机制,其中一个用法是 EventBus。可以使用 EventBus 来解决组件间的数据通信问题。
1.创建事件中心管理组件之间的通信
// event-bus.js
import Vue from 'vue'
export const EventBus = new Vue()
2.发送事件
<template>
<div>
<first-com></first-com>
<second-com></second-com>
</div>
</template>
<script>
import firstCom from './firstCom.vue'
import secondCom from './secondCom.vue'
export default {
components: { firstCom, secondCom }
}
</script>
firstCom组件中发送事件:
<template>
<div>
<button @click="add">加法</button>
</div>
</template>
<script>
import {EventBus} from './event-bus.js' // 引入事件中心
export default {
data(){
return{
num:0
}
},
methods:{
add(){
EventBus.$emit('addition', {
num:this.num++
})
}
}
}
</script>
3.接收事件
在secondCom组件中发送事件:
<template>
<div>求和: {{count}}</div>
</template>
<script>
import { EventBus } from './event-bus.js'
export default {
data() {
return {
count: 0
}
},
mounted() {
EventBus.$on('addition', param => {
this.count = this.count + param.num;
})
}
}
</script>
(2)Vue双向绑定机制的场景
发布-订阅模式在源码中应用很多,比如Vue双向绑定机制的场景。
响应式大致就是使用 Object.defineProperty 把数据转为 getter/setter,并为每个数据添加一个订阅者列表的过程。这个列表是 getter 闭包中的属性,将会记录所有依赖这个数据的组件。也就是说,响应式化后的数据相当于发布者。
每个组件都对应一个 Watcher 订阅者。当每个组件的渲染函数被执行时,都会将本组件的 Watcher 放到自己所依赖的响应式数据的订阅者列表里,这就相当于完成了订阅,一般这个过程被称为依赖收集(Dependency Collect)。
组件渲染函数执行的结果是生成虚拟 DOM 树(Virtual DOM Tree),这个树生成后将被映射为浏览器上的真实的 DOM树,也就是用户所看到的页面视图。
当响应式数据发生变化的时候,也就是触发了 setter 时,setter 会负责通知(Notify)该数据的订阅者列表里的 Watcher,Watcher 会触发组件重渲染(Trigger re-render)来更新(update)视图。
Vue 的源码:
// src/core/observer/index.js 响应式化过程
Object.defineProperty(obj, key, {
enumerable: true,
configurable: true,
get: function reactiveGetter() {
// ...
const value = getter ? getter.call(obj) : val // 如果原本对象拥有getter方法则执行
dep.depend() // 进行依赖收集,dep.addSub
return value
},
set: function reactiveSetter(newVal) {
// ...
if (setter) { setter.call(obj, newVal) } // 如果原本对象拥有setter方法则执行
dep.notify() // 如果发生变更,则通知更新
}
})
策略模式
定义 : 要实现某一个功能,有多种方案可以选择。我们定义策略,把它们一个个封装起来,并且使它们可以相互转换。
作用:
1.避免大量冗余的代码判断,比如if else等;
2.降低了使用成本以及不同算法之间的耦合。
未使用策略模式
function checkInfo(data) {
if (data.role !== 'admin') {
return '不是管理员用户';
}
if (data.age < 18) {
return '您还未成年!';
}
if (data.time !== '10') {
return '不是指定时间!';
}
if (data.type !== 'eat melons') {
return '不是吃瓜群众';
}
}
代码的问题 :
- checkInfo 函数会爆炸
- 策略项无法复用
- 违反开闭原则
使用策略模式
// 维护权限列表
const timeList = ['10', '11'];
// 策略
var strategies = {
checkRole: function(value) {
return value === 'admin';
},
checkAge: function(value) {
return value >= 18;
},
checkTime: function(value) {
return jobList.indexOf(value) > 1;
},
checkEatType: function(value) {
return value === 'eat melons';
}
};
验证:
// 校验规则
var Validator = function() {
this.cache = [];
// 添加策略事件
this.add = function(value, method) {
this.cache.push(function() {
return strategies[method](value);
});
};
// 检查
this.check = function() {
for (let i = 0; i < this.cache.length; i++) {
let valiFn = this.cache[i];
var data = valiFn(); // 开始检查
if (!data) {
return false;
}
}
return true;
};
};
策略模式的实际应用
表单验证
除了表格中的 formatter 之外,策略模式也经常用在表单验证的场景。Element UI 的 Form 表单 具有表单验证功能,用来校验用户输入的表单内容。实际需求中表单验证项一般会比较复杂,所以需要给每个表单项增加 validator 自定义校验方法。
实现通用的表单验证方法:
// src/utils/validates.js
// 姓名校验 由2-10位汉字组成
export function validateUsername(str) {
const reg = /^[\u4e00-\u9fa5]{2,10}$/
return reg.test(str)
}
// 手机号校验 由以1开头的11位数字组成
export function validateMobile(str) {
const reg = /^1\d{10}$/
return reg.test(str)
}
// 邮箱校验
export function validateEmail(str) {
const reg = /^[a-zA-Z0-9_-]+@[a-zA-Z0-9_-]+(\.[a-zA-Z0-9_-]+)+$/
return reg.test(str)
}
增加一个柯里化方法,用来生成表单验证函数:
// src/utils/index.js
import * as Validates from './validates.js'
// 生成表格自定义校验函数
export const formValidateGene = (key, msg) => (rule, value, cb) => {
if (Validates[key](value)) {
cb()
} else {
cb(new Error(msg))
}
}
在Vue里使用:
<template>
<el-form ref="ruleForm"
label-width="100px"
class="demo-ruleForm"
:rules="rules"
:model="ruleForm">
<el-form-item label="用户名" prop="username">
<el-input v-model="ruleForm.username"></el-input>
</el-form-item>
<el-form-item label="手机号" prop="mobile">
<el-input v-model="ruleForm.mobile"></el-input>
</el-form-item>
<el-form-item label="邮箱" prop="email">
<el-input v-model="ruleForm.email"></el-input>
</el-form-item>
</el-form>
</template>
<script type='text/javascript'>
import * as Utils from '../utils'
export default {
name: 'ElTableDemo',
data() {
return {
ruleForm: { pass: '', checkPass: '', age: '' },
rules: {
username: [{
validator: Utils.formValidateGene('validateUsername', '姓名由2-10位汉字组成'),
trigger: 'blur'
}],
mobile: [{
validator: Utils.formValidateGene('validateMobile', '手机号由以1开头的11位数字组成'),
trigger: 'blur'
}],
email: [{
validator: Utils.formValidateGene('validateEmail', '不是正确的邮箱格式'),
trigger: 'blur'
}]
}
}
}
}
</script>
实际效果:
代理模式
代理模式是指使用一个代理对象来控制对另一个对象的访问。这个模式非常适合那些需要控制对某些敏感资源的访问的场景。下面是一个使用代理模式的例子:
class Image {
constructor(url) {
this.url = url;
this.loadImage();
}
loadImage() {
console.log(`Loading image from ${this.url}`);
}
}
class ProxyImage {
constructor(url) {
this.url = url;
}
loadImage() {
if (!this.image) {
this.image = new Image(this.url);
}
console.log(`Displaying cached image from ${this.url}`);
}
}
const image1 = new Image('https://example.com/image1.jpg');
const proxyImage1 = new ProxyImage('https://example.com/image1.jpg');
proxyImage1.loadImage(); // Loading image from https://example.com/image1.jpg
proxyImage1.loadImage(); // Displaying cached image from https://example.com/image1.jpg
const image2 = new Image('https://example.com/image2.jpg');
const proxyImage2 = new ProxyImage('https://example.com/image2.jpg');
proxyImage2.loadImage(); // Loading image from https://example.com/image2.jpg
proxyImage2.loadImage(); // Displaying cached image from https://example.com/image2.jpg
上述代码中,我们创建了一个图片类 Image 和一个代理图片类 ProxyImage。
代理图片类包含了一个图片对象,用于控制对其加载和显示的访问。
在主程序中,我们首先创建了一个真实的图片对象,并使用代理图片对象进行访问。
第一次访问时,代理图片对象会加载并显示真实的图片;
第二次访问时,代理图片对象直接从缓存中获取并显示图片。
代理模式的应用
(1)拦截器
在项目中经常使用 Axios 的实例来进行 HTTP 的请求,使用拦截器 interceptor 可以提前对 request 请求和 response 返回进行一些预处理,比如:
request请求头的设置,和Cookie信息的设置;- 权限信息的预处理,常见的比如验权操作或者
Token验证; - 数据格式的格式化,比如对组件绑定的
Date类型的数据在请求前进行一些格式约定好的序列化操作; - 空字段的格式预处理,根据后端进行一些过滤操作;
response的一些通用报错处理,比如使用Message控件抛出错误。
除了 HTTP 相关的拦截器之外,还有路由跳转的拦截器,可以进行一些路由跳转的预处理等操作。
(2)前端框架的数据响应式化
Vue 2.x 中通过 Object.defineProperty 来劫持各个属性的 setter/getter,在数据变动时,通过发布-订阅模式发布消息给订阅者,触发相应的监听回调,从而实现数据的响应式化,也就是数据到视图的双向绑定。
为什么 Vue 2.x 到 3.x 要从 Object.defineProperty 改用 Proxy 呢,是因为前者的一些局限性,导致的以下缺陷:
- 无法监听利用索引直接设置数组的一个项,例如:
vm.items[indexOfItem] = newValue; - 无法监听数组的长度的修改,例如:
vm.items.length = newLength; - 无法监听 ES6 的
Set、WeakSet、Map、WeakMap的变化; - 无法监听
Class类型的数据; - 无法监听对象属性的新加或者删除。
适配器模式
适配器模式(Adapter Pattern)又称包装器模式,将一个类(对象)的接口(方法、属性)转化为用户需要的另一个接口,解决类(对象)之间接口不兼容的问题。
主要功能是进行转换匹配,目的是复用已有的功能,而不是来实现新的接口。也就是说,访问者需要的功能应该是已经实现好了的,不需要适配器模式来实现,适配器模式主要是负责把不兼容的接口转换成访问者期望的格式而已。
适配器的实际案例
(1)Vue 计算属性
Vue 中的计算属性也是一个适配器模式的实例,以官网的例子为例:
<template>
<div id="example">
<p>Original message: "{{ message }}"</p> <!-- Hello -->
<p>Computed reversed message: "{{ reversedMessage }}"</p> <!-- olleH -->
</div>
</template>
<script type='text/javascript'>
export default {
name: 'demo',
data() {
return {
message: 'Hello'
}
},
computed: {
reversedMessage: function() {
return this.message.split('').reverse().join('')
}
}
}
</script>
对原有数据并没有改变,只改变了原有数据的表现形式。
(2) 源码中的适配器模式
Axios 的用来发送请求的 adapter 本质上是封装浏览器提供的 API XMLHttpRequest。
module.exports = function xhrAdapter(config) {
return new Promise(function dispatchXhrRequest(resolve, reject) {
var requestData = config.data
var requestHeaders = config.headers
var request = new XMLHttpRequest()
// 初始化一个请求
request.open(config.method.toUpperCase(),
buildURL(config.url, config.params, config.paramsSerializer), true)
// 设置最大超时时间
request.timeout = config.timeout
// readyState 属性发生变化时的回调
request.onreadystatechange = function handleLoad() { ... }
// 浏览器请求退出时的回调
request.onabort = function handleAbort() { ... }
// 当请求报错时的回调
request.onerror = function handleError() { ... }
// 当请求超时调用的回调
request.ontimeout = function handleTimeout() { ... }
// 设置HTTP请求头的值
if ('setRequestHeader' in request) {
request.setRequestHeader(key, val)
}
// 跨域的请求是否应该使用证书
if (config.withCredentials) {
request.withCredentials = true
}
// 响应类型
if (config.responseType) {
request.responseType = config.responseType
}
// 发送请求
request.send(requestData)
})
}
模块主要是对请求头、请求配置和一些回调的设置,并没有对原生的 API 有改动,所以也可以在其他地方正常使用。这个适配器可以看作是对 XMLHttpRequest 的适配,是用户对 Axios 调用层到原生 XMLHttpRequest 这个 API 之间的适配层。
总结
以上就是一些常见的设计模式以及它们的应用场景和示例代码。
当然,这只是冰山一角,还有很多其他的设计模式可以应用于不同的场景。熟悉各种设计模式并且能够灵活运用它们可以让你成为更优秀的开发者。
现有的设计模式就有大约50种,常见的也有20种左右,所以设计模式是一门宏大而深奥的学问,需要我们不断的去学习和在实践中总结。