JavaScript-5大常见的设计模式
前言
设计模式有很多种, 但在前端中如何运用?在前端编码实践中, 常见的设计模式有哪些?
设计模式的定义
设计模式, 是在面向对象软件设计中针对特定问题的简洁&优雅的解决方案. 在不同的编程语言中, 对设计模式的实现, 可能会有区别. 比如Java和JavaScript, 在Java这种静态编译型语言中, 无法动态地给已存在的对象添加职责, 所以一般通过包装类的方式来实现装饰者模式. 但在JavaScript中, 给对象动态添加职责是很简单的事, 这也就造成了JavaScript语言的装饰者模式不再关注于给对象动态添加职责, 而是关注于给函数动态添加职责.
前端常用的5大设计模式
- 工厂模式
- 单例模式
- 代理模式
- 策略模式
- 观察者模式
工厂模式
工厂模式, 是用来创建对象的一种常用的设计模式, 不暴露创建对象的具体逻辑, 而是将逻辑封装在一个函数中. 即这个函数可以视为一个工厂, 工厂模式根据抽象程度的不同可以分为:
- 简单工厂
- 工厂方法
- 抽象工厂
下面主要来介绍下简单工厂和工厂就去在JavaScript中的运用的一些简单示例.
简单工厂
简单工厂,又称为静态工厂, 由一个工厂对象决策创建某一种产品对象类的实例, 主要用来创建同一类对象.
例如, 在实际项目开发中, 我们可能需要根据用户权限来渲染不同的页面, 高级权限的用户所拥有的页面有些是无法被低级权限的用户所查看. 因此我们可以在不同权限等级用户的构造函数中, 保存该用户能够看到的页面.
function UserFactory(role){
function SuperAdmin(){
this.name = 'superManager'
this.viewPage = ['home', 'userManage', 'orderManage', 'appManage', 'permManage']
}
function Admin(){
this.name = 'admin'
this.viewPage = ['home', 'orderMange', 'appManage']
}
function NormalUser(){
this.name = 'normalUser'
this.viewPage = ['home', 'orderManage']
}
switch(role){
case 'superAdmin':
return new SuperAdmin()
break
case 'admin':
return new Admin()
break
case 'user':
return new NormalUser()
break
default:
throw new Error('参数错误, 可选参数:superAdmin、admin、user')
}
}
let superAdmin = UserFactory('superAdmin')
let admin = UserFactory('admin')
let normalUser = UserFactory('user')
小结
在上面的示例中, UserFactory就是一个简单工厂, 在该函数中有3个构造函数, 分别对应不同权限的用户, 当我们调用工厂函数时, 只需要传递superAdmin, admin, user这3个可选参数, 即可获取一个对应的实例对象.
- 优点: 只需要一个正确的参数, 就可以获取到你所需要的对象, 而无需知道其他创建的具体细节.
- 缺点: 在函数内包含了所有对象的创建逻辑(构造函数)和判断逻辑代码, 每增加一个新的构建函数, 还需要修改判断逻辑代码, 若上面的对象是30个时或更多时, 这个函数会成为一个庞大的超级函数, 难以维护. 简单工厂只能作用于创建的对象数量较少,对象的创建逻辑不复杂时使用
工厂方法
工厂方法, 是将实际创建对象的工作推迟到子类中, 这样核心类就变成了抽象类, 但是在JavaScript中, 很难像传统面向对象那样去创建抽象类, 因此在JavaScript中我们只要参考它的核心思想即可, 我们可以将工厂方法看作是一个实例化对象的工厂类.
比如, 上面的例子, 我们使用工厂方法来改造下. 工厂方法, 我们只把它看作是一个实例化对象的工厂, 它只做实例化对象这一件事情, 我们采用安全模式创建对象.
function UserFactory(role){
if(this instanceof UserFactory){
if(typeof this[role] !== 'function') throw new Error('参数错误, 可选参数:superAdmin、admin、user')
const s = new this[role]();
return s;
}else {
return new UserFactory(role)
}
}
UserFactory.prototype = {
SuperAdmin: function (){
this.name = 'superManager'
this.viewPage = ['home', 'userManage', 'orderManage', 'appManage', 'permManage']
},
Admin: function (){
this.name = 'admin'
this.viewPage = ['home', 'orderMange', 'appManage']
},
NormalUser: function (){
this.name = 'normalUser'
this.viewPage = ['home', 'orderManage']
}
}
const superAdmin = UserFactory('SuperAdmin');
const admin = UserFactory('Admin')
const normalUser = UserFactory('NormalUser')
const user = UserFactory('user')
小结
在简单工厂中,如果我们新增加一个用户类型,需要修改两处地方的代码:
- 增加新的用户构造函数
- 在逻辑判断中增加对新的用户的判断
而在抽象工厂方法中,我们只需要在UserFactory.prototype中添加就可以啦。
单例模式
单例模式, 保证一个类只有一个实例, 并且提供一个访问它的全局访问点
在一些特定的需求场景中, 我们需要保证一个对象只需一个, 例如:
- 线程池
- 全局缓存
- 浏览器中的Window对象
- 登录窗等
实现思路: 用一个变量标识当前是否已经为某个类创建过对象, 如果是, 则在下一次获取这个类的实例时, 直接返回之前创建的对象.
这样做的优点是:
- 可以用来划分命名空间, 减少全局变量的数量
- 可以被实例化, 且只实例化一次
下面我们来看一个简单的示例, 在JavaScript中我们可以使用闭包来实现这种模式:
var cls = (function(){
var instance;
function getInstance(){
if(instance === undefined){
instance = new Construct()
}
return instance;
}
function Construct(){
// ... 构造函数
}
return {
getInstance: getInstance
}
})()
小结
在上面的代码中,我们可以使用cls.getInstance来获取到单例,并且每次调用均获取到同一个单例.
在我们平时的开发中,我们也经常会用到这种模式,比如当我们单击登录按钮的时候,页面中会出现一个登录框,而这个浮窗是唯一的,无论单击多少次登录按钮,这个浮窗只会被创建一次,因此这个登录浮窗就适合用单例模式。
代理模式
代理模式是一种非常有意义的模式, 在生活中可以找到很多代理模式的场景.
比如明星都有经纪人作为代理. 如果想请明星来办一场 商业演出, 只能联系他的经纪人. 经纪人会把商业演出的细节和报酬都谈好之后, 再把合同交给明星签. 代理模式的关键是, 当客户不方便直接访问一个对象或者不满足需要的时候, 提供一个替身对象来控制对这个对象的访问, 客户实际 上访问的是替身对象. 替身对象对请求做出一些处理之后, 再把请求转交给本体对象.
虚拟代理实现图片预加载
在Web开发中, 图片预加载是一种常用的技术, 如果直接给某个img标签节点设置src属性, 由于图片过大或者网络不佳, 图片的位置 往往有段时间会是一片空白. 常见的做法是先用一张loading图片占位, 然后用异步的方式加载图片, 等图片加载好了再把它填充 到img节点里, 这种场景就很适合使用虚拟代理.
var myImage = (function(){
var imgNode = document.createElement("img");
document.body.appendChild(imgNode);
return function(src){
imgNode.src = src;
}
})();
var proxyImage = (function(){
var img = new Image;
img.onload = function(){
myImage(this.src);
};
return function(src){
myImage("file:// /C:/Users/sven/Desktop/loading.gif");
img.src = src;
}
})();
proxyImage("http://img/cache/com/music/dklddsafla.jpg");
虚拟代理合并HTTP请求
在Web开发中, 也许最大的开销就是网络请求. 假设我们在做一个文件同步的功能, 当我们选中一个checkbox的时候, 它对应的文件 就会被同步到另一台备用服务器上.当我们选中3个checkbox的时候,依次往服务器发送了3次同步文件的请求. 而点击并不是一个很复 杂的操作, 一秒内点中4个checkbox并不是什么难事, 如此频繁的网络请求将会带来相当大的开销. 解决方案是, 可以通过一个代理函数proxySynchronousFile来收集一段时间内的请求, 最后一次性发送给服务器.比如我们等待2秒之 后才把这2秒之内需要同步的文件ID打包发给服务器, 如果不是对实时性要求非常高的系统, 2秒的延迟不会带来太大的副作用, 却能 大大减轻服务器的压力.代码如下:
var synchronousFile = function(id){
console.log("开始同步文件, id为: " + id);
};
var proxySynchronousFile = (function(){
var cache = [];
var timer;
return function(id){
cache.push(id);
if(timer){
return;
}
timer = setTimeout(function(){
synchronousFile(cache.join(","));
clearTimeout(timer);
timer = null;
cache.length = 0;
}, 2000);
};
})();
var checkbox = document.getElementsByTagName("input");
for(var i= 0, c; c=checkbox[i++];){
c.onclick = function(){
if(this.checked === true){
proxySynchronousFile(this.id);
}
}
}
策略模式
策略模式, 指的是定义一些列的算法,把他们一个个封装起来,目的就是将算法的使用与算法的实现分离开来,避免多重判断条件,更具有扩展性。
举个例子,现在超市有活动,vip为5折,老客户3折,普通顾客没折,计算最后需要支付的金额,如果不使用策略模式,我们的代码可能和下面一样:
function Price(personType, price) {
//vip 5 折
if (personType == 'vip') {
return price * 0.5;
}
else if (personType == 'old'){ //老客户 3 折
return price * 0.3;
} else {
return price; //其他都全价
}
}
在上面的代码中,我们需要很多个判断,如果有很多优惠,我们又需要添加很多判断,这里已经违背了刚才说的设计模式的六大原则中的开闭原则了,如果使用策略模式,我们的代码可以这样写:
class Customer{
constructor(name,discount){
this.name = name
this.discount = discount
}
getPrice(price){
return this.discount * price
}
hello(){
throw new Error("方法未实现!")
}
}
class VipCustomer extends Customer{
constructor(username){
super('vip', 0.5)
this.username = username
}
hello(){
return `尊敬的VIP用户: ${this.username} 你好!`
}
}
class OldCustomer extends Customer{
constructor(username){
super('normal', 0.89)
this.username = username
}
hello(){
return `尊敬的老用户: ${this.username} 你好!`
}
}
const vip = new VipCustomer('小平')
window.console.log(vip.hello())
window.console.log("vip客户 的结账价为:", vip.getPrice(200))
const old = new OldCustomer('小军')
window.console.log(old.hello())
window.console.log("普通客户 的结账价为:", old.getPrice(200))
小结
总结:在上面的代码中,通过策略模式,使得客户的折扣与算法解藕,又使得修改跟扩展能独立的进行。
当我们的代码中有很多个判断分支,每一个条件分支都会引起该“类”的特定行为以不同的方式作出改变,这个时候就可以使用策略模式,可以改进我们代码的质量,也更好的可以进行单元测试。
观察者模式
发布-订阅模式的通用实现
真实的例子 --- 网站登录
假如我们正在开发一个商城网站, 网站里有header头部, nav导航, 消息列表, 购物车等模块. 这几个模块的渲染有一个共同的前提 条件, 就是必须先用ajax异步请求获取用户的登录信息. 至于ajax请求什么时候能成功返回用户信息, 这点无法确定. 现在的情节 看起来像极了售楼处的例子, 小明不知道什么时候开发商的售楼手续能够成功办下来. 更重要的一点是, 我们不知道除了header头部, nav导航, 消息列表, 购物车之外, 将来还有哪些模块需要使用这些用户信息. 如果 它们和用户信息模块产生了强耦合, 比如下面这样的形式:
login.succ(function(data){
header.setAvatar(data.avatar); // 设置header模块的头像
nav.setAvatar(data.avatar); // 设置导航模块的头像
message.refresh(); // 刷新消息列表
cart.refresh(); // 刷新购物车列表
});
这种耦合性会使程序变得僵硬, header模块不能随意再改变setAvatar的方法名, 它自身的名字也不能被改为header1, header2.这是 针对实现编程的典型例子, 针对具体实现编程是不被赞同的. 用发布-订阅模式重写之后, 对用户信息感兴趣的业务模块将自行订阅登录成功的消息事件. 当登录成功时, 登录模块只需要发布登录 成功的消息, 而业务方接受到消息之后, 就会开始进行各自的业务处理, 登录模块并不关心业务方究竟要做什么, 也不想去了解它们 的内部细节. 改善后的代码下:
$.ajax("http://xxx.com?login", function(data){ // 登录成功
login.trigger("loginSucc", data); // 发布登录成功的消息
});
// 和模块监听登录成功的消息
var header = (function(){ // header模块
login.listen("loginSucc", function(data){
header.setAvatar(data.avatar);
});
return {
setAvatar: function(data){
console.log("设置header模块的头像!");
}
}
})();
var nav = (function(){ // nav模块
login.listen("loginSucc", function(data){
nav.setAvatar(data.avatar);
});
return {
setAvatar: function(avatar){
console.log("设置nav模块的头像!");
}
}
})();
var address = (function(){ // 收货地址模块
login.listen("loginSucc", function(obj){
address.refresh(obj);
});
return {
refresh: function(avatar){
console.log("刷新收货地址列表!");
}
}
})();
全局的发布-订阅对象
在程序中, 发布-订阅模式可以用一个全局的Event对象来实现, 订阅者不需要了解消息来自哪个发布者, 发布者也不知道消息会推送 给哪些订阅者, Event作为一个类似"中介者"的角色, 把订阅者和发布者联系起来.见如下代码:
但这里我们要留意另一个问题, 模块之间如果用了太多的全局发布-订阅模式来通信, 那么模块与模块之间的联系就被隐藏到 背后. 我们最终会搞不清楚消息来自哪个模块, 或者消息会流向哪些模块, 这又会给我们的维护带来一些麻烦, 也许某个模块 的作用就是暴露一些接口给其他模块调用.
全局事件的命名冲突
我们所了解的发布-订阅模式中, 都是订阅者先订阅一个消息, 随后才能接收到发布者发布的消息. 如果把顺序返过来, 发布者先发 布一条消息, 而在此之前并没有对象来订阅它, 这条消息无疑将消失在宇宙中. 在某些情况下, 我们需要先将这条消息保存下来, 等到有对象来订阅它的时候, 再重新把消息发布给订阅者. 就如同QQ中的离线消息 一样, 离线消息被保存在服务器中, 接收人下次登录上线之后, 可以重新收到这条消息. 这种需救济在实际项目中是存在的, 比如在之前折商城网站中, 获取到用户信息之后才能渲染用户导航模块, 而获取用户信息的操作 是一个ajax异步请求. 当ajax请求成功返回之后会发布一个事件, 在此之前订阅了此事件的用户导航模块可以接收到这些用户信息. 但这只是理想的状况, 因为异步的原因, 我们不能保证ajax请求返回的时间, 有时它返回得比较快, 而此时用户导航模块的代码还没有 加载好(还没有订阅相应的事件), 特别是在用了一些模块化惰性加载的技术后, 这是很可能发生的事情. 也许我们还需要一个 方案, 使得我们的发布-订阅对象拥有先发布后订阅的能力. 为了满足这个需求, 我们要建立一个存放离线事件的堆栈, 当事件发布的时候, 如果此时还没有订阅者来订阅这个事件, 我们暂时把 发布事件的动作包裹在一个函数里, 这些包装函数将被存入堆栈中, 等到终于有对象来订阅此事件的时候, 我们将遍历堆栈并且依次 执行这些包装函数, 也就是重新发布里面的事件.当然离线事件的生命周期只有一次, 就像QQ的未读信息只会被重新阅读一次, 所以 刚才的操作我们只能进行一次.