xingbofeng / xingbofeng.github.io

counterxing的博客
https://xingbofeng.github.io
175 stars 18 forks source link

Vue组件库开发总结:通信方式 #32

Open xingbofeng opened 6 years ago

xingbofeng commented 6 years ago

问题:不用Vuex怎么让兄弟组件便捷通信?甚至让业务组件和内部组件通信?

答案:使用eventHub

如果不使用EventHub,我们想让父组件的两个子组件,甚至两个孙子组件之间进行通信,怎么办?

方案一:Vue自带的原生的emiton的观察者模式

此处demo可见官方文档

弊端:必须经过父组件,并且必须为此给父组件增加一个状态。如果组件层级过深,不可维护!

方案二:自己实现一个broadcastdispatch

虽然broadcastdispatch方法已经被Vue官方所废弃,但是我们仍然可以自己实现一个broadcastdispatch方法。原理是componentName参数传递需要被通知的组件,然后在组件树中用递归的方式找到正确的组件名称,之后通过apply调用对应组件的$emit方法:

function broadcast(componentName, eventName, params) {
    this.$children.forEach(child => {
        const name = child.$options.name;
        if (name === componentName) {
            child.$emit.apply(child, [eventName].concat(params));
        } else {

            broadcast.apply(child, [componentName, eventName].concat([params]));
        }
    });
}
function broadcastAll( eventName, params) {
    this.$children.forEach(child => {
        const name = child.$options.name;
        child.$emit.apply(child, [eventName].concat(params));

        broadcastAll.apply(child, [eventName].concat([params]));
    });
}
export default {
    methods: {
        dispatch(componentName, eventName, params) {
            let parent = this.$parent || this.$root;
            let name = parent.$options.name;

            while (parent && (!name || name !== componentName)) {
                parent = parent.$parent;

                if (parent) {
                    name = parent.$options.name;
                }
            }
            if (parent) {
                parent.$emit.apply(parent, [eventName].concat(params));
            }
        },
        broadcast(componentName, eventName, params) {
            broadcast.call(this, componentName, eventName, params);
        },
        broadcastAll( eventName, params) {
            broadcastAll.call(this,  eventName, params);
        },

    }
};

之后,我们可以通过Vuemixins的方式把上述方法引入到组件实例中:

import Emitter from '../../mixins/emitter';

export default {
    // ...
    mixins: [Emitter],
    // ...
}

思考如图所示的组件模型:

Dicom-ViewDicom-Canvas的父级组件,Dicom-Canvas组件又包括有多个Dicom-View-Port组件,我们在Dicom-View-Port中触发一个事件,希望改变另一个Dicom-View-Port组件的状态。

我们在Dicom-View-Port组件中,触发一个点击事件,此时想父组件发送一个名为on-click-select-view-port的事件:

handleMouseDown(event) {
    this.dispatch('DicomCanvas', 'on-click-select-view-port',[this.index, this.element, this.seriesId, this.windowName]);
},

在二级组件Dicom-Canvas中,我们监听了这样的一个事件,希望向下广播,并希望Dicom-View也接收这一事件,我们又在Dicom-Canvas中向上传播这一事件:

// 监听被选中的视窗
this.$on('on-click-select-view-port',(index, element, seriesId, windowName)=>{
    this.dispatch('DicomView','on-click-select-view-port',[index, element, seriesId, windowName]);
    this.broadcast('DicomViewPort','on-click-select-view-port',[index]);
    this.selectedViewPortIndex = index;
});

Dicom-View组件,监听到该事件,改变了状态:

// 监听被选中的视窗
this.$on('on-click-select-view-port', (index, element, seriesId, windowName) => {
    this.selectedViewPortIndex = index;
    this.element = element;
    this.seriesId = seriesId;
    this.windowName = windowName;
});

如上,比起第一种方法,我们可以看到它的优势:即可以不需要一级一级地传递组件状态,因为broadcastdispatch是递归地向上或向下传递状态。但同时我们也看到了劣势:必须手动通过mixins的方式引入我们的辅助函数,并且,事件传递只能单向。递归可能造成溢出的风险,性能损耗等等。

甚至还有这样的情况,Dicom-View是我们的公共组件,我们并不想在业务组件里面都引入这样的broadcastdispatch方法!并且,我们并不会把所有功能组件的内容全都暴露给业务组件来调用,个人认为,这样的方式缺乏可行性。

方案三:使用闭包mixins

利用闭包不会被垃圾回收机制回收的特征,采用闭包minins。参考Vue 另类状态管理

终极方案:使用eventHub

开发功能组件的问题是:如何定义功能组件供给外部组件调用的接口,常用的方式是,Vue中我们一般是通过props传递状态进入功能组件,在功能组件中watch这个状态的变化。

看如图所示的情况:

我们在业务组件1中引入我们的公共组件Dicom-View,我们希望在业务组件2中去监听Dicom-View-Port组件的一个事件。

如果按照方案二的方式:由于Dicom-View是功能组件暴露给外部的唯一接口,因此外部调用功能组件只能通过Dicom-View的接口来进行调用,我们的业务组件2必须通过整个应用的状态流转来流转到业务组件1的调用处来进行调用,在不使用Vuex的情况下,我们怎么避免如此冗余的调用链,那么eventHub的模式就登场了:

eventHub类似于服务定位器模式和观察者模式的结合,eventHub为一个中心点,所有事件的监听和发送都会经过eventHub这样一个中心点,如图所示:

EventHub作为事件的中心定位器,所有的事件都经过eventHub来进行转发,不需要经过父子组件中的状态传递,我们可以把EventHub放在Vue的原型下面,这样可以在组件实例中直接运用:

首先,在webpack的入口处,定义EventHub和所有事件的原型EVENTS

import Vue from 'vue';
import DicomView from './components/dicom-view';
import EVENTS from './utils/events';

Vue.prototype.$DicomView = DicomView;
Vue.prototype.$DicomView.$EventHub = new Vue();
Vue.prototype.$DicomView.$EVENTS = EVENTS;

在我们的Dicom-View-Port组件中,注册事件:

handleMouseDown(event) {
this.$DicomView.$EventHub.$emit(EVENTS.ON_CLICK_SELECT_VIEW_PORT, {
    index: this.index,
    element: this.element,
    seriesId: this.seriesId,
    windowName: this.windowName,
});
// this.dispatch('DicomCanvas', 'on-click-select-view-port',[this.index, this.element, this.seriesId, this.windowName]);
},

在实际业务组件中,在created钩子函数中,注册事件监听器:

created() {
    this.$DicomView.$EventHub.$on(this.$DicomView.$EVENTS.ON_CLICK_SELECT_VIEW_PORT, ({ seriesId, index }) => {
        this.activeSeriesId = seriesId;
    });
}

最好在组件销毁前,使用$off清除事件监听:

beforeDestroy() {
    this.$DicomView.$EventHub.$off(this.$DicomView.$EVENTS.ON_CLICK_SELECT_VIEW_PORT, ({ seriesId, index }) => {
        this.activeSeriesId = seriesId;
    });
}

如上,就完成了上述的如此复杂的组件间消息通信。

总结:通过EventHub的方式,更便捷清晰地解决了在不使用Vuex的情况下的组件间通信和状态共享问题,更便捷地实现功能组件和业务组件的通信,使用EventHub来进行功能组件的开发,不失为一种便捷的方法。

xuexb commented 6 years ago

赞,虽然我还没怎么研究 Vue ,但看面你上面的解析,我大概明白了 Vue 里数据的传递。且我理解你的 eventHub 方案应该也是 Vuex 的简化版,只是 Vuex 做的更强大、更完善些。

最近我也要进军 Vue 了,得多向你讨教了~

xingbofeng commented 6 years ago

@xuexb 使用eventHub的目的是为了解决特定的组件库开发过程中,组件层级较深,并且无法使用Vuex情况下的状态通信问题。通常的组件库都是UI组件库,往往层级较浅,仅仅依靠props downevent up就可以实现组件库与外部业务组件的状态通信。但此次组件库开发遇到的问题是组件层级较深的问题,所以探讨了几种通信形式。由于Vue本身的状态绑定和数据通信就是通过Object.defineProperty或者Proxy代理的形式来拦截底层set操作实现的观察者模式,就之前的EventHub就直接使用了Vue实例:

Vue.prototype.$DicomView.$EventHub = new Vue();

最近考虑到event的触发顺序问题,因此自己实现了一个eventHub,可见自己实现一个带权重的事件监听器