Vue 中组件化是很重要的一个概念。而组件之间通信是很常见的一个需求。
组件间通信最常见的就是父子组件之间通信了。父组件状态发生变化,向子组件分发事件;子组件状态变化,向父组件提交事件。当然有时也会遇到两个组件在组件树中相隔甚远却需要通信的情况,这时候我们可以使用一个事件总线来处理,vuex 之类的库可以看成是高级一点的事件总线。
所以我们就来看看 Vue 中是如何实现父子组件通信的。
父组件 -> 子组件 Prop 正如官方文档中所说的,在父组件向子组件通信的时候,应该使用 Prop 传递数据。
这个传递过程具体是怎么样的呢?如果阅读了前面的几篇博客应该会有所了解。下面我们再梳理一下。
父组件状态发生变化
触发 vm._watcher
(这个 Watcher 是触发组件更新的)
生成新的 VNode,并且子组件 VNode 上挂载对应 vm
的 prepatch
函数
更新 DOM 的同时对子组件的 vm
实例进行 prepatch
,改变 vm
的 props
子组件的 props 变化后,订阅 props 的 Watcher 会自动执行(异步执行 ,会放在一个事件队列中)
在 src/core/vdom/create-component.js
中,有一个 componentVNodeHooks
对象。对象上挂载了 init
prepatch
等钩子函数,会在组件 init 以及 patch 时执行。我们在这两个函数以及其他一些函数上加上标记,看看下面的这个例子 会是如何的执行顺序。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 Vue.component("child", { props: { number: { default: 0 } }, render(h) { return h("span", "father number is " + this.number); } }); new Vue({ el: "#app", data() { return { number: 1 }; }, render(h) { return h("div", {}, [ h("h1", "father number is " + this.number), h("child", { props: { number: this.number } }) ]); }, mounted() { setTimeout(() => { this.number = 2; }, 3000); } });
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 _render start // 父组件开始 `_render` _render end // 父组件 `_render` 结束,生成 h1 child div 3个 VNode _update start div // 父组件开始 `_update` init // 创建 3个 VNode 对应的 DOM,遇到 child 组件时,执行 `init` _render start // 子组件 child 开始 `_render` _render end // 子组件 child `_render` 结束,生成 span 1个 VNode _update start span // 子组件 child 开始 `_update` _update end span // 子组件 child `_update` 结束 _update end div // 父组件 `_update` 结束 // 3秒后计时器触发 _render start // 父组件 `vm._watcher` 触发,开始 `_render` _render end // 父组件 `_render` 结束,生成 h1 child div 3个 VNode _update start div // 父组件开始 `_update` prepatch // child 组件 `vm` 已经存在,所以不会执行 `init`,执行 `prepatch`,更改 props _update end div // 父组件 `_update` 结束(Vue 会把要执行的 Watcher 放进一个队列中,在下一个事件循环中执行,因此此处并不会马上执行 child 组件的 `_render`) _render start // 子组件 child 开始 `_render` _render end // 子组件 child `_render` 结束,生成 span 1个 VNode _update start span // 子组件 child 开始 `_update` _update end span // 子组件 child `_update` 结束
子组件 -> 父组件 自定义事件 提到事件,不可避免的和 DOM 的原生事件联系上了。而在官方文档中,也是通过自定义事件讲解子组件向父组件通信的。
Vue 自定义事件 Vue.prototype.$on
Vue.prototype.$emit
的代码都在 src/core/instance/events.js
中,代码很短,直接贴上来了。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 const hookRE = /^hook:/ Vue.prototype.$on = function (event: string | Array<string>, fn: Function): Component { const vm: Component = this if (Array.isArray(event)) { for (let i = 0, l = event.length; i < l; i++) { this.$on(event[i], fn) } } else { (vm._events[event] || (vm._events[event] = [])).push(fn) // optimize hook:event cost by using a boolean flag marked at registration // instead of a hash lookup if (hookRE.test(event)) { vm._hasHookEvent = true } } return vm }
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 Vue.prototype.$emit = function (event: string): Component { const vm: Component = this if (process.env.NODE_ENV !== 'production') { const lowerCaseEvent = event.toLowerCase() if (lowerCaseEvent !== event && vm._events[lowerCaseEvent]) { tip( `Event "${lowerCaseEvent}" is emitted in component ` + `${formatComponentName(vm)} but the handler is registered for "${event}". ` + `Note that HTML attributes are case-insensitive and you cannot use ` + `v-on to listen to camelCase events when using in-DOM templates. ` + `You should probably use "${hyphenate(event)}" instead of "${event}".` ) } } let cbs = vm._events[event] if (cbs) { cbs = cbs.length > 1 ? toArray(cbs) : cbs const args = toArray(arguments, 1) for (let i = 0, l = cbs.length; i < l; i++) { cbs[i].apply(vm, args) } } return vm }
逻辑很简单,$on
将事件名称和回调 push 到 vm._events
中,$emit
将对应事件名称的回调从 vm._events
中取出然后执行。
或许看到这里会有一个小小的疑惑。直觉上,$on
应该是在父组件实例上,$emit
在子组件上,并且会沿着组件树向上层传递,这样感觉和浏览器原生的 listener 比较接近。
但其实并不是这样的。$on
其实也是在子组件实例上的,在父组件中定义只是为了回调能获取父组件的上下文 。正如代码所表明的,Vue 中的自定义事件只能在父与子之间定义,超过一个层级的事件应该用其他方式来实现。
例如可以定义一个空的 Vue 实例,把 $on
$emit
都绑定在它上边,这样它就成为了一个事件总线。或者直接采用 vuex 来管理状态。
Vue DOM 原生事件 接下来我们看看 DOM 原生事件是怎么绑定在组件上的。
代码位置为 src/platforms/web/runtime/modules/events.js
1 2 3 4 5 6 7 8 9 10 function updateDOMListeners (oldVnode: VNodeWithData, vnode: VNodeWithData) { if (isUndef(oldVnode.data.on) && isUndef(vnode.data.on)) { return } const on = vnode.data.on || {} const oldOn = oldVnode.data.on || {} target = vnode.elm normalizeEvents(on) updateListeners(on, oldOn, add, remove, vnode.context) }
其实就是在生成或者更新 DOM 时,根据新旧的 VNode 更新 listeners。具体实现有兴趣的可以去看看代码。
或许看到这里你会有个疑问,为什么是 vnode.data.on
而不是 vnode.data.nativeOn
。其实这是因为在 createComponent
生成 VNode 这一步的时候。执行了这样几行代码
1 2 const listeners = data.on data.on = data.nativeOn
这样的话 vnode.data.on
里边就是 native 事件了。所以我们再看一个例子
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 Vue.component("child", { render(h) { return h("button", { domProps: { innerHTML: "button" } }); }, mounted() { setTimeout(() => { this.$emit("click"); }, 3000); } }); new Vue({ el: "#app", methods: { eventClick() { console.log("child event click"); }, nativeClick() { console.log("child native click"); } }, render(h) { return h("div", [ h("child", { on: { click: this.eventClick }, nativeOn: { click: this.nativeClick } }) ]); } });
例子很简单,nativeOn
放的是原生 DOM 事件。 on
放的是 Vue 自定义事件。
总结 以上就是 Vue 实现组件间通信的原理了。总的来说还是挺直观的。当然组件之间离得远的话或者状态多的话就得用 vuex 了。那就是另外一个大坑了。。