写在前面
生活中的很多事做多了熟练以后经常能结出一些套路,使用套路能让事情的处理变得容易起来。而设计模式就是写代码的套路,好比剑客的剑谱,说白了就是一个个代码的模板。
就算从没看过设计模式的人工作中肯定多多少少也接触或不经意间就使用过某种设计模式。因为代码的原理都是一样的,实现某个需求的时候可能自然而然就以最佳的模式去实现了。
这些模式已经有前人做了整理总结,不仅可以直接拿来使用,在阅读开源库源码时也会发现这些库都大量的使用了设计模式。
本系列内容主要来自于对张容铭所著《JavaScript设计模式》一书的理解与总结(共6篇),由于文中有我自己的代码实现,并使用了部分新语法,抛弃了一些我认为繁琐的内容,甚至还有对书中 "错误" 代码的修正。所以如果发现我理解有误,代码写错的地方麻烦务必指出!非常感谢!
前置知识
掌握javaScript基础语法,并对js原理(特别是原型链)有较深理解。
创建型设计模式
一、简单工厂模式
又叫静态工厂方法,由一个工厂对象决定创建某一种产品对象类的实例。主要用来创建同一类对象。
创建一批相同作用不同基类的对象时,只对外提供一个统一的工厂方法去创建,而不是声明很多的类单独去创建,好处在于尽量减少了创建全局变量,利于团队开发。
方式一: 对不同的类实例化
// 篮球基类
function BasketBall() {
this.intro = '篮球流行于美国'
}
BasketBall.prototype = {
getMember() {
console.log('每个队伍需要5名队员')
}
}
// 足球基类
function FootBall() {
this.intro = '足球在世界范围内很流行'
}
FootBall.prototype = {
getMember() {
console.log('每个队伍需要11名队员')
}
}
// 网球基类
function Tennis() {
this.intro = '每年很多网球系列赛'
}
Tennis.prototype = {
getMember() {
console.log('每个队伍需要1名队员')
}
}
// 运动工厂
function SportFactory(type) {
switch(type) {
case 'basketball':
return new BasketBall()
case 'football':
return new FootBall()
case 'tennis':
return new Tennis()
}
}
复制代码
//使用
const football = SportFactory('football')
console.log(football.intro) // 足球在世界范围内很流行
football.getMember() // 每个队伍需要11名队员
// 相同基类的实例原型方法共用
const football2 = SportFactory('football')
console.log(football.getMember === football2.getMember) // true
复制代码
方式二:创建一个对象,并对对象拓展属性和方法(可以提取抽象公用部分)
function createSport(type, intro, menubar) {
const o = new Object()
// 公用部分
o.intro = intro
o.getMember = function() {
console.log('每个队伍需要' + menubar + '队员')
}
// 差异部分
if(type === 'basketball') {
// do sometion
o.nba = true
}
if(type === 'football') {
// do sometion
o.wordCup = true
}
if(type === 'tennis') {
// do sometion
o.frenchOpen = true
}
return o
}
复制代码
// 使用
const football = createSport('football', '足球在世界范围内很流行', 11)
console.log(football.intro) // 足球在世界范围内很流行
football.getMember() // 每个队伍需要11队员
console.log(football.wordCup) // true
console.log(football.nba) // undefined
// 每个创建出来的对象都是新的个体,方法不共用
const football2 = createSport('football', '足球在世界范围内很流行', 11)
console.log(football.getMember === football2.getMember) // false
复制代码
方式一与方式二的主要差异在于:通过基类创建还是通过一个全新的对象创建。方式一通过基类创建的同类对象继承自原型的方法可以共用,而方式二不可以。
二、工厂方法模式
通过对产品类的抽象使其创建业务主要负责用于创建多类产品的实例。
假设采用简单工厂的方式一进行开发时,面对新增的需求可能会不断新增基类,而每新增一个基类不仅需要编写基类,还需要对工厂方法本身进行修改,也就是新增一个类需要修改两处,而工厂方法模式可以解决这个问题。
// 工厂类
function SportFactory(type, content) {
const instance = new this[type](content)
return instance
}
// 在工厂原型上设置所有基类
SportFactory.prototype = {
BasketBall: function(content) {
this.content = content
// 其他业务逻辑
console.log('篮球在美国流行:' + content)
},
FootBall: function(content) {
this.content = content
// 其他业务逻辑
console.log('国足夺得世界杯:' + content)
},
Tennis: function(content) {
this.content = content
// 其他业务逻辑
console.log('纳达尔是顶级球星:' + content)
}
}
复制代码
// 使用
const array = [
{type: 'BasketBall', content: '篮球一个队伍需要5人'},
{type: 'FootBall', content: '足球一个队伍需要11人'},
{type: 'FootBall', content: '室内足球一个队伍需要5人'},
{type: 'Tennis', content: '网球一个队伍一人'},
]
for(let item of array) {
new SportFactory(item.type, item.content)
// 篮球在美国流行:篮球一个队伍需要5人
// 国足夺得世界杯:足球一个队伍需要11人
// 国足夺得世界杯:室内足球一个队伍需要5人
// 纳达尔是顶级球星:网球一个队伍一人
}
复制代码
通过这种方式定义工厂方法,再想添加基类时只需要写在工厂的原型中就可以了,工厂函数都可以找到对应的基类。
示例代码只是用简单的内容演示工厂方法模式的原理,工厂方法适用于基类间有一定共性,却又各自有较大不同的逻辑。比如在页面上创建一个按钮,但按钮的类型有很多,各自的功能都不相同。
tips:安全模式类
书中在本章提到一种安全创建类的方式,并采用这种方式声明工厂方法,原因是当构造函数没有使用new关键字调用时不仅不能创建实例,还会声明全局变量。通过这种方式可以保证无论使用new关键字还是直接调用构造函数,都可以达到预期目的。
function Demo() {
if(this instanceof Demo) {
this.test = true
} else {
return new Demo
}
}
const d1 = new Demo()
const d2 = Demo()
console.log(d1.test) // true
console.log(d2.test) // true
复制代码
我不采用这种方式定义类因为我认为一个类一会使用new关键字一会直接调用,反而不利于团队代码协作与维护,其二在es6中可以直接使用class关键字定义类规避此问题。
tips: 不是所有函数都可以当做构造函数
一直只知道箭头函数不能用作构造函数,一开始我是这样在工厂方法的原型上定义基类的。
SportFactory.prototype = {
BasketBall(content) {
this.content = content
// 其他业务逻辑
console.log('篮球在美国流行:' + content)
}
// ...
}
复制代码
结果不断报错,排查后发现是因为使用了对象属性的简洁表示法。按理说属性的简洁表示法仅仅只是简写而已,其他并无区别,但经过查阅了解到简写方式的函数确实不能用作构造函数。
三、抽象工厂模式
通过对类的工厂抽象,使其业务用于对产品类簇的抽象,而不负责某一类产品的实例。
js中的抽象类
不用于创建实例,只用来继承的类称为抽象类
js中本身不存在抽象类,可以通过以下方式模拟抽象类。
function Car() {}
Car.prototype = {
getPrice() {
return new Error('抽象方法不能调用')
},
getSpeed() {
return new Error('抽象方法不能调用')
}
}
复制代码
Car类没有任何属性,原型上的方法也不能使用,但在继承上却很有用,因为定义了一种类,并定义了该类所必备的方法(子类在继承时重写)。
抽象类的作用之一:定义一个产品簇,并声明一些必备的方法,如果子类没有重写就抛出错误。
抽象工厂方法
抽象类说白了就是类的类,定义一个抽象类其实就相当于ts中定义了一个接口,然后让子类继承抽象类相当于用这个接口去约束子类。
比如轿车可以按品牌分为很多类。
// 宝马
function BMW(price, speed) {
this.price = price
this.speed = speed
}
// 奥迪
function Audi(price, speed) {
this.price = price
this.speed = speed
}
// ...等等
复制代码
如果希望给所有品牌的类定义一个总的Car类(抽象类),约束这些子类都有共同的type和两个方法。
function Car() {
this.type = 'car'
}
Car.prototype = {
getPrice() {
return new Error('抽象方法不能调用')
},
getSpeed() {
return new Error('抽象方法不能调用')
}
}
复制代码
抽象工厂方法就可以派上用场,它的作用就是使BMW, Audi这些子类去继承Car。
首先定义抽象工厂方法以及抽象类Car。
// subType就是要继承父类的子类(BMW,Audi)
// superTypeName是父类的标识('Car')
function abstructFactory(subType, superTypeName) {
// 创建过渡类
function F() {}
// 将过渡类的原型赋值为父类的实例
F.prototype = new abstructFactory[superTypeName]()
// 将子类的原型赋值为过渡类的实例
subType.prototype = new F()
// 由于过渡类的原型是父类的实例,所以过渡类的实例继承了父类的属性与方法
// 而子类的原型是过渡类的实例,则子类的实例也会继承父类的属性与方法
}
// 将抽象类定义在工厂方法的属性上
abstructFactory.Car = function() {
this.type = 'car'
}
abstructFactory.Car.getPrice = function() {
return new Error('抽象方法不能调用')
}
abstructFactory.Car.getSpeed = function() {
return new Error('抽象方法不能调用')
}
复制代码
然后定义子类,并使用抽象工厂方法使子类继承父类。
// 定义子类BMW
function BMW(price, speed) {
this.price = price
this.speed = speed
}
// 子类BMW继承父类Car
abstructFactory(BMW, 'Car')
// 子类实现父类上的方法
BMW.prototype.getPrice = function() {
console.log(this.price)
}
BMW.prototype.getSpeed = function() {
console.log(this.speed)
}
// 结果
const bmw = new BMW('30w', '1.5t')
console.log(bmw.type) // car
bmw.getPrice() // 30w
bmw.getSpeed() // 1.5t
console.log(bmw.__proto__.constructor) // F(){}
复制代码
可以在最后一行console中发现一个问题,bmw实例原型对象的constructor指向的是过渡函数F,而不是其构造函数BMW。
在抽象工厂方法中添加一行代码修正指向:
function abstructFactory(subType, superTypeName) {
// 创建过渡类
function F() {}
// 将过渡类的原型赋值为父类的实例
F.prototype = new abstructFactory[superTypeName]()
// 修正子类原型对象constructor的指向
F.prototype.constructor = subType
// 将子类的原型赋值为过渡类的实例
subType.prototype = new F()
}
复制代码
// 再次打印,指向正确
console.log(bmw.__proto__.constructor) // BMW(price, speed){}
复制代码
使用抽象工厂可以统一管理同一类的抽象类,比如除了轿车抽象类Car,还可以定义公交车抽象类Bus,货车抽象类Truck,每个抽象类约束不同子类需要实现的不同方法。
// Car
abstructFactory.Car = function() {
this.type = 'car'
}
abstructFactory.Car.getPrice = function() {
return new Error('抽象方法不能调用')
}
abstructFactory.Car.getSpeed = function() {
return new Error('抽象方法不能调用')
}
// Bus
abstructFactory.Bus = function() {
this.type = 'bus'
}
abstructFactory.Bus.getPrice = function() {
return new Error('抽象方法不能调用')
}
abstructFactory.Bus.getBusRoute = function() {
return new Error('抽象方法不能调用')
}
// Truck
abstructFactory.Truck = function() {
this.type = 'truck'
}
abstructFactory.Truck.getPrice = function() {
return new Error('抽象方法不能调用')
}
abstructFactory.Truck.getTrainload = function() {
return new Error('抽象方法不能调用')
}
复制代码
抽象工厂模式是设计模式中最抽象的一种,该模式创建出的结果不是一个真实的对象实例,而是一个类簇,它制定了类的结构。
tips:代码差异
上文中的抽象工厂方法与书中有一行差异:
function abstructFactory(subType, superTypeName) {
function F() {}
F.prototype = new abstructFactory[superTypeName]()
subType.constructor = subType // 书中原文的代码
// F.prototype.constructor = subType 上文修改后的代码
subType.prototype = new F()
}
复制代码
关于 'subType.constructor = subType' 这一行代码我百思不得其解,上网搜查后发现不少人也对此提出了疑问:
这个回答中认为书中代码没有错误,并进行了解释,但我实在没看懂他表达的意思,反而我觉得他好像把类的constructor与类的原型对象的constructor搞混了。
这个博主认为确实是书中代码有误,并觉得这里作者本身就是想修正子类原型对象的constructor指向问题,应该这样写:
function abstructFactory(subType, superTypeName) {
function F() {}
F.prototype = new abstructFactory[superTypeName]()
// 先对子类的原型赋值
subType.prototype = new F()
// 再修改子类原型的constructor
subType.prototype.constructor = subType
}
复制代码
但这种方式意味着原文中不仅一行代码有误,连代码的顺序也有误,因为如果先修正原型对象的constructor指向,赋值后就变得没有意义。
我认为不太可能同时出现连续两个的低级错误,我猜测作者原本就是想使用寄生式继承,既不改变代码顺序,也实现了修正子类原型对象的constructor指向,所以最终将抽象工厂方法定义为上文中的写法。
四、建造者模式
将一个复杂对象的构建层与其表示层相互分离,同样的构建过程可采用不同的表示。
一个复杂的对象(复合对象)中可能会存在一些较为独立的部分,这些独立的部分组合起来形成一个复杂的个体,建造者模式就是将这些独立的部分独立维护,最后再组装起来。
比如处理应聘者信息时,创建一个应聘者:
- 首先应该创建一个人:person,并包含姓名这样的基础信息:
person.fullName
- 另外包含有一些比较隐私的信息应该分开处理:
person.privacy.xxx
- 另外职位信息也是一个比较独立的部分:
person.work.xxx
最终这些信息组合起来才能形成一个完整的应聘者。
// 基础的 "人" 类
function Man(name) {
this.fullName = name
if(name.split(' ').length > 0) {
this.firstName = name.split(' ')[0]
this.secondName = name.split(' ')[1]
}
}
// 隐私信息类
function Privacy(age, sex) {
this.age = age || '保密'
this.sex = sex || '未知'
}
Privacy.prototype = {
getAge() {
console.log(this.age)
},
getSex() {
console.log(this.sex)
}
}
// 职位类
function Work(workType) {
switch(workType) {
case 'code':
this.job = '程序员'
this.jobDesc = '程序员掉头发'
break
case 'ui':
this.job = '设计师'
this.jobDesc = 'ui有很多小姐姐'
break
case 'product':
this.job = '产品经理'
this.jobDesc = '产品很烦人'
break
default:
this.job = '暂无此职位'
this.jobDesc = '暂无职位描述'
}
}
// 应聘者建造者
function Person(name, age, sex, workType) {
// 创建应聘者
const _person = new Man(name)
// 添加隐私信息
_person.privacy = new Privacy(age, sex)
// 添加职位信息
_person.work = new Work(workType)
// 返回组合完成的应聘者
return _person
}
复制代码
// 使用
const p = new Person('王狗蛋', '', '男', 'code')
console.log(p.fullName) // 王狗蛋
p.privacy.getAge() // 保密
p.privacy.getSex() // 男
console.log(p.work.jobDesc) // 程序员掉头发
复制代码
建造者模式通常将创建对象的类模块化,这样使被创建的类的每一个模块都可以得到灵活的运用与高质量哦复用。但这种方式对于整体对象的拆分无形中也增加了结构的复杂性。
五、原型模式
用原型实例指向创建对象的类,使用于创建新的对象的类共享原型对象的属性以及方法。
简单来说就是利用类的实例会共享类的原型对象(包括原型链向上寻找的所有原型对象)的特性,共享方法和属性,再对个别子类中需要重写的部分进行重写,避免不必要消耗。
就像无论继承了多少层父类的对象调用toString()
方法时,都不用在创建对象时再初始化这个方法,调用的都是Object.prototype
上的,除非在中间的某一层重写。
实现需求
假设需求在页面上创建轮播图,轮播图既有共同的属性与方法,也根据不同的种类有所差异,比如样式不同,切换方式不同等,可以这样定义:
// 基础轮播图类
function LoopImgs(imgArray, container) {
this.imgArray = imgArray // 轮播图片数组
this.container = container // 轮播图容器
// 创建轮播图
this.createImg = function() {
console.log('基础创建轮播图')
}
// 切换下一张图片
this.changeImg = function() {
console.log('基础左右滑动切换轮播图')
}
}
// 上下滑动切换轮播图类
function SlideLoopImgs(imgArray, container) {
// 继承基础轮播图类
LoopImgs.call(this, imgArray, container)
// 重写切换图片方法
this.changeImg = function() {
console.log('上下滑动切换轮播图')
}
}
// 点击箭头切换轮播图类
function ArrowLoopImgs(imgArray, container, arrow) {
// 继承基础轮播图类
LoopImgs.call(this, imgArray, container)
// 子类的独有属性
this.arrow = arrow
// 重写切换图片方法
this.changeImg = function() {
console.log('点击箭头切换轮播图')
}
}
复制代码
// 使用
const slideLoop = new SlideLoopImgs(['img1.png', 'img2.png'], '.container1')
const arrowLoop = new ArrowLoopImgs(['img1.png', 'img2.png'], '.container2', ['left-arrow.png', 'right-arrow.png'])
slideLoop.createImg() // 基础创建轮播图
slideLoop.changeImg() // 上下切换轮播图
arrowLoop.createImg() // 基础创建轮播图
arrowLoop.changeImg() // 点击箭头切换轮播图
复制代码
使用原型模式优化
上面的代码确实实现了需求,但是可以发现每次创建子类的实例时都会在父类的构造函数中做一些重复性的工作,如果父类的构造函数中存在一些复杂的逻辑,会造成很多不必要的消耗。
所以可以将这些重复性的工作放在父类的原型对象上,再将子类的原型对象赋值为父类的实例。
// 基础轮播图类
function LoopImgs(imgArray, container) {
this.imgArray = imgArray // 轮播图片数组
this.container = container // 轮播图容器
}
LoopImgs.prototype = {
// 创建轮播图
createImg() {
console.log('基础创建轮播图')
},
// 切换下一张图片
changeImg() {
console.log('基础左右滑动切换轮播图')
}
}
// 上下滑动切换轮播图类
function SlideLoopImgs(imgArray, container) {
// 继承轮播图类
LoopImgs.call(this, imgArray, container)
}
SlideLoopImgs.prototype = new LoopImgs()
// 重写切换图片方法
SlideLoopImgs.prototype.changeImg = function() {
console.log('上下切换轮播图')
}
// 点击箭头切换轮播图类
function ArrowLoopImgs(imgArray, container, arrow) {
// 继承轮播图类
LoopImgs.call(this, imgArray, container)
// 子类的独有属性
this.arrow = arrow
}
ArrowLoopImgs.prototype = new LoopImgs()
// 重写切换图片方法
ArrowLoopImgs.prototype.changeImg = function() {
console.log('点击箭头切换轮播图')
}
复制代码
如此一来便共享了原型上的方法,同时由于原型对象是共享的,所以无论是父类还是子类的原型对象都可以进行拓展。
// 使用并拓展
const slideLoop = new SlideLoopImgs(['img1.png', 'img2.png'], '.container1')
const arrowLoop = new ArrowLoopImgs(['img1.png', 'img2.png'], '.container2', ['left-arrow.png', 'right-arrow.png'])
slideLoop.createImg() // 基础创建轮播图
slideLoop.changeImg() // 上下切换轮播图
arrowLoop.createImg() // 基础创建轮播图
arrowLoop.changeImg() // 点击箭头切换轮播图
// 对父类原型拓展
LoopImgs.prototype.getImgsLength = function() {
console.log(this.imgArray.length)
}
// 对子类原型拓展
SlideLoopImgs.prototype.getImgContainer = function() {
console.log(this.container)
}
slideLoop.getImgsLength() // 2
slideLoop.getImgContainer() // .container1
复制代码
原型模式是javaScript语言的灵魂,在js中很多面向对象编程思想或者设计模式都是基于原型模式继承实现的。
原型继承思想的运用
原型模式的核心思想是对类的原型对象赋值,并不是只能通过父类子类的方式。
有时业务复杂时可以通过创建多个对象实现,此时最好不要用new关键字去赋值基类,而是定义一个原型模式的对象复制方法。
function prototypeExtend(...args) {
// 创建过渡类
function F() {}
// 合并模板对象到原型对象(浅拷贝,也可以根据需要深拷贝)
F.prototype = Object.assign({}, ...args)
// 返回过渡类实例
return new F()
}
复制代码
比如在企鹅游戏中创建一个企鹅对象,如果没有企鹅基类,只提供一些动作对象模板,又想要一份独立的原型对象,就可以通过原型模式的对象复制方法实现对这些对象模板的继承,并创建实例:
// 使用
const penguin = prototypeExtend(
{
speed: 20,
run() {
console.log('跑步速度:' + this.speed)
}
},
{
swim() {
console.log('游泳速度:' + this.speed)
}
},
{
jumo() {
console.log('跳跃')
}
}
)
penguin.run() // 跑步速度:20
penguin.swim() // 游泳速度:20
penguin.jumo() // 跳跃
复制代码
六、单例模式
又称为单体模式,是只允许实例化一次的对象类。有时我们也用一个对象来规划一个命名空间,井井有条的管理对象上的属性与方法。
命名空间(namespace)
将变量或方法暴露在全局中,多人协作或者维护代码时可能会造成命名重复问题,可以使用命名空间约束每个人定义的变量。就像使用jquery时,所有方法都通过jQuery
这个命名空间访问,不会对别的代码产生影响。
// 小王的命名空间
const Wang = {
fn1() {
console.log('wang: fn1')
},
fn2() {
this.fn1()
}
}
// 小李的命名空间
const Li = {
fn1() {
console.log('li: fn1')
},
fn2() {
this.fn1()
}
}
Wang.fn2() // wang: fn1
Li.fn2() // li: fn1
复制代码
模块分明
单例模式除了定义命名空间,还可以管理命名空间中的各个模块,是的代码库的结构更加清晰,方便使用。
比如定制一个小型代码库:
const Wang = {
Tools: {
tool_fn1() {},
tool_fn2() {},
},
Message: {
success() {},
warning() {},
danger() {}
},
Ajax: {
get() {},
post() {}
}
}
复制代码
无法改变的静态变量
单例模式用来创建一个只能访问,不能修改,且创建后就能使用的变量也很合适。
const Conf = (function() {
const conf = {
MAX_NUM: 100,
MIN_NUM: 10
}
return {
getMax() {
return conf.MAX_NUM
},
getMin() {
return conf.MIN_NUM
}
}
})()
console.log(Conf.getMax()) // 100
console.log(Conf.getMin()) // 10
复制代码
惰性单例
比如需求是创建一个人,只允许创建一次,之后再调用不再创建新的人物。
const LazySingle = (function() {
let _instance = null
function Single(name, age) {
this.name = name
this.age = age
}
return function(name, age) {
if(!_instance) {
_instance = new Single(name, age)
}
return _instance
}
})()
复制代码
// 测试
const p1 = LazySingle('王狗蛋', 18)
console.log(p1.name) // 王狗蛋
console.log(p1.age) // 18
const p2 = LazySingle('李富贵', 68)
console.log(p2.name) // 王狗蛋
console.log(p2.age) // 18
console.log(p1 === p2) // true
复制代码