# 设计模式
# 1 创建型-工厂模式
# 1.1 简单工厂模式
其实就是将创建对象的过程单独封装,同时它的应用场景也非常容易识别:有构造函数的地方,我们就应该想到简单工厂;在写了大量构造函数、调用了大量的 new、自觉非常不爽的情况下,我们就应该思考是不是可以掏出工厂模式重构我们的代码了
// 给不同工种分配职责说明
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 = ['喝茶', '看报', '见客户']
break;
}
}
return new User(name, age, career, work)
}
const wang = new Factory('wang', 50, 'boss');
const sun = new Factory('sun', 25, 'coder');
const li = new Factory('li', 30, 'product-manager');
console.log(wang)
console.log(sun)
console.log(li)
# 1.2 抽象工厂模式
// 定义操作系统这类产品的抽象产品类
class OS {
constructor() {
if (new.target === OS) {
throw new Error('不能实例化抽象类');
}
}
}
// 定义具体操作系统的具体产品类
class AndroidOS extends OS {
constructor() {
super();
}
controlHardware() {
console.log('我会用安卓的方式去操作硬件');
}
}
class AppleOS extends OS {
constructor() {
super();
}
controlHardware() {
console.log('我会用苹果的方式去操作硬件');
}
}
// 定义手机硬件这类产品的抽象产品类
class Hardware {
constructor() {
if (new.target === Hardware) {
throw new Error('不能实例化抽象类');
}
}
// 手机硬件的共性方法,这里提取了“根据命令运转”这个共性
operateByOrder() {
throw new Error('抽象产品方法不允许直接调用,你需要将我重写!');
}
}
// 定义具体硬件的具体产品类
class QualcommHardware extends Hardware {
constructor() {
super();
}
operateByOrder() {
console.log('我会用高通的方式去运转')
}
}
class MiWare extends Hardware {
constructor() {
super();
}
operateByOrder() {
console.log('我会用小米的方式去运转')
}
}
class MobilePhoneFactory {
// 提供操作系统的接口
createOS() {
throw new Error("抽象工厂方法不允许直接调用,你需要将我重写!");
}
// 提供硬件的接口
createHardware() {
throw new Error("抽象工厂方法不允许直接调用,你需要将我重写!");
}
}
// 具体工厂继承自抽象工厂
class FakeStarFactory extends MobilePhoneFactory {
constructor() {
super()
}
createOS() {
// 提供安卓系统实例
return new AndroidOS()
}
createHardware() {
// 提供高通硬件实例
return new QualcommHardware()
}
}
// 这是我的手机
const myPhone = new FakeStarFactory()
// 让它拥有操作系统
const myOS = myPhone.createOS()
// 让它拥有硬件
const myHardWare = myPhone.createHardware()
// 启动操作系统(输出‘我会用安卓的方式去操作硬件’)
myOS.controlHardware()
// 唤醒硬件(输出‘我会用高通的方式去运转’)
myHardWare.operateByOrder()
抽象工厂和简单工厂的思路,思考一下:它们之间有哪些异同?
- 它们的共同点,在于都尝试去分离一个系统中变与不变的部分。
- 它们的不同在于场景的复杂度。
在简单工厂的使用场景里,处理的对象是类,并且是一些非常好对付的类——它们的共性容易抽离,同时因为逻辑本身比较简单,故而不苛求代码可扩展性。抽象工厂本质上处理的其实也是类,但是是一帮非常棘手、繁杂的类,这些类中不仅能划分出门派,还能划分出等级,同时存在着千变万化的扩展可能性——这使得我们必须对共性作更特别的处理、使用抽象类去降低扩展的成本,同时需要对类的性质作划分
# 2 创建型-单例模式
保证一个类仅有一个实例,并提供一个访问它的全局访问点,这样的模式就叫做单例模式。
# 2.1 单例模式的实现思路
- 单例模式想要做到的是,不管我们尝试去创建多少次,它都只给你返回第一次所创建的那唯一的一个实例。
- 要做到这一点,就需要构造函数具备判断自己是否已经创建过一个实例的能力。我们现在把这段判断逻辑写成一个静态方法(其实也可以直接写入构造函数的函数体里):
// 定义:保证一个类仅有一个实例,并提供一个访问它的全局访问点。
class Singleton {
constructor(name) {
this.name = name;
this.instance = null;
}
getName(){
console.log(this.name);
}
getInstance(name){
if(!this.instance){
this.instance = new Singleton(name);
}
return this.instance;
}
}
const singleton = new Singleton();
const a = singleton.getInstance('a');
const b = singleton.getInstance('b');
console.log(a);
console.log(b);
console.log(a === b);
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
除了楼上这种实现方式之外,getInstance
的逻辑还可以用闭包来实现:
SingleDog.getInstance = (function() {
// 定义自由变量instance,模拟私有变量
let instance = null
return function() {
// 判断自由变量是否为null
if(!instance) {
// 如果为null则new出唯一实例
instance = new SingleDog()
}
return instance
}
})()
可以看出,在
getInstance
方法的判断和拦截下,我们不管调用多少次,SingleDog
都只会给我们返回一个实例,s1
和s2
现在都指向这个唯一的实例
# 2.2 生产实践:Vuex中的单例模式
Redux
和Vuex
,它们都实现了一个全局的Store
用于存储应用的所有状态。这个Store
的实现,正是单例模式的典型应用
1. 理解 Vuex 中的 Store
Vuex
使用单一状态树,用一个对象就包含了全部的应用层级状态。至此它便作为一个“唯一数据源 (SSOT)”而存在。这也意味着,每个应用将仅仅包含一个 store 实例。单一状态树让我们能够直接地定位任一特定的状态片段,在调试的过程中也能轻易地取得整个当前应用状态的快照。 ——Vuex官方文档
当组件非常多、组件间关系复杂、且嵌套层级很深的时候,这种原始的通信方式会使我们的逻辑变得复杂难以维护。这时最好的做法是将共享的数据抽出来、放在全局,供组件们按照一定的的规则去存取数据,保证状态以一种可预测的方式发生变化。于是便有了 Vuex,这个用来存放共享数据的唯一数据源,就是 Store。
2. Vuex如何确保Store的唯一性
我们先来看看如何在项目中引入 Vuex
:
// 安装vuex插件
Vue.use(Vuex)
// 将store注入到Vue实例中
new Vue({
el: '#app',
store
})
通过调用
Vue.use()
方法,我们安装了Vuex
插件。Vuex
插件是一个对象,它在内部实现了一个install
方法,这个方法会在插件安装时被调用,从而把Store
注入到Vue
实例里去。也就是说每install
一次,都会尝试给Vue
实例注入一个Store
在install
方法里,有一段逻辑和我们楼上的 getInstance
非常相似的逻辑:
let Vue // 这个Vue的作用和楼上的instance作用一样
...
export function install (_Vue) {
// 判断传入的Vue实例对象是否已经被install过Vuex插件(是否有了唯一的state)
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)
}
楼上便是
Vuex
源码中单例模式的实现办法了,套路可以说和我们的getInstance
如出一辙。通过这种方式,可以保证一个Vue
实例(即一个 Vue 应用)只会被install
一次Vuex
插件,所以每个 Vue 实例只会拥有一个全局的Store
3. 思考时间
思考一下:如果我在
install
里没有实现单例模式,会带来什么样的麻烦?
我们通过上面的源码解析可以看出,每次 install
都会为Vue实例初始化一个 Store
。假如 install
里没有单例模式的逻辑,那我们如果在一个应用里不小心多次安装了插件:
// 在主文件里安装Vuex
Vue.use(Vuex)
...(中间添加/修改了一些store的数据)
// 在后续的逻辑里不小心又安装了一次
Vue.use(Vuex)
失去了单例判断能力的 install
方法,会为当前的Vue实例重新注入一个新的 Store
,也就是说你中间的那些数据操作全都没了,一切归 0。因此,单例模式在此处是非常必要的。
除了说在 Vuex
中大展身手,我们在 Redux
、jQuery
等许多优秀的前端库里也都能看到单例模式的身影
# 2.3 单例模式——面试真题
实现一个
Storage
1. 描述
实现
Storage
,使得该对象为单例,基于localStorage
进行封装。实现方法setItem(key,value)
和getItem(key)
2. 思路
具体实现上,把判断逻辑写入静态方法或者构造函数里都没关系,最好能把闭包的版本也写出来
3. 实现:静态方法版
// 定义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
4. 实现: 闭包版
// 先实现一个基础的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
# 2.4 实现一个全局的模态框
实现一个全局唯一的Modal弹框
思路
这道题比较经典,基本上所有讲单例模式的文章都会以此为例,同时它也是早期单例模式在前端领域的最集中体现。
万变不离其踪,记住
getInstance
方法、记住instance
变量、记住闭包和静态方法,这个题除了要多写点 HTML 和CSS
之外,对大家来说完全不成问题。
实现
<!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 版本的实现大家自己尝试默写一下,相信对现在的你来说已经非常简单了)。
这就是单例模式面试题的特点,准确地说,是所有设计模式相关面试题的特点——牢记核心思路,就能举一反三。所以说设计模式的学习是典型的一分耕耘一分收获,性价比极高。
# 3 结构型-装饰器模式
装饰器模式,又名装饰者模式。它的定义是“在不改变原对象的基础上,通过对其进行包装拓展,使原有对象可以满足用户的更复杂需求”。
# 3.1 装饰器模式初相见
为了不被已有的业务逻辑干扰,当务之急就是将旧逻辑与新逻辑分离,把旧逻辑抽出去:
// 将展示Modal的逻辑单独封装
function openModal() {
const modal = new Modal()
modal.style.display = 'block'
}
编写新逻辑:
// 按钮文案修改逻辑
function changeButtonText() {
const btn = document.getElementById('open')
btn.innerText = '快去登录'
}
// 按钮置灰逻辑
function disableButton() {
const btn = document.getElementById('open')
btn.setAttribute("disabled", true)
}
// 新版本功能逻辑整合
function changeButtonStatus() {
changeButtonText()
disableButton()
}
然后把三个操作逐个添加open按钮的监听函数里:
document.getElementById('open').addEventListener('click', function() {
openModal()
changeButtonStatus()
})
如此一来,我们就实现了“只添加,不修改”的装饰器模式,使用changeButtonStatus的逻辑装饰了旧的按钮点击逻辑。以上是ES5中的实现,ES6中,我们可以以一种更加面向对象化的方式去写:
// 定义打开按钮
class OpenButton {
// 点击后展示弹框(旧逻辑)
onClick() {
const modal = new Modal()
modal.style.display = 'block'
}
}
// 定义按钮对应的装饰器
class Decorator {
// 将按钮实例传入
constructor(open_button) {
this.open_button = open_button
}
onClick() {
this.open_button.onClick()
// “包装”了一层新逻辑
this.changeButtonStatus()
}
changeButtonStatus() {
this.changeButtonText()
this.disableButton()
}
disableButton() {
const btn = document.getElementById('open')
btn.setAttribute("disabled", true)
}
changeButtonText() {
const btn = document.getElementById('open')
btn.innerText = '快去登录'
}
}
const openButton = new OpenButton()
const decorator = new Decorator(openButton)
document.getElementById('open').addEventListener('click', function() {
// openButton.onClick()
// 此处可以分别尝试两个实例的onClick方法,验证装饰器是否生效
decorator.onClick()
})
大家这里需要特别关注一下 ES6 这个版本的实现,这里我们把按钮实例传给了 Decorator,以便于后续 Decorator 可以对它为所欲为进行逻辑的拓展。在 ES7 中,
Decorator
作为一种语法被直接支持了,它的书写会变得更加简单,但背后的原理其实与此大同小异
# 3.2 深入装饰器原理
在 ES7 中,我们可以像写 python 一样通过一个@
语法糖轻松地给一个类装上装饰器:
// 装饰器函数,它的第一个参数是目标类
function classDecorator(target) {
target.hasDecorator = true
return target
}
// 将装饰器“安装”到Button类上
@classDecorator
class Button {
// Button类的相关逻辑
}
// 验证装饰器是否生效
console.log('Button 是否被装饰了:', Button.hasDecorator)
也可以用同样的语法糖去装饰类里面的方法:
// 具体的参数意义,在下个小节,这里大家先感知一下操作
function funcDecorator(target, name, descriptor) {
let originalMethod = descriptor.value
descriptor.value = function() {
console.log('我是Func的装饰器逻辑')
return originalMethod.apply(this, arguments)
}
return descriptor
}
class Button {
@funcDecorator
onClick() {
console.log('我是Func的原有逻辑')
}
}
// 验证装饰器是否生效
const button = new Button()
button.onClick()
# 3.3 装饰器语法糖背后的故事
所谓语法糖,往往意味着“美好的表象”。正如
class
语法糖背后是大家早已十分熟悉的 ES5 构造函数一样,装饰器语法糖背后也是我们的老朋友,不信我们一起来看看@decorator
都帮我们做了些什么:
上一节我们使用
ES6
实现装饰器模式时曾经将按钮实例传给了Decorator
,以便于后续Decorator
可以对它进行逻辑的拓展。这也正是装饰器的最最基本操作——定义装饰器函数,将被装饰者“交给”装饰器。这也正是装饰器语法糖首先帮我们做掉的工作 —— 函数传参&调用。
1. 类装饰器的参数
当我们给一个类添加装饰器时:
function classDecorator(target) {
target.hasDecorator = true
return target
}
// 将装饰器“安装”到Button类上
@classDecorator
class Button {
// Button类的相关逻辑
}
此处的 target
就是被装饰的类本身。
2. 方法装饰器的参数
而当我们给一个方法添加装饰器时:
function funcDecorator(target, name, descriptor) {
let originalMethod = descriptor.value
descriptor.value = function() {
console.log('我是Func的装饰器逻辑')
return originalMethod.apply(this, arguments)
}
return descriptor
}
class Button {
@funcDecorator
onClick() {
console.log('我是Func的原有逻辑')
}
}
此处的 target
变成了Button.prototype
,即类的原型对象。这是因为 onClick
方法总是要依附其实例存在的,修饰 onClik
其实是修饰它的实例。但我们的装饰器函数执行的时候,Button
实例还并不存在。为了确保实例生成后可以顺利调用被装饰好的方法,装饰器只能去修饰 Button
类的原型对象。
3. 装饰器函数调用的时机
装饰器函数执行的时候,
Button
实例还并不存在。这是因为实例是在我们的代码运行时动态生成的,而装饰器函数则是在编译阶段就执行了。所以说装饰器函数真正能触及到的,就只有类这个层面上的对象
# 3.4 将“属性描述对象”交到你手里
在编写类装饰器时,我们一般获取一个
target
参数就足够了。但在编写方法装饰器时,我们往往需要至少三个参数:
function funcDecorator(target, name, descriptor) {
let originalMethod = descriptor.value
descriptor.value = function() {
console.log('我是Func的装饰器逻辑')
return originalMethod.apply(this, arguments)
}
return descriptor
}
第一个参数的意义,前文已经解释过。第二个参 数name,是我们修饰的目标属性属性名,也没啥好讲的。关键就在这个 d
escriptor
身上,它也是我们使用频率最高的一个参数,它的真面目就是“属性描述对象”(attributes object
)。这个名字大家可能不熟悉,但Object.defineProperty
方法我想大家多少都用过,它的调用方式是这样的:
Object.defineProperty(obj, prop, descriptor)
此处的
descriptor
和装饰器函数里的descriptor
是一个东西,它是JavaScript
提供的一个内部数据结构、一个对象,专门用来描述对象的属性。它由各种各样的属性描述符组成,这些描述符又分为数据描述符和存取描述符:
- 数据描述符:
- 包括
value
(存放属性值,默认为默认为undefined
) writable
(表示属性值是否可改变,默认为true)enumerable
(表示属性是否可枚举,默认为true
)configurable
(属性是否可配置,默认为true
)。
- 包括
- 存取描述符:
- 包括
get
方法(访问属性时调用的方法,默认为undefined
) set
(设置属性时调用的方法,默认为undefined
)
- 包括
很明显,拿到了
descriptor
,就相当于拿到了目标方法的控制权。通过修改descriptor
,我们就可以对目标方法为所欲为的逻辑进行拓展了~
在上文的示例中,我们通过
descriptor
获取到了原函数的函数体(originalMethod
),把原函数推迟到了新逻辑(console)的后面去执行。这种做法和我们上一节在ES5中实现装饰器模式时做的事情一模一样,所以说装饰器就是这么回事儿,换汤不换药~
# 3.5 React中的装饰器:HOC
高阶组件就是一个函数,且该函数接受一个组件作为参数,并返回一个新的组件。
HOC (Higher Order Component) 即高阶组件。它是装饰器模式在 React 中的实践,同时也是 React 应用中非常重要的一部分。通过编写高阶组件,我们可以充分复用现有逻辑,提高编码效率和代码的健壮性。
我们现在编写一个高阶组件,它的作用是把传入的组件丢进一个有红色边框的容器里(拓展其样式)。
import React, { Component } from 'react'
const BorderHoc = WrappedComponent => class extends Component {
render() {
return <div style={{ border: 'solid 1px red' }}>
<WrappedComponent />
</div>
}
}
export default borderHoc
用它来装饰目标组件
import React, { Component } from 'react'
import BorderHoc from './BorderHoc'
// 用BorderHoc装饰目标组件
@BorderHoc
class TargetComponent extends React.Component {
render() {
// 目标组件具体的业务逻辑
}
}
// export出去的其实是一个被包裹后的组件
export default TargetComponent
可以看出,高阶组件从实现层面来看其实就是上文我们提到的类装饰器。在高阶组件的辅助下,我们不必因为一个小小的拓展而大费周折地编写新组件或者把一个新逻辑重写
N
多次,只需要轻轻@
一下装饰器即可。
# 3.6 使用装饰器改写 Redux connect
Redux 是热门的状态管理工具。在 React 中,当我们想要引入 Redux 时,通常需要调用 connect 方法来把状态和组件绑在一起:
import React, { Component } from 'react'
import { connect } from 'react-redux'
import { bindActionCreators } from 'redux'
import action from './action.js'
class App extends Component {
render() {
// App的业务逻辑
}
}
function mapStateToProps(state) {
// 假设App的状态对应状态树上的app节点
return state.app
}
function mapDispatchToProps(dispatch) {
// 这段看不懂也没关系,下面会有解释。重点理解connect的调用即可
return bindActionCreators(action, dispatch)
}
// 把App组件与Redux绑在一起
export default connect(mapStateToProps, mapDispatchToProps)(App)
这里给没用过 redux 的同学解释一下 connect 的两个入参:mapStateToProps
是一个函数,它可以建立组件和状态之间的映射关系;mapDispatchToProps
也是一个函数,它用于建立组件和store.dispatch
的关系,使组件具备通过 dispatch 来派发状态的能力。
总而言之,我们调用 connect 可以返回一个具有装饰作用的函数,这个函数可以接收一 个React 组件作为参数,使这个目标组件和 Redux 结合、具备 Redux 提供的数据和能力。既然有装饰作用,既然是能力的拓展,那么就一定能用装饰器来改写:
把 connect
抽出来:
import { connect } from 'react-redux'
import { bindActionCreators } from 'redux'
import action from './action.js'
function mapStateToProps(state) {
return state.app
}
function mapDispatchToProps(dispatch) {
return bindActionCreators(action, dispatch)
}
// 将connect调用后的结果作为一个装饰器导出
export default connect(mapStateToProps, mapDispatchToProps)
在组件文件里引入connect
:
import React, { Component } from 'react'
import connect from './connect.js'
@connect
export default class App extends Component {
render() {
// App的业务逻辑
}
}
这样一来,我们的代码结构是不是清晰了很多
推荐一个非常赞的装饰模式库 —— core-decorators (opens new window) (opens new window)。core-decorators 帮我们实现好了一些使用频率较高的装饰器,比如
@readonly
(使目标属性只读)、@deprecate
(在控制台输出警告,提示用户某个指定的方法已被废除)等
# 4 结构型-适配器模式
# 4.1 兼容接口
大家知道我们现在有一个非常好用异步方案叫
fetch
,它的写法比ajax
优雅很多。因此在不考虑兼容性的情况下,我们更愿意使用fetch
、而不是使用ajax来发起异步请求。李雷是拜fetch
教的忠实信徒,为了能更好地使用fetch
,他封装了一个基于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
库,喜上眉梢——原来老板也是个拜 fetch 教。老板说李雷,咱们公司以后要做潮流公司了,写代码不再考虑兼容性,我希望你能把公司所有的业务的网络请求都迁移到你这个 HttpUtils 上来,这样以后你只用维护这一个库了,也方便。李雷一听,悲从中来——他是该公司的第 99 代员工,对远古时期的业务一无所知。而该公司第1代员工封装的网络请求库,是基于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){
// 失败的回调逻辑
})
李雷佛了 —— 不仅接口名不同,入参方式也不一样,这手动改要改到何年何日呢?
还好李雷学过设计模式,他立刻联想到了专门为我们抹平差异的适配器模式。要把老代码迁移到新接口,不一定要挨个儿去修改每一次的接口调用——正如我们想用 iPhoneX + 旧耳机听歌,不必挨个儿去改造耳机一样,我们只需要在引入接口时进行一次适配,便可轻松地 cover 掉业务里可能会有的多次调用(具体的解析在注释里):
// 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
,并用适配器去承接旧接口的参数,就可以实现新旧接口的无缝衔接了~
# 4.2 生产实践:axios中的适配器
数月之后,李雷的老板发现了网络请求神库axios,于是团队的方案又整个迁移到了
axios
——对于心中有适配器的李雷来说,这现在已经根本不是个事儿。不过本小节我们要聊的可不再是“如何使现有接口兼容axios”了。此处引出axios,一是因为大家对它足够熟悉(不熟悉的同学,点这里 (opens new window) (opens new window)可以快速熟悉一下~),二是因为axios本身就用到了我们的适配器模式,它的兼容方案值得我们学习和借鉴。
在使用axios
时,作为用户我们只需要掌握以下面三个最常用的接口为代表的一套api
:
// Make a request for a user with a given ID
axios.get('/user?ID=12345')
.then(function (response) {
// handle success
console.log(response);
})
.catch(function (error) {
// handle error
console.log(error);
})
.then(function () {
// always executed
})
axios.post('/user', {
firstName: 'Fred',
lastName: 'Flintstone'
})
.then(function (response) {
console.log(response);
})
.catch(function (error) {
console.log(error);
});
axios({
method: 'post',
url: '/user/12345',
data: {
firstName: 'Fred',
lastName: 'Flintstone'
}
})
- 便可轻松地发起各种姿势的网络请求,而不用去关心底层的实现细节。
- 除了简明优雅的api之外,axios 强大的地方还在于,它不仅仅是一个局限于浏览器端的库。在Node环境下,我们尝试调用上面的 api,会发现它照样好使 —— axios 完美地抹平了两种环境下api的调用差异,靠的正是对适配器模式的灵活运用。
在 axios 的核心逻辑 (opens new window) (opens new window)中,我们可以注意到实际上派发请求的是 dispatchRequest 方法 (opens new window) (opens new window)。该方法内部其实主要做了两件事:
- 数据转换,转换请求体/响应体,可以理解为数据层面的适配;
- 调用适配器。
调用适配器的逻辑如下:
// 若用户未手动配置适配器,则使用默认的适配器
var adapter = config.adapter || defaults.adapter;
// dispatchRequest方法的末尾调用的是适配器方法
return adapter(config).then(function onAdapterResolution(response) {
// 请求成功的回调
throwIfCancellationRequested(config);
// 转换响应体
response.data = transformData(
response.data,
response.headers,
config.transformResponse
);
return response;
}, function onAdapterRejection(reason) {
// 请求失败的回调
if (!isCancel(reason)) {
throwIfCancellationRequested(config);
// 转换响应体
if (reason && reason.response) {
reason.response.data = transformData(
reason.response.data,
reason.response.headers,
config.transformResponse
);
}
}
return Promise.reject(reason);
});
大家注意注释的第一行,“若用户未手动配置适配器,则使用默认的适配器”。手动配置适配器允许我们自定义处理请求,主要目的是为了使测试更轻松。
实际开发中,我们使用默认适配器的频率更高。默认适配器在
axios/lib/default.js
(opens new window) (opens new window)里是通过getDefaultAdapter
方法来获取的:
function getDefaultAdapter() {
var adapter;
// 判断当前是否是node环境
if (typeof process !== 'undefined' && Object.prototype.toString.call(process) === '[object process]') {
// 如果是node环境,调用node专属的http适配器
adapter = require('./adapters/http');
} else if (typeof XMLHttpRequest !== 'undefined') {
// 如果是浏览器环境,调用基于xhr的适配器
adapter = require('./adapters/xhr');
}
return adapter;
}
我们再来看看 Node
的 http
适配器和 xhr
适配器大概长啥样:
http
适配器:
module.exports = function httpAdapter(config) {
return new Promise(function dispatchHttpRequest(resolvePromise, rejectPromise) {
// 具体逻辑
}
}
xhr 适配器:
module.exports = function xhrAdapter(config) {
return new Promise(function dispatchXhrRequest(resolve, reject) {
// 具体逻辑
}
}
具体逻辑啥样,咱们目前先不关心,有兴趣的同学,可以狠狠地点这里 (opens new window) (opens new window)阅读源码。咱们现在就注意两个事儿:
- 两个适配器的入参都是
config
; - 两个适配器的出参都是一个
Promise
。
Tips:要是仔细读了源码,会发现两个适配器中的 P
romise
的内部结构也是如出一辙。
这么一来,通过 axios
发起跨平台的网络请求,不仅调用的接口名是同一个,连入参、出参的格式都只需要掌握同一套。这导致它的学习成本非常低,开发者看了文档就能上手;同时因为足够简单,在使用的过程中也不容易出错,带来了极佳的用户体验,axios
也因此越来越流行。
这正是一个好的适配器的自我修养——把变化留给自己,把统一留给用户。在此处,所有关于 http
模块、关于 xhr
的实现细节,全部被 Adapter
封装进了自己复杂的底层逻辑里,暴露给用户的都是十分简单的统一的东西——统一的接口,统一的入参,统一的出参,统一的规则。用起来就是一个字 —— 爽!
# 5 结构型-代理模式
代理模式,式如其名——在某些情况下,出于种种考虑/限制,一个对象不能直接访问另一个对象,需要一个第三者(代理)牵线搭桥从而间接达到访问目的,这样的模式就是代理模式。
代理模式非常好理解,因为你可能天天都在用,只是没有刻意挖掘过它背后的玄机——比如大家耳熟能详的科学上网,就是代理模式的典型案例。
# 5.1 ES6中的Proxy
在 ES6 中,提供了专门以代理角色出现的代理器 —— Proxy
。它的基本用法如下:
const proxy = new Proxy(obj, handler)
第一个参数是我们的目标对象,也就是上文中的“未知妹子”。handler
也是一个对象,用来定义代理的行为,相当于上文中的“婚介所”。当我们通过 proxy
去访问目标对象的时候,handler
会对我们的行为作一层拦截,我们的每次访问都需要经过 handler
这个第三方。
# 5.2 “婚介所”的实现
未知妹子的个人信息,刚问了下我们已经注册了 VIP 的同事哥,大致如下:
// 未知妹子
const girl = {
// 姓名
name: '小美',
// 自我介绍
aboutMe: '...'(大家自行脑补吧)
// 年龄
age: 24,
// 职业
career: 'teacher',
// 假头像
fakeAvatar: 'xxxx'(新垣结衣的图片地址)
// 真实头像
avatar: 'xxxx'(自己的照片地址),
// 手机号
phone: 123456,
}
婚介所收到了小美的信息,开始营业。大家想,这个姓名、自我介绍、假头像,这些信息大差不差,曝光一下没问题。但是人家妹子的年龄、职业、真实头像、手机号码,是不是属于非常私密的信息了?要想 get 这些信息,平台要考验一下你的诚意了 —— 首先,你是不是已经通过了实名审核?如果通过实名审核,那么你可以查看一些相对私密的信息(年龄、职业)。然后,你是不是 VIP ?只有 VIP 可以查看真实照片和联系方式。满足了这两个判定条件,你才可以顺利访问到别人的全部私人信息,不然,就劝退你提醒你去完成认证和VIP购买再来。
// 普通私密信息
const baseInfo = ['age', 'career']
// 最私密信息
const privateInfo = ['avatar', 'phone']
// 用户(同事A)对象实例
const user = {
...(一些必要的个人信息)
isValidated: true,
isVIP: false,
}
// 婚介所登场了
const lovers = new Proxy(girl, {
get: function(girl, key) {
if(baseInfo.indexOf(key)!==-1 && !user.isValidated) {
alert('您还没有完成验证哦')
return
}
//...(此处省略其它有的没的各种校验逻辑)
// 此处我们认为只有验证过的用户才可以购买VIP
if(user.isValidated && privateInfo.indexOf(key) && !user.isVIP) {
alert('只有VIP才可以查看该信息哦')
return
}
}
})
以上主要是
getter
层面的拦截。假设我们还允许会员间互送礼物,每个会员可以告知婚介所自己愿意接受的礼物的价格下限,我们还可以作setter
层面的拦截。:
// 规定礼物的数据结构由type和value组成
const present = {
type: '巧克力',
value: 60,
}
// 为用户增开presents字段存储礼物
const girl = {
// 姓名
name: '小美',
// 自我介绍
aboutMe: '...'(大家自行脑补吧)
// 年龄
age: 24,
// 职业
career: 'teacher',
// 假头像
fakeAvatar: 'xxxx'(新垣结衣的图片地址)
// 真实头像
avatar: 'xxxx'(自己的照片地址),
// 手机号
phone: 123456,
// 礼物数组
presents: [],
// 拒收50块以下的礼物
bottomValue: 50,
// 记录最近一次收到的礼物
lastPresent: present,
}
// 婚介所推出了小礼物功能
const lovers = new Proxy(girl, {
get: function(girl, key) {
if(baseInfo.indexOf(key)!==-1 && !user.isValidated) {
alert('您还没有完成验证哦')
return
}
//...(此处省略其它有的没的各种校验逻辑)
// 此处我们认为只有验证过的用户才可以购买VIP
if(user.isValidated && privateInfo.indexOf(key) && !user.isVIP) {
alert('只有VIP才可以查看该信息哦')
return
}
}
set: function(girl, key, val) {
// 最近一次送来的礼物会尝试赋值给lastPresent字段
if(key === 'lastPresent') {
if(val.value < girl.bottomValue) {
alert('sorry,您的礼物被拒收了')
return
}
// 如果没有拒收,则赋值成功,同时并入presents数组
girl[lastPresent] = val
girl[presents] = [...presents, val]
}
}
})
# 5.3 事件代理
事件代理,可能是代理模式最常见的一种应用方式,也是一道实打实的高频面试题。它的场景是一个父元素下有多个子元素,像这样:
事件代理,可能是代理模式最常见的一种应用方式,也是一道实打实的高频面试题。它的场景是一个父元素下有多个子元素,像这样:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta >
<meta http-equiv="X-UA-Compatible" content="ie=edge">
<title>事件代理</title>
</head>
<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>
</html>
我们现在的需求是,希望鼠标点击每个 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}`)
}
} )
在这种做法下,我们的点击操作并不会直接触及目标子元素,而是由父元素对事件进行处理和分发、间接地将其作用于子元素,因此这种操作从模式上划分属于代理模式。
# 5.4 虚拟代理
简单地给大家描述一下懒加载是个什么东西:它是针对图片加载时机的优化:在一些图片量比较大的网站,比如电商网站首页,或者团购网站、小游戏首页等。如果我们尝试在用户打开页面的时候,就把所有的图片资源加载完毕,那么很可能会造成白屏、卡顿等现象。
此时我们会采取“先占位、后加载”的方式来展示图片 —— 在元素露出之前,我们给它一个 div 作占位,当它滚动到可视区域内时,再即时地去加载真实的图片资源,这样做既减轻了性能压力、又保住了用户体验。
除了图片懒加载,还有一种操作叫图片预加载。预加载主要是为了避免网络不好、或者图片太大时,页面长时间给用户留白的尴尬。常见的操作是先让这个 img 标签展示一个占位图,然后创建一个 Image 实例,让这个 Image 实例的 src 指向真实的目标图片地址、观察该 Image 实例的加载情况 —— 当其对应的真实图片加载完毕后,即已经有了该图片的缓存内容,再将 DOM 上的 img 元素的 src 指向真实的目标图片地址。此时我们直接去取了目标图片的缓存,所以展示速度会非常快,从占位图到目标图片的时间差会非常小、小到用户注意不到,这样体验就会非常好了。
上面的思路,我们可以不假思索地实现如下
class PreLoadImage {
// 占位图的url地址
static LOADING_URL = 'xxxxxx'
constructor(imgNode) {
// 获取该实例对应的DOM节点
this.imgNode = imgNode
}
// 该方法用于设置真实的图片地址
setSrc(targetUrl) {
// img节点初始化时展示的是一个占位图
this.imgNode.src = PreLoadImage.LOADING_URL
// 创建一个帮我们加载图片的Image实例
const image = new Image()
// 监听目标图片加载的情况,完成时再将DOM上的img节点的src属性设置为目标图片的url
image.onload = () => {
this.imgNode.src = targetUrl
}
// 设置src属性,Image实例开始加载图片
image.src = srcUrl
}
}
这个 PreLoadImage
乍一看没问题,但其实违反了我们设计原则中的单一职责原则。PreLoadImage
不仅要负责图片的加载,还要负责 DOM
层面的操作(img 节点的初始化和后续的改变)。这样一来,就出现了两个可能导致这个类发生变化的原因。
好的做法是将两个逻辑分离,让
PreLoadImage
专心去做 DOM 层面的事情(真实 DOM 节点的获取、img 节点的链接设置),再找一个对象来专门来帮我们搞加载——这两个对象之间缺个媒婆,这媒婆非代理器不可:
class PreLoadImage {
constructor(imgNode) {
// 获取真实的DOM节点
this.imgNode = imgNode
}
// 操作img节点的src属性
setSrc(imgUrl) {
this.imgNode.src = imgUrl
}
}
class ProxyImage {
// 占位图的url地址
static LOADING_URL = 'xxxxxx'
constructor(targetImage) {
// 目标Image,即PreLoadImage实例
this.targetImage = targetImage
}
// 该方法主要操作虚拟Image,完成加载
setSrc(targetUrl) {
// 真实img节点初始化时展示的是一个占位图
this.targetImage.setSrc(ProxyImage.LOADING_URL)
// 创建一个帮我们加载图片的虚拟Image实例
const virtualImage = new Image()
// 监听目标图片加载的情况,完成时再将DOM上的真实img节点的src属性设置为目标图片的url
virtualImage.onload = () => {
this.targetImage.setSrc(targetUrl)
}
// 设置src属性,虚拟Image实例开始加载图片
virtualImage.src = targetUrl
}
}
ProxyImage
帮我们调度了预加载相关的工作,我们可以通过ProxyImage
这个代理,实现对真实 img 节点的间接访问,并得到我们想要的效果。
在这个实例中,virtualImage
这个对象是一个“幕后英雄”,它始终存在于 JavaScript 世界中、代替真实 DOM 发起了图片加载请求、完成了图片加载工作,却从未在渲染层面抛头露面。因此这种模式被称为“虚拟代理”模式。
# 5.5 缓存代理
缓存代理比较好理解,它应用于一些计算量较大的场景里。在这种场景下,我们需要“用空间换时间”——当我们需要用到某个已经计算过的值的时候,不想再耗时进行二次计算,而是希望能从内存里去取出现成的计算结果。这种场景下,就需要一个代理来帮我们在进行计算的同时,进行计算结果的缓存了。
一个比较典型的例子,是对传入的参数进行求和:
// addAll方法会对你传入的所有参数做求和操作
const addAll = function() {
console.log('进行了一次新计算')
let result = 0
const len = arguments.length
for(let i = 0; i < len; i++) {
result += arguments[i]
}
return result
}
// 为求和方法创建代理
const proxyAddAll = (function(){
// 求和结果的缓存池
const resultCache = {}
return function() {
// 将入参转化为一个唯一的入参字符串
const args = Array.prototype.join.call(arguments, ',')
// 检查本次入参是否有对应的计算结果
if(args in resultCache) {
// 如果有,则返回缓存池里现成的结果
return resultCache[args]
}
return resultCache[args] = addAll(...arguments)
}
})()
我们把这个方法丢进控制台,尝试同一套入参两次,结果喜人:
我们发现
proxyAddAll
针对重复的入参只会计算一次,这将大大节省计算过程中的时间开销。现在我们有 6 个入参,可能还看不出来,当我们针对大量入参、做反复计算时,缓存代理的优势将得到更充分的凸显。
# 6 行为型-策略模式
定义一系列的算法,把它们一个个封装起来, 并且使它们可相互替换。
例子
定义:根据不同参数可以命中不同的策略
优点:
能减少大量的if语句
复用性好
优化使用:
- 封装代码,暴露方法
export default {
xxx
}
import xxx from 'x.js'
const strategy = {
'A': (salary) => {
return salary * 2;
},
'B': (salary) => {
return salary * 3;
},
'C': (salary) => {
return salary * 4;
}
}
const calculateBonus = (level, salary) => {
return strategy[level](salary)
}
const A = calculateBonus('A', 10000);
console.log(A); // 20000
// 暴露方法
export default {
calculateBonus
}
# 6.1 先来看一个真实场景
有一天,产品经理韩梅梅找到李雷,给李雷提了这么个需求:
马上大促要来了,我们本次大促要做差异化询价。啥是差异化询价?就是说同一个商品,我通过在后台给它设置不同的价格类型,可以让它展示不同的价格。具体的逻辑如下:
- 当价格类型为“预售价”时,满 100 - 20,不满 100 打 9 折
- 当价格类型为“大促价”时,满 100 - 30,不满 100 打 8 折
- 当价格类型为“返场价”时,满 200 - 50,不叠加
- 当价格类型为“尝鲜价”时,直接打 5 折
李雷扫了一眼 prd,立刻来了主意。他首先将四种价格做了标签化:
预售价 - pre
大促价 - onSale
返场价 - back
尝鲜价 - fresh
接下来李雷仔细研读了 prd 的内容,作为资深 if-else 侠,他三下五除二就写出一套功能完备的代码:
// 询价方法,接受价格标签和原价为入参
function askPrice(tag, originPrice) {
// 处理预热价
if(tag === 'pre') {
if(originPrice >= 100) {
return originPrice - 20
}
return originPrice * 0.9
}
// 处理大促价
if(tag === 'onSale') {
if(originPrice >= 100) {
return originPrice - 30
}
return originPrice * 0.8
}
// 处理返场价
if(tag === 'back') {
if(originPrice >= 200) {
return originPrice - 50
}
return originPrice
}
// 处理尝鲜价
if(tag === 'fresh') {
return originPrice * 0.5
}
}
# 6.2 if-else 侠,人人喊打
随便跑一下,上述代码运行起来确实没啥毛病。但也只是“运行起来”没毛病而已。作为人人喊打的 if-else 侠,李雷必须为他的行为付出代价。我们一起来看看这么写代码会带来什么后果:
- 首先,它违背了“单一功能”原则。一个 function 里面,它竟然处理了四坨逻辑——这个函数的逻辑太胖了!这样会带来什么样的糟糕后果,笔者在前面的小节中已经 BB 过很多次了:比如说万一其中一行代码出了 Bug,那么整个询价逻辑都会崩坏;与此同时出了 Bug 你很难定位到底是哪个代码块坏了事;再比如说单个能力很难被抽离复用等等等等。相信跟着我一路学下来的各位,也已经在重重实战中对胖逻辑的恶劣影响有了切身的体会。总之,见到胖逻辑,我们的第一反应,就是一个字——拆!
- 不仅如此,它还违背了“开放封闭”原则。假如有一天韩梅梅再次找到李雷,要他加一个满 100 - 50 的“新人价”怎么办?他只能继续 if-else:
function askPrice(tag, originPrice) {
// 处理预热价
if(tag === 'pre') {
if(originPrice >= 100) {
return originPrice - 20
}
return originPrice * 0.9
}
// 处理大促价
if(tag === 'onSale') {
if(originPrice >= 100) {
return originPrice - 30
}
return originPrice * 0.8
}
// 处理返场价
if(tag === 'back') {
if(originPrice >= 200) {
return originPrice - 50
}
return originPrice
}
// 处理尝鲜价
if(tag === 'fresh') {
return originPrice * 0.5
}
// 处理新人价
if(tag === 'newUser') {
if(originPrice >= 100) {
return originPrice - 50
}
return originPrice
}
}
没错,李雷灰溜溜地跑去改了
askPrice
函数!随后他恬不知耻地徐徐转头,对背后的测试同学说:哥,我改了询价函数,麻烦你帮我把整个询价逻辑回归一下。测试同学莞尔一笑, 心中早已有无数头羊驼在狂奔。他强忍着周末加班的悲痛,做完了这漫长而不必要的回归测试,随后默默点击了同事系统里的举报按钮对李雷说:哥,求你学学设计模式吧!!
# 6.3 重构询价逻辑
现在我们基于我们已经学过的设计模式思想,一点一点改造掉这个臃肿的 askPrice。
1. 单一功能改造
首先,我们赶紧把四种询价逻辑提出来,让它们各自为政:
// 处理预热价
function prePrice(originPrice) {
if(originPrice >= 100) {
return originPrice - 20
}
return originPrice * 0.9
}
// 处理大促价
function onSalePrice(originPrice) {
if(originPrice >= 100) {
return originPrice - 30
}
return originPrice * 0.8
}
// 处理返场价
function backPrice(originPrice) {
if(originPrice >= 200) {
return originPrice - 50
}
return originPrice
}
// 处理尝鲜价
function freshPrice(originPrice) {
return originPrice * 0.5
}
function askPrice(tag, originPrice) {
// 处理预热价
if(tag === 'pre') {
return prePrice(originPrice)
}
// 处理大促价
if(tag === 'onSale') {
return onSalePrice(originPrice)
}
// 处理返场价
if(tag === 'back') {
return backPrice(originPrice)
}
// 处理尝鲜价
if(tag === 'fresh') {
return freshPrice(originPrice)
}
}
OK,我们现在至少做到了一个函数只做一件事。现在每个函数都有了自己明确的、单一的分工:
prePrice - 处理预热价
onSalePrice - 处理大促价
backPrice - 处理返场价
freshPrice - 处理尝鲜价
askPrice - 分发询价逻辑
如此一来,我们在遇到 Bug 时,就可以做到“头痛医头,脚痛医脚”,而不必在庞大的逻辑海洋里费力去定位到底是哪块不对。
同时,如果我在另一个函数里也想使用某个询价能力,比如说我想询预热价,那我直接把 prePrice 这个函数拿去调用就是了,而不必在 askPrice 肥胖的身躯里苦苦寻觅、然后掏出这块逻辑、最后再复制粘贴到另一个函数去——更何况万一哪天 askPrice 里的预热价逻辑改了,你还得再复制粘贴一次,扎心啊老铁!
到这里,在单一功能原则的指引下,我们已经解决了一半的问题。
我们现在来捋一下,其实这个询价逻辑整体上来看只有两个关键动作:
询价逻辑的分发 ——> 询价逻辑的执行
在改造的第一步,我们已经把“询价逻辑的执行”给摘了出去,并且实现了不同询价逻辑之间的解耦。接下来,我们就要拿“分发”这个动作开刀。
2. 开放封闭改造
剩下一半的问题是啥呢?就是咱们上面说的那个新人价的问题——这会儿我要想给 askPrice 增加新人询价逻辑,我该咋整?我只能这么来:
// 处理预热价
function prePrice(originPrice) {
if(originPrice >= 100) {
return originPrice - 20
}
return originPrice * 0.9
}
// 处理大促价
function onSalePrice(originPrice) {
if(originPrice >= 100) {
return originPrice - 30
}
return originPrice * 0.8
}
// 处理返场价
function backPrice(originPrice) {
if(originPrice >= 200) {
return originPrice - 50
}
return originPrice
}
// 处理尝鲜价
function freshPrice(originPrice) {
return originPrice * 0.5
}
// 处理新人价
function newUserPrice(originPrice) {
if(originPrice >= 100) {
return originPrice - 50
}
return originPrice
}
function askPrice(tag, originPrice) {
// 处理预热价
if(tag === 'pre') {
return prePrice(originPrice)
}
// 处理大促价
if(tag === 'onSale') {
return onSalePrice(originPrice)
}
// 处理返场价
if(tag === 'back') {
return backPrice(originPrice)
}
// 处理尝鲜价
if(tag === 'fresh') {
return freshPrice(originPrice)
}
// 处理新人价
if(tag === 'newUser') {
return newUserPrice(originPrice)
}
}
在外层,我们编写一个 newUser 函数用于处理新人价逻辑;在 askPrice 里面,我们新增了一个 if-else 判断。可以看出,这样其实还是在修改 askPrice 的函数体,没有实现“对扩展开放,对修改封闭”的效果。
那么我们应该怎么做?我们仔细想想,楼上用了这么多 if-else,我们的目的到底是什么?是不是就是为了把 询价标签-询价函数 这个映射关系给明确下来?那么在 JS 中,有没有什么既能够既帮我们明确映射关系,同时不破坏代码的灵活性的方法呢?答案就是对象映射!
咱们完全可以把询价算法全都收敛到一个对象里去嘛:
// 定义一个询价处理器对象
const priceProcessor = {
pre(originPrice) {
if (originPrice >= 100) {
return originPrice - 20;
}
return originPrice * 0.9;
},
onSale(originPrice) {
if (originPrice >= 100) {
return originPrice - 30;
}
return originPrice * 0.8;
},
back(originPrice) {
if (originPrice >= 200) {
return originPrice - 50;
}
return originPrice;
},
fresh(originPrice) {
return originPrice * 0.5;
},
};
当我们想使用其中某个询价算法的时候:通过标签名去定位就好了:
// 询价函数
function askPrice(tag, originPrice) {
return priceProcessor[tag](originPrice)
}
如此一来,askPrice 函数里的 if-else 大军彻底被咱们消灭了。这时候如果你需要一个新人价,只需要给 priceProcessor 新增一个映射关系:
priceProcessor.newUser = function (originPrice) {
if (originPrice >= 100) {
return originPrice - 50;
}
return originPrice;
}
这样一来,询价逻辑的分发也变成了一个清清爽爽的过程。当李雷以这种方式新增一个新人价的询价逻辑的时候,就可以底气十足地对测试同学说:老哥,我改了询价逻辑,但是改动范围仅仅涉及到新人价,是一个单纯的功能增加。所以你只测这个新功能点就 OK,老逻辑不用管!
从此,李雷就从人人喊打的 if-else 侠,摇身一变成为了测试之友、中国好开发。业务代码里的询价逻辑,也因为李雷坚守设计原则100年不动摇,而变得易读、易维护。
# 6.4 总结
说起来你可能不相信,咱们上面的整个重构的过程,就是对策略模式的应用。
现在大家来品品策略模式的定义:
定义一系列的算法,把它们一个个封装起来, 并且使它们可相互替换。
回头看看,咱们忙活到现在,是不是就干了这事儿?
但你要直接读这句话,可能确实会懵圈——啥是算法?如何封装?可替换又是咋做到的?
如今你你已经自己动手实现了算法提取、算法封装、分发优化的整个一条龙的操作流,相信面对这条定义,你可以会心一笑——算法,就是我们这个场景中的询价逻辑,它也可以是你任何一个功能函数的逻辑;“封装”就是把某一功能点对应的逻辑给提出来;“可替换”建立在封装的基础上,只是说这个“替换”的判断过程,咱们不能直接怼 if-else,而要考虑更优的映射方案。
# 7 行为型-状态模式
# 7.1 一台咖啡机的诞生
作为一个具备强大抽象思维能力的程序员,李雷没有辜负自己这么多年来学过的现代前端框架。他敏锐地感知到,韩梅梅所说的这些不同的”选择“间的切换,本质就是状态的切换。在这个能做四种咖啡的咖啡机体内,蕴含着四种状态:
- 美式咖啡态(american):只吐黑咖啡
- 普通拿铁态(latte):黑咖啡加点奶
- 香草拿铁态(vanillaLatte):黑咖啡加点奶再加香草糖浆
- 摩卡咖啡态(mocha):黑咖啡加点奶再加点巧克力
嘿嘿,这么一梳理,李雷的思路一下子清晰了起来。作为死性不改的 if-else 侠,他再次三下五除二写出了一套功能完备的代码:
class CoffeeMaker {
constructor() {
/**
*这里略去咖啡机中与咖啡状态切换无关的一些初始化逻辑
**/
// 初始化状态,没有切换任何咖啡模式
this.state = 'init';
}
// 关注咖啡机状态切换函数
changeState(state) {
// 记录当前状态
this.state = state;
if(state === 'american') {
// 这里用 console 代指咖啡制作流程的业务逻辑
console.log('我只吐黑咖啡');
} else if(state === 'latte') {
console.log(`给黑咖啡加点奶`);
} else if(state === 'vanillaLatte') {
console.log('黑咖啡加点奶再加香草糖浆');
} else if(state === 'mocha') {
console.log('黑咖啡加点奶再加点巧克力');
}
}
}
测试一下,完美无缺:
const mk = new CoffeeMaker();
mk.changeState('latte'); // 输出 '给黑咖啡加点奶'
鉴于 if-else 使不得,李雷赶紧翻出了他在策略模式中学到的“单一职责”和“开放封闭”原则,比猫画虎地改造起了自己的咖啡机:
# 7.2 改造咖啡机的状态切换机制
1. 职责分离
首先,映入李雷眼帘最大的问题,就是咖啡制作过程不可复用:
changeState(state) {
// 记录当前状态
this.state = state;
if(state === 'american') {
// 这里用 console 代指咖啡制作流程的业务逻辑
console.log('我只吐黑咖啡');
} else if(state === 'latte') {
console.log(`给黑咖啡加点奶`);
} else if(state === 'vanillaLatte') {
console.log('黑咖啡加点奶再加香草糖浆');
} else if(state === 'mocha') {
console.log('黑咖啡加点奶再加点巧克力');
}
}
李雷发现,这个 changeState 函数,它好好管好自己的事(状态切换)不行吗?怎么连做咖啡的过程也写在这里面?这不合理。
class CoffeeMaker {
constructor() {
/**
这里略去咖啡机中与咖啡状态切换无关的一些初始化逻辑
**/
// 初始化状态,没有切换任何咖啡模式
this.state = 'init';
}
changeState(state) {
// 记录当前状态
this.state = state;
if(state === 'american') {
// 这里用 console 代指咖啡制作流程的业务逻辑
this.americanProcess();
} else if(state === 'latte') {
this.latteProcress();
} else if(state === 'vanillaLatte') {
this.vanillaLatteProcress();
} else if(state === 'mocha') {
this.mochaProcress();
}
}
americanProcess() {
console.log('我只吐黑咖啡');
}
latteProcress() {
this.americanProcess();
console.log('加点奶');
}
vanillaLatteProcress() {
this.latteProcress();
console.log('再加香草糖浆');
}
mochaProcress() {
this.latteProcress();
console.log('再加巧克力');
}
}
const mk = new CoffeeMaker();
mk.changeState('latte');
输出结果符合预期:
我只吐黑咖啡
加点奶
# 7.3 开放封闭
复用的问题解决了,if-else 却仍然活得好好的。
现在咱们假如要增加”气泡美式“这个咖啡品种,就不得不去修改 changeState 的函数逻辑,这违反了开放封闭的原则。
同时,一个函数里收敛这么多判断,也着实不够体面。咱们现在要像策略模式一样,想办法把咖啡机状态和咖啡制作工序之间的映射关系(也就是咱们上节谈到的分发过程)用一个更优雅地方式做掉。如果你策略模式掌握得足够好,你会第一时间反映出对象映射的方案:
const stateToProcessor = {
american() {
console.log('我只吐黑咖啡');
},
latte() {
this.american();
console.log('加点奶');
},
vanillaLatte() {
this.latte();
console.log('再加香草糖浆');
},
mocha() {
this.latte();
console.log('再加巧克力');
}
}
class CoffeeMaker {
constructor() {
/**
这里略去咖啡机中与咖啡状态切换无关的一些初始化逻辑
**/
// 初始化状态,没有切换任何咖啡模式
this.state = 'init';
}
// 关注咖啡机状态切换函数
changeState(state) {
// 记录当前状态
this.state = state;
// 若状态不存在,则返回
if(!stateToProcessor[state]) {
return ;
}
stateToProcessor[state]();
}
}
const mk = new CoffeeMaker();
mk.changeState('latte');
输出结果符合预期:
我只吐黑咖啡
加点奶
当我们这么做时,其实已经实现了一个 js 版本的状态模式。
但这里有一点大家需要引起注意:这种方法仅仅是看上去完美无缺,其中却暗含一个非常重要的隐患——stateToProcessor 里的工序函数,感知不到咖啡机的内部状况。
# 7.4 进一步改造
按照我们这一通描述,当务之急是要把咖啡机和它的状态处理函数建立关联。
如果你读过一些早期的设计模式教学资料,有一种思路是将每一个状态所对应的的一些行为抽象成类,然后通过传递 this 的方式来关联状态和状态主体。
这种思路也可以,不过它一般还需要你实现抽象工厂,比较麻烦。实际业务中这种做法极为少见。我这里要给大家介绍的是一种更方便也更常用的解决方案——非常简单,把状态-行为映射对象作为主体类对应实例的一个属性添加进去就行了:
class CoffeeMaker {
constructor() {
/**
*这里略去咖啡机中与咖啡状态切换无关的一些初始化逻辑
**/
// 初始化状态,没有切换任何咖啡模式
this.state = 'init';
// 初始化牛奶的存储量
this.leftMilk = '500ml';
}
stateToProcessor = {
that: this,
american() {
// 尝试在行为函数里拿到咖啡机实例的信息并输出
console.log('咖啡机现在的牛奶存储量是:', this.that.leftMilk)
console.log('我只吐黑咖啡');
},
latte() {
this.american()
console.log('加点奶');
},
vanillaLatte() {
this.latte();
console.log('再加香草糖浆');
},
mocha() {
this.latte();
console.log('再加巧克力');
}
}
// 关注咖啡机状态切换函数
changeState(state) {
this.state = state;
if (!this.stateToProcessor[state]) {
return;
}
this.stateToProcessor[state]();
}
}
const mk = new CoffeeMaker();
mk.changeState('latte');
输出结果为:
咖啡机现在的牛奶存储量是: 500ml
我只吐黑咖啡
加点奶
如此一来,我们就可以在 stateToProcessor 轻松拿到咖啡机的实例对象,进而感知咖啡机这个主体了。
# 7.5 状态模式复盘
和策略模式一样,咱们仍然是敲完代码之后,一起来复盘一下状态模式的定义:
状态模式(State Pattern) :允许一个对象在其内部状态改变时改变它的行为,对象看起来似乎修改了它的类。
这个定义比较粗糙,可能你读完仍然 get 不到它想让你干啥。这时候,我们就应该把目光转移到它解决的问题上来:
状态模式主要解决的是当控制一个对象状态的条件表达式过于复杂时的情况。把状态的判断逻辑转移到表示不同状态的一系列类中,可以把复杂的判断逻辑简化。
仔细回忆一下我们这节做的事情,也确实就是这么回事儿。
唯一的区别在于,定义里强调了”类“的概念。但我们的示例中,包括大家今后的实践中,一个对象的状态如果复杂到了你不得不给它的每 N 种状态划分为一类、一口气划分很多类这种程度,我更倾向于你去反思一个这个对象是不是做太多事情了。事实上,在大多数场景下,我们的行为划分,都是可以像本节一样,控制在”函数“这个粒度的。
# 8 行为型-观察者模式
观察者模式定义了一种一对多的依赖关系,让多个观察者对象同时监听某一个目标对象,当这个目标对象的状态发生变化时,会通知所有观察者对象,使它们能够自动更新。 —— Graphic Design Patterns
观察者模式,是所有 JavaScript 设计模式中使用频率最高,面试频率也最高的设计模式,所以说它十分重要——如果我是面试官,考虑到面试时间有限、设计模式这块不能多问,我可能在考查你设计模式的时候只会问观察者模式这一个模式。该模式的权重极高
重点不一定是难点。观察者模式十分重要,但它并不抽象,理解难度不大。这种模式不仅在业务开发中遍地开花,在日常生活中也是非常常见的。为了帮助大家形成初步的理解,在进入代码世界之前,我们照例来看一段日常:
# 8.1 生活中的观察者模式
周一刚上班,前端开发李雷就被产品经理韩梅梅拉进了一个钉钉群——“员工管理系统需求第99次变更群”。这个群里不仅有李雷,还有后端开发 A,测试同学 B。三位技术同学看到这简单直白的群名便立刻做好了接受变更的准备、打算撸起袖子开始干了。此时韩梅梅却说:“别急,这个需求有问题,我需要和业务方再确认一下,大家先各忙各的吧”。这种情况下三位技术同学不必立刻投入工作,但他们都已经做好了本周需要做一个新需求的准备,时刻等待着产品经理的号召。
一天过去了,两天过去了。周三下午,韩梅梅终于和业务方确认了所有的需求细节,于是在“员工管理系统需求第99次变更群”里大吼一声:“需求文档来了!”,随后甩出了"需求文档.zip"文件,同时@所有人。三位技术同学听到熟悉的“有人@我”提示音,立刻点开群进行群消息和群文件查收,随后根据群消息和群文件提供的需求信息,投入到了各自的开发里。上述这个过程,就是一个典型的观察者模式。
重点角色对号入座
观察者模式有一个“别名”,叫发布 - 订阅模式
(之所以别名加了引号,是因为两者之间存在着细微的差异,下个小节里我们会讲到这点)。这个别名非常形象地诠释了观察者模式里两个核心的角色要素——“发布者”与“订阅者”。
在上述的过程中,需求文档(目标对象)的发布者只有一个——产品经理韩梅梅。而需求信息的接受者却有多个——前端、后端、测试同学,这些同学的共性就是他们需要根据需求信息开展自己后续的工作、因此都非常关心这个需求信息,于是不得不时刻关注着这个群的群消息提醒,他们是实打实的订阅者,即观察者对象。
现在我们再回过头来看一遍开头我们提到的略显抽象的定义:
观察者模式定义了一种一对多的依赖关系,让多个观察者对象同时监听某一个目标对象,当这个目标对象的状态发生变化时,会通知所有观察者对象,使它们能够自动更新。
在我们上文这个钉钉群里,一个需求信息对象对应了多个观察者(技术同学),当需求信息对象的状态发生变化(从无到有)时,产品经理通知了群里的所有同学,以便这些同学接收信息进而开展工作:角色划分 --> 状态变化 --> 发布者通知到订阅者,这就是观察者模式的“套路”。
# 8.2 在实践中理解定义
结合我们上面的分析,现在大家知道,在观察者模式里,至少应该有两个关键角色是一定要出现的——发布者和订阅者。用面向对象的方式表达的话,那就是要有两个类。
首先我们来看这个代表发布者的类,我们给它起名叫Publisher
。这个类应该具备哪些“基本技能”呢?大家回忆一下上文中的韩梅梅,韩梅梅的基本操作是什么?首先是拉群(增加订阅者),然后是@
所有人(通知订阅者),这俩是最明显的了。此外作为群主&产品经理,韩梅梅还具有踢走项目组成员(移除订阅者)的能力。OK,产品经理发布者类的三个基本能力齐了,下面我们开始写代码:
// 定义发布者类
class Publisher {
constructor() {
this.observers = []
console.log('Publisher created')
}
// 增加订阅者
add(observer) {
console.log('Publisher.add invoked')
this.observers.push(observer)
}
// 移除订阅者
remove(observer) {
console.log('Publisher.remove invoked')
this.observers.forEach((item, i) => {
if (item === observer) {
this.observers.splice(i, 1)
}
})
}
// 通知所有订阅者
notify() {
console.log('Publisher.notify invoked')
this.observers.forEach((observer) => {
observer.update(this)
})
}
}
ok,搞定了发布者,我们一起来想想订阅者能干啥——其实订阅者的能力非常简单,作为被动的一方,它的行为只有两个——被通知、去执行(本质上是接受发布者的调用,这步我们在Publisher
中已经做掉了)。既然我们在Publisher中做的是方法调用,那么我们在订阅者类里要做的就是方法的定义:
// 定义订阅者类
class Observer {
constructor() {
console.log('Observer created')
}
update() {
console.log('Observer.update invoked')
}
}
以上,我们就完成了最基本的发布者和订阅者类的设计和编写。在实际的业务开发中,我们所有的定制化的发布者/订阅者逻辑都可以基于这两个基本类来改写。比如我们可以通过拓展发布者类,来使所有的订阅者来监听某个特定状态的变化。
仍然以开篇的例子为例,我们让开发者们来监听需求文档(prd)的变化:
// 定义一个具体的需求文档(prd)发布类
class PrdPublisher extends Publisher {
constructor() {
super()
// 初始化需求文档
this.prdState = null
// 韩梅梅还没有拉群,开发群目前为空
this.observers = []
console.log('PrdPublisher created')
}
// 该方法用于获取当前的prdState
getState() {
console.log('PrdPublisher.getState invoked')
return this.prdState
}
// 该方法用于改变prdState的值
setState(state) {
console.log('PrdPublisher.setState invoked')
// prd的值发生改变
this.prdState = state
// 需求文档变更,立刻通知所有开发者
this.notify()
}
}
作为订阅方,开发者的任务也变得具体起来:接收需求文档、并开始干活:
class DeveloperObserver extends Observer {
constructor() {
super()
// 需求文档一开始还不存在,prd初始为空对象
this.prdState = {}
console.log('DeveloperObserver created')
}
// 重写一个具体的update方法
update(publisher) {
console.log('DeveloperObserver.update invoked')
// 更新需求文档
this.prdState = publisher.getState()
// 调用工作函数
this.work()
}
// work方法,一个专门搬砖的方法
work() {
// 获取需求文档
const prd = this.prdState
// 开始基于需求文档提供的信息搬砖。。。
...
console.log('996 begins...')
}
}
下面,我们可以 new
一个 PrdPublisher
对象(产品经理),她可以通过调用 setState
方法来更新需求文档。需求文档每次更新,都会紧接着调用 notify
方法来通知所有开发者:
目标对象的状态发生变化时,会通知所有观察者对象,使它们能够自动更新。
OK,下面我们来看看韩梅梅和她的小伙伴们是如何搞事情的吧:
// 创建订阅者:前端开发李雷
const liLei = new DeveloperObserver()
// 创建订阅者:服务端开发小A(sorry。。。起名字真的太难了)
const A = new DeveloperObserver()
// 创建订阅者:测试同学小B
const B = new DeveloperObserver()
// 韩梅梅出现了
const hanMeiMei = new PrdPublisher()
// 需求文档出现了
const prd = {
// 具体的需求内容
...
}
// 韩梅梅开始拉群
hanMeiMei.add(liLei)
hanMeiMei.add(A)
hanMeiMei.add(B)
// 韩梅梅发送了需求文档,并@了所有人
hanMeiMei.setState(prd)
以上,就是观察者模式在代码世界里的完整实现流程了。
相信走到这一步,大家对观察者模式的核心思想、基本实现模式都有了不错的掌握。下面我们趁热打铁,一起来看看如何凭借观察者模式,在面试中表演真正的技术~
# 8.3 Vue数据双向绑定(响应式系统)的实现原理
1. 解析
Vue 框架是热门的渐进式 JavaScript框架。在 Vue 中,当我们修改状态时,视图会随之更新,这就是Vue的数据双向绑定(又称响应式原理)。数据双向绑定是Vue 最独特的特性之一。如果读者没有接触过 Vue,强烈建议阅读Vue官方对响应式原理的介绍 (opens new window) (opens new window)。此处我们用官方的一张流程图来简要地说明一下Vue响应式系统的整个流程:
在 Vue
中,每个组件实例都有相应的 watcher
实例对象,它会在组件渲染的过程中把属性记录为依赖,之后当依赖项的 setter
被调用时,会通知 watcher
重新计算,从而致使它关联的组件得以更新——这是一个典型的观察者模式。这道面试题考察了受试者对Vue底层原理的理解、对观察者模式的实现能力以及一系列重要的JS知识点,具有较强的综合性和代表性。
值得注意的是,在面试过程中,面试官多数情况下不会要求大家写出完整的响应式原理实现代码,而是要求你“说说自己的理解”。在本节,我们不会带大家一行一行写代码(具体深入Vue框架的相关知识,建议大家阅读Vue源码 (opens new window) (opens new window)及这本专门写Vue的小册 (opens new window) (opens new window)。),而是针对Vue响应式系统中与观察者模式紧密关联的这部分知识作讲解,帮助大家捋清楚整套流程里的来龙去脉、加深对观察者模式的理解。
在Vue数据双向绑定的实现逻辑里,有这样三个关键角色:
observer
(监听器):注意,此observer
非彼observer
。在我们上面的解析中,observer
作为设计模式中的一个角色,代表“订阅者”。但在Vue
数据双向绑定的角色结构里,所谓的observer
不仅是一个数据监听器,它还需要对监听到的数据进行转发——也就是说它同时还是一个发布者。watcher
(订阅者):observer
把数据转发给了真正的订阅者——watcher
对象。watcher
接收到新的数据后,会去更新视图。compile
(编译器):MVVM
框架特有的角色,负责对每个节点元素指令进行扫描和解析,指令的数据初始化、订阅者的创建这些“杂活”也归它管~
这三者的配合过程如图所示:
OK,实现方案搞清楚了,下面我们给整个流程中涉及到发布-订阅这一模式的代码来个特写:
2. 核心代码
实现observer
首先我们需要实现一个方法,这个方法会对需要监听的数据对象进行遍历、给它的属性加上定制的
getter
和setter
函数。这样但凡这个对象的某个属性发生了改变,就会触发setter
函数,进而通知到订阅者。这个setter
函数,就是我们的监听器:
// observe方法遍历并包装对象属性
function observe(target) {
// 若target是一个对象,则遍历它
if(target && typeof target === 'object') {
Object.keys(target).forEach((key)=> {
// defineReactive方法会给目标属性装上“监听器”
defineReactive(target, key, target[key])
})
}
}
// 定义defineReactive方法
function defineReactive(target, key, val) {
// 属性值也可能是object类型,这种情况下需要调用observe进行递归遍历
observe(val)
// 为当前属性安装监听器
Object.defineProperty(target, key, {
// 可枚举
enumerable: true,
// 不可配置
configurable: false,
get: function () {
return val;
},
// 监听器函数
set: function (value) {
console.log(`${target}属性的${key}属性从${val}值变成了了${value}`)
val = value
}
});
}
下面实现订阅者
Dep
:
// 定义订阅者类Dep
class Dep {
constructor() {
// 初始化订阅队列
this.subs = []
}
// 增加订阅者
addSub(sub) {
this.subs.push(sub)
}
// 通知订阅者(是不是所有的代码都似曾相识?)
notify() {
this.subs.forEach((sub)=>{
sub.update()
})
}
}
现在我们可以改写
defineReactive
中的setter
方法,在监听器里去通知订阅者了:
function defineReactive(target, key, val) {
const dep = new Dep()
// 监听当前属性
observe(val)
Object.defineProperty(target, key, {
set: (value) => {
// 通知所有订阅者
dep.notify()
}
})
}
# 8.4 实现一个Event Bus / Event Emitter
Event Bus
(Vue、Flutter 等前端框架中有出镜)和Event Emitter
(Node中有出镜)出场的“剧组”不同,但是它们都对应一个共同的角色——全局事件总线。
全局事件总线,严格来说不能说是观察者模式,而是发布-订阅模式。它在我们日常的业务开发中应用非常广。
如果只能选一道题,那这道题一定是
Event Bus/Event Emitter
的代码实现——我都说这么清楚了,这个知识点到底要不要掌握、需要掌握到什么程度,就看各位自己的了。
在Vue中使用Event Bus来实现组件间的通讯
Event Bus/Event Emitter
作为全局事件总线,它起到的是一个沟通桥梁的作用。我们可以把它理解为一个事件中心,我们所有事件的订阅/发布都不能由订阅方和发布方“私下沟通”,必须要委托这个事件中心帮我们实现。
在Vue中,有时候 A 组件和 B 组件中间隔了很远,看似没什么关系,但我们希望它们之间能够通信。这种情况下除了求助于 Vuex
之外,我们还可以通过 Event Bus
来实现我们的需求。
创建一个 Event Bus
(本质上也是 Vue 实例)并导出:
const EventBus = new Vue()
export default EventBus
在主文件里引入EventBus
,并挂载到全局:
import bus from 'EventBus的文件路径'
Vue.prototype.bus = bus
订阅事件:
// 这里func指someEvent这个事件的监听函数
this.bus.$on('someEvent', func)
发布(触发)事件:
// 这里params指someEvent这个事件被触发时回调函数接收的入参
this.bus.$emit('someEvent', params)
大家会发现,整个调用过程中,没有出现具体的发布者和订阅者(比如上面的
PrdPublisher
和DeveloperObserver
),全程只有bus
这个东西一个人在疯狂刷存在感。这就是全局事件总线的特点——所有事件的发布/订阅操作,必须经由事件中心,禁止一切“私下交易”!
下面,我们就一起来实现一个Event Bus
(注意看注释里的解析):
class EventEmitter {
constructor() {
// handlers是一个map,用于存储事件与回调之间的对应关系
this.handlers = {}
}
// on方法用于安装事件监听器,它接受目标事件名和回调函数作为参数
on(eventName, cb) {
// 先检查一下目标事件名有没有对应的监听函数队列
if (!this.handlers[eventName]) {
// 如果没有,那么首先初始化一个监听函数队列
this.handlers[eventName] = []
}
// 把回调函数推入目标事件的监听函数队列里去
this.handlers[eventName].push(cb)
}
// emit方法用于触发目标事件,它接受事件名和监听函数入参作为参数
emit(eventName, ...args) {
// 检查目标事件是否有监听函数队列
if (this.handlers[eventName]) {
// 如果有,则逐个调用队列里的回调函数
this.handlers[eventName].forEach((callback) => {
callback(...args)
})
}
}
// 移除某个事件回调队列里的指定回调函数
off(eventName, cb) {
const callbacks = this.handlers[eventName]
const index = callbacks.indexOf(cb)
if (index !== -1) {
callbacks.splice(index, 1)
}
}
// 为事件注册单次监听器
once(eventName, cb) {
// 对回调函数进行包装,使其执行完毕自动被移除
const wrapper = (...args) => {
cb.apply(...args)
this.off(eventName, wrapper)
}
this.on(eventName, wrapper)
}
}
在日常的开发中,大家用到
EventBus/EventEmitter
往往提供比这五个方法多的多的多的方法。但在面试过程中,如果大家能够完整地实现出这五个方法,已经非常可以说明问题了,因此楼上这个EventBus
希望大家可以熟练掌握。学有余力的同学,推荐阅读FaceBook推出的通用EventEmiiter库的源码 (opens new window) (opens new window),相信你会有更多收获。
# 8.5 观察者模式与发布-订阅模式的区别是什么?
在面试过程中,一些对细节比较在意的面试官可能会追问观察者模式与发布-订阅模式的区别。这个问题可能会引发一些同学的不适,因为在大量参考资料以及已出版的纸质书籍中,都会告诉大家“发布-订阅模式和观察者模式是同一个东西的两个名字”。本书在前文的叙述中,也没有突出强调两者的区别。其实这两个模式,要较起真来,确实不能给它们划严格的等号。
为什么大家都喜欢给它们强行划等号呢?这是因为就算划了等号,也不影响我们正常使用,毕竟两者在核心思想、运作机制上没有本质的差别。但考虑到这个问题确实可以成为面试题的一个方向,此处我们还是单独拿出来讲一下。
回到我们上文的例子里。韩梅梅把所有的开发者拉了一个群,直接把需求文档丢给每一位群成员,这种发布者直接触及到订阅者的操作,叫观察者模式。但如果韩梅梅没有拉群,而是把需求文档上传到了公司统一的需求平台上,需求平台感知到文件的变化、自动通知了每一位订阅了该文件的开发者,这种发布者不直接触及到订阅者、而是由统一的第三方来完成实际的通信的操作,叫做发布-订阅模式。
相信大家也已经看出来了,观察者模式和发布-订阅模式之间的区别,在于是否存在第三方、发布者能否直接感知订阅者(如图所示)。
观察者模式
发布-订阅模式
在我们见过的这些例子里,韩梅梅拉钉钉群的操作,就是典型的
观察者模式
;而通过EventBus
去实现事件监听/发布
,则属于发布-订阅模式
。
既生瑜,何生亮?既然有了观察者模式,为什么还需要发布-订阅模式呢?
大家思考一下:为什么要有观察者模式?
观察者模式
,解决的其实是模块间的耦合问题,有它在,即便是两个分离的、毫不相关的模块,也可以实现数据通信。但观察者模式仅仅是减少了耦合,并没有完全地解决耦合问题——被观察者必须去维护一套观察者的集合,这些观察者必须实现统一的方法供被观察者调用,两者之间还是有着说不清、道不明的关系。
而发布-订阅模式
,则是快刀斩乱麻了——发布者完全不用感知订阅者
,不用关心它怎么实现回调方法,事件的注册和触发都发生在独立于双方的第三方平台(事件总线)上
。发布-订阅模式
下,实现了完全地解耦。
但这并不意味着,
发布-订阅模式就比观察者模式“高级”
。在实际开发中,我们的模块解耦诉求并非总是需要它们完全解耦。如果两个模块之间本身存在关联,且这种关联是稳定的、必要的,那么我们使用观察者模式就足够了。而在模块与模块之间独立性较强、且没有必要单纯为了数据通信而强行为两者制造依赖的情况下,我们往往会倾向于使用发布-订阅模式
。
# 9 行为型-迭代器模式
迭代器模式提供一种方法顺序访问一个聚合对象中的各个元素,而又不暴露该对象的内部表示。 ——《设计模式:可复用面向对象软件的基础》
迭代器模式是设计模式中少有的目的性极强的模式。所谓“目的性极强”就是说它不操心别的,它就解决这一个问题——遍历。
# 9.1 “公元前”的迭代器模式
遍历作为一种合理、高频的使用需求,几乎没有语言会要求它的开发者手动去实现。在JS中,本身也内置了一个比较简陋的数组迭代器的实现——
Array.prototype.forEach
通过调用forEach
方法,我们可以轻松地遍历一个数组:
const arr = [1, 2, 3]
arr.forEach((item, index)=>{
console.log(`索引为${index}的元素是${item}`)
})
但forEach
方法并不是万能的,比如下面这种场景:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta >
<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>
我想拿到所有的a标签,我可以这样做:
const aNodes = document.getElementsByTagName('a')
console.log('aNodes are', aNodes)
我想取其中一个a
标签,可以这样做:
const aNode = aNodes[i]
在这个操作的映衬下,aNodes
看上去多么像一个数组啊!但当你尝试用数组的原型方法去遍历它时:
aNodes.forEach((aNode, index){
console.log(aNode, index)
})
你发现报错了:
原来这个aNodes
是个假数组!准确地说,它是一个类数组对象,并没有为你实现好用的forEach
方法。也就是说,要想实现类数组的遍历,你得另请高明。
现在问题就出现了:普通数组是不是集合?是!aNodes
是不是集合?是!同样是集合,同样有遍历需求,我们却要针对不同的数据结构执行不同的遍历手段,好累!再回头看看迭代器的定义是什么——遍历集合的同时,我们不需要关心集合的内部结构。而forEach
只能做到允许我们不关心数组这一种集合的内部结构,看来想要一套统一的遍历方案,我们非得请出一个更强的通用迭代器不可了。
这个小节的标题定语里有三个字“公元前”,这个“公元前”怎么定义呢?其实它说的就是ES标准内置迭代器之前的那些日子——差不多四五年之前,彼时还没有这么多轮子,jQuery
风头正盛。当时面试可不问什么Vue
原理、React
原理、Webpack
这些,当时问的最多的是你读过jQuery源码吗?答读过,好,那咱们就有的聊了。答没有?fine,看来你只是个调包侠,回见吧——因为前端的技术点在那时还很有限,所以可考察的东西也就这么点,读jQuery源码的程序员和不读jQuery源码的程序员在面试官眼里有着质的区别。但这也从一个侧面反映出来,jQuery这个库其实是非常优秀的,至少jQuery里有太多优秀的设计模式可以拿来考考你。就包括咱们当年想用一个真·迭代器又不想自己搞的时候,也是请jQuery实现的迭代器来帮忙:
首先我们要在页面里引入jQuery:
<script src="https://cdn.bootcss.com/jquery/3.3.0/jquery.min.js" type="text/javascript"></script>
借助jQuery
的each
方法,我们可以用同一套遍历规则遍历不同的集合对象:
const arr = [1, 2, 3]
const aNodes = document.getElementsByTagName('a')
$.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}`)
})
输出结果仍然没问题:
可以看出,
jQuery
的迭代器为我们统一了不同类型集合的遍历方式,使我们在访问集合内每一个成员时不用去关心集合本身的内部结构以及集合与集合间的差异,这就是迭代器存在的价值~
# 9.2 ES6对迭代器的实现
在“公元前”,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...
遍历各种各种的集合,全靠迭代器模式在背后给力。
ps:此处推荐阅读迭代协议 (opens new window) (opens new window),相信大家读过后会对迭代器在ES6中的实现有更深的理解。
# 9.3 实现一个迭代器生成函数
ok,看过了迭代器从古至今的操作,我们一起来实现一个自定义的迭代器。
楼上我们说迭代器对象全凭迭代器生成函数帮我们生成。在ES6
中,实现一个迭代器生成函数并不是什么难事儿,因为ES6早帮我们考虑好了全套的解决方案,内置了贴心的生成器(Generator
)供我们使用:
// 编写一个迭代器生成函数
function *iteratorGenerator() {
yield '1号选手'
yield '2号选手'
yield '3号选手'
}
const iterator = iteratorGenerator()
iterator.next()
iterator.next()
iterator.next()
丢进控制台,不负众望:
写一个生成器函数并没有什么难度,但在面试的过程中,面试官往往对生成器这种语法糖背后的实现逻辑更感兴趣。下面我们要做的,不仅仅是写一个迭代器对象,而是用ES5
去写一个能够生成迭代器对象的迭代器生成函数(解析在注释里):
// 定义生成器函数,入参是任意集合
function iteratorGenerator(list) {
// idx记录当前访问的索引
var idx = 0
// len记录传入集合的长度
var len = list.length
return {
// 自定义next方法
next: function() {
// 如果索引还没有超出集合长度,done为false
var done = idx >= len
// 如果done为false,则可以继续取值
var value = !done ? list[idx++] : undefined
// 将当前值与遍历是否完毕(done)返回
return {
done: done,
value: value
}
}
}
}
var iterator = iteratorGenerator(['1号选手', '2号选手', '3号选手'])
iterator.next()
iterator.next()
iterator.next()
此处为了记录每次遍历的位置,我们实现了一个闭包,借助自由变量来做我们的迭代过程中的“游标”。
运行一下我们自定义的迭代器,结果符合预期:
迭代器模式比较特别,它非常重要,重要到语言和框架都争着抢着帮我们实现。但也正因为如此,大家业务开发中需要手动写迭代器的场景几乎没有,所以很少有同学会去刻意留意迭代器模式、思考它背后的实现机制。通过阅读本节,希望大家可以领略迭代器模式的妙处(为什么会有,为什么要用)和迭代器模式的实现思路(方便面试)。
# 10 中介者模式
定义:对象与对象之间借助第三方中介者通信
class Player {
constructor(name) {
this.name = name;
this.playerMiddle = new PlayerMiddle();
this.playerMiddle.add(name);
}
win() {
this.playerMiddle.win(this.name);
}
lose() {
this.playerMiddle.lose(this.name);
}
}
// 中介者
class PlayerMiddle {
constructor() {
this.players = [];
this.winArr = [];
this.loseArr = [];
}
add(name) {
this.players.push(name)
}
win(name) {
this.winArr.push(name)
if (this.winArr.length + this.loseArr.length === this.players.length) {
this.show()
}
}
lose(name) {
this.loseArr.push(name)
if (this.winArr.length + this.loseArr.length === this.players.length) {
this.show()
}
}
show() {
for (let winner of this.winArr) {
console.log(winner + '挑战成功;')
}
for (let loser of this.loseArr) {
console.log(loser + '挑战失败;')
}
}
}
const a = new Player('A 选手')
const b = new Player('B 选手')
const c = new Player('C 选手')
a.win()
b.win()
c.lose()
# 11 享元模式
定义:一种优化程序性能的模式, 本质为减少对象创建的个数。
以下情况可以使用享元模式
- 有大量相似的对象,占用了大量内存
- 对象中大部分状态可以抽离为外部状态
// 题目:某商家有 50 种男款内衣和 50 种款女款内衣, 要展示它们!
class Model {
constructor(gender) {
this.gender = gender
// this.underwear = ''
}
/* 方法一 */
// takePhoto() {
// console.log(`${this.gender}穿着${this.underwear}`)
// }
/* 方法二 */
takePhoto(i) {
console.log(`${this.gender}穿着${i}款衣服`)
}
}
const maleModel = new Model('male')
const femaleModel = new Model('female')
for (let i = 1; i < 51; i++) {
// maleModel.underwear = `第${i}款衣服`
// maleModel.takePhoto()
maleModel.takePhoto(i)
}
for (let i = 1; i < 51; i++) {
// femaleModel.underwear = `第${i}款衣服`
// femaleModel.takePhoto()
maleModel.takePhoto(i)
}