前端常见八大设计模式
设计模式
一、设计模式是什么?
设计模式是在某种场合下对某个问题的一种解决方案。设计模式是通过概念总结出来的模版,总结出来的固定的东西。每一个模式描述了一个在我们周围不断重复发生的问题,以及该问题的解决方案的核心。
二、设计原则–设计模式的指导思想
设计原则是设计模式的指导理论,它可以帮助我们规避不良的软件设计。
SOLID五大设计原则
SOLID 指代的五个基本原则分别是:
●单一职责原则(Single Responsibility Principle)
●开放封闭原则(Opened Closed Principle)
●里式替换原则(Liskov Substitution Principle)
●接口隔离原则(Interface Segregation Principle)
●依赖反转原则(Dependency Inversion Principle)
在 JavaScript 设计模式中,主要用到的设计模式基本都围绕“单一职责”和“开放封闭”这两个原则来展开。
单一职责原则
定义:就一个类(通常也包括对象和函数等)而言,应该仅有一个引起它变化的原因。
在 JavaScript 中,需要用到类的场景并不太多,单一职责原则更多地是被运用在对象或者方法级别上,因此我们的讨论大多基于对象和方法。做什么都要分开,一个程序只做好一件事,如果功能过于复杂就拆分开,每个部分都保持独立。
职责被定义为“引起变化的原因”。单一职责原则指的是,如果一个对象承担了多项职责,就意味着这个对象将变得巨大,引起它变化的原因可能会有多个。面向对象设计鼓励将行为分布到细粒度的对象之中,如果一个对象承担的职责过多,等于把这些职责耦合到了一起,这种耦合会导致脆弱和低内聚的设计。当变化发生时,设计可能会遭到意外的破坏。
开放封闭原则
定义:当需要改变一个程序的功能或者给这个程序增加新功能的时候,可以使用增加代码的方式,但是不允许改动程序的源代码。
对扩展开放,对修改封闭。增加需求时,扩展新代码,而非修改已有代码
举例说明单一职责原则和开放封闭原则
单一职责原则:每个then中的逻辑只做好一件事。
开放封闭原则:如果新增需求,扩展then,而不是修改原有的then。对扩展开放,对修改封闭。
三、前端八大设计模式
(一) 简单工厂模式
通过工厂类创建对象,并且根据传入参数决定具体子类对象的方法,就是简单工厂模式。
工厂模式其实就是将创建对象的过程单独封装。
它很像我们去餐馆点菜:比如说点一份西红柿炒蛋,我们不用关心西红柿怎么切、怎么打鸡蛋这些菜品制作过程中的问题,我们只关心摆上桌那道菜。在工厂模式里,我传参这个过程就是点菜,工厂函数里面运转的逻辑就相当于炒菜的厨师和上桌的服务员做掉的那部分工作——这部分工作我们同样不用关心,我们只要能拿到工厂交付给我们的实例结果就行了。
总结一下:工厂模式的目的,就是为了实现无脑传参。
应用场景
它的应用场景也非常容易识别:有构造函数的地方,我们就应该想到简单工厂;在写了大量构造函数、调用了大量的 new,就应该考虑是否使用工厂模式,将new操作单独封装。
通用实现
1、Factory: 工厂类,负责返回产品实例
2、Product:产品类,访问者从工厂拿到产品实例
3、Factory是工厂,工厂通过getInstance函数创建产品,通过getInstance把真正的构造函数Product封装起来了。
通用实现代码(class)
/* 工厂类 */
class Factory {
static getInstance(type) {
switch (type) {
case 'Product1':
return new Product1()
case 'Product2':
return new Product2()
default:
throw new Error('当前没有这个产品')
}
}
}
/* 产品类1 */
class Product1 {
constructor() { this.type = 'Product1' }
operate() { console.log(this.type) }
}
/* 产品类2 */
class Product2 {
constructor() { this.type = 'Product2' }
operate() { console.log(this.type) }
}
const prod1 = Factory.getInstance('Product1')
prod1.operate() // 输出: Product1
const prod2 = Factory.getInstance('Product2')
prod2.operate() // 输出: Product2
应用举例
假设我们要开发一个公司岗位及其工作内容的录入信息,不同岗位的工作内容不一致。
function Factory(career) {
function User(career, work) {
this.career = career
this.work = work
}
let work
switch(career) {
case 'coder':
work = ['写代码', '修Bug']
return new User(career, work)
break
case 'hr':
work = ['招聘', '员工信息管理']
return new User(career, work)
break
case 'driver':
work = ['开车']
return new User(career, work)
break
case 'boss':
work = ['喝茶', '开会', '审批文件']
return new User(career, work)
break
}
}
let coder = new Factory('coder')
console.log(coder)
let boss = new Factory('boss')
console.log(boss)
Factory就是一个简单工厂。当我们调用工厂函数时,只需要传递career就可以获取到包含用户工作内容的实例对象。
简单工厂的优点
简单工厂的优点就是我们只要传递正确的参数,就能获得所需的对象,而不需要关心其创建的具体细节。
通过工厂,把真正的构造函数和使用者隔离开。让创建对象,创建实例的时候有个统一的入口。不是把所有的构造函数开放给所有的人,让他们自己去生成,这样会导致开放的东西过多。
简单工厂模式应用场景:
jquery
可以发现jquery的
函数就是用了工厂模式,那么工厂模式有哪些好处呢?不需要自己调用
n
e
w
j
Q
u
e
r
y
。直接用
函数就是用了工厂模式,那么工厂模式有哪些好处呢? 不需要自己调用new jQuery。直接用
函数就是用了工厂模式,那么工厂模式有哪些好处呢?不需要自己调用newjQuery。直接用很方便,也使得链式操作成为可能。
$这个函数就是一个工厂,这个工厂封装了返回实例的操作。
React.createElement
jsx语法:
jsx语法编译完成后
React.createElement就是一个工厂模式,最终创建的是一个实例,但是具体创建的是什么实例,我们不清楚,它其实最终创建的是Vnode实例。
从下面的源码中,可以看到createElement函数内会进行VNode的具体创建,框架提供的createElement工厂方法封装了复杂的创建与验证过程,VNode的实现对开发者不可见,对于使用者来说就很方便了。
设计原则验证
构造函数和创建者分离。
1.工厂类的扩展较困难,一旦添加新的产品类型,就必须修改工厂的创建逻辑,违反开闭原则。
2.产品类型较多时,工厂的创建逻辑可能比较复杂,一旦出错,可有导致所有产品创建失败,不利于系统维护。违反单一职责原则。
(二) 单例模式
定义:
保证一个类仅有一个实例,这个实例在系统中唯一被使用,并提供一个访问它的全局访问点。
单例模式的核心是确保只有一个实例,并提供全局访问。
单例模式是一种常用的模式,有一些对象我们往往只需要一个,比如浏览器中的 window 对象等。在 JavaScript 开发中,单例模式的用途同样非常广泛。试想一下,当我们单击登录按钮的时候,页面中会出现一个登录浮窗,而这个登录浮窗是唯一的,无论单击多少次登录按钮,这个浮窗都只会被创建一次,那么这个登录浮窗就适合用单例模式来创建,购物车同理。
单例模式的实现:
思路:
要实现一个标准的单例模式并不复杂,无非是用一个变量来标志当前是否已经为某个类创建过对象,如果是,则在下一次获取该类的实例时,直接返回之前创建的对象。在Javascript里,我们使用静态方法和闭包来实现单例模式。
通过静态方法使得客户能够去访问这个唯一实例,通过闭包来存储唯一被初始化的实例。
代码实现:
我们定义一个Singleton类,该类定义一个GetInstance操作,允许客户访问它的唯一实例。GetInstance是一个静态方法,主要负责创建自己的唯一实例。代码保证了Singleton只new一次,用户每次访问的都是同一个东西。
(PS:关于静态方法: js里面,如果某个方法是挂在class上的,那么它是静态的方法,无论这个class被new了多少个,这个静态方法始终只有一个,该方法不会被实例继承,而是直接通过类来调用,这就称为“静态方法”)
class Singleton{
constructor(name){
this.name = name;
}
getName(){
console.log(this.name);
}
}
// 此静态方法是获取本类实例的唯一访问点
Singleton.getInstance = (function() {
let instance
return function(name){
// 若实例不存在,则new一个新实例,否则返回已有实例
if (!instance) {
instance = new Singleton(name)
}
return instance
}
})()
// 比较两次实例化后对象的结果是实例相同
let A = Singleton.getInstance('A');
let B = Singleton.getInstance('B');
console.log(A===B); // true
console.log(A.getName()); // 'A'
console.log(B.getName()); // 'A'
适用场景
1.引用第三方库(多次引用只会使用一个库引用,如 jQuery)
项目中引入第三方库时,重复多次加载库文件时,全局只会实例化一个库对象,如 jQuery,lodash,moment …, 其实它们的实现理念也是单例模式应用的一种:
// 引入代码库 libs(库别名)
// 如果有了,我们就直接用,如果没有,我们就实例化一个。
if (window.libs != null) {
return window.libs; // 直接返回
} else {
window.libs = '...'; // 初始化
}
2.弹窗(登录框)
比如说页面登录框,只可能有一个登录框,那么你就可以用单例的思想去实现他,当然你不用单例的思想实现也行,那带来的结果可能就是你每次要显示登陆框的时候都要重新生成一个登陆框并显示(耗费性能),或者是不小心显示出了两个登录框。
3.购物车 (一个用户只有一个购物车)
4.全局状态管理 store (Vuex / Redux)
我们知道Vuex是用来做状态存储或者是数据存储的。我们想要不同页面不同的模块访问获得的这个store值是完全一样的,这样才可以实现数据共享。保证一个 Vue 应用,一个React应用,只有一个全局的 Store。所以store是通过单例模式实现的。
设计原则的验证
单一职责原则,只实例化一个唯一的对象。
(三) 适配器模式
定义:
适配器模式通过把一个类的接口变换成客户端所期待的另一种接口,可以帮我们解决不兼容的问题。旧接口格式和使用者不兼容,中间加一个适配转换接口。
适配器模式类似于转换插头,港式的电器插头比大陆的电器插头体积要大一些。如果从香港买了一个 Mac book,我们会发现充电器无法插在家里的插座上,为此而改造家里的插座显然不方便,所以我们需要一个转换器转换一下。
适配器模式的举例(封装旧接口):
大家知道我们现在有一个非常好用异步方案叫fetch,它的写法比ajax优雅很多。因此在不考虑兼容性的情况下,我们更愿意使用fetch、而不是使用ajax来发起异步请求。如下,我们封装了一个基于fetch的http方法库:
export default class HttpUtils {
// get方法
static get(url) {
return new Promise((resolve, reject) => {
// 调用fetch
fetch(url)
.then(response => response.json())
.then(result => {
resolve(result)
})
.catch(error => {
reject(error)
})
})
}
// post方法,data以object形式传入
static post(url, data) {
return new Promise((resolve, reject) => {
// 调用fetch
fetch(url, {
method: 'POST',
headers: {
Accept: 'application/json',
'Content-Type': 'application/x-www-form-urlencoded'
},
// 将object类型的数据格式化为合法的body参数
body: this.changeData(data)
})
.then(response => response.json())
.then(result => {
resolve(result)
})
.catch(error => {
reject(error)
})
})
}
// body请求体的格式化方法
static changeData(obj) {
var prop,
str = ''
var i = 0
for (prop in obj) {
if (!prop) {
return
}
if (i == 0) {
str += prop + '=' + obj[prop]
} else {
str += '&' + prop + '=' + obj[prop]
}
i++
}
return str
}
}
当我想使用 fetch 发起请求时,只需要这样轻松地调用,而不必再操心繁琐的数据配置和数据格式化:
// 定义目标url地址
const URL = "xxxxx"
// 定义post入参
const params = {
...
}
// 发起post请求
const postResponse = await HttpUtils.post(URL,params) || {}
// 发起get请求
const getResponse = await HttpUtils.get(URL) || {}
如果我们现在有个需求,把公司所有的业务的网络请求都迁移到这个 HttpUtils库上来,而公司老项目封装的网络请求库,是基于 XMLHttpRequest 的,差不多长这样:
function Ajax(type, url, data, success, failed){
// 创建ajax对象
var xhr = null;
if(window.XMLHttpRequest){
xhr = new XMLHttpRequest();
} else {
xhr = new ActiveXObject('Microsoft.XMLHTTP')
}
...(此处省略一系列的业务逻辑细节)
var type = type.toUpperCase();
// 识别请求类型
if(type == 'GET'){
if(data){
xhr.open('GET', url + '?' + data, true); //如果有数据就拼接
}
// 发送get请求
xhr.send();
} else if(type == 'POST'){
xhr.open('POST', url, true);
// 如果需要像 html 表单那样 POST 数据,使用 setRequestHeader() 来添加 http 头。
xhr.setRequestHeader("Content-type", "application/x-www-form-urlencoded");
// 发送post请求
xhr.send(data);
}
// 处理返回数据
xhr.onreadystatechange = function(){
if(xhr.readyState == 4){
if(xhr.status == 200){
success(xhr.responseText);
} else {
if(failed){
failed(xhr.status);
}
}
}
}
}
实现逻辑我们简单描述了一下,这个不是重点,重点是它是这样调用的:
// 发送get请求
Ajax('get', url地址, post入参, function(data){
// 成功的回调逻辑
}, function(error){
// 失败的回调逻辑
})
不仅接口名不同,入参方式也不一样,这手动改要改到何年何日呢?
这种情况,我们可以考虑专门为我们抹平差异的适配器模式。要把老代码迁移到新接口,不一定要挨个儿去修改每一次的接口调用,我们只需要在引入接口时进行一次适配即可(具体的解析在注释里):
// Ajax适配器函数,入参与旧接口保持一致
async function AjaxAdapter(type, url, data, success, failed) {
const type = type.toUpperCase()
let result
try {
// 实际的请求全部由新接口发起
if(type === 'GET') {
result = await HttpUtils.get(url) || {}
} else if(type === 'POST') {
result = await HttpUtils.post(url, data) || {}
}
// 假设请求成功对应的状态码是1
result.statusCode === 1 && success ? success(result) : failed(result.statusCode)
} catch(error) {
// 捕捉网络错误
if(failed){
failed(error.statusCode);
}
}
}
// 用适配器适配旧的Ajax方法
async function Ajax(type, url, data, success, failed) {
await AjaxAdapter(type, url, data, success, failed)
}
如此一来,我们只需要编写一个适配器函数AjaxAdapter,并用适配器去承接旧接口的参数,就可以实现新旧接口的无缝衔接了~
适配器模式的应用场景:
vue computed计算属性。
使用者的要求和你现有的情况不兼容,通过计算属性进一步转换。
设计原则的验证
适配器模式满足开放封闭原则,对扩展开放,对修改封闭,我们使用适配器的一个理念就是对于原有的代码不进行修改,添加适配器满足扩展的需求。
(四) 代理模式
定义:
代理模式,式如其名——在某些情况下,出于种种考虑/限制,一个对象不能直接访问另一个对象,需要一个第三者(代理)牵线搭桥从而间接达到访问目的,这样的模式就是代理模式。
代理模式的关键是,当客户不方便直接访问一个对象或者不满足需要的时候,提供一个替身对象来控制对这个对象的访问,客户实际上访问的是替身对象。替身对象对请求做出一些处理之后,再把请求转交给本体对象。
如图 6-1 和图 6-2 所示。
代理模式举例–科学上网
科学上网,就是咱们常说的 VPN(虚拟专用网络)。大家知道,正常情况下,我们尝试去访问 Google.com,Chrome会给你一个这样的提示:
这是为啥呢?这就要从网络请求的整个流程说起了。一般情况下,当我们访问一个 url 的时候,会发生下图的过程:
这是为啥呢?这就要从网络请求的整个流程说起了。一般情况下,当我们访问一个 url 的时候,会发生下图的过程:
为了屏蔽某些网站,一股神秘的东方力量会作用于你的 DNS 解析过程,告诉它:“你不能解析出xxx.xxx.xxx.xxx(某个特殊ip)的地址”。而我们的 Google.com,不幸地出现在了这串被诅咒的 ip 地址里,于是你的 DNS 会告诉你:“对不起,我查不到”。
但有时候,一部分人为了搞学习,通过访问VPN,是可以间接访问到 Google.com 的。这背后,就是代理模式在给力。在使用VPN时,我们的访问过程是这样的:
没错,比起常规的访问过程,多出了一个第三方 —— 代理服务器。这个第三方的 ip 地址,不在被禁用的那批 ip 地址之列,我们可以顺利访问到这台服务器。而这台服务器的 DNS 解析过程,没有被施加咒语,所以它是可以顺利访问 Google.com 的。代理服务器在请求到 Google.com 后,将响应体转发给你,使你得以间接地访问到目标网址 —— 像这种第三方代替我们访问目标对象的模式,就是代理模式。
代理模式场景
1.网页事件代理
考虑到事件本身具有“冒泡”的特性,当我们点击 a 元素时,点击事件会“冒泡”到父元素 div 上,从而被监听到。如此一来,点击事件的监听函数只需要在 div 元素上被绑定一次即可,而不需要在子元素上被绑定 N 次——这种做法就是事件代理,它可以很大程度上提高我们代码的性能。
在这种做法下,我们的点击操作并不会直接触及目标子元素,而是由父元素作为代理。对事件进行处理和分发、间接地将其作用于子元素,因此这种操作从模式上划分属于代理模式。
2.es6 proxy --保护代理实现方案
保护代理,就是在访问层面做文章,在 getter 和 setter 函数里去进行校验和拦截,确保一部分变量是安全的。目前实现保护代理时,考虑的首要方案就是 ES6 中的 Proxy。
明星经纪人举例,为了保护明星的私人信息,phone是隐私不能公开给公众的,那么我们拦截对phone属性的访问,返回的是经纪人的联系方式。明星工作的报价,也由经纪人来处理,低于预期,那么我们不接受这个价格的设定。
3.jquery $.proxy
jQuery.proxy()方法,它接受函数作为参数,并返回一个始终具有特定上下文的新对象。这确保了函数中的this值是我们所需要的值。
$("button").on("click", function () {
setTimeout(function () {
// this没有引用我们的元素,而是引用到window对象上了
$(this).addClass("active");
});
});
$("button").on("click", function () {
setTimeout($.proxy(function () {
// 这里的this即引用到我们所想的元素上了
$(this).addClass("active");
}, this), 500);
// 后面传递给$.proxy()的this所引用的就是我们的DOM元素
});
4.虚拟代理实现图片预加载
在 Web 开发中,图片预加载是一种常用的技术,如果直接给某个 img 标签节点设置 src 属性,由于图片过大或者网络不佳,图片的位置往往有段时间会是一片空白。常见的做法是先用一张loading 图片占位,然后用异步的方式加载图片,等图片加载好了再把它填充到 img 节点里,这种场景就很适合使用虚拟代理,我们使用虚拟代理来存放实例化需要很长时间的真实对象(真实图片的路径和尺寸)。
如下代码所示,我们引入代理对象 proxyImage,通过这个代理对象,在图片被真正加载好之前,页面中将出现一张占位的菊花图 loading.gif, 来提示用户图片正在加载。
var myImage = (function(){
var imgNode = document.createElement( 'img' );
document.body.appendChild( imgNode );
return {
setSrc: function(src){
imgNode.src = src;
}
}
})();
var proxyImage = (function(){
var img = new Image;
img.onload = function(){
myImage.setSrc( this.src );
}
return {
setSrc: function( src ){
myImage.setSrc( 'file:///C:/Users/svenzeng/Desktop/loading.gif' );
img.src = src;
}
}
})();
proxyImage.setSrc('http:// imgcache.qq.com/music/photo/k/000GGDys0yA0Nk.jpg' );
我们通过 proxyImage 间接地访问 MyImage。proxyImage 控制了客户对 MyImage 的访问,并且在此过程中加入一些额外的操作,在真正的图片加载好之前,先把 img 节点的 src 设置为一张本地的 loading 图片。
设计原则的验证
代理类和目标类分离,隔离开目标类和使用者,符合单一职责原则和开放封闭原则。
拿预加载图片举例,我们现在已有一个给图片设置 src 的函数 myImage,当我们想为它增加图片预加载功能时,一种做法是改动 myImage 函数内部的代码,如下所示不用代理的预加载图片函数的实现
var MyImage = (function(){
var imgNode = document.createElement( 'img' );
document.body.appendChild( imgNode );
var img = new Image;
img.onload = function(){
imgNode.src = img.src;
};
return {
setSrc: function( src ){
imgNode.src = 'file:// /C:/Users/svenzeng/Desktop/loading.gif';
img.src = src;
}
}
})();
MyImage.setSrc('http:// imgcache.qq.com/music/photo/k/000GGDys0yA0Nk.jpg');
上段代码中的 MyImage 对象除了负责给 img 节点设置 src外,还要负责预加载图片。
我们考虑以下情况,如果我们只是从网络上获取一些体积很小的图片,或者 5 年后的网速快到根本不再需要预加载,我们可能希望把预加载图片的这段代码从 MyImage 对象里删掉。这时候就不得不改动MyImage 对象了。
实际上,我们需要的只是给 img 节点设置 src,预加载图片只是一个锦上添花的功能。如果能把这个操作放在另一个对象里面,自然是一个更好的方法。于是代理的作用在这里就体现出来了,我们更好的做法是提供一个代理函数 proxyMyImage,代理函数负责图片预加载,在图片预加载完成之后,再将请求转交给原来的 myImage 函数,myImage 在这个过程中不需要任何改动。
预加载图片的功能和给图片设置 src 的功能被隔离在两个函数里,它们可以单独改变而互不影响。myImage 不知晓代理的存在,它可以继续专注于自己的职责——给图片设置 src。
我们并没有改变或者增加 MyImage 的接口,但是通过代理对象,实际上给系统添加了新的行为。这是符合开放—封闭原则的。
给 img 节点设置 src 和图片预加载这两个功能,被隔离在两个对象里,这两个对象各自都只有一个被修改的动机,它们可以各自变化而不影响对方。何况就算有一天我们不再需要预加载,那么只需要改成请求本体而不是请求代理对象即可。这是符合单一职责原则的。
(五) 外观模式
为子系统中的一组接口提供了一个高层接口,使用者使用这个高层接口。
使用者和高层接口去联系,然后高层接口统一调度子系统里的子模块。子模块之间的联系是在子系统里封装好的,不会与外面有什么联系。
比如很常见的封装事件监听函数,要求能实现代理绑定和普通绑定两个功能,
如果不使用外观模式,需要写两个函数的,一个函数接收三个参数,一个函数接收四个参数。
通过高层接口bindEvent集成这两个函数的功能
function bindEvent(ele, type, selector, fn) {
// 外观模式
if (fn == null) {
fn = selector;
selector = null;
}
ele.addEventListener(type, event => {
const target = event.target;
if (selector) {
// 代理绑定
if (target.matches(selector)) { // 判断一个DOM元素是不是符合于选择器
fn.call(target, event); // call方法可以改变this指向
}
} else {
// 普通绑定
fn.call(ele, event);
}
})
设计原则的验证
不符合单一职责原则和开放封闭原则,谨慎使用。
(六) 观察者模式(发布-订阅模式)
观察者模式定义了一种一对多的依赖关系,让多个观察者对象同时监听某一个目标对象,当这个目标对象的状态发生变化时,会通知所有观察者对象,使它们能够自动更新。
小明最近看上了一套房子,到了售楼处之后才被告知,该楼盘的房子早已售罄。好在售楼MM 告诉小明,不久后还有一些尾盘推出,开发商正在办理相关手续,手续办好后便可以购买。但到底是什么时候,目前还没有人能够知道。
小明离开之前,把电话号码留在了售楼处。售楼 MM 答应他,新楼盘一推出就马上发信息通知小明。小红、小强和小龙也是一样,他们的电话号码都被记在售楼处的花名册上,新楼盘推出的时候,售楼 MM 会翻开花名册,遍历上面的电话号码,依次发送一条短信来通知他们。
发送短信通知就是一个典型的发布—订阅模式,小明、小红等购买者都是订阅者,他们订阅了房子开售的消息。售楼处作为发布者,会在合适的时候遍历花名册上的电话号码,依次给购房者发布消息。
发布-订阅模式用来降低多个对象之间的依赖关系,它可以取代对象之间硬编码的通知机制,一个对象不用再显式地调用另外一个对象的某个接口(小明不用隔三差五跑去售楼处咨询什么时候有尾盘推出)。当有新的订阅者出现时,发布者的代码不需要进行任何修改;同样当发布者需要改变时,也不会影响到之前的订阅者。
通用实现
左侧的Observer类是观察者。当观察者需要被触发的时候,会执行update().Subject是主题类,可以绑定多个观察者observers。当主题设置状态后,即调用setState后,会触发观察者(notifyAllObservers),然后所有观察者会去调用update方法。作为发布者,增加订阅者和通知订阅者。作为订阅者,被通知,去执行更新
观察者模式应用
1.网页事件绑定
实际上,只要我们曾经在 DOM 节点上面绑定过事件函数,那我们就曾经使用过发布—订阅模式。
所有事件监听机制,全部用观察者模式。按钮被点击,回调函数才执行。注意观察者模式的一对多也可以是一对一,确切的来说是一对n的关系。
在这里需要监控用户点击 document.body 的动作,但是我们没办法预知用户将在什么时候点击。所以我们订阅 document.body 上的 click 事件,当 body 节点被点击时(按钮点击其实也是一种状态变化),body 节点便会向订阅者发布这个消息。这很像购房的例子,购房者不知道房子什么时候开售,于是他在订阅消息后等待售楼处发布消息。
2.promise
我们先把then里边的函数先订阅上
resolve和reject就表示状态的变化
3.jquery的callback
$.callbacks是底层api,服务于其他的api
4.自定义事件
首先要指定好谁充当发布者,然后给发布者添加一个缓存列表,用于存放回调函数以便通知订阅者,最后发布消息的时候,发布者会遍历这个缓存列表,依次触发里面存放的订阅者回调函数。
设计原则验证:
主题(被观察者)和观察者分离, 不是主动触发而是被动监听。
符合开放封闭原则。开放-封闭原则具体体现在当有新的订阅者出现时,发布者的代码不需要进行任何修改。我们可以在不修改源码的情况下随时加入新的事件监听者, 作出新的响应。
(七) 状态模式
定义:
允许对象在内部状态发生改变时改变它的行为,对象看起来好像修改了它的类。
定义解析:
第一部分的意思是将状态封装成独立的类,并将请求委托给当前的状态对象,当对象的内部状态改变时,会带来不同的行为变化。电灯的例子足以说明这一点,在 off 和 on 这两种不同的状态下,我们点击同一个按钮,得到的行为反馈是截然不同的。
第二部分是从客户的角度来看,我们使用的对象,在不同的状态下具有截然不同的行为,这个对象看起来是从不同的类中实例化而来的,实际上这是使用了委托的效果。
在状态模式中,对象的行为是依赖于它的状态(属性)。当对象与外部交互时,触发其内部状态迁移,从而使得对象的行为也随之发生改变,状态模式又称为状态机模式。
我们来想象这样一个场景:
有一个电灯,电灯上面只有一个开关。当电灯开着的时候,此时按下开关,电灯会切换到关闭状态;再按一次开关,电灯又将被打开。同一个开关按钮,在不同的状态下,表现出来的行为是不一样的。
许多酒店里有一种电灯,这种电灯也只有一个开关,但它的表现是:第一次按下打开弱光,第二次按下打开强光,第三次才是关闭电灯。
非状态模式实现:
// 非状态模式
class Light {
constructor(){
this.state = 'off'
this.button = null
}
init(){
var button = document.createElement( 'button' )
button.innerHTML = '开关';
this.button = document.body.appendChild( button );
this.button.onclick = function(){
this.buttonWasPressed(); }
}
buttonWasPressed(){
if ( this.state === 'off' ){
console.log( '开灯' );
this.state = 'on';
}else if ( this.state === 'on' ){
console.log( '关灯' );
this.state = 'off';
}
}
}
var light = new Light();
light.init();
//
if ( this.state === 'off' ){
console.log( '弱光' );
this.state = 'weakLight';
}else if ( this.state === 'weakLight' ){
console.log( '强光' );
this.state = 'strongLight';
}else if ( this.state === 'strongLight' ){
console.log( '关灯' );
this.state = 'off';
}
存在的问题:
● 很明显 buttonWasPressed 方法是违反开放-封闭原则的,每次新增或者修改 light 的状态,都需要改动 buttonWasPressed 方法中的代码,这使得 buttonWasPressed 成为了一个非常不稳定的方法。
● 所有跟状态有关的行为,都被封装在 buttonWasPressed 方法里,如果以后这个电灯又增加了强强光、超强光和终极强光,那我们将无法预计这个方法将膨胀到什么地步。当然为了简化示例,此处在状态发生改变的时候,只是简单地打印一条 log。在实际开发中,要处理的事情可能比这多得多,也就是说,buttonWasPressed方法要比现在庞大得多。
● 状态的切换非常不明显,仅仅表现为对 state 变量赋值,比如 this.state = ‘weakLight’。在实际开发中,这样的操作很容易被程序员不小心漏掉。
我们也没有办法一目了然地明白电灯一共有多少种状态,除非耐心地读完 buttonWasPressed 方法里的所有代码。当状态的种类多起来的时候,某一次切换的过程就好像被埋藏在一个巨大方法的某个阴暗角落里。
● 状态之间的切换关系,不过是往 buttonWasPressed 方法里堆砌 if、else 语句,增加或者修改一个状态可能需要改变若干个操作,这使 buttonWasPressed 更加难以阅读和维护。
状态模式的实现
class OffLightState{
constructor(light){
this.light = light
}
// 1.状态模式的关键是把事物的 每种状态都封装成单独的类--满足了开放封闭原则
// 跟此种状态有关的行为都被封装在这个类的内部,
// 2.原来buttonWasPressed这个方法里做的事情被拆分到各个状态类里了
// 解决了buttonWasPressed 方法体积庞大的问题
buttonWasPressed(){
console.log( '弱光' );
this.light.setState(this.light.weakLightState)
}
}
class WeakLightState{
constructor(light){
this.light = light
}
buttonWasPressed(){
console.log( '强光' );
this.light.setState(this.light.strongLightState)
}
}
class StrongLightState{
constructor(light){
this.light = light
}
buttonWasPressed(){
console.log( '关灯' );
this.light.setState(this.light.offLightState)
}
}
class Light{
constructor(){
// 3.4.我们在 Light 类的构造函数里为每个状态类都创建一个状态对象,
// 这样一来我们可以 很明显地看到电灯一共有多少种状态
this.offLightState = new OffLightState( this );
this.weakLightState = new WeakLightState( this );
this.strongLightState = new StrongLightState( this );
this.button = null;
}
init(){
var button = document.createElement( 'button' );
this.button = document.body.appendChild( button );
this.button.innerHTML = '开关';
// 当前是关闭开关的状态
this.currState = this.offLightState;
this.button.onclick = ()=>{
this.currState.buttonWasPressed();
}
}
// 传递的永远是状态类。
setState(newState){
this.currState = newState;
}
}
var light = new Light();
light.init();
状态模式的关键是把事物的每种状态都封装成单独的类,跟此种状态有关的行为都被封装在这个类的内部,所以 button 被按下的的时候,只需要在上下文中,把这个请求委托给当前的状态对象即可,该状态对象会负责渲染它自身的行为。
代码解读:
首先定义了 Light 类,Light类在这里也被称为上下文(Context)。随后在 Light 的构造函数中,我们要创建每一个状态类的实例对象,Context 将持有这些状态对象的引用,以便把请求委托给状态对象。用户的请求,即点击 button 的动作也是实现在 Context 中的。我们要编写各种状态类,light 对象被传入状态类的构造函数,状态对象也需要持有 light 对象的引用,以便调用 light 中的方法或者直接操作 light 对象。
使用状态模式的目的:
状态模式主要解决的是当控制一个对象状态的条件表达式过于复杂时的情况(一个对象有状态变化,每次状态变化都会触发一个逻辑,不能总是用if…else…)。把状态的判断逻辑转移到表示不同状态的一系列类中,可以把复杂的判断逻辑简化。
状态模式的使用场景:
一个对象的行为取决于它的状态,并且它必须在运行时刻根据状态改变它的行为。
一个操作中含有大量的分支语句,而且这些分支语句依赖于该对象的状态。状态通常为一个或多个枚举常量的表示。
采用 javascript-state-machine 状态机插件完成状态模式的取消收藏功能
new StateMachine相当于是state的类,可以处理状态怎么变化,状态变化如何处理。剩下的相当于是context
import StateMachine from 'javascript-state-machine'
import $ from 'jquery'
var fsm = new StateMachine({
init: '收藏',
transitions: [
{ name: 'doStore', from: '收藏', to: '取消收藏' },
{ name: 'deleteStore', from: '取消收藏', to: '收藏' }
],
methods: {
onDoStore: function () {
console.log('收藏成功')
updateText()
},
onDeleteStore: function () {
console.log('取消收藏成功')
updateText()
}
}
})
const updateText = function () {
$('#btn1').text(fsm.state)
}
$('#btn1').on('click', function () {
if (fsm.is('收藏')) {
fsm.doStore()
} else {
fsm.deleteStore()
}
})
// 初始化
updateText()
实现一个promise代码(利用状态机)
import StateMachine from 'javascript-state-machine'
// 模型
var fsm = new StateMachine({
init: 'pending',
transitions: [
// 反映状态的变化
{
name: 'resolve',
from: 'pending',
to: 'fullfilled'
},
{
name: 'reject',
from: 'pending',
to: 'rejected'
}
],
// 状态变化的时候的处理逻辑
methods: {
// 成功
onResolve: function (state, data) {
// 参数:state - 当前状态示例;
//data - fsm.resolve(xxx) 执行时传递过来的参数
data.successList.forEach(fn => fn())
},
// 失败
onReject: function (state, data) {
// 参数:state - 当前状态示例;
//data - fsm.reject(xxx) 执行时传递过来的参数
data.failList.forEach(fn => fn())
}
}
})
// 定义 Promise
class MyPromise {
constructor(fn) {
this.successList = []
this.failList = []
fn(() => {
// resolve 函数
fsm.resolve(this)
}, () => {
// reject 函数
fsm.reject(this)
})
}
then(successFn, failFn) {
this.successList.push(successFn)
this.failList.push(failFn)
}
}
// 测试代码
function loadImg(src) {
const promise = new MyPromise(function (resolve, reject) {
var img = document.createElement('img')
img.onload = function () {
resolve(img)
}
img.onerror = function () {
reject()
}
img.src = src
})
return promise
}
var src = 'http://www.imooc.com/static/img/index/logo_new.png'
var result = loadImg(src)
result.then(function (img) {
console.log('success 1')
}, function () {
console.log('failed 1')
})
result.then(function (img) {
console.log('success 2')
}, function () {
console.log('failed 2')
})
设计原则验证
将状态对象和主题对象分离,状态的变化逻辑单独处理,新增状态就新增一个状态类,不会去修改源代码,符合开放封闭原则
(八) 迭代器模式
定义
提供一种方法顺序访问一个聚合对象中各个元素,而又不暴露该对象的内部表示。
定义解析:
顺序访问一个集合,使用者无需知道集合的内部结构。无需知道集合究竟是set,map还是数组等结构,你只需要知道我能顺序访问一个集合,我对它进行操作。目的在于生成访问机制,这个访问机制能顺序访问一个集合,但是不需要外界知道集合的内部结构,比如我们可以把所有的符合这个机制原理的数据结构类型都往这个访问机制里塞,这个访问机制可以遍历。我们没必要针对每一种数据类型都去写一种怎么去访问,怎么去遍历。我们只需要写一种,作为一种封装,抽象,它可以兼容多种数据类型的访问。
实现一个迭代器模式
class Iterator {
constructor(conatiner) {
this.list = conatiner.list
this.index = 0
}
next() {
if (this.hasNext()) {
return this.list[this.index++]
}
return null
}
hasNext() {
if (this.index >= this.list.length) {
return false
}
return true
}
}
class Container {
constructor(list) {
this.list = list
}
getIterator() {
return new Iterator(this)
}
}
// 测试代码
let container = new Container([1, 2, 3, 4, 5])
let iterator = container.getIterator()
while(iterator.hasNext()) {
console.log(iterator.next())
}
迭代器应用场景:Jquery each方法
借助jQuery的each方法,我们可以用同一套遍历规则遍历不同的集合对象:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="ie=edge">
<title>事件代理</title>
</head>
<body>
<a href="#">链接1号</a>
<a href="#">链接2号</a>
<a href="#">链接3号</a>
<a href="#">链接4号</a>
<a href="#">链接5号</a>
<a href="#">链接6号</a>
</body>
</html>
const aNodes = document.getElementsByTagName('a')
const arr = [1, 2, 3]
// 遍历数组
$.each(arr, function (index, item) {
console.log(`数组的第${index}个元素是${item}`)
})
// 遍历类数组对象
$.each(aNodes, function (index, aNode) {
console.log(`DOM类数组的第${index}个元素是${aNode.innerText}`)
})
//遍历jQuery自己的集合对象
const jQNodes = $('a')
$.each(jQNodes, function (index, aNode) {
console.log(`jQuery集合的第${index}个元素是${aNode.innerText}`)
})
迭代器应用场景:ES6 Iterator
JS原生的集合类型数据结构,只有Array(数组)和Object(对象),而ES6中,又新增了Map和Set。四种数据结构各自有着自己特别的内部实现,但我们仍期待以同样的一套规则去遍历它们,所以ES6在推出新数据结构的同时也推出了一套统一的接口机制——迭代器(Iterator)。
ES6约定,任何数据结构只要具备Symbol.iterator属性(这个属性就是Iterator的具体实现,它本质上是当前数据结构默认的迭代器生成函数),就可以被遍历——准确地说,是被for…of…循环和迭代器的next方法遍历。 事实上,for…of…的背后正是对next方法的反复调用。
在ES6中,针对Array、Map、Set、String、TypedArray、函数的 arguments 对象、NodeList 对象这些原生的数据结构都可以通过for…of…进行遍历。原理都是一样的,此处我们拿最简单的数组进行举例,当我们用for…of…遍历数组时:
const arr = [1, 2, 3]
const len = arr.length
for(item of arr) {
console.log(`当前元素是${item}`)
}
之所以能够按顺序一次一次地拿到数组里的每一个成员,是因为我们借助数组的Symbol.iterator生成了它对应的迭代器对象,通过反复调用迭代器对象的next方法访问了数组成员,像这样:
const arr = [1, 2, 3]
// 通过调用iterator,拿到迭代器对象
const iterator = arr[Symbol.iterator]()
// 对迭代器对象执行next,就能逐个访问集合的成员
iterator.next()
iterator.next()
iterator.next()
丢进控制台,我们可以看到next每次会按顺序帮我们访问一个集合成员:
而for…of…做的事情,基本等价于下面这通操作:
// 通过调用iterator,拿到迭代器对象
const iterator = arr[Symbol.iterator]()
// 初始化一个迭代结果
let now = { done: false }
// 循环往外迭代成员
while(!now.done) {
now = iterator.next()
if(!now.done) {
console.log(`现在遍历到了${now.value}`)
}
}
可以看出,for…of…其实就是iterator循环调用换了种写法。在ES6中我们之所以能够开心地用for…of…遍历各种各种的集合,全靠迭代器模式在背后给力。
设计原则验证:
我们有这样一段代码,先遍历一个集合,然后往页面中添加一些 div,这些 div 的 innerHTML分别对应集合里的元素:
var appendDiv = function( data ){
for ( var i = 0, l = data.length; i < l; i++ ){
var div = document.createElement( 'div' );
div.innerHTML = data[ i ];
document.body.appendChild( div );
}
};
appendDiv( [ 1, 2, 3, 4, 5, 6 ] );
appendDiv 函数本来只是负责渲染数据,但是在这里它还承担了遍历聚合对象 data 的职责。我们想象一下,如果有一天 data 数据格式从 array 变成了 object,那我们遍历 data 的代码就会出现问题,必须改成 for ( var i in data )的方式,这时候必须去修改 appendDiv 里的代码,否则因为遍历方式的改变,导致不能顺利往页面中添加 div 节点。
我们有必要把遍历 data 的职责提取出来,这正是迭代器模式的意义。
当把迭代聚合对象的职责单独封装在 each 函数中后,即使以后还要增加新的迭代方式,我
们只需要修改 each 函数即可,appendDiv 函数不会受到牵连。
var each = function( obj, callback ) {
var value,
i = 0,
length = obj.length,
isArray = isArraylike( obj );
if ( isArray ) {
// 迭代类数组
for ( ; i < length; i++ ) {
// isArraylike 函数未实现,可以翻阅 jQuery 源代码
callback.call( obj[ i ], i, obj[ i ] );
}
} else {
for ( i in obj ) { // 迭代object对象
value = callback.call( obj[ i ], i, obj[ i ] );
}
}
return obj;
};
var appendDiv = function( data ){
each( data, function( i, n ){
var div = document.createElement( 'div' );
div.innerHTML = n;
document.body.appendChild( div );
});
};
appendDiv( [ 1, 2, 3, 4, 5, 6 ] );
appendDiv({a:1,b:2,c:3,d:4} );
单一职责原则:
像上面的例子,appendDiv只需要渲染数据即可,对它的遍历是由each来帮它实现,如果对于遍历有需要修改的,也只需要修改each就可以了。
将使用者和目标对象隔离开,使用者不需要了解目标对象的长度啊数据结构啊等信息,目标对象可以是任意允许的数据类型。
四、主要参考资料
JavaScript 设计模式与开发实践 by 曾探
大话设计模式 by 程杰
慕课网-Javascript 设计模式系统讲解与应用 by 双越
掘金-JavaScript设计模式核心原理与应用实践 by 修言
更多推荐
所有评论(0)