在近两年996模式下的近乎疯狂的迭代需求打磨平台的锻炼下,积累了一些前端通信方面的一些实践经验,在这里做一个汇总。一来对自己做一个总结,二来也是给小伙伴们提供一些吸收。
由于作者使用的是vue.js,所有主要对vue.js的组件通信做总结。而且是.vue单文件组件的形式。用react.js的小伙伴不要失望,文章中有很多通用的通信知识点:比如DOM通过自定义事件通信,基于nodejs的EventEmitter通信,多Window通信 / Tab间通信等等。
这里只讨论前端内部的通信,不涉及前后端通信。前后端之间的http通信,mqtt通信,跨域,文件上传等等等等,讲不完的。以后会单独开一篇文章做梳理。
触发事件 <-->增加了自定义事件DOM
DOM通过自定义事件通信的意思是:可以为DOM增加一些自定义的事件,然后在某些情况下去触发这些事件,然后事件做出响应。
说简单一些就是:增加了自定义事件的DOM,是一个鲜活的听话的人,发送对应的命令给它,它就会去做事。
var event = new Event('build');
// Listen for the event.
elem.addEventListener('build', function (e) {
/* ... */ }, false);
// Dispatch the event.
elem.dispatchEvent(event);
CustomEvent()可以通过detail属性为事件增加数据。
var event = new CustomEvent('build', {
detail: "foo" });
elem.addEventListener('build', function (e) {
console.log(e.detail) });
关于应用 DOM通过自定义事件通信 的实战,可以参考我的这篇博客:如何为DOM创建自定义事件?。
组件间通信实在是一个老生常谈的话题,因为真的是每天都会遇到。
父组件的数据单向传递到子组件。
// 父组件 Parent.vue
<template>
<Child :foo="hello child">Child>
template>
<script>
import Child from './child';
export default {
name: 'parent',
components: {
Child },
};
script>
// 子组件 Child.vue
<template>
<div>{
{foo}}div>
template>
<script>
export default {
name: 'child',
props: {
foo: {
type: String,
default: '',
},
},
};
script>
子组件监听的父组件属性如果不仅仅做类似{ {foo}}这样的模板渲染,可以使用watch做监听。
父组件中的传入子组件props的变量发生变化时,可以通过watch监听对应的prop属性,做出对应的操作。
这也算是一种父子组件通信的方式。
// 父组件
<Child :foo="parent.foo" @child-msg-emit="childMsgOn">Child>
// 子组件
watch: {
foo(val) {
console.log("foo更新为:", val);
}
},
子组件通过$emit向父组件传递数据。
父组件通过v-on接收数据。
二者需要约定好相同的事件名。
// 父组件 Parent.vue
<template>
<Child :foo="hello child" @child-msg-emit="childMsgOn">Child>
template>
<script>
import Child from './child';
export default {
name: 'parent',
components: {
Child },
methods: {
childMsgOn(msg) {
console.log(msg); //'hello parent'
},
},
};
script>
// Child.vue
<template>
<div>{
{foo}}div>
template>
<script>
export default {
name: 'child',
props: {
foo: {
type: String,
default: '',
},
},
mounted() {
this.$emit('child-msg-emit', 'hello parent');
},
};
script>
除了用$emit和v-on,父组件传入子组件的prop可以双向绑定吗?可以用.sync。
可能有小伙伴对这个.sync修饰符不熟悉,但它其实非常有用。
sync是一个语法糖,简化v-bind和v-on为v-bind.sync和this.$emit(‘update:xxx’)。为我们提供了一种子组件快捷更新父组件数据的方式。
首先将传递给foo的值放在一个变量中。
...
<Child :foo="parent.foo" @child-msg-emit="childMsgOn">Child>
data() {
return {
parent: { foo: "hello child" }
}
},
methods: {
childMsgOn(msg) {
console.log(msg); //'hello parent'
this.parent.foo = msg;
},
}
...
<Child
v-bind:foo="parent.foo"
v-on:child-msg-emit="childMsgOn"
>Child>
在vue中,父组件向子组件传递的props是无法被子组件直接通过this.props.foo = newFoo
去修改的。
除非我们在组件this.$emit("child-msg-emit", newFoo)
,然后在父组件使用v-on做事件监听child-msg-emit事件。若是想要可读性更好,可以在$emit的name上改为update:foo,然后v-on:update:foo。
有没有一种更加简洁的写法呢???
那就是我们这里的.sync操作符。
可以简写为:
<Child v-bind:foo.sync="parent.foo">Child>
子组件触发:this.$emit("update:foo", newFoo);
然后在子组件通过this.$emit("update:foo", newFoo);
去触发,注意这里的事件名必须是update:xxx的格式,因为在vue的源码中,使用.sync修饰符的属性,会自定生成一个v-on:update:xxx的监听。
<Child v-bind:foo="parent.foo" v-on:update:foo="childMsgOn">Child>
如果想从源码层面理解v-bind:foo.sync,可以参考我的这篇文章:如何理解vue中的v-bind?。
父->子 props, watch
子->父 $emit, v-on
父<–>子 v-bind:xxx.sync
除上述3种方法外,我们还可以直接通过获得父子组件的实例去调用它们的方法,是一种伪通信。
子组件通过 p a r e n t 可 以 拿 到 父 组 件 的 v u e 实 例 , 从 而 调 用 属 性 和 方 法 。 父 组 件 可 以 通 过 parent可以拿到父组件的vue实例,从而调用属性和方法。 父组件可以通过 parent可以拿到父组件的vue实例,从而调用属性和方法。父组件可以通过refs拿到子组件的vue实例,从而调用属性和方法。
// parent.vue
<Child ref="child" :foo="parent.foo" @child-msg-emit="childMsgOn">Child>
methods: {
parentMethod() {
console.log("I am a parent method");
},
$refCall() {
this.$refs.child.childMethod(); // I am a child method
}
}
// child.vue
methods: {
childMethod() {
console.log("I am a child method");
},
$parentCall() {
this.$parent.parentMethod(); // I am a parent method
}
}
想想一种情况,有这样一个组件树。
红色组件想和黄色组件进行通信。
红色组件可以通过逐级向上$emit,然后通过props逐级向下watch,最后更新黄色组件。
显然这是一种很愚蠢的方法,在vue中有多种方式去做更加快速的跨组件通信,比如event bus 跨组件通信,vue-router 区分新增与编辑,vuex 全局状态树和provide, inject 跨组件通信。
名字听起来高大上,但其实使用起来很简单。
下面演示一个注册为plugin的用法。
// plugins/bus/bus.js
import Vue from 'vue';
const bus = new Vue();
export default bus;
// plugins/bus/index.js
import bus from './bus';
export default {
install(Vue) {
Vue.prototype.$bus = (() => bus)();
},
};
// main.js
import bus from 'src/plugins/bus';
Vue.use(bus);
注册为全局plugin之后,就可以通过this.$bus使用我们的event bus了。
红色组件发送事件:
this.$bus.$emit('yellowUpdate', 'hello yellow.');
黄色组件接收事件:
this.$bus.$on('yellowUpdate',(payload)=>{
console.log(payload); // hello yellow
});
vuex是vue生态很重要的一个附加plugin,进行前端的状态管理。
除前端状态管理之外,因为这是一个全局的状态树,状态在所有组件都是共享的,因此vuex其实也是一个跨组件通信的方式。
定义store
import Vue from 'vue';
import Vuex from 'vuex';
Vue.use(Vuex);
const state = {
userInfo: {
id: '',
name: '',
age: '',
},
};
const mutations = {
UPDATE_USER(state, info) {
state.userInfo = info;
},
};
export default new Vuex.Store({
state,
mutations
});
红色组件:更新状态树的state:mapMutation
<script>
import {
mapMutations } from 'vuex';
export default {
name: 'set-state',
methods: {
...mapMutations(['UPDATE_USER']),
},
created(){
this.UPDATE_USER({
id: 1, name: 'foo', age: 25 });
}
}
</script>
黄色组件:获得状态树的state:mapState
<template>
<ul>
<li>{
{
user.id }}</li>
<li>{
{
user.name }}</li>
<li>{
{
user.age }}</li>
</ul>
</template>
<script>
import {
mapState } from 'vuex';
export default {
name: 'get-state',
computed: {
...mapState({
user: 'userInfo',
})
},
}
</script>
没想到吧,vue-router不仅仅可以做路由管理,还可以区分组件的编辑和新增状态。
因为对于一个新增或者编辑组件,数据基本上都是一致的,一般都是在同一个组件内增加一个标识去区分新增或者编辑。
这个标识可以是组件自身的一个status属性,也可以是通过props传入的status属性。
也可以不加这种标识,直接通过vue-router去做到。而且用vue-router还可以直接将数据带过去。
父组件通过vue-router的query带数据过去。
this.$router.push({
name: 'componentPost', query: {
type: 'edit', data } });
新增/编辑子组件得到数据并做填充。
created() {
// 判断到是编辑状态
if(this.$route.query.type==="edit"){
const data = this.$route.query.data;
// do some thing with data
}
}
provide,inject其实是一种“解决后代组件想访问共同的父组件,$parent层级过深且难以维护问题“的利器。其中心思想是依赖注入。
学习过react的同学应该知道context这个概念,在vue中,provide/inject与之很类似。
通过vue官方的例子我们做一个解释:
<google-map>
<google-map-region v-bind:shape="cityBoundaries">
<google-map-markers v-bind:places="iceCreamShops">google-map-markers>
google-map-region>
google-map>
google-map-region和google-map-markers如果都想获得祖先元素google-map实例,然后调用它的方法getMap。
// google-map-region这样做
this.$parent.getMap();
// google-map-markers这样做
this.$parent.$parent.getMap();
如果还在google-map-markers 组件下还有子组件呢?this.$parent.$parent.$parent.getMap();
这种代码还能看吗???而且后期组件结构有变动的话,根本无法维护。
为了解决这个问题,可以使用provide/inject。
// google-map.vue
<script>
export default {
name: "child",
provide() {
return {
getMap: this.getMap
}
}
};
script>
// google-map-region.vue,google-map-markers.vue等后代组件这样使用
<script>
export default {
name: "child",
inject: ['getMap']
mouted(){
this.getMap(); // 这样就可以访问到google-map的getMap方法了。
}
};
script>
当注入一个属性时,可以将注入值作为默认值或者数据入口。可以通过from改名字。
// google-map.vue
<script>
export default {
name: "child",
provide() {
return {
foo: 'hello inject, I am foo',
bar: 'hello inject, I am bar',
getMap: this.getMap
}
}
};
script>
// google-map-region.vue,google-map-markers.vue等后代组件这样使用
<script>
export default {
name: "child",
inject: {
primitiveFoo: 'foo',
specialFoo: {
from: 'bar',
default: '默认属性'
},
googleMapGetMap: 'getMap',
},
mouted() {
// 这样就可以访问到google-map的foo属性了。
this.primitiveFoo; // 'hello inject, I am foo'
this.specialFoo; // 'hello inject, I am bar'
this.googleMapGetMap(); // 这样就可以访问到google-map的getMap方法了。
}
};
script>
具体可以参考:provide / inject和依赖注入
其实与基于vue实例的event bus很类似。都是很简单的双向通信的,基于订阅发布模型的通信方式。
如果是基于webpack,vue/react等等现代化的基于nodejs开启本地服务器和打包发布的项目,可以在项目中使用nodejs的EventEmitter。
按照自己喜欢的名称overwrite原来的方法:
import {
EventEmitter } from 'events';
class Emitter extends EventEmitter {
$emit(eventName, cargo) {
this.emit(eventName, cargo);
}
$on(eventName, callback) {
this.on(eventName, callback);
}
$off(eventName, callback) {
this.removeListener(eventName, callback);
}
}
export default new Emitter();
红色组件使用emitter $emit发送事件
import emitter from '../emitter';
emitter.$emit('foo-bar-baz', 'hello yellow');
黄色组件使用emitter $on接收事件
import emitter from '../emitter';
emitter.$on('foo-bar-baz', (msg)=>{
console.log(msg); // 'hello yellow'
});
最后使用 o f f 销 毁 事 件 。 若 是 在 v u e 中 , 建 议 在 b e f o r e D e s t r o y ( ) 生 命 周 期 中 使 用 , 并 且 需 要 将 off销毁事件。 若是在vue中,建议在beforeDestroy()生命周期中使用,并且需要将 off销毁事件。若是在vue中,建议在beforeDestroy()生命周期中使用,并且需要将on的callback赋值为一个具名回调。
mounted(){
this.fooBarBazHandler = (msg)=>{
console.log(msg); // 'hello yellow'
}
emitter.$on('foo-bar-baz', this.fooBarBazHandler);
}
beforeDestroy() {
emitter.$off('iText-confirm', this.fooBarBazHandler);
},
组合使用watch,vuex,event bus可能起到意想不到的效果,我手上开发的PC端聊天模块,就是基于watch,vuex和event bus实现的,非常强大。
我相信大家在实际开发中可以找到自己的最佳实践。
这是一个非常常见的场景,当你打开了一个页面需要与另一个页面做数据传递时,组件间通信那一套是行不通的。
因为每个window/page/tab都是单独的一个vue实例,单独的vuex实例,即使是nodejs的e、EventEmitter,也是一个单独的emitter实例。
这要怎么办呢?其实浏览器为我们提供了多种方式去做这件事。
假设下面这样一个场景:点击图片打开一个新的window,1秒后替换成别的图片。
<img :src="src" @click="openChildWindow()"/>
openChildWindow(){
// window.open会返回子window对象
this.childWindow = window.open("https://foo.bar.com/baz.jpg");
setTimeout(()=>{
// 通过this.childWindow访问到子对象进行操作
this.childWindow.location.replace("https://foo.bar.com/baz.png");
}, 1000)
}
this.childWindow.opener就是当前的window实例,在子window内也可以访问到父window进行操作。
这是一种在tab已经打开后,无法明显建立父子关系的场景下常用的方法。
Tab A:在localStorage/sessionStorage中set一个新值
window.localStorage.setItem('localRefresh', +new Date());
window.sessionStorage.setItem('sessionRefresh', +new Date());
Tab B:监听storage的变化
window.onstorage = (e) => {
if (e.key === 'localRefresh') {
// do something
}
if (e.key === 'sessionRefresh'') {
// do something
}
};
这样我们就实现TabA和TabB之间的通信了。
除了通过上述方式之外,还可以专门建立一个通信通道去交换数据。
const bc = new BroadcastChannel('test_channel');
bc.postMessage('This is a test message.');
只要与父window建立同名BroadcastChannel即可。
const bc = new BroadcastChannel('test_channel');
bc.onmessage = function (event) {
console.log(event); // 'This is a test message.'包含在event对象中。
}
手上项目的热力图计算曾经尝试过将计算逻辑转移到worker子线程计算,但是由于种种原因没有成功,但是积累了这方面的经验。
// src/workers/test.worker.js
onmessage = function(evt) {
// 工作线程收到主线程的消息
console.log("worker thread :", evt); // {data:{msg:”Hello worker thread.“}}
// 工作线程向主线程发送消息
postMessage({
msg: "Hello main thread."
});
};
// src/pages/worker.vue
<template>
<div>Main thread</div>
</template>
<script>
import TestWorker from "../workers/test.worker.js";
export default {
name: "worker",
created() {
const worker = new TestWorker();
// 主线程向工作线程发送消息
worker.postMessage({
msg: "Hello worker thread." });
// 主线程接收到工作线程的消息
worker.onmessage = function(event) {
console.log("main thread", event); // {data:{msg:"Hello main thread."}}
};
}
};
</script>
更多如何在vue项目中使用Main thread与Web worker间通信的demo可以查看:一次失败的用web worker提升速度的实践
Shared worker是一种web worker技术。
mdn的这个demo为我们清晰地展示了如何使用SharedWorker,实现tab对worker的共享。
SharedWorker的执行脚本worker.js
onconnect = function(e) {
var port = e.ports[0];
port.onmessage = function(e) {
var workerResult = 'Result: ' + (e.data[0] * e.data[1]);
port.postMessage(workerResult);
}
}
Tab A与Tab B都新建名为worker.js的SharedWorker
var myWorker = new SharedWorker("worker.js");
myWorker.port.start();
console.log('Message posted to worker');
myWorker.port.postMessage();
myWorker.port.onmessage = function(e) {
console.log('Message received from worker');
}
地址:http://mdn.github.io/simple-shared-worker/
worker-1
worker-2
共享了什么,共享了一个乘法worker,worker1和worker2都可以用,在这里是乘法运算。
在这边博文中我们学习到了DOM通信,vue组件间通信,多Window通信 / Tab间通信,Web worker通信等等前端通信的知识点。
但是要知道,这些仅仅是实际开发中的可选项集合,具体使用什么样的技术,还是要结合具体的应用场景。
而且在前端日新月异的更新换代中,会有老的技术消失,会有新的技术出现。一定要保持stay hungry的态度。
参考资料:
期待和大家交流,共同进步,欢迎大家加入我创建的与前端开发密切相关的技术讨论小组:
- SegmentFault专栏:趁你还年轻,做个优秀的前端工程师
- Github博客: 趁你还年轻233的个人博客
- 微信公众号: 大大大前端 / excellent_developers
努力成为优秀前端工程师!