公司业务中有个场景,须要在用户点击标签的时候,把标签内容进行处理成相似微博话题的形式,插入到 textarea
中。textarea
和标签是页面的两个组件,正常状况我能够点击标签后向外抛出事件,页面去监听,而后再把数据传给 textarea
,但这样的处理麻烦,因此就想仿照 Vue 的 EventBus 来实现小程序的兄弟组件传值。html
首先先看下demo:前端
标签的部分和输入框部分是页面的两个组件,咱们要作的就是点击标签的时候,将标签的内容添加到输入框中。linux
实现方式一:点击标签的时候,拿到标签的文本内容,而后调用微信的 triggerEvent
API 向外抛出一个事件;而后在页面中监听这个事件,拿到标签组件抛出的标签文本后,将其设置进页面的 data 中,而后将其传给 输入框组件,输入框组件经过 observers
监听传入的文本数据,最后将其拼好,赋值给输入框的的 value。nginx
方式一是很容易想到的方案,可是缺点很明显,实现的过程太过于繁琐了,因此方式一直接 pass,咱们重点来研究另外一个实现方案——EventBus。git
实现方式二:方式二咱们就仿照 Vue 的 EventBus 来实现兄弟组件传值,实质其实利用发布订阅模式。github
首先是当咱们点击标签的时候,咱们须要向外触发一个事件,而后把标签的内容携带过去,它就是消息的发布者;而后咱们须要在 textarea 组件内监听标签组件触发的事件,而后接收标签的内容,他是消息的订阅者。咱们须要实现的就是标签组件和 textarea 通讯的桥梁。小程序
组件我已经写好了,文末附有 demo 的 github 地址,我下面只展现关键代码,组件基础代码就不演示了。设计模式
首先,咱们须要一个对象,这个对象给咱们提供了一个 on
方法,用来让咱们监听另外一个组件触发的事件。因此这个 on
方法须要接收两个参数,一个是咱们要监听的事件名字,另外一个参数是函数,当事件被触发的时候,经过这个函数来通知咱们。数组
咱们须要把订阅写在组件的生命周期内,确保另外一个组件抛出事件时,咱们是订阅过的。微信
// textArea组件的JS
import bus from '../../utils/eventbus'; // 这里的代码还没写,咱们先假定提供订阅API的对象是这个js模块提供的
Component({
data: {},
lifetimes: {
ready() {
// 当组件的ready生命周期执行的时候,咱们经过bus对象提供的on方法去订阅了sendTag事件
// 当sendTag事件被触发的时候,咱们给它提供了一个函数,这个函数接收个tagText参数,这个就是标签组件被点击要传递的内容
// 咱们这里订阅的是sendTag事件,那么也就是要求标签被点击的时候也必须向外抛出sendTag事件
bus.on('sendTag', tagText => {
console.log(tagText);
});
},
},
methods: {}
})
复制代码
接下来,咱们编写 eventbus 模块,首先这个模块最终必须向外暴露一个对象,这个对象必须拥有 on 方法。
// eventbus.js
class Bus {
on(Event, cb) {
}
}
export default new Bus();
复制代码
咱们继续分析 on 方法,咱们须要将 Event 做为 key,cb 做为 value 存起来,这样当发布者发布消息(向外触发事件)的时候,咱们找到对应的事件,而后去执行对应的方法就行。
为了通用性,同一个事件可能有多个订阅者,好比下面还有三个 textarea 组件,都须要在标签被点击的时候拿到标签内容,因此,咱们就须要一个数组,把多个订阅者都放里面。
class Bus {
constructor() {
// events 是一个容器,里面放的是,各个事件和它的订阅者,数据格式如:
/** this.events = { sendTag: [cb1,cb2,cb3], sendMsg: [cb4,cb5,cb6] }; */
// 固然也能够直接this.sendTag = [cb1,cb2,cb3];我的习惯不一样,我更喜欢放的容器内
this.events = {};
}
on(Event, cb) {
if(this.events[Event]) {
// 若是这个事件存在,那说明以前已经有订阅者了,此时只须要将这个订阅者再push进去便可
// this.events[Event] 是ES6的写法,能够百度搜索 ES6熟悉名表达式了解
// 若是传进来的 Event 是 ‘sendTag’,this.events[Event] 就是 this.events.sendTag,也就在 events 对象上加了一个 sendTag 属性
this.events[Event].push(cb);
} else {
// 若是这个事件不存在,那咱们就须要对其初始化,将咱们做为第一个订阅者,放到数组中赋给这个事件
this.events[Event] = [cb];
}
}
}
export default new Bus();
复制代码
在上面咱们实现了基础的订阅功能,接下来咱们须要实现发布的功能。
首先,咱们须要 bus
对象再给咱们提供一个 emit
方法,这个方法也接收两个参数,第一个参数是咱们发布消息时的事件名字(向外触发事件时的事件name),第二个参数是发布事件时要传递的参数,固然你也能够不传递参数。
咱们须要在标签被点击的时候,调用 emit
方法向外触发事件
<!-- 标签对应的组件 -->
<view class="container">
<view class="label">Tags</view>
<view class="tags">
<!-- bindtap="clickTag" 标签的点击事件-->
<!-- data-tag="{{item}}" 标签被点击的时候,在事件对象内加一个 tag 属性,值就是标签的文本内容,小程序文档:https://developers.weixin.qq.com/miniprogram/dev/framework/view/wxml/event.html -->
<view class="tag" wx:for="{{tags}}" wx:key="index" bindtap="clickTag" data-tag="{{item}}">{{item}}</view>
</view>
</view>
复制代码
// 标签组件对应的 js 逻辑
// 引入 bus 对象
import bus from '../../utils/eventbus';
Component({
data: {
tags: ["被骂哭的导盲犬主", "全国65亿网民月", "鲍毓明养女发声", "大学没有谈过恋爱", "多名学生曝被班主", "林奕含去世三周年", "李国庆发人事调整", "肖战发声", "和对象一块儿长胖是", "奔跑吧", "最春天的照片", "张杰爱人啊", "拔牙后千万不要嗜睡", "中国第四个新冠疫", "蒋凡被除名合伙人"]
},
methods: {
clickTag({target: {dataset: {tag}}}) {
// 当标签被点击的时候,调用 bus 的 emit 方法,而后向外抛一个 sendTag 事件,同时把标签内容传出去
// 抛出的事件必定要和监听事件对应起来,呃。。。。。
bus.emit('sendTag', `#${tag}#`);
}
}
})
复制代码
接下来在 bus
对象上增长一个 emit
方法。
class Bus {
constructor() {
// events 是一个容器,里面放的是,各个事件和它的订阅者,数据格式如:
/** this.events = { sendTag: [cb1,cb2,cb3], sendMsg: [cb4,cb5,cb6] }; */
// 固然也能够直接this.sendTag = [cb1,cb2,cb3];我的习惯不一样,我更喜欢放的容器内
this.events = {};
}
// 新加的 emit 方法
emit(Event, obj) {
}
on(Event, cb) {
if(this.events[Event]) {
// 若是这个事件存在,那说明以前已经有订阅者了,此时只须要将这个订阅者再push进去便可
// this.events[Event] 是ES6的写法,能够百度搜索 ES6熟悉名表达式了解
// 若是传进来的 Event 是 ‘sendTag’,this.events[Event] 就是 this.events.sendTag,也就在 events 对象上加了一个 sendTag 属性
this.events[Event].push(cb);
} else {
// 若是这个事件不存在,那咱们就须要对其初始化,将咱们做为第一个订阅者,放到数组中赋给这个事件
this.events[Event] = [cb];
}
}
}
export default new Bus();
复制代码
接下来,继续分析 emit
方法须要完成什么工做
当 emit
方法被调用的时候,说明有人须要向外发布消息了,这个时候咱们须要从 events
对象上找到对应的事件(events[event]
),而后去通知全部订阅了这个事件的订阅者(events[event]
的 value 是数组,数组元素是这个订阅者提供的通知他们的函数,因此就是遍历这个数组,挨个调用这些函数,再把发布者传递的内容传入这些函数内)
class Bus {
constructor() {
// events 是一个容器,里面放的是,各个事件和它的订阅者,数据格式如:
/** this.events = { sendTag: [cb1,cb2,cb3], sendMsg: [cb4,cb5,cb6] }; */
// 固然也能够直接this.sendTag = [cb1,cb2,cb3];我的习惯不一样,我更喜欢放的容器内
this.events = {};
}
// 新加的 emit 方法
emit(Event, obj) {
// 若是要发布的这个事件不存在,说明这个事件没有订阅者,直接 return ,什么都不须要作
if(!this.events[Event]) return
// 这个事件存在,说明它是有订阅者的,咱们去遍历它,而后再把发布者要传递的内容传入这些订阅者提供的函数内
this.events[Event].forEach(cb => {
cb(obj)
})
}
on(Event, cb) {
if(this.events[Event]) {
// 若是这个事件存在,那说明以前已经有订阅者了,此时只须要将这个订阅者再push进去便可
// this.events[Event] 是ES6的写法,能够百度搜索 ES6熟悉名表达式了解
// 若是传进来的 Event 是 ‘sendTag’,this.events[Event] 就是 this.events.sendTag,也就在 events 对象上加了一个 sendTag 属性
this.events[Event].push(cb);
} else {
// 若是这个事件不存在,那咱们就须要对其初始化,将咱们做为第一个订阅者,放到数组中赋给这个事件
this.events[Event] = [cb];
}
}
}
export default new Bus();
复制代码
编写完成这一部分,咱们就能够先看下效果了
能够看到,经过上方的代码,咱们算是基本实现了两个组件间的通讯,接下来,咱们继续完善 textarea
的代码,咱们监听到 sendTag
事件被触发的时候,须要把另外一个组件传过来的值添加到输入框中
<!-- textarea 组件模板 -->
<view class="container">
<view class="label">内容</view>
<!-- value="{{value}}" 控制输入框的内容 -->
<textarea class="textarea" placeholder="请输入内容。。。" value="{{value}}" show-confirm-bar="{{false}}" bindinput="handleInput"></textarea>
</view>
复制代码
// textArea组件的JS
import bus from '../../utils/eventbus'; // 这里的代码还没写,咱们先假定提供订阅API的对象是这个js模块提供的
Component({
data: {
value: ''
},
lifetimes: {
ready() {
// 当组件的ready生命周期执行的时候,咱们经过bus对象提供的on方法去订阅了sendTag事件
// 当sendTag事件被触发的时候,咱们给它提供了一个函数,这个函数接收个tagText参数,这个就是标签组件被点击要传递的内容
// 咱们这里订阅的是sendTag事件,那么也就是要求标签被点击的时候也必须向外抛出sendTag事件
bus.on('sendTag', tagText => {
const {value} = this.data;
this.setData({
value: `${value} ${tagText} `
});
});
},
},
methods: {
handleInput({detail: {value}}) {
this.setData({value});
},
}
})
复制代码
咱们再看下此时的效果
经过上面的演示,彷佛已经完成了咱们的需求了,但实际上其实还隐藏着一个bug,咱们在订阅的代码里,打印一下传递过来的参数和当前的 this
bus.on('sendTag', tagText => {
console.log(tagText,this);
const {value} = this.data;
this.setData({
value: `${value} ${tagText} `
})
});
复制代码
而后咱们看一下 bug 是什么
能够看到咱们第一次进入这个页面点击标签的时候,控制台纸打印了一条记录;第二次进入这个页面点击标签时,控制台打印了两次;第三次进入点击标签打印了三条记录,但咱们每次进入都是只点击了一次标签,为何会打印多条记录呢。
同时经过查看第三次进入页面点击标签时控制台打印的三条记录,咱们能够发现,第三条记录才是咱们输入框内显示的内容;而第二条记录是第二次输入框的内容加上第三次进入页面所点击标签的内容;第一条记录是第一次输入框内容,加上第二次第三次进入页面所点击标签的内容。
因此咱们能够肯定这个 bug 产生的缘由是由于咱们返回的时候,页面销毁了,可是本次订阅的事件并无取消订阅,且订阅的函数内 存在对当前组件 this 的引用,因此出现了点击一次标签,对应事件被屡次触发的状况。
为了解决这个问题,咱们就须要在当前页面被销毁组件被从页面移除时,取消对应的时间订阅。
取消事件订阅实质也就是把这个订阅函数从 events[event]
的数组中删除,这就要求咱们订阅时提供的函数和取消订阅时提供的函数是同一个,怎么保证是同一个,两次提供的函数内存地址相同。
取消订阅的方法咱们取名叫 off
,一样接收两个参数,第一个订阅时的事件名,第二个参数订阅时提供的函数
(为了使代码看起来比较清晰,我仅展现了关键代码)
import bus from '../../utils/eventbus';
Component({
data: {},
lifetimes: {
ready() {
// 组件 ready 生命周期执行时进行订阅,订阅的方法是 this.handleTag
bus.on('sendTag', this.handleTag);
},
detached() {
// 组件销毁时进行取消订阅,方法一样是 this.handleTag
bus.off('sendTag', this.handleTag)
}
},
methods: {
handleTag(tagText) {
// 这里专门把 this 打印出来,是由于这里存在一个 this 指向的问题
console.log(tagText, this)
}
}
})
复制代码
咱们再编写一下 bus (为了使代码看起来比较清晰,我仅展现了关键代码)
class Bus {
constructor() {
// events 是一个容器,里面放的是,各个事件和它的订阅者,数据格式如:
/** this.events = { sendTag: [cb1,cb2,cb3], sendMsg: [cb4,cb5,cb6] }; */
// 固然也能够直接this.sendTag = [cb1,cb2,cb3];我的习惯不一样,我更喜欢放的容器内
this.events = {};
}
// 取消订阅
off(Event,cb) {
// 若是要取消的这个事件不存在,说明这个事件一直都没有订阅者,直接 return ,什么都不须要作
if(!this.events[Event]) return
// 根据你提供的 你在订阅时提供的订阅函数 到全部的订阅函数中查找它所在索引
const index = this.events[Event].findIndex(item => item === cb);
// 若是要取消订阅的这个事件存在,可是根据你提供的函数,并无在数组内查找到,说明你提供的函数并非订阅者
// 那就给用户一个报错信息,让用户去检查下他的代码
if (index === -1) {
console.error(new Error('该 handle 没有订阅者,取消订阅失败'));
return;
}
// 若是找到了,直接根据索引删除
this.events[Event].splice(index, 1);
}
}
export default new Bus();
复制代码
咱们再看下此时控制台的打印
根据控制台的打印,每次点击标签都是只打印了一条记录,说明咱们取消订阅是成功了;可是打印的 this
倒是 undefined ,this 是 undefined,说明咱们就无法给输入框设置内容,因此咱们须要更改订阅函数的 this 指向(为了使代码看起来比较清晰,我仅展现了关键代码)
import bus from '../../utils/eventbus';
Component({
data: {},
lifetimes: {
ready() {
// 经过 bind 修改 this 指向
bus.on('sendTag', this.handleTag.bind(this));
},
detached() {
// 经过 bind 修改 this 指向
bus.off('sendTag', this.handleTag.bind(this))
}
},
})
复制代码
我使用 bind
去修改了 this 的指向,可是调用 bind 方法会返回一个新的函数,我订阅和取消订阅都使用了 bind 因此每次都会返回新函数,这就形成两个函数的内存地址不一致,取消订阅失败。 因此,须要在 data
中在定义一个属性来接收 bind 返回的新函数(为了使代码看起来比较清晰,我仅展现了关键代码)
import bus from '../../utils/eventbus';
Component({
data: {
value: '',
_handle: undefined, // 接收bind返回的新函数
},
lifetimes: {
ready() {
this.setData({
// 将bind返回的新函数赋给 _handle
_handle: this.handleTag.bind(this)
})
// 将 _handle 提供给 on
bus.on('sendTag', this.data._handle);
},
detached() {
// 将 _handle 提供给 off ,订阅和取消订阅提供的同一个 _handle 内存地址一致,因此能够成功取消订阅
bus.off('sendTag', this.data._handle)
}
},
methods: {
handleInput({detail: {value}}) {
this.setData({value});
},
handleTag(tagText) {
console.log(tagText, this)
const {value} = this.data;
this.setData({
value: `${value} ${tagText} `
})
}
}
})
复制代码
最后咱们再看一下效果
咱们的需求基本已是完成了,但为了使 EventBus 使用起来更加友好,咱们还能够再作一些优化。
消息订阅的时候添加匿名函数的判断,由于匿名函数是没法被取消订阅的,因此若是用户提供的是匿名函数,咱们最好给用户一个提示
class Bus {
constructor() {
this.events = {}
}
on(Event, cb) {
if(this.events[Event]) {
this.events[Event].push(cb);
} else {
this.events[Event] = [cb];
}
// 若是是匿名函数就给用户个警告
if(!cb.name) {
console.warn('on 接口的 handler 参数推荐使用具名函数。具名函数可使用 off 接口取消订阅,匿名函数没法取消订阅。')
}
}
emit(Event, obj) {
if(!this.events[Event]) return
this.events[Event].forEach(cb => {
cb(obj)
})
}
off(Event,cb) {
if(!this.events[Event]) return
const index = this.events[Event].findIndex(item => item === cb);
if (index === -1) {
console.error(new Error('该 handle 没有订阅者,取消订阅失败'));
return;
}
this.events[Event].splice(index, 1);
}
}
export default new Bus;
复制代码
由于时间订阅和取消必须是同一个对象,因此咱们最好再加个限制,Bus类只容许有一个实例对象
class Bus {
constructor() {
this.events = {}
}
on(Event, cb) {
if(this.events[Event]) {
this.events[Event].push(cb);
} else {
this.events[Event] = [cb];
}
// 若是是匿名函数就给用户个警告
if(!cb.name) {
console.warn('on 接口的 handler 参数推荐使用具名函数。具名函数可使用 off 接口取消订阅,匿名函数没法取消订阅。')
}
}
emit(Event, obj) {
if(!this.events[Event]) return
this.events[Event].forEach(cb => {
cb(obj)
})
}
off(Event,cb) {
if(!this.events[Event]) return
const index = this.events[Event].findIndex(item => item === cb);
if (index === -1) {
console.error(new Error('该 handle 没有订阅者,取消订阅失败'));
return;
}
this.events[Event].splice(index, 1);
}
// 给这个类加一个静态方法,用来判断这个类以前有没有生成过对象
static getInstance() {
if (!Bus.instance) {
Bus.instance = new Bus()
}
return Bus.instance;
}
}
// 导出这里返回 Bus 类静态 getInstance 的执行结果
export default Bus.getInstance();
复制代码
本文的 EventBus 模块,算是发布订阅模式的一种典型使用场景,但也不局限于小程序的组件间通讯,在其余的相似场景中也彻底能够通用,本文核心其实也是讲发布订阅模式,但愿在项目开发中,你们能灵活运用上设计模式来解决咱们遇到的问题。
本文GitHub地址:github.com/luokaibin/w…