1. 前言
JavaScript 是一门动态语言,在实现设计模式的时候,往往会比 Java 等静态语言更简便,本文将介绍在 JavaScript 中如何实现代理模式和发布订阅模式。
2. 代理模式
2.1. 定义
在介绍定义时还是以类图为主,虽然 JavaScript 实现设计模式时可能不会使用到类,但是类图提供了一种通用的设计模式实现思想。
代理模式定义:为其他对象提供一种代理以控制对这个对象的访问。
其类图如下:
图片
类图中的三个角色:
- Subject 抽象主题角色:定义了具体主题和代理主题的共同接口,这样在任何使用具体主题的地方都可以使用代理主题。
- RealSubject 具体主题角色:逻辑的具体执行者。
- Proxy 代理主题角色:实现了抽象主题接口,并持有对具体主题的引用。
2.2. 实现
在 JavaScript 中,你可以使用 Proxy 轻松实现代理模式,比如可以通过代理模式实现一个只接收 number 类型值的数组。
const arr = []
const numArr = new Proxy(arr, {
set(target, key, value, proxy) {
if (typeof value !== 'number') {
throw Error("属性只能是 number 类型");
}
return Reflect.set(target, key, value, proxy);
}
})
numArr.push(0)
numArr.push('1') // Uncaught Error: 属性只能是 number 类型
console.log(numArr) // Proxy(Array) {0: 0}
利用 Proxy,你还可以实现响应式编程。
const data = { userName: '' }
const render = (info) => {
console.log(info)
// 根据数据渲染界面
}
const proxyData = new Proxy(data, {
set(target, key, value, receiver) {
// 设置值
Reflect.set(target, key, value, receiver)
// 重新触发渲染
render(target)
}
})
data.userName = 'xiaoming'// 控制台输出 { userName: 'xiaoming' }
当然你也可以利用 Proxy 来实现日志功能,用于跟踪函数调用情况。
function add(a, b) {
return a + b;
}
// 日志记录函数
function log(message) {
console.log(message);
}
// 创建代理对象
const proxy = newProxy(add, {
// 拦截函数调用
apply(target, thisArg, args) {
const result = Reflect.apply(target, thisArg, args);
log(`函数 ${target.name} 被调用,参数: [${args.join(', ')}],返回值: ${result}`);
return result;
}
});
const sum1 = proxy(1, 2); // 输出: 函数 add 被调用,参数: [1, 2],返回值: 3
const sum2 = proxy(3, 4); // 输出: 函数 add 被调用,参数: [3, 4],返回值: 7
2.3. 小结
最后提一下代理模式和装饰模式的异同点,两者的共同点是代理类或装饰类和原本类都具有相同的接口,不同点则是代理模式着重对代理过程的控制,而装饰模式则是对类的功能进行加强或减弱。
3. 发布订阅模式
3.1. 定义
发布订阅模式的定义:定义对象间一种一对多的依赖关系,使得每当一个对象改变状态,则所有依赖于它的对象都会得到通知并被自动更新。
其类图如下:
图片
发布订阅模式经常会和观察者模式做对比,两个设计模式广义上设计理念是一致的,在实现上有些差别,本文更注重实际应用,故不展开此内容,借用一张图来说明。
图片
3.2. 实现
接着来完成发布订阅模式的简单实现,主要是实现 subscribe 和 publish 方法。
const event = {
listeners: [], // 所有订阅者集合
// 订阅函数
subscribe: function(fn) {
this.listeners.push(fn)
},
// 发布函数
publish: function() {
for(let i = 0; i < this.listeners.length; i++) {
this.listeners[i]()
}
},
// 移除订阅函数
unsubcribe: function(fn) {
const fns = this.listeners;
// 倒序访问方便使用 splice 移除订阅函数
for (let l = fns.length - 1; l >=0; l--) {
const _fn = fns[l];
if (_fn === fn){
fns.splice(l, 1);
}
}
}
}
const fn1 = () => { console.log('trigger1') }
const fn2 = () => { console.log('trigger2') }
event.subscribe(fn1)
event.subscribe(fn2)
event.publish() // 控制台打印 trigger1, trigger2
event.unsubcribe(fn1)
event.publish() // 控制台打印 trigger2
到此我们实现了一个简单版本的发布订阅。
接下来我们基于发布订阅模式,在 React 中实现一个类似 Zustand 的状态管理功能。
首先我们需要了解一个 React 官方 Hook useSyncExternalStore,这个 Hook 可以让你订阅一个外部数据源,当其中数据发生变化时,React 会触发重新渲染。
const snapshot = useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot?)
可以看到该 Hook 参数有三个,这里我们主要关注前两个,第一个参数即订阅函数,第二个参数为获取数据源的函数,第三个和服务端渲染相关。
接下来我们要结合发布订阅模式和 useSyncExternalStore 实现一个简单版本的 Zustand。
const createImpl = (createState) => {
// 相比发布订阅模式,多了个状态值
let state
let initialState
const listeners = newSet()
// 类似发布订阅模式中的 publish 方法,最终会触发订阅者
const setState = (nextState) => {
// 对比状态值是否有变化
if (!Object.is(nextState, state)) {
const previousState = state
state = Object.assign({}, state, nextState)
// 触发订阅函数
listeners.forEach((listener) => listener(state, previousState))
}
}
const getState = () => state
const getInitialState = () => initialState
const subscribe = (listener) => {
listeners.add(listener)
// 返回一个取消订阅的方法
return() => {
listeners.delete(listener)
}
}
// 清空订阅
const destory = () => listeners.clear()
const api = {
setState,
getState,
getInitialState,
subscribe,
destory
}
// 调用 createState 方法返回初始状态值,createState 参数为 set、get 和 api 对象
initialState = (state = createState(setState, getState, api))
return api
}
const create = (createState) => {
const api = createImpl(createState)
// 传入订阅方法和获取数据方法到 useSyncExternalStore
const useStore = () => useSyncExternalStore(api.subscribe, api.getState)
// 把 api 合并到 Hook 上
Object.assign(useStore, api)
return useStore
}
exportdefault create
先来看下 createImpl 函数,相比于我们实现的简单版发布订阅模式,createImpl 内部多维护了一个状态值,在调用发布方法(setState)时,会更新状态值,并触发订阅函数,订阅函数入参为新旧状态值。
最后我们看下如何使用自己的状态管理功能。
// create 方法接收一个函数参数,内部会调用函数初始化状态值,最终返回一个 Hook
const useStore = create((set) => ({
num: 1,
// 通过 set 方法更新状态值,更新后触发所有订阅函数的调用
random: () =>set({ num: Math.round(Math.random() * 1000) }),
}))
function Counter() {
// 调用 useStore,useStore 会调用 React useSyncExternalStore
const { num, random } = useStore();
return (
<div>
<p>{`Number: ${num}`}</p>
<button onClick={random}>Random</button>
</div>
)
}
create 方法接收一个函数参数,用于初始化状态,最终 create 会返回一个 Hook。在状态值中, random 方法会调用发布方法(setState)触发更新,因为 useSyncExternalStore 会使用第一个参数完成订阅动作,所以此时它能接收到数据更新,随后便返回最新的状态值,并触发重新渲染。
在线代码示例:https://stackblitz.com/edit/react-9nvjhwhx?file=demo.tsx
至此我们实现了一个简单版本 Zustand。
3.3. 小结
发布订阅模式的优点非常明显,一为时间上的解耦,二为对象之间的解耦,但如果过度使用的话,对象和对象之间的必要联系也将被深埋在背后,会导致程序难以跟踪维护和理解。
3. 总结
设计模式大体思想是要把系统中不变和变化的部分分开,封装不变的部分,根据业务灵活替换变化的部分,这样就可以保证系统的健壮性和可拓展性。同时在实现设计模式的同时,你通常也会很好的遵守了设计模式原则,如单一职责、依赖倒置、开闭原则、迪米特原则等。