前言
本篇文章所有的代码,都是在 vue + vite + ts 项目基础之上实现的,这样也是为了方便大家直接用源码,在开始之前建议大家阅读这篇《零基础搭建 vite项 目教程》。此项目就是这个教程搭建的,本篇文章关于设计模式的相关代码是此项目的一个分支(design-patterns)。如果你没有时间阅读详细的教程,你也可以直接在 git 上克隆项目。
learn-vite: 搭建简单的vite+ts+vue框架https://gitee.com/yangjihong2113/learn-vite本篇文章的所有代码都在 design-patterns 分支上,基本内容大致如下(后续代码可能会有优化,建议以最新的代码为准),由于在《JavaScript设计模式与开发实践》一书中设计模式有14种内容比较多,所以我将分为两篇文章作为总结。本篇文章是第一篇,从单例模式到发布命令模式共 6 个,剩下的一部分会在下一篇文章中总结。
把项目克隆之后切换到 design-patterns 分支,运行 pnpm install ,然后运行 npm run dev 直接访问 http://localhost/designPattern 就可以看到本篇文章涉及的全部内容。
本篇文章的宗旨是让我们彻底搞明白设计模式是个什么东西,每个设计模式最好记一个例子,并且会在 vue 项目中用,。比如在开发一个新功能的时候可以先想想,可以使用哪些设计模式使代码更加合理,更加优雅,更加方便扩展。
说实话,我之前也是看过好几次这本书,每次都是单纯的看,也不动手写代码,也不举一反三,虽然书中的例子都能看懂,但是一到面试的时候,还是说不出个所以然。更别提在实际开发过程中这个模式的应用了。
所以我花了两周时间,将这本书重新认真读了一遍,然后把每一个设计模式的例子都自己写了一遍,甚至找了其他的应用实例。
因为书中的代码多数都是用原型写的,有了 vue 之后我也很久没有使用原型写代码了,所以我的代码都是用 es6 的类 + vue 写的,这样和我正在开发的项目框架相吻合,能够很好的应用我学到的设计模式。
在开始之前,还是建议大家先把书认真开一遍,有很多知识点,这本书的电子书可以在项目中找到
一、单例模式
1.1 非惰性单例-登录弹框
单例模式的特点是,一个系统中只有一个实例,比如一个系统中只有一个登录弹框,我们就可以说这个登录弹框是单例模式的应用。
1.1.1 全局唯一实例
(1)在 Js 中
注意”整个系统中只有一个实例“就说明有一个全局实例,即全局变量用来保存这个单例唯一的实例,我们要实现一个单例模式,如果使用了 es6 中的 Class,那么这个变量可以写在类中,比如下面的例子:
// 单例模式的类
export class Singleton {
num: number
static instance: Singleton
constructor(n: number) {
this.num = n
}
// 获取实例,单例实例是唯一的,不依赖于外部参数
static getInstance(n: number) {
if (!Singleton.instance) {
// 如果已经有实例,就返回这个已经存在的实例
Singleton.instance = new Singleton(n)
}
console.log('当前返回的实例', Singleton.instance)
return Singleton.instance
}
}
如果用纯 js 实现一个单例模式,那么可是使用 var instance = null 来定义一个全局变量。
(2)在 vue 组件中
我们在 vue 项目中做一个登录弹框的时候,根本用不到手写的类,直接创建一个组件,然后全局引用(一般是 app.vue 中)就行了,所以这种情况下,这个组件的实例就是唯一的变量,只需要确保在整个系统中只引用一次,它便是单例模式的。然后在我们的整个项目中,任何需要打开登录弹框的操作都展示这个组件即可。
除了登录弹框,项目中很多弹框都可以使用单例模式,比如图片预览弹框,只需要把图片的 url 动态的传入。系统中任何地方的图片的预览都使用这个弹框,这样也方便改样式,修改一个组件就行。
1.1.2 非惰性单例
非惰性单例,就是在整个系统初始化的时候就已经生成这个实例了,对于登录弹框,意味着无论你是否点击登录按钮,登录这个弹框都存在于整个页面的 dom 树中。也就是在最开始的时候我们在 app.vue(或其他页面) 中引入登录组件就行。
1.1.2 主要代码
<template>
<div class="page-container">
<h1 class="title">非惰性单例-登录弹框</h1>
<div class="login-btn" @click="toLogin">点击登录</div>
<!-- 非惰性单例,就是在整个系统初始化的时候就已经生成这个实例了,
无论你是否点击登录按钮,登录这个弹框都存在于整个页面的 dom 树中 -->
<LoginDialog v-model:show-login-dialog="showLoginDialog" />
</div>
</template>
<script lang="ts" setup>
import LoginDialog from './components/loginDialog.vue'
import { ref } from 'vue'
const showLoginDialog = ref(false)
const toLogin = () => {
showLoginDialog.value = true
}
</script>
1.2 惰性单例-登录弹框
惰性意味着,如果不点击登录按钮,不会初始化登录弹框,登录弹框不会存在于整个页面的 dom 树中,可以自在自己点击登录按钮之前,打开开发者工具自己看一下。
vue3 中可以使用动态组件来实现惰性加载一个组件,传值的方式和普通组件一样,主要代码如下:
<template>
<div class="page-container">
<h1 class="title">惰性单例-登录弹框</h1>
<div class="login-btn" @click="toLogin">点击登录</div>
<!-- vue3 中可以使用动态组件来实现惰性加载一个组件,传值的方式和普通组件一样 -->
<component :is="dynamicComponent" v-model:show-login-dialog="showLoginDialog"></component>
</div>
</template>
<script lang="ts" setup>
import LoginDialog from './components/loginDialog.vue'
import { ref } from 'vue'
const dynamicComponent = ref()
const showLoginDialog = ref(false)
const toLogin = () => {
// 在这里初始化登录弹框,并保证它只有一个实例
dynamicComponent.value = LoginDialog
showLoginDialog.value = true
}
</script>
1.3 用 js 手写一个单例模式
上面两个例子都是单例模式 在 vue 中的应用,但是很多面试的时候会让我们手写一个单例模式,你可以选择用 js 原型(参考书中的例子),或者使用 es6 中的 Class,下面是使用 Class 实现的一个单例模式。
// 单例模式的类
export class Singleton {
num: number
static instance: Singleton
constructor(n: number) {
this.num = n
}
// 获取实例,单例实例是唯一的,不依赖于外部参数
static getInstance(n: number) {
if (!Singleton.instance) {
// 如果已经有实例,就返回这个已经存在的实例
Singleton.instance = new Singleton(n)
}
console.log('当前返回的实例', Singleton.instance)
return Singleton.instance
}
}
<template>
<div class="page-container">
<h1 class="title">用 js 的 class 手写一个单例模式</h1>
<ol>
<li>单例:多次创建实例都只返回同一个结果</li>
<li>确保只有一个实例,并提供全局访问</li>
</ol>
</div>
</template>
<script lang="ts" setup>
import { Singleton } from '.'
// 不管获取多少次实例,都只返回相同的对象
const res1 = Singleton.getInstance(1)
const res2 = Singleton.getInstance(2)
const res3 = Singleton.getInstance(3)
const res4 = Singleton.getInstance(4)
console.log('四个实例是否相等', res1 === res2 && res2 === res3 && res3 === res4)
</script>
二、策略模式
策略模式必须包含的两个要素:
- 策略:在 js 中一般是一个对象 stategyObj,对象中的每个 key 是策略的唯一标识(字符串),值是一个函数,就是该策略对应的要执行的函数【stategyObj 对应书中的策略类,但是在 js 中我们不用写类,我们用的是对象】
- 验证函数:也就是选择策略的函数 validator,stategyObj 作为参数【validator 对应书中的 Context 上下文来接受用户的请求】
策略模式的好处有:
- 不同策略之间解耦
- 方便扩展,新增策略只需要在策略对象中添加新的字段即可,无需修改验证函数
- 可以优化 if-else 语句
2.1 表单验证
策略模式的一个很常见的应用就是表单验证,如果我们使用组件库(如 elementPlus)的时候就知道 form 表单一般有一个 rules 字段,我们只要简单的按照语法传入一个对象就可以进行验证。
组件库的表单验证功能的实现原理也是使用了策略模式,可以自己看一下 elementPlus 的源码。
在 elementPlus 中,使用了一个 npm 验证的包
下面不使用这个包,我们使用策略模式简单的实现一下表单验证的的功能。
2.1.1 代码
下面的代码中,我们可以按着规则自己增加或者删除 rules 中的规则。
关于表单验证,实际我们在开发过程中完全没必要自己写,如果是大规模的表单验证,直接使用组件库就行,如果是只有一两个表单的验证,直接使用 if-else 就行,完全没必要为了使用某个设计模式,把我们的代码复杂化。
<template>
<div class="page-container">
<h1 class="title">策略模式-表单验证</h1>
<ol>
<li>定义表单中需要验证的字段</li>
<li>定义表单中各个字段对应的错误提示对象</li>
<li>对表单中的各个字段定义验证规则</li>
</ol>
<div class="form-box">
<div class="form-item">
<div class="label">姓名</div>
<input v-model="form.name" class="value" placeholder="请输入姓名" />
<span v-if="formErrorObj?.name?.length" class="error-msg">{{ formErrorObj.name[0] }}</span>
</div>
<div class="form-item">
<div class="label">年龄</div>
<input v-model="form.age" class="value" placeholder="请输入年龄" />
<span v-if="formErrorObj?.age?.length" class="error-msg">{{ formErrorObj.age[0] }}</span>
</div>
<div class="form-item">
<div class="label">手机号</div>
<input v-model="form.phone" class="value" placeholder="请输入手机号" />
<span v-if="formErrorObj?.phone?.length" class="error-msg">{{ formErrorObj.phone[0] }}</span>
</div>
<div class="form-item">
<div class="label">说明</div>
<input v-model="form.desc" class="value" placeholder="请输入说明" />
<span v-if="formErrorObj?.desc?.length" class="error-msg">{{ formErrorObj.desc[0] }}</span>
</div>
<div class="form-item">
<div class="confrim-btn" @click="submit">提交</div>
</div>
<div v-if="isPass !== null" class="form-item">
<div class="label">状态</div>
<div>{{ isPass ? '验证通过' : '验证未通过' }}</div>
</div>
</div>
</div>
</template>
<script lang="ts" setup>
import { ref, Ref } from 'vue'
const isPass: Ref<null | Boolean> = ref(null)
// 定义表单中所有的字段类型
interface FormType {
name: string
age: string
phone: string
desc: string
}
// 定义验证规则中的字段类型
interface ValidateType {
require?: boolean
message: string
minLen?: number
pattern?: RegExp
}
// 不能使用 interface,interface 不支持像 type 那样的条件类型或映射类型语法来直接转换值的类型
// 根据表单中的字段,定义规则类型
type FormRuleType = {
[P in keyof FormType]?: Array<ValidateType>
}
// 根据表单中的字段,定义表单的错误提示类型
type FormErrorType = {
[P in keyof FormType]?: Array<string>
}
// 表单数据
const form: Ref<FormType> = ref({
name: '',
age: '',
phone: '',
desc: '',
})
// ts: 使用 let 可以不用初始化,如果使用 const 就必须初始化
// 表单错误提示数据
let formErrorObj: Ref<FormErrorType> = ref({})
// 表单的验证规则
const rules: FormRuleType = {
name: [
{
require: true,
message: '请输入姓名',
},
{
minLen: 5,
message: '最少输入5个字符',
},
],
age: [
{
minLen: 3,
message: '最少输入3个字符',
},
],
phone: [
{
pattern: /^1[3456789]\d{9}$/,
message: '手机号格式不正确',
},
],
}
// 根据规则验证表单
const validate = () => {
// 获取表单中所有需要验证的 key
const allFormKey = Object.keys(form.value)
allFormKey.forEach((keyItem: string) => {
// 给每一个 key 设置对应的错误提示文案数组,因为可能有不只一个错误文案,所以是数组
formErrorObj.value[keyItem as keyof FormType] = []
// 根据规则,找到每个 key 对应的规则
const keyRules = rules[keyItem as keyof FormType]
if (keyRules) {
keyRules.forEach((ruleItem: any) => {
// 找到表单中每个 key 对应的真实值
const keyValue = form.value[keyItem as keyof FormType]
if (ruleItem.require && !keyValue) {
formErrorObj.value[keyItem as keyof FormType]?.push(ruleItem.message)
}
// 最小长度
if (ruleItem.minLen && keyValue.length < ruleItem.minLen) {
formErrorObj.value[keyItem as keyof FormType]?.push(ruleItem.message)
}
// 模式匹配
if (ruleItem.pattern && !ruleItem.pattern.test(keyValue)) {
formErrorObj.value[keyItem as keyof FormType]?.push(ruleItem.message)
}
})
}
})
// 根据表单中所有的错误提示判断是否验证通过
const isPass = Object.entries(formErrorObj.value).every((item: [string, string[]]) => item[1] && item[1]?.length === 0)
return isPass
}
const submit = () => {
const res = validate()
if (res) {
isPass.value = true
} else {
isPass.value = false
}
}
</script>
<style lang="scss" scoped>
.page-container {
padding: 24px;
ol {
list-style: decimal;
padding-left: 14px;
}
.title {
margin: 10px 0;
}
.form-box {
display: flex;
flex-direction: column;
align-items: flex-start;
justify-content: center;
width: 400px;
padding: 10px 20px 20px;
background: #fff;
border: 1px solid #ddd;
border-radius: 8px;
position: relative;
.form-item {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
height: 42px;
position: relative;
&.title {
text-align: center;
font-weight: 600;
font-size: 20px;
}
.label {
width: 50px;
flex-shrink: 0;
}
.value {
padding: 0 10px;
flex-grow: 1;
background: transparent;
border: 1px solid #ddd;
height: 100%;
border-radius: 4px;
outline: none;
}
.confrim-btn {
display: inline-flex;
align-items: center;
justify-content: center;
width: 100%;
height: 100%;
color: #fff;
background: #333;
cursor: pointer;
border-radius: 4px;
}
.error-msg {
font-size: 12px;
position: absolute;
left: 50px;
top: 40px;
color: red;
}
}
.form-item + .form-item {
margin-top: 24px;
}
}
}
</style>
2.2 使用策略模式优化 if-else
2.2.1 基本说明
策略模式的主要应用就是可以用来优化项目中的 if-else 语句,尤其是一整片的 if-else-if ,下面是一个简单的需求,假设系统中针对不同格式的文件名称需要增加不同的前缀:
-
jpg、png 格式需要加上【图片】
-
doc、docx 格式需要加上【文档】
-
xls、xlsx 格式需要加上【表格】
-
zip、rar 格式需要加上【压缩包
为什么可以使用策略模式:
- 因为需求中有大量的 if-else
-
可能会新增对其他的文件格式的处理,比如 ppt 和 pptx 的处理
-
针对不同的文件格式有不同的处理策略
2.2.2 策略表 vs 策略模式
不是所有对象映射都可以称为策略模式,只有当映射的值是函数时,才符合策略模式的特性。如果只是简单的 key-value 的替换,那就是简单的映射,当映射表变得更加复杂时,就可以称之为策略模式的实现,如果映射的是函数(复杂了些),并且能动态执行,制定不同的策略,那就是策略模式。
但是我认为,在开发过程中不必纠结这个到底是策略表还是策略模式这种字面的定义,不管用映射值是不是函数,能够实现我们的需求,并且比一大堆 if-else 好用就是好代码。
2.2.3 代码
<template>
<div class="page-container">
<h1 class="title">使用策略模式优化 if-else</h1>
<ol>
<li>假设系统中针对不同格式的文件名称需要增加不同的前缀</li>
<li>jpg、png 格式需要加上【图片】</li>
<li>doc、docx 格式需要加上【文档】</li>
<li>xls、xlsx 格式需要加上【表格】</li>
<li>zip、rar 格式需要加上【压缩包】</li>
</ol>
<h2>可以使用策略模式优化是因为:</h2>
<ol>
<li>有大量的 if-else</li>
<li>可能会新增对其他的文件格式的处理,比如 ppt 和 pptx 的处理</li>
<li>针对不同的文件格式有不同的处理策略</li>
</ol>
<h3>映射表 vs 策略模式</h3>
<ol>
<li>不是所有对象映射都可以称为策略模式,只有当映射的值是函数时,才符合策略模式的特性。</li>
<li>如果只是简单的 key-value 的替换,那就是简单的映射</li>
<li>当映射表变得更加复杂时,就可以称之为策略模式的实现</li>
<li>如果映射的是函数(复杂了些),并且能动态执行,制定不同的策略,那就是策略模式</li>
</ol>
<h3>策略模式的好处</h3>
<ol>
<li>优化了 if-else 语句</li>
<li>更容易扩展,新增策略只需要在映射对象中添加新的字段即可</li>
</ol>
</div>
</template>
<script lang="ts" setup>
// 不使用策略模式的代码
const addPrefixToFileName = (fileType: string) => {
if (fileType === 'jpg' || fileType === 'png') {
return '图片'
} else if (fileType === 'doc' || fileType === 'docx') {
return '文档'
} else if (fileType === 'xls' || fileType === 'xlsx') {
return '表格'
} else if (fileType === 'zip' || fileType === 'rar') {
return '压缩包'
}
}
const res = addPrefixToFileName('jpg')
console.log('需要添加的文件名前缀:', res)
// 使用策略模式
// 简单的映射表
const strategyObj = {
jpg: '图片',
png: '图片',
doc: '文档',
docx: '文档',
xls: '表格',
xlsx: '表格',
zip: '压缩包',
rar: '压缩包',
}
const addPrefixToFileNameStrategy = (fileType: string) => {
return strategyObj[fileType as keyof typeof strategyObj]
}
const res2 = addPrefixToFileNameStrategy('jpg')
console.log('使用映射表——需要添加的文件名前缀:', res2)
// 对每个策略有更为复杂的处理,封装成函数,就可以称之为策略模式
const strategyObj1 = {
jpg: () => '图片',
png: () => '图片',
doc: () => '文档',
docx: () => '文档',
xls: () => '表格',
xlsx: () => '表格',
zip: () => '压缩包',
rar: () => '压缩包',
}
const addPrefixToFileNameStrategy2 = (fileType: string) => {
return strategyObj1[fileType as keyof typeof strategyObj1]()
}
const res3 = addPrefixToFileNameStrategy2('jpg')
console.log('真正的策略模式——动态选择策略——需要添加的文件名前缀:', res3)
</script>
<style lang="scss" scoped>
.page-container {
padding: 24px;
ol {
list-style: decimal;
padding-left: 14px;
}
.title {
margin: 10px 0;
}
.form-box {
display: flex;
flex-direction: column;
align-items: flex-start;
justify-content: center;
width: 400px;
padding: 10px 20px 20px;
background: #fff;
border: 1px solid #ddd;
border-radius: 8px;
position: relative;
.form-item {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
height: 42px;
position: relative;
&.title {
text-align: center;
font-weight: 600;
font-size: 20px;
}
.label {
width: 50px;
flex-shrink: 0;
}
.value {
padding: 0 10px;
flex-grow: 1;
background: transparent;
border: 1px solid #ddd;
height: 100%;
border-radius: 4px;
outline: none;
}
.confrim-btn {
display: inline-flex;
align-items: center;
justify-content: center;
width: 100%;
height: 100%;
color: #fff;
background: #333;
cursor: pointer;
border-radius: 4px;
}
.error-msg {
font-size: 12px;
position: absolute;
left: 50px;
top: 40px;
color: red;
}
}
.form-item + .form-item {
margin-top: 24px;
}
}
}
</style>
三、代理模式
3.1 虚拟代理 vs 保护代理
- 虚拟代理:把一些开销很大的对象,延迟到真正需要它的时候才去创建
- 保护代理:用于控制不同权限的对象对目标对象的访问,涉及身份验证、角色权限等
javascript 中不容易实现保护代理,所以 js 中的代理模式一般都是虚拟代理,代理模式的核心是通过一个代理对象来控制对真实对象的访问。
代理这个词其实我们经常听到,但是如果让我实现一个代理,说实话我也不经常写。但是其实可能我们无意间写的一段代码就是代理模式的应用,只是我们没有想着去给他对应一个设计模式,比如我们下面要说的缓存代理,缓存功能可能大家都写过,但是他属于一个代理模式的应用可能印象却不深刻。
学完代理模式,以后在面试的过程中问你,代理模式有哪些应用啊?你可以毫不犹豫的说出可以用来实现缓存代理。
3.2 浏览器的缓存
缓存代理实例之一就是浏览器缓存,浏览器的缓存想必大家都不陌生,有复杂的缓存机制。关于浏览器的缓存相关的知识,也可以看一下我的这篇文章。
代理模式是浏览器的缓存机制中的一个核心模式,在浏览器中,代理模式通过缓存代理来拦截对资源的请求,并决定是从缓存中直接返回数据,还是向服务器发起请求以获取新的数据,缓存就像一个代理对象,它在客户端和服务器之间充当中介,减少了不必要的网络请求和延迟,缓存代理的目标是性能优化。
3.3 复杂的计算结果的缓存
3.3.1 基本说明
手动实现一个缓存代理常用语比较复杂的计算,或者异步请求结果,鉴于我没有现成可用的接口用来模拟异步请求,所以我的例子是一个计算的缓存(虽然这个例子中并不复杂)。
需求如下:
- 假设有一个很复杂的计算函数 Fn,计算过程有一定的耗时
- 点击按钮每次生成一个随机数 num
- 根据随机数 num 和函数 Fn 的到计算结果
- 每次计算结果放入缓存中,下次有同样的数字重新从缓存中取,不需要重新计算
先看使用缓存的结果,如下图,我们可以看到如果使用了代理,相同的计算可以消耗更短的时间,达到性能优化的效果,红色的一行代表命中了缓存。
3.3.2 代码
注意,在 js 中的代码一般使用对象来保存的缓存。
<template>
<div class="page-container">
<h1 class="title">
1. 代理模式-
<span class="highlight">虚拟代理</span>
vs 保护代理
</h1>
<ol>
<li>虚拟代理:把一些开销很大的对象,延迟到真正需要它的时候才去创建</li>
<li>保护代理:用于控制不同权限的对象对目标对象的访问,涉及身份验证、角色权限等</li>
<li>javascript 中不容易实现保护代理,所以 js 中的代理模式一般都是虚拟代理</li>
<li>代理模式的核心:通过一个代理对象来控制对真实对象的访问</li>
</ol>
<h1 class="title">2. 缓存代理实例之——浏览器缓存</h1>
<ol>
<li>代理模式是浏览器的缓存机制中的一个核心模式</li>
<li>在浏览器中,代理模式通过缓存代理来拦截对资源的请求,并决定是从缓存中直接返回数据,还是向服务器发起请求以获取新的数据</li>
<li>缓存就像一个代理对象,它在客户端和服务器之间充当中介,减少了不必要的网络请求和延迟</li>
<li>
缓存代理的目标是
<span class="highlight">性能优化</span>
</li>
</ol>
<h1 class="title">3. 缓存代理实例之——复杂的计算结果的缓存</h1>
<ol>
<li>假设有一个很复杂的计算函数 Fn,计算过程有一定的耗时</li>
<li>点击按钮每次生成一个随机数 num</li>
<li>根据随机数 num 和函数 Fn 的到计算结果</li>
<li>每次计算结果放入缓存中,下次有同样的数字重新从缓存中取,不需要重新计算</li>
</ol>
<div class="sample-box">
<h1>例子:</h1>
<div class="add-btn" :class="{ loading: isLoading }" @click="addItem">{{ isLoading ? '计算中...' : '点击生成一个随机数' }}</div>
<table>
<tr>
<th>随机数</th>
<th>计算结果</th>
<th>耗时</th>
<th>是否来自缓存</th>
</tr>
<tr v-for="(item, index) in result" :key="index" :class="{ 'is-cache': item.isCache }">
<td>{{ item.randomNum }}</td>
<td>{{ item.calcResult }}</td>
<td>{{ item.time }}</td>
<td>{{ item.isCache }}</td>
</tr>
</table>
</div>
</div>
</template>
<script lang="ts" setup>
import { onMounted, ref } from 'vue'
// 用来保存所有的计算结果
const result: any = ref([])
// 模拟一个有延迟的很复杂的计算函数
const delay = (ms: number) => {
return new Promise(resolve => setTimeout(resolve, ms))
}
const delayCalcFn = async (randomNum: number) => {
await delay(randomNum * 1000)
return `计算结果_${randomNum}`
}
// 缓存列表,js 中的缓存一般都是用对象来保存的
const cache: any = {}
const isLoading = ref(false)
const addItem = async () => {
if (isLoading.value) {
return
}
isLoading.value = true
let now = new Date().getTime()
// 生成一个随机数,未了增加随机数的重复概率,这里 * 10 取整
const randomNum = Math.floor(Math.random() * 10)
let calcResult: any = 0
let isCache = false
// 如果缓存中有对应的值,直接从缓存里面取
if (randomNum in cache) {
isCache = true
calcResult = cache[randomNum]
} else {
// 缓存中没有值,重新计算
calcResult = await delayCalcFn(randomNum)
// 重新计算之后更新缓存
cache[randomNum] = calcResult
}
// 计算延迟,用于对比使用缓存之后的优化效果
const time = new Date().getTime() - now
result.value.push({
randomNum,
calcResult,
time,
isCache,
})
isLoading.value = false
}
onMounted(() => {})
</script>
<style lang="scss" scoped>
.page-container {
padding: 24px;
ol {
list-style: decimal;
padding-left: 14px;
}
.title {
margin: 10px 0;
}
.highlight {
color: red;
}
.sample-box {
margin-top: 10px;
padding: 10px;
border: 1px solid #ccc;
border-radius: 6px;
background: #eee;
.add-btn {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 4px 10px;
border-radius: 4px;
border: 1px solid #ccc;
cursor: pointer;
&.loading {
opacity: 0.5;
}
}
table {
margin-top: 10px;
th,
td {
padding: 4px 10px;
}
tr.is-cache {
color: red;
}
}
}
}
</style>
3.4 代理模式实现图片预加载
这是书中的例子,自己手动实现一下,方便我们的理解,首页性能优化的时候可以尝试着用一下这个方法。
3.4.1 基本说明
- 先在页面上显示一个 img 标签,src 设置为 loading.png,一个比较小的图片
- 同时再创建一个隐藏的 img 标签,给这个隐藏的图片设置 src = bg-img-1.jpg, 这个图片是我们最终想要渲染的比较大的图片
- 注意:在给一个 img 设置 src 之后,该 src 指向的图片才开始下载
- 监听隐藏的 img 的 onload 事件,onload 之后代表这个图片已经下载完
- 更换 1 中的 img 标签的 src , 实现预览图片的替换
3.4.2 代码
<template>
<div class="page-container">
<h1 class="title">图片预加载</h1>
<ol>
<li>先在页面上显示一个 img 标签,src 设置为 loading.png,一个比较小的图片</li>
<li>同时再创建一个隐藏的 img 标签,给这个隐藏的图片设置 src = bg-img-1.jpg, 这个图片是我们最终想要渲染的比较大的图片</li>
<li>注意:在给一个 img 设置 src 之后,该 src 指向的图片才开始下载</li>
<li>监听隐藏的 img 的 onload 事件,onload 之后代表这个图片已经下载完</li>
<li>更换 1 中的 img 标签的 src , 实现预览图片的替换</li>
</ol>
<div ref="sampleEle" class="sample-box"></div>
</div>
</template>
<script lang="ts" setup>
import { onMounted, ref } from 'vue'
const sampleEle = ref()
// 这个例子完全来自书《javascript 设计模式与开发实践》中 92 页
onMounted(() => {
// 立即执行函数
const myImage = (function () {
// 创建一个用于页面预览的图片
let imgNode = document.createElement('img')
// 挂载到页面上
sampleEle.value.appendChild(imgNode)
// 设置某些样式
imgNode.classList.add('my-image')
return {
setSrc: (src: string) => {
// 给图片设置一个 src
imgNode.src = src
},
}
})()
// 图片加载的代理,立即执行函数
const proxyImage = (function () {
// 这个 img 标签不会展示在页面上,只是用来加载图片用的
const img = new Image()
// 在这个代理图片加载完成后,更新页面上的图片的 src
img.onload = () => {
// 下面函数执行完,页面上展示的图片是我们最终想要的 bg-img-1.jpg
myImage.setSrc(img.src)
}
return {
setSrc: (src: string) => {
// 这是一个 loading 的图片,执行下面语句,页面上显示的的图片是 loading.png
myImage.setSrc('https://jannet-life-manager.s3.us-east-1.amazonaws.com/loading.png')
// 执行下面语句,开始下载真正需要预览的图片 bg-img-1.jpg
img.src = src
},
}
})()
// 使用代理,这是真正需要展示的图片 url
proxyImage.setSrc('https://jannet-life-manager.s3.us-east-1.amazonaws.com/bg-img-1.jpg')
})
</script>
<style lang="scss" scoped>
.page-container {
padding: 24px;
ol {
list-style: decimal;
padding-left: 14px;
}
.title {
margin: 10px 0;
}
:deep(.sample-box) {
margin-top: 10px;
width: 400px;
height: 400px;
border-radius: 6px;
position: relative;
overflow: hidden;
border: 1px dashed #ccc;
.my-image {
position: absolute;
left: 0;
top: 0;
width: 100%;
}
}
}
</style>
四、迭代器模式
4.1 原生 JS 迭代器模式的应用
在《javascript 高级程序设计(第4版)》一书中第 7 章有专门一节介绍迭代器的知识,结合《javascript 设计模式与开发实践》迭代器模式一起看能更好理解。
这两本书的 PDF 可以再项目中 designPattern 分支的 src/assets/pdf 目录下找到。
这一小节是纯理论知识,如果看不懂没关系,先有个印象,等慢慢熟悉了,你就会豁然开朗。
迭代器模式是指提供一种方法顺序访问一个聚合对象中的各个元素,而又不需要暴露该对象的内部表示,现在流行的语言都已经有内置的迭代器实现,包括 js,在 es6 之后也支持了迭代器模式。迭代器可以中止,但不是必须可以。
迭代器分为内部迭代器和外部迭代器两种类型:
- 内部迭代器:函数内部定义好了迭代规则,它完全接手整个迭代过程,外部只需要一次初始调用,js 中内置的迭代方法都属于是内部迭代器,因为我们可以直接使用
- 外部迭代器:需要显式的请求迭代下一个元素,一般是使用 next 方法
4.1.1 js 中的可迭代协议
Javascript 中的可迭代协议:
- 实现可迭代协议(也就是 Iterable 接口)要求,
- 支持迭代的自我识别能力
- 支持创建实现 Iterator 接口的对象的能力。
- 在 es6 中,暴露一个属性作为“默认迭代器”,且这个属性必须是【Symbol.iterator】作为键,值是一个函数,这个函数有以下的要求:
- 返回名称为 next 的方法
- next 方法的返回值为对象 { value: any, done: boolean}
- next 方法返回 { done: true } 时代表迭代器中止
4.1.2 可迭代对象 vs 类数组
(1)可迭代对象
迭代器模式不仅可以迭代数组,还可以迭代一些类数组的对象,也就是下面说的可迭代对象。可以被迭代的对象有两个要素:
- 有 length 属性
- 可以用下标访问,如 arr[0],arr[1], ...
(2)js 中的可迭代对象
实现了可迭代接口(Iterable 接口)的对象就是可迭代对象,每个迭代器都会关联一个可迭代对象。js 中内置的可迭代对象如下,这意味着这些对象都有一个 Symbol.iterator 属性:
- array【可迭代对象】
- String【可迭代对象】
- Map【可迭代对象】
- Set【可迭代对象】
- TypedArray【可迭代对象】【类数组】
- arguments【可迭代对象】【类数组】
- NodeList【可迭代对象】【类数组】
(3)可迭代对象 vs 类数组
- js 中的可迭代对象可以使用 for...of 循环遍历,支持迭代协议的可迭代对象一定可以使用 for of 循环
- 可迭代对象和类数组是不同的概念,但是有部分重叠,本质不相同,类数组:具有数字索引和length属性,不必须有 Symbol.iterator 属性
- 判断是否是可迭代对象,直接判断是否有 Symbol.iterator 属性就行
- 类数组不是数组,不能直接使用数组的迭代方法,需要使用 Array.from 或者扩展运算符(如[...nodeList])转成数组
(4)js 内置的迭代方法
- for...of 使用 break 可中止
- Array.some 返回 true可中止
- Array.every 返回 false 可中止
- Array.forEach 不可中止
- Array.map 不可中止
- Array.filter 不可中止
这块就可以联想到一个面试题,for...of 和 for...in 的区别,for ...of 的本质是针对可迭代对象的遍历。
for...in 只能遍历对象的可枚举属性。
对象不是可迭代对象所以不能用 for...of 来遍历
4.1.3 总结
说了一大堆都是理论知识,而且看得云里雾里,貌似也没什么用,作为菜鸟我们先记住以下几点:
- 有些 javascript 原生的方法是实现了迭代器模式
- 怎么判断是否是可迭代对象:判断是否有 Symbol.iterator 属性即可。
- 可迭代对象可以使用 for...of 循环
- 类数组不是数组,不能直接使用数组的迭代方法,需要使用 Array.from 或者 [...nodeList] 扩展运算符,转成数组才能用数组的方法
4.2 使用迭代器模式优化 if-else
在《javascript 设计模式与开发实践》迭代器模式一节中(107页),举了一个例子是关于文件上传的,其中判断了各种浏览器是否支持的上传组件,然后选择相应的方案。
4.2.1 基本说明
参照这个例子,我发现项目有也有可以使用迭代器模式优化的代码——全屏模式,不同浏览器的全屏模式的方法也不同,需要依次找出浏览器支持的方法,直到找到可以使用的。
- 注意要和策略模式区分
- 迭代器模式中有一个迭代函数,策略模式中的是验证函数
- 策略模式中的策略是一个对象,对象的键是一个策略的key,对象的每一个key的值是一个函数。
- 迭代器中每个需要迭代值的都是一个函数,然后把这些值组合成数组传入迭代函数中
- 迭代函数需要时一个通用的,不和具体业务逻辑耦合
- 迭代器模式要单独写一个迭代方法,和业务逻辑分开
- 迭代器模式一般有按需终止遍历的逻辑
4.2.2 代码
<template>
<div class="page-container">
<h1 class="title">使用迭代器模式优化 if-else</h1>
<div>在《javascript 设计模式与开发实践》迭代器模式一节中(107页)</div>
<div>举了一个例子是关于文件上传的,其中判断了各种浏览器是否支持的上传组件,然后选择相应的方案</div>
<div>参照这个例子,我发现项目有也有可以使用迭代器模式优化的代码——全屏模式</div>
<ol>
<li>注意要和策略模式区分</li>
<li>迭代器模式要单独写一个迭代方法,和业务逻辑分开</li>
<li>迭代器模式一般有按需终止遍历的逻辑</li>
</ol>
<div class="sample-box">
<div>点击 esc 退出全屏</div>
<div class="sample-btn" @click="toggleFullscreen">点击全屏1</div>
<div class="sample-btn" @click="toggleFullscreen2">迭代器模式点击全屏</div>
</div>
</div>
</template>
<script lang="ts" setup>
// 优化前的全屏代码,有很多 if-else
const toggleFullscreen = () => {
const element = document.body
if (element.requestFullscreen) {
element.requestFullscreen()
} else if (element.webkitRequestFullScreen) {
element.webkitRequestFullScreen()
} else if (element.mozRequestFullScreen) {
element.mozRequestFullScreen()
} else if (element.msRequestFullscreen) {
element.msRequestFullscreen()
}
}
// 使用迭代器优化全屏代码标准写法
const standardFullscreenFn = () => {
try {
return document.body.requestFullscreen()
} catch (err) {
return false
}
}
// Firefox 旧式
const mozFullscreenFn = () => {
try {
return document.body.mozRequestFullScreen()
} catch (err) {
return false
}
}
// IE、Edge 旧式写法
const msFullscreenFn = () => {
try {
return document.body.msRequestFullscreen()
} catch (err) {
return false
}
}
// WebKit 旧式写法,safari、chrome
const webkitFullscreenFn = () => {
try {
return document.body.webkitRequestFullScreen()
} catch (err) {
return false
}
}
// 迭代方法
const walkerFn = (...args: any) => {
const arr = [...args]
for (let i = 0; i < arr.length; i++) {
// 遍历控制
if (arr[i]()) {
// 执行当前策略
return arr[i] // 找到有效策略后终止迭代
}
}
}
const toggleFullscreen2 = () => {
// 注意这个顺序,为了调用更少的次数,可以把最可能调用的方法(requestFullscreen)放到前面
const finallyFn = walkerFn(standardFullscreenFn, mozFullscreenFn, msFullscreenFn, webkitFullscreenFn)
console.log('最终使用的全屏方法', finallyFn)
}
</script>
<style lang="scss" scoped>
.page-container {
padding: 24px;
ol {
list-style: decimal;
padding-left: 14px;
}
.title {
margin: 10px 0;
}
.highlight {
color: red;
}
.sample-box {
margin-top: 10px;
padding: 10px;
border: 1px solid #ccc;
border-radius: 6px;
background: #eee;
.sample-btn {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 4px 10px;
margin-right: 10px;
cursor: pointer;
border: 1px solid #999;
border-radius: 6px;
}
}
}
</style>
五、发布订阅模式
5.1 观察者模式
5.1.1 观察者模式 vs 发布订阅模式
在《javascript 设计模式与开发实践》书中的第 110 页第 8 章的第一句话是 “发布—订阅模式又叫观察者模式” ,这种说法是一个简单版本的说法,要是细区分的话观察者模式和发布订阅模式还是点区别的
- 在权威的 GoF 提出的《设计模式可复用面向对象软件基础》书中,只有“观察者模式”,没有“发布订阅模式”
- 这意味着:“发布订阅模式”是“观察者模式的一个进化升级版本”
- “发布订阅模式”可以视为“观察者模式”的一个解耦升级版
- “观察者模式”的关键字是 1.耦合、2.同步、3.显式调用、4.一对多
- “发布订阅模式”的关键字是 1.解耦、2.异步、3.非显式调用、4.多对多
- 在 vue 中 响应式的实现是“观察者模式”的应用
- 事件总线(eventBus)是“发布订阅模式”的应用
5.2.2 代码
观察者模式有两个核心的词汇:
- 主题:Subject 也就是被观察的东西,一般只有一个
- 观察者:Observer 有很多个,所以会对应一个观察者列表,这个列表和 Subject 绑定,所以一般是写在 Subject 类中。一个 subject(主题) 对应一个 observerList(观察者列表),一个observerList(观察者列表)里面有 n 个观察者。
// 观察者模式
// 观察者类
export class Observer {
name: string = ''
constructor(name: string) {
this.name = name
console.log(`观察者_${name}_创建成功`)
}
// 观察者类中必须有一个更新函数(当前函数名字叫啥倒是无所谓)
update(val: string) {
console.log('观察者', this.name, val)
}
}
// 主题(被观察者)的类
export class Subject {
// 维护一个观察者列表
observerList: Observer[] = []
constructor() {}
// 添加观察者的方法
add(observer: Observer) {
this.observerList.push(observer)
}
// 主题发生变化的通知函数
notifyAll(val: string) {
this.observerList.forEach((observerInstance: Observer) => observerInstance.update(val))
}
}
import { Observer, Subject } from '.'
// 手写一个观察者模式(限时5分钟内写完)
// 总结就是 Observer 观察 Subject
const observer1 = new Observer('观察者1')
const observer2 = new Observer('观察者2')
// 创建被观察者
const subject = new Subject()
// 添加观察者
// 一个被观察者对应多个观察者 =>【一对多】
subject.add(observer1)
subject.add(observer2)
// 假设一秒后有内容更新
setTimeout(() => {
// 需要【显示调用】调用方法,才能达到通知所有观察者的目的 =>【耦合】
subject.notifyAll('1秒后更新的内容')
}, 1000)
5.2 观察者模式实现 vue3 的 mvvm 原理
5.2.1 mvvm 原理
vue3 中的 mvvm 的双向绑定的基本原理是数据劫持 + 观察者模式 + 模版编译
- 数据劫持(即响应式原理):vue3中使用的Proxy, vue2 中使用的是 Object.defineproperty,数据劫持就是把数据变成响应式的过程
- 观察者模式:同步、一对多、直接调用
- 一个完整的双向绑定的案例在本项目的 src/pages/designPattern/index.html 目录下,是从模版编译开始实现的
很多关于 vue 的双向绑定原理的教程中,有很多术语,下面是双向绑定术语和观察者模式的对应
- Dep:对应观察者模式中的主题 Subject
- 副作用 effect:对应观察者 Observer
- 依赖收集:给主题增加观察者
5.2.2 代码
这个代码很长,第一次看也看不懂,但是我的建议是自己手动写一遍,即便是抄也有用。在面试的过程中我们只要写出两个核心的类就可以了,尤其是 Dep 类和 reactive 方法。
关于 vue3 的 MVVM 原理,这部分有必要新开一个单独的文章写,因为我感觉我理解的也不透彻,在设计模式这篇文章中,我们先记住 mvvm 的实现原理是 数据劫持 + 观察者模式 + 模版编译。
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Document</title>
</head>
<body>
<div id="app">
<input id="inputTextEle" v-model="inputText"/>
<p>{{ inputText }}</p>
<p>{{ inputText }}</p>
<div>{{ message }}</div>
<div class="red">{{obj.name}}</div>
</div>
<script>
// 1. WeakMap 的键只能是对象,值可以是任何类型,没有直接获取 WeakMap 长度的方法,笨方法是可以自己打印从浏览器的控制看。
// 2. targetMap 是一个全局的 WeakMap 类型,存放了整个双向绑定(MVVM)系统中的响应式数据,和每个对象中属性对应的“通知函数”
// 3. 在本例中 targetMap 的键只有一个就是全局的 $data,即this.$data,也就是可以直接在模版中使用的各种变量,也就是在初始化 MVVM 传入的的 data 函数返回的对象
// 4. 本例中 targetMap 的唯一一个键值对的键时一个对象 => this.$data,值时一个 Map,注意这里时 Map,Map 的键可以时任何类型的数据,值也可以时任何类型的数据
// 5. 本例中targetMap 的唯一一个键值对中的值是一个一个 Map,此 Map 有多个值,每一个 Map 的键是一个 Dep 类型(对应者观察者模式中的 subject),对应着 this.$data 的每一个属性,如 inputText
// 将每一个键设置为 Dep 类型,是为了方便给每一个 subject 添加观察者,因为,Dep 中我们定义了 depend方法,用来依赖收集(也就是添加观察者)
// 值时一个集合类型的 Set,代表着每一个属性(如 inputText) 的观察者列表(列表中的每一个观察者是一个 effect,在这个例子中是一个函数), 这个 set 类型的值就是观察者模式中的观察者列表(observerList)。
const targetMap = new WeakMap()
// 判断当前【观察者】是否有效,观察者就是副作用
let activeEffect = null
// 对应观察者模式的主题 Subject【全称:依赖,dependency】在本例中,Dep 作为 targetMap 的第一个键值对的值的 Map 的key出现
class Dep {
constructor() {
// this.subscribers 对应观察者模式中的 observerList,如:作为inputText 属性的观察者
// Set 集合类型,值不会重复,每个值都是一个函数,这个函数也就是每个观察者,对应观察者模式中的 observer类的某一个实例
this.subscribers = new Set()
}
//添加观察者的方法:对应观察者模式中的 Subject 类的 add 方法
depend() {
// 保证当前的副作用(观察者)有效,才收集依赖(添加观察者)
if (activeEffect) {
// 这里面 add 是一个集合类型 Set 的一个原生方法
// activeEffect 是一个函数
this.subscribers.add(activeEffect)
}
}
// 主题(Subject/Dep)发生变化时,通知所有观察者的函数,对应观察者模式中的 notifyAll
notify() {
// 遍历所有的观察者列表,调用对应的更新函数
// 某个属性比如 inputText 在模版中使用了 n 次,它对应的观察者就有 n 个,即 subscribers.size === n ,subscribers 是个 set 类型
this.subscribers.forEach(effect => {
// 这里打印 effect 函数中的 fn, 是闭包的原理
effect()
})
}
}
// 依赖收集,对应观察者模式中的添加观察者
function track(target, key) {
// target: this.$data
// key 是每一个属性 如:message、inputText 等
// 判断全局的观察者列表(说是列表其实是一个 WeakMap) targetMap 中是否已经有同样的值
let depsMap = targetMap.get(target)
if (!depsMap) {
// 如果没有值,新建一个 Map,注意不是 WeakMap 了
// Map 的键可以是任意类型的
depsMap = new Map()
// 给全局观察者列表增加一个属性,键:target,值是上面新建的空的 Map
targetMap.set(target, depsMap)
}
// 取出每个指定 key 的对应的依赖,dep 的类型是 Map
let dep = depsMap.get(key)
if (!dep) {
// 如果没有值,创建一个新的 Dep(主题) 实例
dep = new Dep()
// 给每一个属性都增加一个对应的 Dep 依赖实例
depsMap.set(key, dep)
}
// 添加观察者,对应观察者模式的 subject.add(observer1) 方法
dep.depend()
}
// 主题变化通知所有观察者,进而调用每个观察者对应的更新函数(effect)
function trigger(target, key) {
// target: 就是 this.vm.$data
// key: $data 中的每一个属性
const depsMap = targetMap.get(target)
if (!depsMap) return
// 取出某个 key 对应的主题函数,调用这个主题的观察者列表中的所有观察者的更新函数
const dep = depsMap.get(key)
if (dep) {
dep.notify()
}
}
// 响应式方法,数据劫持
function reactive(target) {
// 使用代理代理整个对象
// target 就是整个 $data 对象
return new Proxy(target, {
get(target, key, receiver) {
// key 对象中的每一个属性,inputText、message 等
// 获取值的时候开始追踪对象,也就是“依赖收集”
track(target, key)
// 替换 .运算符,来获取对象的某个属性
// 静态方法 Reflect.get 允许你从一个对象(target)中读取一个属性(key)的值
return Reflect.get(target, key, receiver)
},
set(target, key, value, receiver) {
const oldValue = target[key]
// 静态方法 Reflect.set 在对象(target)上设置一个属性(key)值为(value),替换 . 运算符
const result = Reflect.set(target, key, value, receiver)
// 如果值有变化(即,主题变化),就调用主题 Dep 的通知函数 notify,进而调用这个主题(Dep)每个观察者的更新函数 effect
if (oldValue !== value) {
trigger(target, key)
}
return result
},
})
}
// 对应观察者模式的 Observer 类
// effect 回调的 fn 对应观察者 update 函数
function effect(fn) {
const effectFn = () => {
// 在执行前将当前副作用 effect 设置为当前激活的 effect,以便依赖收集时能正确关联
// 全局的“当前有效的副作用” 等于函数本身
// 保证整个 副作用effect 有效的时候才调用观察者的更新函数
activeEffect = effectFn
fn()
activeEffect = null
}
effectFn()
}
// 虚拟 DOM 和模版编译器
class Compiler {
constructor(el, vm) {
// 根据传入的选择器,获取真实的 dom
this.el = document.querySelector(el)
// vm === 传入的 MVVM 的实例(也就是 MVVM 中的 this)
// 此时 vm 是一个对象有属性 $data 和 $options(这个貌似没啥用)
this.vm = vm
// 编译获取的模版节点
this.compile(this.el)
}
// 编译 DOM 节点
compile(node) {
if (node.nodeType === 1) {
// 元素节点
this.compileElement(node)
} else if (node.nodeType === 3) {
// 文本节点
this.compileText(node)
}
// 递归子节点
if (node.childNodes && node.childNodes.length) {
Array.from(node.childNodes).forEach(child => this.compile(child))
}
}
// 编译节点
compileElement(node) {
// 遍历每个节点的属性
Array.from(node.attributes).forEach(attr => {
// attr 是类似 v-model="inputText" 的字符串
// 找到 v- 开头的指令
if (attr.name.startsWith('v-')) {
const dir = attr.name.substring(2)
// 找到 v-model 属性
if (dir === 'model') {
// attr.value 对应原生输入框的的 value 属性
this.handleModel(node, attr.value)
}
}
})
}
// 处理输入框的 v-model 指令
handleModel(node, exp) {
// node 是对应的 DOM 节点,
// exp 是每个指令对应的变量名称,如 v-model="inputText" 中的 inputText
// 更新 DOM 中的值
this.update(node, exp, 'model')
node.addEventListener('input', e => {
this.vm.$data[exp] = e.target.value
this.vm.$data['obj']['name'] = 'hh'
})
}
// 编译文本节点,根据模版中花括号 {{}} 中的内容
compileText(node) {
// 正则表达式,匹配双花括号中的内容
const reg = /\{\{(.*?)\}\}/g
if (reg.test(node.textContent)) {
this.update(node, RegExp.$1, 'text')
}
}
// 更新 DOM 中的值
update(node, exp, dir) {
// node DOM 节点
// exp 模版中花括号 {{ }} 中的所有内容,因为前后可能会包含空格,所以需要 .trim()
// dir 类型,如 model、text
exp = exp.trim()
// 更新函数,这个是所有观察者的更新函数,以参数的形式传入【创建观察者的函数 effect 】中作为回调
const updater = () => {
if (dir === 'model') {
// 根据最新的数据更新页面上的节点中的内容
node.value = this.vm.$data[exp]
} else if (dir === 'text') {
// 根据最新的数据更新页面上的节点中的内容
// 简单处理对象的情况
if (exp.includes('.')) {
// 处理对象 obj.name 实际还需要数组等各种类型
const arr = exp.split('.')
const objName = arr[0]
const attrName = arr[1]
node.textContent = this.vm.$data[objName][attrName]
} else {
node.textContent = this.vm.$data[exp]
}
}
}
// 创建观察者,对应实例化观察者模式中的 Observer 类,new Observer
effect(() => {
updater()
})
}
}
// MVVM 类
class MVVM {
// 构造函数
constructor(options) {
this.$options = options.data
// 类的私有变量,也就是整个系统中的数据,调用函数,保证每个组件实例不相互影响
this.$data = options.data()
// 这一步是使用 proxy 代理整个对象,vue3的原理,数据劫持
this.$data = this.observe(this.$data)
// Proxy 实例无法通过 instanceof 检测,会报错,且没有暴漏内部属性,可以自己打印然后在浏览器的控制台看到 Proxy 字样
// 编译模板,第一个参数是挂载的 dom
new Compiler(options.el, this)
}
// 监控整个对象,
observe(data) {
// 让 $data 变成响应式的,返回一个代理对象
return reactive(data)
}
}
// 初始化双向绑定系统,对应 vue2 项目中的 $mount('#app')方法
// 对应 vue3 中的 app.mount('#app')
const app = new MVVM({
el: '#app', // 根节点元素
// 相当于 vue2 中的 data
// vue2 中data 是一个函数,返回了一个对象,为了避免多个组件实例之间的对象相互影响
// 这个 data 函数在 MVVM 的类的构造函数中会调用
data: () => {
return {
message: 'Hello MVVM!',
inputText: 'Edit me',
obj: {
name: '小红'
}
}
}
})
</script>
</body>
</html>
5.3 发布-订阅模式
5.3.1 基本说明
发布订阅模式的核心有几下几点:
- 关键字:解构、异步、非显式调用、多对多
- 发布订阅的另一个名称是事件总线
- 需要手动实现一个发布订阅模式 class
- class 中需要含有一下几个方法
- on 监听
- off 移除监听
- emit 发布
- once 只执行一次的事件
发布订阅模式是在 vue 项目开发中最常用的一个设计模式,因为他真的很方便,尤其是组件层级很深的时候传递消息很方便。
但是也有不好的点,就是使用发布订阅传递消息,不好追踪消息来源,因为可能是任何组件发送或监听消息的结果。
有一个很好用的包 eventemitter3 可供我们使用,这样就不用每次都手写了。eventemitter3 - npmEventEmitter3 focuses on performance while maintaining a Node.js AND browser compatible interface.. Latest version: 5.0.1, last published: 2 years ago. Start using eventemitter3 in your project by running `npm i eventemitter3`. There are 6598 other projects in the npm registry using eventemitter3.
https://www.npmjs.com/package/eventemitter3
5.3.2 代码
下面是部分核心代码,再 vue 中使用的完整的代码可以在项目中找到。
// 发布订阅模式,订阅发布模式又名事件总线
export class EventBus {
// 所有的监听的事件
allEvent: any = {}
constructor() {}
// 订阅
on(eventName: string, callback: Function, isOnce: boolean = false) {
// 如果没有监听过该事件,那么增加一个对应名称的事件监听,值时一个数组,代表着可以多次监听
if (!this.allEvent[eventName]) {
this.allEvent[eventName] = []
}
this.allEvent[eventName].push({
callback: callback,
isOnce: isOnce,
})
}
// 移除监听
off(eventName: string, callback: Function) {
if (!this.allEvent[eventName]) {
return
}
// 注意要增加这部,如果不传 callback,把所有监听都移除
if (!callback) {
delete this.allEvent[eventName]
return
}
const allCallback = this.allEvent[eventName]
// 找到指定的函数,并且移除
const targetCallbackIndex = allCallback.findIndex((item: any) => item.callback === callback)
// 需要做数组越界处理
if (targetCallbackIndex >= 0) {
// 移除某个监听函数
allCallback.splice(targetCallbackIndex, 1)
// 注意着步,当没有任何监听函数的时候,删除改属性
if (allCallback.length === 0) {
delete this.allEvent[eventName]
}
}
}
// 发送消息
emit(eventName: string, ...args: any) {
if (!this.allEvent[eventName]) {
return
}
const allParams = args
// 避免遍历原数组,否则在遍历的时候调用了 off方法会导致出错,所以使用副本
const targetCallbackList = [...this.allEvent[eventName]]
targetCallbackList.forEach((item: any) => {
item.callback(...allParams)
if (item.isOnce) {
this.off(eventName, item.callback)
}
})
}
// 只执行一次的消息,调用本来就有的函数 on,加一个标志符,而不是重新写一个方法
once(eventName: string, callback: Function) {
this.on(eventName, callback, true)
}
}
import { EventBus } from './index'
// 需要在10 分钟内完成
const eventBus = new EventBus()
const changeName = (name: string) => {
console.log('名字修改了', name)
}
eventBus.once('change-name', changeName) // 值执行一次
// eventBus.on('change-name', changeName)
changeName('小明')
setTimeout(() => {
eventBus.emit('change-name', '小红') // 使用 once 多次发送消息只执行一次
eventBus.emit('change-name', '小红')
}, 1000)
</script>
六、命令模式
6.1 基本说明
命令模式是最简单和优雅的模式之一,没错,它居然是最简单的,但是我之前一次都没用过,命令模式有以下特点:
- 命令之间松耦合
- 有一个 command 抽象类,command 类有一个 execute 方法、一个 undo方法,所有具体的命令都继承这个抽象类
- 有一个 commandHistory 作为命令历史,即命令管理器
- 支持撤销和重做操作
- 撤销命令可以用于实现文本编辑器 ctrl + z 功能
- 有一个命令队列,依次存放待执行的命令,为了实现撤销和重做,要记录当前命令的指针,根据指针左右移动实现撤销和重做
- 命令模式的由来,其实是回调函数(callback)函数的一个面向对象的替代品
6.2 使用命令模式实现编辑器
我之前有一个面试的时候,我说我做过富文本编辑器相关的功能,然后他问我那你知道撤销的原理是什么吗?我当时回答不知道,现在学习了命令模式之后,我想他希望得到的答案就是命令模式的应用。
6.2.1 基本需求
一个简单的编辑器,有四个按钮,分别是
- 【增加文字】按钮:点击增加文案“你好”
- 【增加表情】按钮:点击增加表情”❤*❤“
- 【撤销】按钮:点击撤销上一个命令
- 【重做】按钮:点击重做上一个命令
实现的基本页面如下,在下面可以看到是一系列的命令列表,红色的一行是当前的命令指针,支持撤销重做的原理就是在命令列表中移动指针,找到前一个或者后一个命令,然后执行。
6.2.2 代码
// 命令的父类
export class Command {
// 命令的接受者
receiver: any = null
constructor(receiver: any) {
this.receiver = receiver
}
execute() {}
undo() {}
}
// 命令管理器/命令历史
export class CommandHistory {
// 顺序存放命令执行的历史,就是一个命令队列
commandList: Command[] = []
// 当前执行的命令的索引,永远是最后一个执行的命令的索引
currentIndex = -1
constructor() {}
// 执行命令,向命令管理器中增加一个命令,并执行
executeCommand(command: Command) {
// 从当前索引的下一个位置开始,删除数组后面的所有元素
// 删除后续所有旧命令
// 输入文字 A → B → C(currentIndex=2)
// 按撤销回到 B(currentIndex=1)
// 此时若直接输入新内容 D,系统会自动删除 C,确保历史记录是 A → B → D
this.commandList.splice(this.currentIndex + 1)
// 增加一个新命令
this.commandList.push(command)
// 当前执行的命令索引 + 1
this.currentIndex++
// 执行这个命令
command.execute()
}
undo() {
// 撤销命令,当索引有效的时候
if (this.currentIndex >= 0) {
// 注意:先执行当前索引的撤销,再左移索引
// 执行索引所在的命令
this.commandList[this.currentIndex].undo()
// 更新索引: 索引左移
this.currentIndex--
}
}
redo() {
// 重做:索引右移动,且有效
if (this.currentIndex < this.commandList.length - 1) {
// 注意右移索引,再执行新索引所在的命令
// 更新索引:索引右移
this.currentIndex++
// 执行命令
this.commandList[this.currentIndex].execute()
}
}
}
// 命令的接收者:编辑器类
export class TextEditor {
content: string = ''
// 用于更新页面上内容的回调
updateCb: any = null
constructor(content: string, updateCb: any) {
this.content = content
this.updateCb = updateCb
}
// 累加文本
addText(text: string) {
this.content += text
this.updateCb(this.content)
}
// 获取文本
getText() {
return this.content
}
// 替换文本
setText(text: string) {
this.content = text
this.updateCb(this.content)
}
}
// 添加文字的命令,继承抽象命令 Command
export class AddTextCommand extends Command {
// 参数传入的,需要更新的文本
text: string = ''
// 上一步的文本
previousText: string = ''
// 命令的唯一标识
id: string = 'addText'
constructor(receiver: any, text: string) {
// 调用父类的构造函数,接收命令的接受者
super(receiver)
this.text = text
this.previousText = ''
}
execute(): void {
// 找到之前的文案,用于撤销
this.previousText = this.receiver.getText()
// 增加文本
this.receiver.addText(this.text)
}
undo(): void {
// 撤销:使用上一步的文本直接替换整个文本内容
this.receiver.setText(this.previousText)
}
}
// 添加字符的命令,继承抽象命令 Command
export class AddCharCommand extends Command {
// 参数传入的,需要更新的字符
char: string = ''
// 上一步的文本
previousText = ''
// 命令的唯一标识
id: string = 'addChar'
constructor(receiver: any, char: string) {
// 调用父类的构造函数,接收命令的接受者
super(receiver)
this.char = char
this.previousText = ''
}
execute(): void {
// 找到之前的文案,用于撤销
this.previousText = this.receiver.getText()
// 增加字符
this.receiver.addText(this.char)
}
undo(): void {
// 撤销:使用上一步的文本直接替换整个文本内容
this.receiver.setText(this.previousText)
}
}
<template>
<div class="page-container">
<div class="sample-box">
<div class="btn-groups">
<div v-for="item in btnList" :key="item.id" class="btn" @click="item.onclick">{{ item.name }}</div>
</div>
<div ref="outputEle" class="content"></div>
<div class="title">命令队列</div>
<div class="command-box">
<div class="command-item header">
<div>id</div>
<div>text</div>
<div>previousText</div>
<div>command info</div>
</div>
<div v-for="(item, index) in curCommandList" :key="index" class="command-item" :class="{ 'is-current': currentIndex === index }">
<div>{{ item.id }}</div>
<div>{{ item.text || item.char }}</div>
<div>{{ item.previousText }}</div>
<div :title="JSON.stringify(item)">{{ item }}</div>
</div>
</div>
</div>
</div>
</template>
<script lang="ts" setup>
import { ref, computed } from 'vue'
import { AddCharCommand, AddTextCommand, CommandHistory, TextEditor } from './index2'
// 10分钟写完
const outputEle = ref()
// 编辑器的实例
const editor = new TextEditor('', (text: string) => {
if (outputEle.value) {
outputEle.value.innerText = text
}
})
// 命令管理器的实例
const commandManage = ref(new CommandHistory())
// 定义一组操作命令
const btnList = ref([
{
id: 'add',
name: '增加文字',
onclick: () => {
// 每一个命令执行的语句,使用命令管理器执行
// 新建命令的时候,把命令的接收者 receiver 传入,如:editor,同时传入要增加的文字
commandManage.value.executeCommand(new AddTextCommand(editor, '你好'))
},
},
{
id: 'add表情',
name: '增加表情',
onclick: () => {
// 每一个命令执行的语句,使用命令管理器执行
// 新建命令的时候,把命令的接收者 receiver 传入,如:editor,同时传如要增加的表情
commandManage.value.executeCommand(new AddCharCommand(editor, '❤*❤'))
},
},
{
id: 'undo',
name: '撤销',
onclick: () => {
// 直接使用“命令管理器”的撤销命令即可,意味着“命令管理器”中必须有一个 undo 方法
commandManage.value.undo()
},
},
{
id: 'redo',
name: '重做',
onclick: () => {
// 直接使用“命令管理器”的重做命令即可,意味着“命令管理器”中必须有一个 redo 方法
commandManage.value.redo()
},
},
])
// 命令队列
const curCommandList = computed(() => {
return commandManage.value.commandList
})
// 当前指针
const currentIndex = computed(() => {
return commandManage.value.currentIndex
})
</script>
<style lang="scss" scoped>
.page-container {
padding: 24px;
ol {
list-style: decimal;
padding-left: 14px;
}
.title {
margin: 10px 0;
}
.highlight {
color: red;
}
.sample-box {
display: flex;
flex-direction: column;
margin-top: 10px;
min-height: 200px;
padding: 10px;
border: 1px solid #ccc;
border-radius: 6px;
background: #eee;
user-select: none;
.btn-groups {
width: 100%;
display: flex;
align-items: center;
justify-content: flex-start;
flex-shrink: 0;
.btn {
margin-right: 10px;
padding: 4px 10px;
border-radius: 4px;
border: 1px solid #ccc;
cursor: pointer;
}
}
.content {
margin-top: 10px;
width: 100%;
padding: 10px;
border: 1px solid;
border-radius: 5px;
height: 100px;
}
.command-box {
display: flex;
flex-direction: column;
align-items: flex-start;
justify-content: flex-start;
padding: 10px;
border: 1px solid;
border-radius: 4px;
.command-item {
display: flex;
align-items: center;
justify-content: space-between;
margin-top: 10px;
width: 100%;
background: #ddd;
&.header {
background: #aaa;
}
div {
width: 10%;
height: 40px;
padding: 10px;
display: inline-flex;
align-items: center;
justify-content: flex-start;
flex-shrink: 0;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
&:nth-child(4) {
width: 50%;
}
}
&.is-current {
background: red; // currentIndex 当前指针所在的位置
color: #fff;
}
}
}
}
}
</style>
七、总结
本篇文章总结了六个设计模式,我感觉最常用的就是下面这三个,所以一定要弄懂。
- 策略模式
- 命令模式
- 发布订阅模式【最重要】
learn-vite: 搭建简单的vite+ts+vue框架https://gitee.com/yangjihong2113/learn-vite
感谢大家阅读,欢迎关注,我们一起学习进步,我会持续更新前端开发相关的系统化的教程,新手建议关注我的系统化专栏《前端工程化系统教程》所有教程都包含源码,内容持续更新中希望对你有帮助。