Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

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

Open
youngwind opened this issue Oct 11, 2016 · 7 comments
Labels

Comments

@youngwind
Copy link
Owner

youngwind commented Oct 11, 2016

前言

#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这些状态管理器。

@youngwind youngwind added the Vue label Oct 11, 2016
@youngwind youngwind changed the title vue早期源码学习系列之十一:组件化原理探索(父子组件通信) vue源码学习系列之十一:组件化原理探索(父子组件通信) Oct 12, 2016
@cdll
Copy link

cdll commented Oct 25, 2016

学习了~👍

@ElinaLuo
Copy link

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

@JasonCloud
Copy link

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

@youngwind
Copy link
Owner Author

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

@JasonCloud

@JasonCloud
Copy link

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

@wakeGISer
Copy link

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

@vnues
Copy link

vnues commented Aug 28, 2020

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

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
Projects
None yet
Development

No branches or pull requests

6 participants