简介
处在知识点贼多的前端领域,总想着学习一些“高性价比“的知识,几经搜寻后,找到了小册中修言的JavaScript 设计模式核?原理与应?实践,想起了这个有点了解但不多的“设计模式”,受益匪浅,将思考总结后的知识和大家分享下,希望共同进步。
在软件工程中,设计模式(design pattern)是对软件设计中普遍存在(反复出现)的各种问题,所提出的解决方案。 ——维基百科
设计模式代表了最佳的实践,通常被有经验的面向对象的软件开发人员所采用。
设计模式是一套被反复使用的、多数人知晓的、经过分类编目的、代码设计经验的总结。使用设计模式是为了重用代码、让代码更容易被他人理解、保证代码可靠性。
设计模式原则
- 单一职责原则:一个程序只做好一件事、如果功能过于复杂就拆分开,每个部分保持独立
- 开放/封闭原则:对扩展开放,对修改封闭;增加需求时,扩展新代码,而非修改已有代码
- 里氏替换原则:子类能覆盖父类、父类能出现的地方子类就能出现
- 接口隔离原则:保持接口的单一独立,类似单一职责原则,这里更关注接口
- 依赖倒转原则:面向接口编程,依赖于抽象而不依赖于具体, 使用方只关注接口而不关注具体类的实现。
在 JavaScript 设计模式中,主要用到的设计模式基本都围绕“单一功能”和“开放封闭”这两个原则来展开。
设计模式详解
本篇文章中舍去了一些设计模式,只留下了“前端中好用,面试中常考”的部分。
- 创建型
- 构造器模式
- 原型模式
- 工厂模式
- 抽象工厂模式
- 单例模式
- 结构型
- 装饰器模式
- 适配器模式
- 代理模式
- 行为型
- 观察者模式
- 迭代器模式
创建型-构造器模式
在面向对象的编程语言中,构造器是一个类中用来初始化新对象的特殊方法,并且可以接受参数用来设定实例对象的属性和方法。
有一天接到个需求,我们需要将三年二班的教职工录入系统中,此时班里只有小明自己,定义学生时,三下五除二就写完了。
// 定义学生
let 小明 = {
name:"小明",
age:12,
gender:'男',
identity: '学生'
}
又进行一天的招生后,来了小红和小强,于是CV后把他也加入了...
// 定义学生
let 小明 = {
name:"小明",
age:12,
gender:'男',
identity: '学生'
}
let 小红 = {
name:"小红",
age:13,
gender:'女',
identity: '学生'
}
let 小强 = {
name:"小强",
age:13,
gender:'男',
identity: '学生'
}
又过了两天你老板过来了 说:“三年二班杀疯了,一天之间招进来了80个学生”。此时继续以上写法,代码肯定是重复并且臃肿。
此时构造器就派上了用场,在面向对象的编程语言中,构造器是一个类中用来初始化新对象的特殊方法,并且可以接受参数用来设定实例对象的属性和方法。
基本构造器 在 JS 中,ES6之前是没有类这个概念的,所以一般用函数来表示一个构造器,使用方法是在构造器函数前使用 new 关键字。
所以,基本的构造器模式看起来是这样的:
function Student(name,gender,age){ // 注意,构造函数首字母一般为大写
this.name = name;
this.gender =gender;
this.age =age;
this.sayName = function(){
console.log("我是" + this.name)
}
}
let 小明 = new Student("小明",'男',12)
let 小红 = new Student("小红",'女',13)
console.log(小明.sayName()) // -> "我是小明"
代码的冗余程度直线减少了,但也有个不理想的地方,就是每次创建一个新对象,都需要重新定义 sayName 这个方法。
为了使 sayName 这个方法在实例之间共享,我们使用原型(prototype)来优化。
创建型-原型模式
原型模式,就是创建一个共享的原型,通过拷贝这个原型来创建新的类,用于创建重复的对象,带来性能上的提升。
ps: 此块使用到原型链知识
继续上面例子:
function Student(name,gender,age){
this.name = name;
this.gender =gender;
this.age =age;
}
// 如果在构造函数的原型属性上添加 sayName 方法,那么所有实例化的对象都会共享这个方法。优化代码是这样的:
Student.prototype.say = function(){
console.log("我是" + this.name)
}
let 小明 = new Student("小明",'男',12)
console.log(小明.sayName()) // -> "我是小明"
扩展:ES6版本 ES6 支持了类的定义,所以写起来风格更加优雅。
class Student {
constructor(name,gender,age) {
this.name = name
this.gender =gender;
this.age =age;
this.work = ['学习','玩游戏']
}
sayName(){
console.log("我是" + this.name)
}
}
let 小明 = new Student("小明",'男',12)
小明.sayName() // -> "我是小明"
特点:
构造函数内不定义属性和方法,把属性和方法都定义在构造函数的原型上。这样所有的对象实例都共享对象原型上的属性和方法
优点:
- 当创建新的对象实例较为复杂时,使用原型模式可以简化对象的创建过程,通过克隆一个已有实例可以提高新实例的创建效率。
- 多个实例可以共享原型上的属性和方法
缺点:
- 修改原型上的一些引用属性,所有实例对应的属性也将被改变,这样可能带来一些问题
创建型-工厂模式
由一个工厂对象决定创建某一种产品对象类的实例。主要用来创建同一类对象。
我们三年二班除了学生还有教师,或许还有专职的助教,此时我们的Student类并不能满足我们的需求,所以此时我们需要再创建“教师”与“助教”
// 教师
class Teacher {
constructor(name,gender,age) {
this.name = name
this.gender = gender;
this.age = age;
this.work = ['教书','偷懒']
}
}
// 助教
class AssistantTeacher {
constructor(name,gender,age) {
this.name = name
this.gender =gender;
this.age =age;
this.work = ['协助教师','收钱']
}
}
现在我们有三个类了(后面可能还会有更多的类),麻烦的事情来了:难道我每从数据库拿到一条数据,都要人工判断一下这个人的身份,然后手动给它分配构造器吗?可以实现,但不推荐,最好还是交给函数去处理:
function Factory(name, age, gemder,identity) {
switch(identity) {
case 'stucent':
return new Student(name, age, gender)
case 'teacher':
return new Teacher(name, age, gender)
case 'assistantTeacher':
return new AssistantTeacher(name, age, gender)
}
}
看起来是好一些了,至少我们不用操心构造函数的分配问题了。
但如果再来几个身份,例如学生家长,例如寝室阿姨,难道要手写十个类、数十行 switch 吗?
当然不!
我们仔细观察上面的代码,发现每个类都有用name、age、gender、work这四个属性,它们之间的区别,也只在于 work 字段需要随 identity 字段取值的不同而改变,而其他三个不变,这样以来,我们是不是对共性封装的不够彻底呢?
现在我们把相同的逻辑封装回User类里,然后把这个承载了共性的 User 类和个性化的逻辑判断写入同一个函数:
function User(name , age, gender, identity, work) {
this.name = name
this.age = age
this.gender = gender
this.identity = identity
this.work = work
}
function Factory(name, age, gender, identity) {
let work = []
switch(identity) {
case 'student':
work = ['学习','玩游戏']
break
case 'teacher':
work = ['教书','偷懒']
break
case 'assistantTeacher':
work = ['协助教师','收钱']
case 'xxx':
// 其它身份
...
return new User(name, age, gender, identity, work)
}
这样一来,是不是爽多了?我们要做的事情可以简单太多,不用时刻想着拿到的这组数据是什么工种,不用想着给他分配什么构造函数,更不用手写无数个构造函数!!Factory函数 已经帮我们做完了一切,而我们只需要像以前一样无脑传参就可以了,舒服了!
简单总结一下,工厂模式其实就是将创建对象的过程单独封装。就像去小卖铺买东西,你不必关心这个东西的制作过程,只用告诉老板你想要的,老板就会把物品return给你。
工厂模式很爽,因为他实现了无脑传参。
创建型-抽象工厂模式
前言
在实际的业务中,我们往往面对的复杂度并非数个类、一个工厂可以解决,而是需要动用多个工厂。
我们继续看上个小节举出的例子,简单工厂函数最后长这样:
function Factory(name, age, gender, identity) {
let work = []
switch(identity) {
case 'student':
work = ['学习','玩游戏']
break
case 'teacher':
work = ['教书','偷懒']
break
case 'assistantTeacher':
work = ['协助教师','收钱']
case 'xxx':
// 教导主任
...
return new User(name, age, gender, identity, work)
}
首先映入眼帘的是我们把所有身份塞进了同一个工厂,例如老师和学生,又例如之后可能会添加进来的教导主任,他们每种身份的权限都会存在着很大的差别,有些操作老师可以执行,又有些操作只有学校的管理层可以执行,因此我们需要对这个群体的对象进行单独的逻辑处理。
怎么办?去修改 Factory 的函数体,增加老师、教导主任相关的判断和处理逻辑吗?单从功能实现上来说,可以。但这么做会让代码变成山,因为学校还有校长、外包的食堂阿姨等等,每考虑到一个新的员工群体,就得去修改一次 Factory 的函数体。
这样做的后果是:
- 坑自己 —— Factory函数体会变得非常庞大,导致每次添加角色的时候都不敢下手,因为一旦写出Bug,就会导致整个Factory函数的崩坏,进而摧毁整个系统;
- 坑队友 —— Factory 的逻辑过于繁杂和混乱,没人想维护它;
- 坑测试 —— 每新加一个工种,他都需要整个Factory 的逻辑进行回归,因为改变是在 Factory 内部发生的
因为没有遵守开放封闭原则:对拓展开放,对修改封闭。
楼上这波操作错就错在我们不是在拓展,而是在疯狂地修改。
详解
抽象工厂模式(Abstract Factory Pattern)是围绕一个超级工厂创建其他工厂。该超级工厂又称为其他工厂的工厂。这种类型的设计模式属于创建型模式,它提供了一种创建对象的最佳方式。
作为上帝,我们想要创建一个动物,基本组成是躯体(Body)与灵魂(Soul)组成,我们准备开一个工厂来量产,但是我们又不知道具体生产的是什么类型的动物,只知道由这两部分组成,所以我先来一个抽象类来约定住动物的基本组成:
class AnimalFactory {
// 创造躯体
createBody (){
throw new Error('抽象工厂方法不允许直接调用,你需要将我重写!');
}
// 创建灵魂
createSoul(){
throw new Error('抽象工厂方法不允许直接调用,你需要将我重写!');
}
}
楼上这个类除了约定动物的基本构成外,啥也不干,如果你尝试new一个AnimalFactory实力并调用里面的方法,它都会给你报错。在抽象工厂模式里,楼上这个类就是我们食物链顶端最大的Boss——AbstractFactory(抽象工厂);
抽象工厂不干活,具体工厂(ConcreteFactory)干活!当我们明确了生产方案以后就可以化抽象为具体,比如现在需要生产哺乳动物,那我就可以定制一个具体工厂:
复制代码
//具体工厂继承自抽象工厂
class Mammals extends AnimalFactory {
createBody() {
// 提供哺乳动物的躯体
return new MammalsBody();
}
createSoul() {
// 提供哺乳动物的灵魂
return new MammalsSoul()
}
}
这里我们在提供哺乳动物的时候,调用了两个构造函数:MammalsBody和MammalsSoul,它们分别用于生成哺乳动物的躯体与灵魂。像这种被我们拿来用于 new 出具体对象的类,叫做具体产品类(ConcreteProduct)。具体产品类往往不会孤立存在,不同的具体产品类往往有着共同的功能,比如哺乳动物的躯体和爬行动物的躯体,虽身体中有着不同的构造,带起码都有个壳。因此我们可以用一个抽象产品(AbstractProduct)类来声明这一类产品应该具有的基本功能。
// 定义操作系统这类产品的抽象产品类
class Body {
walking() {
throw new Error('抽象产品方法不允许直接调用,你需要将我重写!');
}
}
// 定义具体操作系统的具体产品类
class MammalsBody extends Body {
walking() {
console.log('我会用哺乳动物的方式行走')
}
}
class reptilesBody extends Body {
walking() {
console.log('我会用爬行动物的方式行走')
}
}
生产'灵魂'也是同理,这里就不重复了。
// 定义灵魂的抽象类
class Soul {
// 灵性
spiritual() {
throw new Error('抽象产品方法不允许直接调用,你需要将我重写!');
}
}
// 定义具体操作系统的具体产品类
class MammalsSoul extends Soul {
spiritual() {
console.log('我具有哺乳动物的灵性')
}
}
class reptilesSoul extends Soul {
spiritual() {
console.log('我具有爬行动物的灵性')
}
}
如此一来,当我们需要生产一个哺乳动物时,我们只需要:
// 哺乳动物
const Mammals = new Mammals()
const myMammals = {}
// 让它拥有躯体
myMammals.body = Mammals.createBody()
// 让它拥有灵魂
myMammals.soul = Mammals.createSoul()
当之后需要写一个新的物种,则不需要对动物工厂AnimalFactory做任何修改,只需要拓展它的种类:
class 火星某动物 extends AnimalFactory {
createBody() {
// 此种动物躯体
}
createSoul() {
// 此种动物灵魂
}
}
这么个操作,对原有的系统不会造成任何潜在影响所谓的“对拓展开放,对修改封闭”就这么圆满实现了。
抽象工厂和简单工厂有哪些异同?
共同点:在于都尝试去分离一个系统中变与不变的部分。
不同点:场景的复杂度。
抽象工厂模式的定义,是围绕一个超级工厂创建其他工厂,对一些工作经验少的同学来说可能较难理解,但目前来说在JS世界里也应用得并不广泛,所以大家不必拘泥于细节,只需对“开放封闭原则”形成自己的理解,知道它好在哪,知道执行它的必要性。
创建型-单例模式
保证一个类仅有一个实例,并提供一个访问它的全局访问点,这样的模式就叫做单例模式。
意图: 保证一个类仅有一个实例,并提供一个访问它的全局访问点。
主要解决: 一个全局使用的类频繁地创建与销毁。
如何解决: 判断系统是否已经有这个单例,如果有则返回,如果没有则创建。
单例模式是设计模式中相对较为容易理解、容易上手的一种模式,同时因为其具有广泛的应用场景,也是面试题里的常客。
一般情况下,当我们创建了一个类(本质是构造函数)后,可以通过new关键字调用构造函数进而生成任意多的实例对象。像这样:
class SingleDog {
show() {
console.log('俺是一个单例对象')
}
}
const s1 = new SingleDog()
const s2 = new SingleDog()
s1 === s2 // false
楼上我们先 new 创建了一个 s1,又 new 创建了一个 s2, s1与s2显然是没有任何联系的,两者各占一块内存空间,单例模式想要做到的是,无论创建多少次,它都只返回第一次所创建的那个实例。
要做到这一点,就需要构造函数具备判断自己是否已经创建过一个实例的能力。我们现在把这段判断逻辑写成一个静态方法(其实也可以直接写入构造函数的函数体里):
class SingleDog {
show() {
console.log('俺是一个单例对象')
}
static getInstance() {
// 判断是否已经new过1个实例
if (!SingleDog.instance) {
// 若这个唯一的实例不存在,那么先创建它
SingleDog.instance = new SingleDog()
}
// 如果这个唯一的实例已经存在,则直接返回
return SingleDog.instance
}
}
const s1 = SingleDog.getInstance()
const s2 = SingleDog.getInstance()
s1 === s2 // true
除了楼上这种实现方式之外,getInstance的逻辑还可以用闭包来实现:
SingleDog.getInstance = (function() {
// 定义自由变量instance,模拟私有变量
let instance = null
return function() {
// 判断自由变量是否为null
if(!instance) {
// 如果为null则new出唯一实例
instance = new SingleDog()
}
return instance
}
})()
可以看出,在getInstance方法的判断和拦截下,我们不管调用多少次,SingleDog都只会给我们返回一个实例,s1和s2现在都指向这个唯一的实例。
实现一个简易Storage
生产实践:redux、vuex中的Store,或者我们经常使用的Storage都是单例模式。
来实现一下简易Storage:
class Storage{
static getInstance() {
if(!Storage.instance) {
Storage.instance = new Storage();
}
return Storage.instance;
}
getItem(key) {
return localStorage.getItem(key);
}
setItem(key, value){
return localStorage.setItem(key, value);
}
}
const storage1 = Storage.getInstance()
const storage2 = Storage.getInstance()
storage1.setItem('name', '小明')
storage1.getItem('name') // 小明
storage2.getItem('name') // 小明
storage1 === storage2 // true
优点
- 划分命名空间,减少全局变量
- 增强模块性,把自己的代码组织在一个全局变量名下,放在单一位置,便于维护
- 且只会实例化一次。简化了代码的调试和维护
缺点
- 由于单例模式提供的是一种单点访问,所以它有可能导致模块间的强耦合 从而不利于单元测试。无法单独测试一个调用了来自单例的方法的类,而只能把它与那个单例作为一个单元一起测试。
场景例子
- 定义命名空间和实现分支型方法
- 登录框
- vuex 和 redux中的store
结构型-装饰器模式
在我们的开发过程中我们会为了一些通用功能在多个不同的组件、接口或者类中使用,这个时候我们这些功能写到每个组件、接口或者类中,但是这样非常不利于维护。
装饰器模式(Decorator Pattern)允许向一个现有的对象添加新的功能,同时又不改变其结构。
理解了装饰器的能解决了什么问题,那我们在什么情况下考虑使用装饰器模式呢?我的理解是:
- 需要扩展一个类,为这个类附加一个方法或者属性的时候;
- 需要修改一个类的功能,或者重构这个类中的某个方法;
如何定义装饰器
装饰器本质是一个函数,可以分为带参数和不带参数(也叫装饰器工厂),装饰器使用 @expression这种形式,expression求值后必须为一个函数,它会在运行时被调用,被装饰的声明信息做为参数传入。
@Test()
class Hello {}
function Test(target) {
console.log("I am decorator.")
}
装饰器类型
类修饰器
类装饰器一般主要应用于类构造函数,可以监视、修改、替换类的定义,装饰器用来装饰类的时候。装饰器函数的第一个参数,就是所要装饰的目标类本身。
a、添加静态属性或方法
@Test()
class Hello {}
function Test(target) {
target.a = 1;
}
let o = new Hello();
console.log(o.a) ==>1
b、添加实例属性或方法
@Test()
class Hello {}
function Test(target) {
target.prototype.a = 1;
target.prototype.f = function(){
console.log("新增加方法")
};
}
let o = new Hello();
o.f() ==>"新增加方法"
console.log(o.a) ==>1
c、装饰器工厂(函数柯里化)
@Test('hello')
class Hello {}
function Test(str) {
return function(){
target.prototype.a = str;
target.prototype.f = function(){
console.log(str)
};
}
}
let o = new Hello();
o.f() ==>"hello"
console.log(o.a) ==>"hello"
d、重载构造函数
@Test('hello')
class Hello {
constructor(){
this.a= 1
}
f(){
console.log('我是原始方法',this.a)
}
}
function Test(target) {
return class extends target{
f(){
console.log('我是装饰器方法',this.a)
}
}
}
let o = new Hello();
o.f() ==>"我是装饰器方法",1
结构型-适配器模式
适配器模式通过把一个类的接口变换成客户端所期待的另一种接口,可以帮我们解决不兼容的问题。
生活中的适配器
代码中的适配器
1.最简单的适配器
适配器模式没有想象中的那么复杂,举个最简单的例子。 客户端调用一个方法进行加法计算:
const result = add(1,2);
但是我们没有提供add这个方法,提供了同样类似功能的sum方法:
function sum(v1,v2){
return v1 + v2;
}
为了避免修改客户端和服务端,我们增加一个包装函数:
function add (v1,v2){
reutrn sum(v1,v2);
}
这就是一个最简单的适配器模式,我们在两个不兼容的接口之间添加一个包装方法,用这个方法来连接二者使其共同工作。
2.实际应用
如果现有的接口已经能够正常工作,那就永远不会用上适配器模式。适配器模式是一种“亡羊补牢”的模式,没有人会在程序的设计之初就使用它。因为没有人可以完全预料到未来的事情,也许现在好好工作的接口,未来的某天却不再适用于新系统,那么可以用适配器模式把旧接口包装成一个新的接口,使它继续保持生命力。比如在JSON格式流行之前,很多cgi返回的都是XML格式的数据,如果今天仍然想继续使用这些接口,显然可以创造一个XML-JSON的适配器
下面是一个实例,向googleMap和baiduMap都发出“显示”请求时,googleMap和baiduMap分别以各自的方式在页面中展现了地图:
const googleMap = {
show: function(){
console.log( '开始渲染谷歌地图' );
}
};
const baiduMap = {
show: function(){
console.log( '开始渲染百度地图' );
}
};
const gaodeMap = {
display: function(){
console.log( '开始渲染高德地图' );
}
};
const renderMap = function( map ){
if ( map.show instanceof Function ){
map.show();
}
};
renderMap( googleMap ); // 输出:开始渲染谷歌地图
renderMap( baiduMap ); // 输出:开始渲染百度地图
renderMap( gaodeMap ); // 输出:开始渲染百度地图
这段程序得以顺利运行的关键是googleMap和baiduMap、gaodeMap提供了一致的show方法,但第三方的接口方法并不在控制范围之内,但如果gaodeMap提供的显示地图的方法名改了,不叫show而改叫display呢?
gaodeMap这个对象来源于第三方,正常情况下都不应该去改动它。此时可以通过增加gaodeMapAdapter来解决问题:
const googleMap = {
show: function(){
console.log( '开始渲染谷歌地图' );
}
};
const baiduMap = {
show: function(){
console.log( '开始渲染百度地图' );
}
};
const gaodeMap = {
display: function(){
console.log( '开始渲染高德地图' );
}
};
const gaodeMapAdapter = {
show: function(){
return gaodeMap.display();
}
};
renderMap( googleMap ); // 输出:开始渲染谷歌地图
renderMap( baiduMap ); // 输出:开始渲染百度地图
renderMap( gaodeMapAdapter ); // 输出:开始渲染高德地图
又比如vue的computed
原有data 中的数据不满足当前的要求,通过计算属性的规则来适配成我们需要的格式,对原有数据并没有改变,只改变了原有数据的表现形式
<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>
总结
适配器模式的原理很简单,就是新增一个包装类,对新的接口进行包装以适应旧代码的调用,避免修改接口和调用代码。
结构型-代理模式
代理模式:为其他对象提供一种代理以控制对这个对象的访问。在某些情况下,一个对象不适合或者不能直接引用另一个对象,而代理对象可以在客户端和目标对象之间起到中介的作用。
在生活中,代理模式的场景是十分常见的,例如我们现在如果有租房、买房的需求,更多的是去找链家等房屋中介机构,而不是直接寻找想卖房或出租房的人谈。此时,链家起到的作用就是代理的作用。链家和他所代理的客户在租房、售房上提供的方法可能都是一致的(收钱,签合同),可是链家作为代理却提供了访问限制,让我们不能直接访问被代理的客户。
事件代理,可能是代理模式最常见的一种应用方式,也是一道实打实的高频面试题。它的场景是一个父元素下有多个子元素,像这样:
<body>
<div id="father">
<a href="#">链接1号</a>
<a href="#">链接2号</a>
<a href="#">链接3号</a>
<a href="#">链接4号</a>
<a href="#">链接5号</a>
<a href="#">链接6号</a>
</div>
</body>
我们现在的需求是,希望鼠标点击每个 a 标签,都可以弹出“我是xxx”这样的提示。比如点击第一个 a 标签,弹出“我是链接1号”这样的提示。这意味着我们至少要安装 6 个监听函数给 6 个不同的的元素(一般我们会用循环,代码如下所示),如果我们的 a 标签进一步增多,那么性能的开销会更大。
// 假如不用代理模式,我们将循环安装监听函数
const aNodes = document.getElementById('father').getElementsByTagName('a')
const aLength = aNodes.length
for(let i=0;i<aLength;i++) {
aNodes[i].addEventListener('click', function(e) {
e.preventDefault()
alert(`我是${aNodes[i].innerText}`)
})
}
考虑到事件本身具有“冒泡”的特性,当我们点击 a 元素时,点击事件会“冒泡”到父元素 div 上,从而被监听到。如此一来,点击事件的监听函数只需要在 div 元素上被绑定一次即可,而不需要在子元素上被绑定 N 次——这种做法就是事件代理,它可以很大程度上提高我们代码的性能。
事件代理的实现
用代理模式实现多个子元素的事件监听,代码会简单很多:
// 获取父元素
const father = document.getElementById('father')
// 给父元素安装一次监听函数
father.addEventListener('click', function(e) {
// 识别是否是目标子元素
if(e.target.tagName === 'A') {
// 以下是监听函数的函数体
e.preventDefault()
alert(`我是${e.target.innerText}`)
}
} )
在这种做法下,我们的点击操作并不会直接触及目标子元素,而是由父元素对事件进行处理和分发、间接地将其作用于子元素,因此这种操作从模式上划分属于代理模式。
Proxy
Vue2升级到Vue3的核心
es6增建了 MDN Proxy 对象用于创建一个对象的代理,从而实现基本操作的拦截和自定义(如属性查找、赋值、枚举、函数调用等),
总结
- 代理模式能将代理对象与被调用对象分离,降低了系统的耦合度。代理模式在客户端和目标对象之间起到一个中介作用,这样可以起到保护目标对象的作用
- 代理对象可以扩展目标对象的功能;通过修改代理对象就可以了,符合开闭原则;
- 无论是出于什么目的,这种模式的套路就只有一个—— A 不能直接访问 B,A 需要借助一个帮手来访问 B,这个帮手就是代理器。需要代理器出面解决的问题,就是代理模式发光发热的应用场景。
行为型-观察者模式
当对象间存在一对多关系时,则使用观察者模式。让多个观察者对象同时监听某一个主题对象,这个主题对象的状态发生变化时就会通知所有的观察者对象,使它们能够自动更新自己,当一个对象的改变需要同时改变其它对象,并且它不知道具体有多少对象需要改变的时候,就应该考虑使用观察者模式。
生活中的观察者模式
例子1:过年期间,老板说好在大年30当晚发红包,到了当天晚上,大家都已经做好了抢红包的准备,时刻等待着红包的降临。这个观察红包的过程,就是一个典型的观察者模式。
例子2:女神朋友圈官宣新男友。各位潜藏备胎纷纷失恋。
模式特征
- 一个目标者对象 Subject,拥有方法:添加 / 删除 / 通知 Observer;
- 多个观察者对象 Observer,拥有方法:接收 Subject 状态变更通知并处理;
- 目标对象 Subject 状态变更时,通知所有 Observer。
Subject 添加一系列 Observer, Subject 负责维护与这些 Observer 之间的联系,“你对我有兴趣,我更新就会通知你”。
代码实现
// 目标者类
class Subject {
constructor() {
this.observers = []; // 观察者列表
}
// 添加
add(observer) {
this.observers.push(observer);
}
// 删除
remove(observer) {
let idx = this.observers.findIndex(item => item === observer);
idx > -1 && this.observers.splice(idx, 1);
}
// 通知
notify() {
for (let observer of this.observers) {
observer.update();
}
}
}
// 观察者类
class Observer {
constructor(name) {
this.name = name;
}
// 目标对象更新时触发的回调
update() {
console.log(`她发消息了,我是:${this.name}`);
}
}
// 实例化目标者
let subject = new Subject();
// 实例化两个观察者
let obs1 = new Observer('男生A');
let obs2 = new Observer('男生B');
// 向目标者添加观察者
subject.add(obs1);
subject.add(obs2);
// 目标者通知更新
subject.notify();
// 输出:
// 她发消息了,我是男生A
// 她发消息了,我是男生B
优点
- 目标者与观察者,功能耦合度降低,专注自身功能逻辑;
- 观察者被动接收更新,时间上解耦,实时接收目标者更新状态。
缺点
- 过度使用会导致对象与对象之间的联系弱化,会导致程序难以跟踪维护和理解
行为型-迭代器模式
迭代器模式提供一种方法顺序访问一个聚合对象中的各个元素,而又不暴露该对象的内部表示。 ——《设计模式:可复用面向对象软件的基础》
迭代器模式其实就是为了让一切皆可遍历。
模式特点
- 为遍历不同数据结构的 “集合” 提供统一的接口;
- 能遍历访问 “集合” 数据中的项,不关心项的数据结构
模式实现
遍历作为一种合理、高频的使用需求,几乎没有语言会要求它的开发者手动去实现。在JS中,本身也内置了一个比较简陋的数组迭代器的实现——Array.prototype.forEach,我们这里来实现一个简易的forEach
// 统一遍历接口实现
var each = function(arr, callBack) {
for (let i = 0, len = arr.length; i < len; i++) {
// 将值,索引返回给回调函数callBack处理
if (callBack(i, arr[i]) === false) {
break; // 中止迭代器,跳出循环
}
}
}
// 外部调用
each([1, 2, 3, 4, 5], function(index, value) {
if (value > 3) {
return false; // 返回false中止each
}
console.log([index, value]);
})
// 输出:[0, 1] [1, 2] [2, 3]
“迭代器模式的核心,就是实现统一遍历接口。”
模式细分
- 内部迭代器 (jQuery 的 $.each / for...of)
- 外部迭代器 (ES6 的 yield)
内部迭代器
内部迭代器: 内部定义迭代规则,控制整个迭代过程,外部只需一次初始调用
// jQuery 的 $.each(跟上文each函数实现原理类似)
$.each(['小明', '小红', '小蓝'], function(index, value) {
console.log([index, value]);
});
// 输出:[0, 小明] [1, 小红] [2, 小蓝]
优点:调用方式简单,外部仅需一次调用 缺点:迭代规则预先设置,欠缺灵活性。无法实现复杂遍历需求(如: 同时迭代比对两个数组)
外部迭代器
外部迭代器: 外部显示(手动)地控制迭代下一个数据项
借助 ES6 新增的 Generator 函数中的 yield* 表达式来实现外部迭代器。
// ES6 的 yield 实现外部迭代器
function* generatorEach(arr) {
for (let [index, value] of arr.entries()) {
yield console.log([index, value]);
}
}
let each = generatorEach(['Angular', 'React', 'Vue']);
each.next();
each.next();
each.next();
// 输出:[0, 'Angular'] [1, 'React'] [2, 'Vue']
优点:灵活性更佳,适用面广,能应对更加复杂的迭代需求
缺点:需显示调用迭代进行(手动控制迭代过程),外部调用方式较复杂
适用场景
不同数据结构类型的 “数据集合”,需要对外提供统一的遍历接口,而又不暴露或修改内部结构时,可应用迭代器模式实现。
特点
- 访问一个聚合对象的内容而无需暴露它的内部表示。
- 为遍历不同的集合结构提供一个统一的接口,从而支持同样的算法在不同的集合结构上进行操作
总结
对于集合内部结果常常变化各异,不想暴露其内部结构的话,但又想让客户代码透明的访问其中的元素,可以使用迭代器模式
作者:Hyyy
链接:https://juejin.cn/post/7241114001323819063