Appearance
JavaScript 设计模式核心原理与应用实践
技术进步
人作为具有主观能动性的生物,是始终处于动态中的,是会不断发展变化的。你过去的失误和当下的处境不能代表你,更不能定义你的未来。我接触过的很多同事,包括我自己也是从在小团队写jQuery做起,一步一步成长起来的。所以说没关系,别丧气,“没有坑,就先让自己成为萝卜”。
如何成为“萝卜”?这又是另一个巨大的命题。笔者相信每个人都有属于他自己的变强的方法,从这个角度来说,笔者没有立场插手任何一位同学的成长。但如果你确实处于职业生涯的焦虑期,渴望变强却暂时还不知道怎么做,不妨试试从这三件事开始做起:
- 找人。找出你所在团队/圈子里最厉害的人,和他保持技术/工作上的交流,尝试争取/创造和他共事的机会。如果你身边没有这样的人,优秀的技术社区(比如掘金)里一定有。996无法使你迅速成长,但和大牛一起解决问题必定使你受益良多。
- 阅读。读好的书,更要读好的代码。我们平时使用频率最高的那些库和框架,就是最好的阅读材料。静下心,不要急。读不下去是正常的,多试几次——学习的本质不就是在不断的重复中形成自己的理解吗?
- 不挑剔。干活的时候,不要挑简单的做;读书的时候,不要挑“xx21天迅速上手”这样的读——容易的事情任谁都做得来,但日日如此,自己或许也只能沦为芸芸众生中极为平庸而懦弱的那一个。
设计模式的定位
设计模式这玩意儿,和算法一样,是许多非科班同学的软肋,而很多公司恰恰就喜欢用这些东西来淘汰非科班的受试者。一些半路出家的前端会给自己扣上这样一顶帽子——我擅长动手,不喜欢理论,所以我不学理论。这种想法并不酷,它往往是出于恐惧,是一种对知识的逃避。
如何学设计模式
学设计模式,一在多读——读源码、读资料、读好书;二在多练——把你学到的东西还原到业务开发里去,看看它是否OK,有没有问题。如果有问题,如何修复、如何优化?没有一种设计模式是完美的,设计模式和人一样,处在动态发展的过程中。并不是只有GOF提出的23种设计模式可以称之为设计模式,只要一种方案遵循了设计原则、解决了一类问题,那么它都可以被冠以“设计模式”的殊荣。
设计模式的核心思想——封装变化
设计模式出现的背景,是软件设计的复杂度日益飙升。软件设计越来越复杂的“罪魁祸首”,就是变化。
这一点相信大家不难理解——如果说我们写一个业务,这个业务是一潭死水,初始版本是 1.0,100 年后还是 1.0,不接受任何迭代和优化,那么这个业务几乎可以随便写。反正只要实现功能就行了,完全不需要考虑可维护性、可扩展性。
但在实际开发中,不发生变化的代码可以说是不存在的。我们能做的只有将这个变化造成的影响最小化 —— 将变与不变分离,确保变化的部分灵活、不变的部分稳定。
这个过程,就叫“封装变化”;这样的代码,就是我们所谓的“健壮”的代码,它可以经得起变化的考验。而设计模式出现的意义,就是帮我们写出这样的代码。
封装变化,封装的正是软件中那些不稳定的要素,它是一种防患于未然的行为 —— 提前抽离了变化,就为后续的拓展提供了无限的可能性,如此,我们才能做到在变化到来的时候从容不迫。
核心操作
设计模式的核心操作是去观察你整个逻辑里面的变与不变,然后将变与不变分离,达到使变化的部分灵活、不变的地方稳定的目的。我们本节就来验证一下这个思想。
构造器模式
像 User 这样当新建对象的内存被分配后,用来初始化该对象的特殊函数,就叫做构造器。在 JavaScript 中,我们使用构造函数去初始化对象,就是应用了构造器模式。
在创建一个user过程中,谁变了,谁不变?
很明显,变的是每个user的姓名、年龄、工种这些值,这是用户的个性,不变的是每个员工都具备姓名、年龄、工种这些属性,这是用户的共性。
构造器是不是将 name、age、career 赋值给对象的过程封装,确保了每个对象都具备这些属性,确保了共性的不变,同时将 name、age、career 各自的取值操作开放,确保了个性的灵活?
工厂模式
工厂模式的目的,就是为了实现无脑传参,就是为了爽!
系统录入的信息也太简单了,程序员和产品经理之间的区别一个简单的career
字段怎么能说得清?我要求这个系统具备给不同工种分配职责说明的功能。也就是说,要给每个工种的用户加上一个个性化的字段,来描述他们的工作内容。
js
function Coder(name , age) {
this.name = name
this.age = age
this.career = 'coder'
this.work = ['写代码','写系分', '修Bug']
}
function ProductManager(name, age) {
this.name = name
this.age = age
this.career = 'product manager'
this.work = ['订会议室', '写PRD', '催更']
}
function Factory(name, age, career) {
switch(career) {
case 'coder':
return new Coder(name, age)
break
case 'product manager':
return new ProductManager(name, age)
break
...
}
的是什么?不变的又是什么?
Coder 和 ProductManager 两个工种的员工,是不是仍然存在都拥有 name、age、career、work 这四个属性这样的共性?它们之间的区别,在于每个字段取值的不同,以及 work 字段需要随 career 字段取值的不同而改变。这样一来,我们是不是对共性封装得不够彻底?那么相应地,共性与个性是不是分离得也不够彻底?
js
function User(name , age, career, work) {
this.name = name
this.age = age
this.career = career
this.work = work
}
function Factory(name, age, career) {
let work
switch(career) {
case 'coder':
work = ['写代码','写系分', '修Bug']
break
case 'product manager':
work = ['订会议室', '写PRD', '催更']
break
case 'boss':
work = ['喝茶', '看报', '见客户']
case 'xxx':
// 其它工种的职责分配
...
return new User(name, age, career, work)
}
现在我们一起来总结一下什么是工厂模式:工厂模式其实就是将创建对象的过程单独封装。它很像我们去餐馆点菜:比如说点一份西红柿炒蛋,我们不用关心西红柿怎么切、怎么打鸡蛋这些菜品制作过程中的问题,我们只关心摆上桌那道菜。在工厂模式里,我传参这个过程就是点菜,工厂函数里面运转的逻辑就相当于炒菜的厨师和上桌的服务员做掉的那部分工作——这部分工作我们同样不用关心,我们只要能拿到工厂交付给我们的实例结果就行了。
什么时候用工厂模式
将创建对象的过程单独封装,这样的操作就是工厂模式。同时它的应用场景也非常容易识别:有构造函数的地方,我们就应该想到简单工厂;在写了大量构造函数、调用了大量的 new、自觉非常不爽的情况下,我们就应该思考是不是可以掏出工厂模式重构我们的代码了。
学习态度
现在我们回到开篇抛出的那个问题——抽象工厂对于各位而言的价值是什么?这么一个看似鸡肋、其实也确实不怎么常用的一个设计模式,凭什么值得我们花这么大力气去理解它?原因有三:
其一: 开篇我们说过,前端工程师首先是软件工程师。只会写 JavaScript、只理解 JavaScript、只通过 JavaScript 去理解软件世界,是一件可怕的事情,它会窄化你的技术视野——因为 JavaScript 只是编程语言中的一个分支,准确地说,它是一个后辈。虽说它确实很流行,但它还不够强大(正是因为不够强大,所以在演化发展的过程中必然需要借鉴其它优秀语言的优秀特性,也会渐渐遇到其它语言的应用场景,不信大家看看 ES6789 都做了什么,再看看遍地开花的 TypeScript)。
但写这本小册并不是为了把大家指去学 Java/C++,而是为了以最小的时间成本帮大家去理解设计模式的套路和原则。比起要求大家为了这个设计模式去理解强类型语言、去理解强类型语言里的应用场景,我更希望能在这儿用 JavaScript 把这个东西给说清楚,把那些关键的设计模式概念在这儿给大家引出来——哪怕你当下用到它的场景还不是那么多(相信以当下前端语言和前端应用的发展速度和发展趋势来看,它会有用的:))。
其二: 在大家今后的职业生涯里,可能会不止一次地遇到服务端/客户端出身、或者单纯对受试者知识广度有疯狂执念的各种不同背景不同脑回路的面试官。在他们的世界里,不知道抽象工厂就像不知道 this
一样恐怖:)。所以,要学。
其三: 也是最重要的一点。前面我们说过,设计模式的“术”说到底是在佐证它的“道”。充分理解了设计原则后,设计模式纵有 1w 种也难不倒大家。抽象工厂是佐证“开放封闭原则”的良好素材,通过本节的学习,相信大家会对这个抽象的概念有更加具体和感性的认知。在后面的章节中,“开放封闭”作为各位的老朋友,会被反复提及。有了本节的平稳过渡,相信大家在后续的学习中可以真正做到心中有数、游刃有余。
说了这么多,无非是想传达给大家一个学习态度:不要小看那些看似“无用”的知识。
抽象生产模式
抽象工厂不干活,具体工厂(ConcreteFactory)来干活!当我们明确了生产方案,明确某一条手机生产流水线具体要生产什么样的手机了之后,就可以化抽象为具体
抽象工厂:
javascript
class MobilePhoneFactory {
// 提供操作系统的接口
createOS(){
throw new Error("抽象工厂方法不允许直接调用,你需要将我重写!");
}
// 提供硬件的接口
createHardWare(){
throw new Error("抽象工厂方法不允许直接调用,你需要将我重写!");
}
}
比如我现在想要一个专门生产 Android 系统 + 高通硬件的手机的生产线,我给这类手机型号起名叫 FakeStar,那我就可以为 FakeStar 定制一个具体工厂:
javascript
// 具体工厂继承自抽象工厂
class FakeStarFactory extends MobilePhoneFactory {
createOS() {
// 提供安卓系统实例
return new AndroidOS()
}
createHardWare() {
// 提供高通硬件实例
return new QualcommHardWare()
}
}
当我们需要生产的时候
javascript
const myPhone = new FakeStarFactory()
// 让它拥有操作系统
const myOS = myPhone.createOS()
// 让它拥有硬件
const myHardWare = myPhone.createHardWare()
// 启动操作系统(输出‘我会用安卓的方式去操作硬件’)
myOS.controlHardWare()
// 唤醒硬件(输出‘我会用高通的方式去运转’)
myHardWare.operateByOrder()
如果有一天 之前的手机型号淘汰了 我想生产一部新机
那么我们无需把所有的代码全部改变
只要在加一个继承MobilePhoneFactory抽象工厂的具体的工厂模式
javascript
class newStarFactory extends MobilePhoneFactory {
createOS() {
// 操作系统实现代码
return new AppleOS()
}
createHardWare() {
// 硬件实现代码
return new MiWare()
}
}
生产新型号的手机
javascript
const myPhone = new newStarFactory()
下面的代码可以复用不用任何更改
这么个操作,对原有的系统不会造成任何潜在影响 所谓的“对拓展开放,对修改封闭”就这么圆满实现了。前面我们之所以要实现抽象产品类,也是同样的道理。
而例子中的AndroidOS和QualcommHardWare的类也可以用工厂模式来定义,这样我们再加一个系统或者升级硬件就可以在此基础上增加
操作系统的抽象的工厂模式
javascript
// 定义操作系统这类产品的抽象产品类
class OS {
controlHardWare() {
throw new Error('抽象产品方法不允许直接调用,你需要将我重写!');
}
}
// 定义具体操作系统的具体产品类
class AndroidOS extends OS {
controlHardWare() {
console.log('我会用安卓的方式去操作硬件')
}
}
class AppleOS extends OS {
controlHardWare() {
console.log('我会用🍎的方式去操作硬件')
}
}
硬件的抽象工厂模式
javascript
// 定义手机硬件这类产品的抽象产品类
class HardWare {
// 手机硬件的共性方法,这里提取了“根据命令运转”这个共性
operateByOrder() {
throw new Error('抽象产品方法不允许直接调用,你需要将我重写!');
}
}
// 定义具体硬件的具体产品类
class QualcommHardWare extends HardWare {
operateByOrder() {
console.log('我会用高通的方式去运转')
}
}
class MiWare extends HardWare {
operateByOrder() {
console.log('我会用小米的方式去运转')
}
}
抽象工厂和简单工厂的异同
它们的共同点,在于都尝试去分离一个系统中变与不变的部分。它们的不同在于场景的复杂度。
简单工厂
在简单工厂的使用场景里,处理的对象是类,并且是一些非常好对付的类——它们的共性容易抽离,同时因为逻辑本身比较简单,故而不苛求代码可扩展性。
抽象工厂
抽象工厂本质上处理的其实也是类,但是是一帮非常棘手、繁杂的类,这些类中不仅能划分出门派,还能划分出等级,同时存在着千变万化的扩展可能性.这使得我们必须对共性作更特别的处理、使用抽象类去降低扩展的成本,同时需要对类的性质作划分,于是有了这样的四个关键角色:
- 抽象工厂(抽象类,它不能被用于生成具体实例): 用于声明最终目标产品的共性。在一个系统里,抽象工厂可以有多个(大家可以想象我们的手机厂后来被一个更大的厂收购了,这个厂里除了手机抽象类,还有平板、游戏机抽象类等等),每一个抽象工厂对应的这一类的产品,被称为“产品族”。
- 具体工厂(用于生成产品族里的一个具体的产品): 继承自抽象工厂、实现了抽象工厂里声明的那些方法,用于创建具体的产品的类。
- 抽象产品(抽象类,它不能被用于生成具体实例): 上面我们看到,具体工厂里实现的接口,会依赖一些类,这些类对应到各种各样的具体的细粒度产品(比如操作系统、硬件等),这些具体产品类的共性各自抽离,便对应到了各自的抽象产品类。
- 具体产品(用于生成产品族里的一个具体的产品所依赖的更细粒度的产品): 比如我们上文中具体的一种操作系统、或具体的一种硬件等。
抽象工厂对应上述MobilePhoneFactory
具体工厂对应 扩展的MobilePhoneFactory()如FakeStarFactory和newStarFactory
抽象产品如OS和HardWare
具体产品AndroidOS,AppleOS QualcommHardWare,MiWare
定义好了工厂 就需要流程
这样只要流程固定,要新开产品的话 只要更换具体工厂 流程是不需要变化的 这样也就提高了流程代码复用性
javascript
const myPhone = new FakeStarFactory() //FakeStarFactory可根据情况替换成具体工厂
// 下述流程不会变化
// 让它拥有操作系统
const myOS = myPhone.createOS()
// 让它拥有硬件
const myHardWare = myPhone.createHardWare()
// 启动操作系统(输出‘我会用安卓的方式去操作硬件’)
myOS.controlHardWare()
// 唤醒硬件(输出‘我会用高通的方式去运转’)
myHardWare.operateByOrder()
也就是在前端逻辑领域 两个是重点关注的
一个是页面状态 和 事件 无论任何页面都离不开这个 很多的页面状态的数据可以与页面相互影响 然后事件可以改变页面数据
在后端更重要的是
工厂和流程 很多时候流程可以复用 只需要增加工厂就可以 也就是改一下定义不需要改逻辑
单例模式的概念
保证一个类仅有一个实例,并提供一个访问它的全局访问点,这样的模式就叫做单例模式。
单例模式有什么作用
这种设计使得在整个 Vue 应用中,所有组件都能方便地访问同一个 Store 实例。这有助于在整个应用范围内维护一致的状态管理,降低了程序的复杂性。
单例模式实现思路
单例模式想要做到的是,不管我们尝试去创建多少次,它都只给你返回第一次所创建的那唯一的一个实例。
单看它的实现,思考这样一个问题:如何才能保证一个类仅有一个实例?
普通的创建类的实例
javascript
class SingleDog {
show() {
console.log('我是一个单例对象')
}
}
const s1 = new SingleDog()
const s2 = new SingleDog()
// false
s1 === s2
单例模式就需要构造函数具备判断自己是否已经创建过一个实例的能力。我们现在把这段判断逻辑写成一个静态方法(其实也可以直接写入构造函数的函数体里):
javascript
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()
// true
s1 === s2
上述静态方法也可以用闭包实现
javascript
SingleDog.getInstance = (function() {
// 定义自由变量instance,模拟私有变量
let instance = null
return function() {
// 判断自由变量是否为null
if(!instance) {
// 如果为null则new出唯一实例
instance = new SingleDog()
}
return instance
}
})()
可以看出,在getInstance方法的判断和拦截下,我们不管调用多少次,SingleDog都只会给我们返回一个实例,s1和s2现在都指向这个唯一的实例。
vuex中的如何实现单例模式
实际上vuex是本质上是没有实现单例模式的逻辑
javascript
import Vue from 'vue'
import Vuex from 'vuex'
Vue.use(Vuex)
// 创建一个 store 对象 1 号
const store1 = new Vuex.Store({
state: { count: 0 },
mutations: {
increment(state) {
state.count++
}
}
})
// 创建一个 store 对象 2 号
const store2 = new Vuex.Store({
state: { count: 0 },
mutations: {
increment(state) {
state.count++
}
}
})
// false,说明 store1 和 store2 是完全不同的两个 store
console.log(store1 === store2)
Vuex 从整体设计的层面来保证了 Store
在同一个 Vue
应用中的唯一性。
Vue.use()
方法注册vuex对象 (也可以称之为插件)
vuex内部实现了一个install方法 在use的时候会主动调用这哥install方法
javascript
let Vue // 这个Vue的作用和楼上的instance作用一样
...
export function install (_Vue) {
// 判断传入的Vue实例对象是否已经被install过Vuex插件(是否有了唯一的 store)
if (Vue && _Vue === Vue) {
if (process.env.NODE_ENV !== 'production') {
console.error(
'[vuex] already installed. Vue.use(Vuex) should be called only once.'
)
}
return
}
// 若没有,则为这个Vue实例对象install一个唯一的Vuex
Vue = _Vue
// 将Vuex的初始化逻辑写进Vue的钩子函数里
applyMixin(Vue)
}
这里和单例模式的getInstance()判断很像,保证了在同一个 Vue
应用中只存在一个 Vuex
实例。
那vuex是怎么把这一个store实例,共享给全部组件使用呢
实际上vuex的install函数中的applyMixin 中有如下函数
javascript
function vuexInit () {
const options = this.$options
// 将 store 实例挂载到 Vue 实例上
if (options.store) {
this.$store = typeof options.store === 'function'
? options.store()
: options.store
} else if (options.parent && options.parent.$store) {
this.$store = options.parent.$store
}
}
逻辑是当前组件实例的配置对象中不存在 store
,但存在父组件实例(options.parent
)且父组件实例具有 $store
属性,那么将父组件实例的 $store
赋值给当前组件实例的 $store
。
整个 Vue 组件树中的所有组件都会访问到同一个 Store
实例——那就是根组件的Store
实例。
总结vuex的设计
install()
函数通过拦截 Vue.use(Vuex)
的多次调用,保证了在同一个Vue应用只会安装唯一的一个Vuex实例;而 vuexInit()
函数则保证了同一个Vue应用只会被挂载唯一一个Store。这样一来,从效果上来看,Vuex 确实是创造了两次”单例模式“出来。
在不同的 Vue 应用中,当我们想共享唯一的一个 Store 时,仍然需要通过在全局范围内使用单例模式来确保 Store 的唯一性。
也可以采用微前端的vuex的思路多个应用共享一个全局store
子应用可以用自己的vuex的store
我看公司的项目抽离出来的
portal也就是公应用 有单独的store 如果需要要 会暴露出特定的方法去让子应用调用
子应用直接this.$store就可以
防止new多个对象实例 对new的限制
javascript
let Person = (function(){
let instance = null
class Person{}
return function(){
if(!instance){
instance = new Person()
}
return instance
}
})()
//这里利用了闭包能使变量不被垃圾回收
let person1 = new Person()
let person2 = new Person()
console.log(person1 === person2) //true
单例模式的练习
实现一个 Storage
实现:静态方法版
javascript
// 定义Storage
class Storage {
static getInstance() {
// 判断是否已经new过1个实例
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')
// 返回true
storage1 === storage2
实现: 闭包版
javascript
// 先实现一个基础的StorageBase类,把getItem和setItem方法放在它的原型链上
function StorageBase () {}
StorageBase.prototype.getItem = function (key){
return localStorage.getItem(key)
}
StorageBase.prototype.setItem = function (key, value) {
return localStorage.setItem(key, value)
}
// 以闭包的形式创建一个引用自由变量的构造函数
const Storage = (function(){
let instance = null
return function(){
// 判断自由变量是否为null
if(!instance) {
// 如果为null则new出唯一实例
instance = new StorageBase()
}
return instance
}
})()
// 这里其实不用 new Storage 的形式调用,直接 Storage() 也会有一样的效果
const storage1 = new Storage()
const storage2 = new Storage()
storage1.setItem('name', '李雷')
// 李雷
storage1.getItem('name')
// 也是李雷
storage2.getItem('name')
// 返回true
storage1 === storage2
这里闭包与静态方法的区别是
闭包采用的是new 出实例
静态方法是直接调用类的静态方法
实现一个全局的模态框
html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>单例模式弹框</title>
</head>
<style>
#modal {
height: 200px;
width: 200px;
line-height: 200px;
position: fixed;
left: 50%;
top: 50%;
transform: translate(-50%, -50%);
border: 1px solid black;
text-align: center;
}
</style>
<body>
<button id='open'>打开弹框</button>
<button id='close'>关闭弹框</button>
</body>
<script>
// 核心逻辑,这里采用了闭包思路来实现单例模式
const Modal = (function() {
let modal = null
return function() {
if(!modal) {
modal = document.createElement('div')
modal.innerHTML = '我是一个全局唯一的Modal'
modal.id = 'modal'
modal.style.display = 'none'
document.body.appendChild(modal)
}
return modal
}
})()
// 点击打开按钮展示模态框
document.getElementById('open').addEventListener('click', function() {
// 未点击则不创建modal实例,避免不必要的内存占用;此处不用 new Modal 的形式调用也可以,和 Storage 同理
const modal = new Modal()
modal.style.display = 'block'
})
// 点击关闭按钮隐藏模态框
document.getElementById('close').addEventListener('click', function() {
const modal = new Modal()
if(modal) {
modal.style.display = 'none'
}
})
</script>
</html>
es6版本 实际上就是用静态方法替换闭包
javascript
class Modal{
static getModal(){
if(!Modal.modal){
Modal.modal = document.createElement('div')
Modal.modal.innerHTML = '我是一个全局唯一的Modal'
Modal.modal.id = 'modal'
Modal.modal.style.display = 'none'
document.body.appendChild(modal)
}
return Moadl.modal
}
}
// 点击打开按钮展示模态框
document.getElementById('open').addEventListener('click', function() {
// 未点击则不创建modal实例,避免不必要的内存占用;此处不用 new Modal 的形式调用也可以,和 Storage 同理
const modal = Modal.getModal()
modal.style.display = 'block'
})
// 点击关闭按钮隐藏模态框
document.getElementById('close').addEventListener('click', function() {
const modal = Modal.getModal()
if(modal) {
modal.style.display = 'none'
}
})
单例模式跟加缓存一个道理, 有缓存读取缓存,没缓存新建然后存入缓存, 闭包和静态方法的区别可以看做找了一个不同的地方存放缓存