本文会探讨一下发布订阅模式在前端的应用以及双向绑定的实现原理。
1.发布订阅模式
设计模式
软件编程的设计模式起源于上世纪90年代:软件编程开发中,会有一些比较经典的问题以及对应方法,可以归纳总结出来成为通用的思路和方式,以便在后续软件开发人员借鉴使用,上世纪90年代逐渐出现一些零星的设计模式出现,而比较系统并且有代表性的设计模式则是由Design Patterns: Elements of Reusable Object-Oriented Software一书出版后流行开来。
和算法与数据结构一样,设计模式也是优秀程序必须学习掌握的重要一项技能,但是不必刻意追求深入学习某种设计模式,当工程复杂到一定程度或者面对某些复杂需求,就自然会在实践中使用某种设计模式。
发布订阅模式
发布订阅模式(观察者模式),在某些文章中,例Observer vs Pub-Sub Pattern,会将观察者模式和发布订阅模式说成两种模式,不过笔者认为,其实从核心思想来看,还是一类模式,主要解决一类问题。
发布订阅模式中涉及着信息的获取行为,根据获取信息的一方,可以将发布订阅模式分为“推”模式和“拉”模式;根据发布订阅模式的实现程度可以分为“观察者模式”和“发布订阅模式”。
概念图如下:
首先,从“推模式”开始,先看这样一份代码,可以假设发布者和订阅者分别是报社和订报纸的顾客.
const Publisher = new Observable;
const subscriber = function (news) {
}
Publisher.subscribe(subscriber)
.notify('a new news!')
.unsubscribe(Subscriber)
.notify('a new news!')
在这个模型中,报社处于主导地位,负责较多的功能:
-
管理订阅的顾客并且有权利停止为顾客投送;
-
在新报纸出现后为订阅的顾客投送报纸。
下面是一份完整实现这样功能的可执行代码(简单版本):
//简单版本
class Observable {
constructor() {
this.subscribers = []
}
subscribe(fn) {
const ifExist = this.subscribers.some((existSubscriber) => {
return existSubscriber = fn
})
if (!ifExist) {
this.subscribers.push(fn)
}
return this;
}
notify() {
this.subscribers.map(fn => {
fn.apply(this, arguments)
})
return this;
}
unsubscribe(fn) {
this.subscribers = this.subscribers.filter((existSubscriber) => {
return existSubscriber !== fn;
})
return this;
}
}
const Publisher = new Observable;
const subscriber = function (message) {
console.log('recieved ' + message)
}
Publisher.subscribe(subscriber)
.notify('a new message!')
.unsubscribe(Subscriber)
.notify('a new message!')
如果顾客只想订阅某个频道的消息,不想每个消息都被推送到的话怎么处理呢?我们可以更改一下subscribers的数据结构来处理。
//带key版本(带频道的版本)
class Observable {
constructor() {
this.subscribers = {}
}
subscribe(key, fn) {
if (!this.subscribers[key]) {
this.subscribers[key] = []
}
const ifExist = this.subscribers[key].some((existSubscriber) => {
return existSubscriber === fn
})
if (!ifExist) {
this.subscribers[key].push(fn)
}
return this;
}
notify() {
const key = Array.prototype.shift.call(arguments),
fns = this.subscribers[key]
if (!fns || fns.length === 0) {
return false;
}
fns.forEach(fn => {
fn.apply(this, arguments)
});
return this;
}
unsubscribe(key, fn) {
if (!this.subscribers[key]) {
return this;
}
this.subscribers[key] = this.subscribers[key].filter((existSubscriber) => {
return existSubscriber !== fn;
})
return this;
}
}
const Publisher = new Observable;
const subscriberA = function (message) {
console.log('subscriberA recieved ' + message)
}
const subscriberB = function (message) {
console.log('subscriberB recieved ' + message)
}
Publisher.subscribe('sport', subscriberA)
.subscribe('weather', subscriberA)
.subscribe('weather', subscriberB)
.notify('sport', 'a new message about sport!')
.notify('weather', 'a new message about weather')
.unsubscribe('weather', subscriberA)
.notify('weather', 'a new message about weather! ')
“推”模式的发布订阅就这样实现了,大家可以思考一下如何实现一个“拉”模式的发布订阅模式。(待修改)
以上的设计实现可以说是发布者和订阅者比较耦合的场景,发布者内部需要实现管理订阅者,以及推送消息等功能,而订阅者严重依赖发布者,如果有多个发布者呢?如果想减少发布者和订阅者之间的耦合性呢?那么就可以引入一个“中介”来达到这样一个目的。(待修改)
发布订阅模式的优点
- 解耦
发布订阅模式可以在大型程序中用于解耦,可以使各个模块开发时不必担心其他模块迭代带来的影响,例如,一个博客产品,在登录成功后可能会有获取评论、获取权限内文章等等这样的功能,在引入发布订阅模式之前,可能面临着在登录模块中,引入评论模块以及文章模块的一些代码,如果这三个模块分别三个项目组开发,那可能面临着评论模块升级必须有登录模块的人员配合联调。在引入发布订阅模式后,这类耦合性严重的开发迭代问题将不复存在。
- 性能
当面临大量频繁数据改变时,通过一次注册监听数百、上千次的数据变化可以减少多次的事件监听次数。
发布订阅模式的缺点
发布订阅模式在首次创建可观察对象时会带来比较大的开销,所以使用场景比较适合一次创建,多次使用的场景。同时,在js这样单线程模型中,可以使用惰性加载以及预先加载的技术来避免和主进程冲突,影响程序性能。
2.双向绑定
a.为什么要双向绑定
双向绑定可以将视图和数据绑定在一起,减少我们频繁的视图与数据之间的同步。
b.实现一个简单的双向绑定
首先,为了比较清晰地了解双向绑定的原理,我们需要实现一个简单版本的双向绑定,目标有两个:
1.当input和textarea等变化时,实时改变数据并且响应到用来展示其的span等标签上。
2.可以通过js来指定需要双向绑定的key,并且改变其value。
从HTML开始,代码如下:
<div>
Name:
<input data-bind="name" type="text">
<span data-bind="name"></span>
<br>
Email:
<input data-bind="email" type="text">
<span data-bind="email"></span>
</div>
利用data属性标记来帮助定位数据,用这种方式代替Angular和Vue中template的功能,从而简化代码复杂性,便于理解。
在这个简单双向绑定模型中,为了实现两个目标需求,提供的API如下
const mvvm = new MVVM();
mvvm.set("name", "free");
mvvm.set("email");
和所有前端框架一样,第一步,需要实例化我们的简单框架;然后实例提供一个set方法,set方法接受两个参数,key对应HTML代码中data-bind的值,是一个必选值,value对应着双向绑定数据的值,是一个可选值。
在开始实现这个简单框架之前,让我们仔细想一下这样一个简单框架中有哪些行为会触发数据变化:1)input和textarea中的输入行为;2)框架API提供的手动修改绑定数据的行为。
首先,我们先设定一个scope的JSON对象用来存储需要双向绑定的key和value,然后在实例化这个框架的时,对需要需要双向绑定的元素添加事件监听,当事件触发时,使用本简单框架中提供的set方法来更新数据。
class MVVM {
constructor() {
this.scope = {}
const elements = Array.from(document.querySelectorAll('[data-bind]'))
elements.forEach(element => {
if (checkBindElement(element)) {
const currentKey = element.getAttribute('data-bind')
const listenEvents = ['input']
listenEvents.map(event => {
element.addEventListener(event, (e) => {
if (this.checkKeyInScope(currentKey)) {
this.set(currentKey, element.value)
}
})
})
}
})
}
//...
}
需要注意querySelectorAll返回的是一个HTML的nodelist并不是Array,可以使用foreach但不能直接使用map。checkBindElement是用来检查是否是input或者textarea,checkKeyInScope用来检查是否已经含有该key。
然后我们需要一个方法,能够响应constructor中函数监听到的数据变化的行为,并且在数据变化的同时,响应到依赖该数据的UI上。通过在Object.defineProperty中的set拦截方法,我们可以比较容易的实现这一功能。
set(key, value) {
if (key) {
this.bindKey(key)
}
if (value) {
this.scope[key] = value;
}
}
bindKey(key) {
if (!this.checkKeyInScope(key)) {
const aimElements = document.querySelectorAll(`[data-bind=${key}]`);
if (aimElements.length > 0) {
Object.defineProperty(this.scope, key, {
set: function (newValue) {
aimElements.forEach((element) => {
if (checkBindElement(element)) {
element.value = newValue
} else {
element.innerHTML = newValue
}
})
},
get: function (value) {
return value;
},
enumerable: true
})
}
}
}
最后,整体的实现代码如下:
function checkBindElement(element) {
const nodeName = element.nodeName && element.nodeName.toLowerCase();
if (nodeName === 'input' || nodeName === 'textarea') {
return true
} else {
return false
}
}
class MVVM {
constructor() {
this.scope = {}
const elements = Array.from(document.querySelectorAll('[data-bind]'))
elements.forEach(element => {
if (checkBindElement(element)) {
const currentKey = element.getAttribute('data-bind')
const listenEvents = ['input']
listenEvents.map(event => {
element.addEventListener(event, (e) => {
if (this.checkKeyInScope(currentKey)) {
this.set(currentKey, element.value)
}
})
})
}
})
}
set(key, value) {
if (key) {
this.bindKey(key)
}
if (value) {
this.scope[key] = value;
}
}
bindKey(key) {
if (!this.checkKeyInScope(key)) {
const aimElements = document.querySelectorAll(`[data-bind=${key}]`);
if (aimElements.length > 0) {
Object.defineProperty(this.scope, key, {
set: function (newValue) {
aimElements.forEach((element) => {
if (checkBindElement(element)) {
element.value = newValue
} else {
element.innerHTML = newValue
}
})
},
get: function (value) {
return value;
},
enumerable: true
})
}
}
}
checkKeyInScope(key) {
if (this.scope.hasOwnProperty(key)) {
return true
} else {
return false;
}
}
}
var mvvm = new MVVM()
mvvm.set("name", "jeremy");
mvvm.set("email");
可以试试运行它,在控制台中调用mvvm实例内的方法来直接改变目标数据哦。
附:其实这个简单方法实现的是类似Vue的基于数据劫持的双向绑定;有兴趣可以自己实现基于脏检查结合原生pubsub模型实现的双向绑定。