公司業務中有個場景,須要在用戶點擊標籤的時候,把標籤內容進行處理成相似微博話題的形式,插入到 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…