youngwind/blog

vue源码学习系列之十一:组件化原理探索(父子组件通信)

youngwind opened this issue · 7 comments

前言

#93 之后,我们来探索如何实现Vue的父子组件通信
在问如何实现之前,我习惯性地思考为什么
为什么Vue、React这些MVVM框架会出现组件通信这个概念?犹记得我去年初刚刚学前端的时候,学的还是jquery,那时候还没接触组件的概念,所有的DOM都写到一块,A处的DOM和B处的DOM并没有明确的边界。如果A处的DOM想修改B处的DOM,那么一定是A处的DOM触发某个js方法,然后该js方法直接修改B处的DOM。就这样,非常原始,也非常符合js这门脚本语言的风格。
然而,前端早已非“当年吴下阿蒙”。在不断吸收传统软件开发优良**的过程中,前端已经越来越系统化,js也逐渐成为一门正规的语言。组件化的概念其实就很类似传统软件开发中的模块化(各模块之间有明确的边界),既然划分了模块,那么模块之间的通信自然就成为了要解决的问题。这就是为什么这些MVVM框架会出现组件通信这个概念。

目标

本文只解决父子组件通信,不解决兄弟组件通信。预期做到的效果如下所示。
demo

注意:图中展示的效果总共有三层实例/组件,层层嵌套。他们之间传递“姓名”和“年龄”两个字段,又分为”向上冒泡“和”向下广播“两个方向,所以总共有2*2=4种事件。其中,在冒泡(dispatch)和广播(broadcast)的过程中,”姓名“事件的传递不会停止,而”年龄“事件在触发第一次的回调函数的时候,就会停止冒泡或广播。如果希望在首次触发回调之后继续冒泡或广播,那么须在events事件中指定return true。这与vue的实现保持一致。

PS: 此处示例对应的代码比较长,就不在文中展示了,请直接阅读源码,或者运行项目直接debug。

思路

我们考察child组件,发现它跟一般的组件有以下两点不同:

  1. 多了一个events字段,里面定义了许多事件及其对应的回调方法。
  2. methods方法中调用了$dispatch和$broadcast,用来触发及传播事件。

先来看第一点。

事件初始化

我们需要将事件及其回调函数注册到child实例上,这样当其他组件(无论是父组件还是子组件)传来消息的时候,程序才能知道该触发哪个事件,该执行哪个回调函数。

// src/instance/events.js
/**
 * 初始化事件events
 * @private
 */
exports._initEvents = function () {
    let options = this.$options;
    registerCallbacks(this, '$on', options.events);
};

/**
 * 遍历实例的所有事件
 * @param vm {Bue} bue实例
 * @param action {String} 动作类型,此处为'$on',代表绑定事件
 * @param events {Object} 事件对象,可能包含多个事件, 所以需要遍历
 */
function registerCallbacks(vm, action, events) {
    if (!events) return;
    for (let key in events) {
        let event = events[key];
        register(vm, action, key, event);
    }
}

/**
 * 注册单个事件
 * @param vm {Bue} bue实例
 * @param action {String} 动作类型,此处为'$on',代表绑定事件
 * @param key {String} 事件名称, 比如: 'parent-name',代表从父组件那里传递了名称过来
 * @param event {Function} 触发key事件的时候, 对应的回调函数
 */
function register(vm, action, key, event) {
    if (typeof event !== 'function') return;
    vm[action](key, event);
}
// src/instance/api/events.js
/**
 * 注册事件及其回调函数到实例上
 * @param event {String} 事件名称
 * @param fn {Function} 事件对应的回调函数
 * @returns {Bue} 实例本身
 */
exports.$on = function (event, fn) {
    (this._events[event] || (this._events[event] = [])).push(fn);
    return this;
};

初始化的结果可以参考下图。
events-init

触发及传播事件

无论是$dispatch还是$broadcast,他们都有类似的步骤。

如果是向上冒泡事件

  1. 在当前组件实例触发($emit)事件,执行对应的回调函数。如果事件return true,那么代表事件可传播,执行第2步。如果事件不可传播,结束。
  2. 找出当前组件的父组件。没有父组件?结束。有父组件?把父组件当成当前组件,重新执行第1步。

如果是向下广播事件

  1. 找出当前组件的所有子组件。没有子组件?结束。有子组件?执行第2步。
  2. 深度遍历所有子组件,把子组件当成当前组件,触发($emit)事件,执行对应的回调函数。如果事件return true,那么代表事件可传播,重新执行第1步。如果事件不可传播,结束。

相关代码如下:

/**
 * 在当前实例中触发指定的事件, 执行对应的回调函数
 * @param event {String} 事件名称
 * @param val {*} 事件所携带的参数
 * @returns {boolean} true代表事件可以继续传播, false代表事件不可继续传播
 */
exports.$emit = function (event, val) {
    let cbs = this._events[event];
    let shouldPropagate = true;
    if (cbs) {
        shouldPropagate = false;
        // 遍历执行事件
        let args = new Array(Array.from(arguments)[1]);
        cbs.forEach((cb) => {
            let res = cb.apply(this, args);
            // 就是这里, 决定了"只有当events事件返回true的时候, 事件才能在触发之后依然继续传播"
            if (res === true) {
                shouldPropagate = true;
            }
        });
    }

    return shouldPropagate;
};

/**
 * 向上冒泡事件, 沿父链传播
 * @param event {String} 事件的名称
 * @param val {*} 事件所携带的参数
 * @returns {Bue} 实例
 */
exports.$dispatch = function (event, val) {
    // 在当前实例中触发该事件
    let shouldPropagate = this.$emit.apply(this, arguments);
    if (!shouldPropagate) return this;
    let parent = this.$parent;
    // 遍历父链
    while (parent) {
        shouldPropagate = parent.$emit.apply(parent, arguments);
        parent = shouldPropagate ? parent.$parent : null;
    }
    return this;
};

/**
 * 向下广播事件, 沿子链传播
 * @param event {String} 事件的名称
 * @param val {*} 事件所携带的参数
 * @returns {Bue} 实例
 */
exports.$broadcast = function (event, val) {
    let children = this.$children;
    let shouldPropagate = true;
    let args = new Array(Array.from(arguments)[0]);
    children.forEach((child) => {
        shouldPropagate = child.$emit.apply(child, arguments);
        if (shouldPropagate) {
            child.$broadcast.apply(child, arguments);
        }
    });
    return this;
};

注意:Vue在广播事件的时候,是不会在发起广播事件的组件触发该事件的,只会从它的下一个子组件开始触发。这与冒泡事件稍有不同。

That's all。这样我们就实现了Vue父子组件之间的通信了。参考的Vue源码依然是1.0.26版本,实现的完整代码在这儿

后话

虽然我们实现了父子组件通信,但是,兄弟节点A、B间怎么通信呢?一个比较粗糙的思路就是:兄弟A将消息发到兄弟A、B共同的父节点C,然后再经由C转发给兄弟B。但是这样做有一个弊端,当节点很多的时候,这种传递方式就会显得杂乱无章。那么,传统的计算机是如何解决众多兄弟节点(比如,CPU、内存、硬盘等等)之间的通信的呢?答案是总线机制。这么良好的**,我们前端怎么能不借鉴呢?所以就有了redux和vuex这些状态管理器。

cdll commented

学习了~👍

希望楼主多出些这类文章,太受益了~

看了你的文章受益良多,老铁可不可以写篇关于javascript的eventloop的博文。或者老铁可不可以帮我解惑一下关于eventloop的问题https://segmentfault.com/q/1010000008960948?_ea=1787342

  1. 关于 eventloop 细节的争辩,众说纷纭,难有定论。对于此类问题,我个人采取适可而止的策略,能满足实际工作需要便好。
  2. 我赞成宏队列和微队列的观点。
  3. 阮一峰老师的文章可能有不严谨的地方,但是这种问题很难界定。因为在实际观察中发现,浏览器之间的表现也是不同的。有些浏览器没有宏队列,有些浏览器各个队列之间的优先级不同,而且浏览器自身也在不断更新。
  4. 之前我有一篇文章涉及到这个问题,详见这里
  5. 知乎上也有相关的讨论

@JasonCloud

嗯嗯,看了知乎上讨论的那个,明白了不少,thx @youngwind

厉害~ 关注你的博客半年了, vue的源码系列断断续续看完了,也自己实现了一套。 非常感谢!! 以后会持续关注你的博客

vnues commented

vue父子组件通信是基于props属性来实现的 不是基于发布订阅吧