Vue.js 父子组件通信的十种方式&奇技淫巧

面试官:Vue 中父子组件通信有哪些方式?

vue组件通信全揭秘(共7章)

几种通信方式无外乎以下几种:

  • Prop(常用)
  • $emit (组件封装用的较多)
  • .sync语法糖 (较少)
  • $attrs$listeners (组件封装用的较多)
  • provideinject (高阶组件/组件库用的较多)
  • 其他方式通信

Prop

英式发音:[prɒp]。这个在我们日常开发当中用到的非常多。简单来说,我们可以通过 Prop 向子组件传递数据。用一个形象的比喻来说,父子组件之间的数据传递相当于自上而下的下水管子,只能从上往下流,不能逆流。这也正是 Vue 的设计理念之单向数据流。而 Prop 正是管道与管道之间的一个衔接口,这样水(数据)才能往下流。说这么多,看代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
<divid="app">
<child:content="message"></child>
</div>

let Child = Vue.extend({
template: '<h2>{{ content }}</h2>',
props: {
content: {
type: String,
default: () => { return'from child' }
}
}
})

new Vue({
el: '#app',
data: {
message: 'from parent'
},
components: {
Child
}
})

1
from parent

$emit

英式发音:[iˈmɪt]。官方说法是触发当前实例上的事件。附加参数都会传给监听器回调。按照我的理解不知道能不能给大家说明白,先简单看下代码吧:

<divid="app">
    <my-button @greet="sayHi"></my-button>
</div>

let MyButton = Vue.extend({
  template: '<button @click="triggerClick">click</button>',
  data () {
    return {
      greeting: 'vue.js!'
    }
  },
  methods: {
    triggerClick () {
      this.$emit('greet', this.greeting)
    }
  }
})

new Vue({
  el: '#app',
  components: {
    MyButton
  },
  methods: {
    sayHi (val) {
      alert('Hi, ' + val)   // 'Hi, vue.js!'
    }
  }
})

大致逻辑是酱婶儿的:当我在页面上点击按钮时,触发了组件 MyButton 上的监听事件 greet,并且把参数传给了回调函数 sayHi 。说白了,当我们从子组件 Emit(派发) 一个事件之前,其内部都提前在事件队列中 On(监听)了这个事件及其监听回调。其实相当于下面这种写法:

vm.$on('greet', functionsayHi (val) {
  console.log('Hi, ' + val)
})
vm.$emit('greet', 'vue.js')
// => "Hi, vue.js"

.sync 修饰符

这个家伙在 vue@1.x 的时候曾作为双向绑定功能存在,即子组件可以修改父组件中的值。因为它违反了单向数据流的设计理念,所以在 vue@2.0 的时候被干掉了。但是在 vue@2.3.0+ 以上版本又重新引入了这个 .sync 修饰符。但是这次它只是作为一个编译时的语法糖存在。它会被扩展为一个自动更新父组件属性的 v-on 监听器。说白了就是让我们手动进行更新父组件中的值了,从而使数据改动来源更加的明显。下面引入自官方的一段话:

在有些情况下,我们可能需要对一个 prop 进行“双向绑定”。不幸的是,真正的双向绑定会带来维护上的问题,因为子组件可以修改父组件,且在父组件和子组件都没有明显的改动来源。

既然作为一个语法糖,肯定是某种写法的简写形式,哪种写法呢,看代码:

1
2
3
4
<text-document
v-bind:title="doc.title"
v-on:update:title="doc.title = $event">
</text-document>

于是我们可以用 .sync 语法糖简写成如下形式:

<text-document v-bind:title.sync="doc.title"></text-document>

废话这么多,如何做到“双向绑定” 呢?让我们进段广告,广告之后更加精彩!

好的,欢迎回来。假如我们想实现这样一个效果:改变子组件文本框中的值同时改变父组件中的值。怎么做?列位不妨先想想。先看段代码:

<divid="app">
    <login:name.sync="userName"></login> 
    {{ userName }}
</div>

let Login = Vue.extend({
  template: `
    <div class="input-group">
      <label>姓名:</label>
      <input v-model="text">
    </div>
  `,
  props: ['name'],
  data () {
    return {
      text: ''
    }
  },
  watch: {
    text (newVal) {
      this.$emit('update:name', newVal)
    }
  }
})

new Vue({
  el: '#app',
  data: {
    userName: ''
  },
  components: {
    Login
  }
})

下面划重点,代码里有这一句话:

this.$emit('update:name', newVal)

官方语法是:update:myPropName 其中 myPropName 表示要更新的 prop 值。当然如果你不用 .sync 语法糖使用上面的 .$emit 也能达到同样的效果。仅此而已!

$attrs$listeners

  • 官网对 $attrs 的解释如下:

包含了父作用域中不作为 prop 被识别 (且获取) 的特性绑定 (classstyle 除外)。当一个组件没有声明任何 prop 时,这里会包含所有父作用域的绑定 (classstyle 除外),并且可以通过 v-bind="$attrs" 传入内部组件——在创建高级别的组件时非常有用。

  • 官网对 $listeners 的解释如下:

包含了父作用域中的 (不含 .native 修饰器的) v-on 事件监听器。它可以通过 v-on="$listeners" 传入内部组件——在创建更高层次的组件时非常有用。

我觉得 $attrs$listeners 属性像两个收纳箱,一个负责收纳属性,一个负责收纳事件,都是以对象的形式来保存数据。看下面的代码解释:

<divid="app">
    <child:foo="foo":bar="bar"
        @one.native="triggerOne"
        @two="triggerTwo">
    </child>
</div>

从 Html 中可以看到,这里有俩属性和俩方法,区别是属性一个是 prop 声明,事件一个是 .native 修饰器。

let Child = Vue.extend({
  template: '<h2>{{ foo }}</h2>',
  props: ['foo'],
  created () {
    console.log(this.$attrs, this.$listeners)
    // -> {bar: "parent bar"}
    // -> {two: fn}

    // 这里我们访问父组件中的 `triggerTwo` 方法
    this.$listeners.two()
    // -> 'two'
  }
})

new Vue({
  el: '#app',
  data: {
    foo: 'parent foo',
    bar: 'parent bar'
  },
  components: {
    Child
  },
  methods: {
    triggerOne () {
      alert('one')
    },
    triggerTwo () {
      alert('two')
    }
  }
})

可以看到,我们可以通过 $attrs$listeners 进行数据传递,在需要的地方进行调用和处理,还是很方便的。当然,我们还可以通过 v-on="$listeners" 一级级的往下传递,子子孙孙无穷尽也!

一个插曲!

当我们在组件上赋予了一个非Prop 声明时,编译之后的代码会把这些个属性都当成原始属性对待,添加到 html 原生标签上,看上面的代码编译之后的样子:

<h2 bar="parent bar">parent foo</h2>

这样会很难看,同时也爆了某些东西。如何去掉?这正是 inheritAttrs 属性的用武之地!给组件加上这个属性就行了,一般是配合 $attrs 使用。看代码:

// 源码
let Child = Vue.extend({
  ...
  inheritAttrs: false, // 默认是 true
  ...
})

再次编译:

<h2>parent foo</h2>

provide / inject

他俩是对CP, 感觉挺神秘的。来看下官方对 provide / inject 的描述:

provideinject 主要为高阶插件/组件库提供用例。并不推荐直接用于应用程序代码中。并且这对选项需要一起使用,以允许一个祖先组件向其所有子孙后代注入一个依赖,不论组件层次有多深,并在起上下游关系成立的时间里始终生效。

看完描述有点懵懵懂懂!一句话总结就是:小时候你老爸什么东西都先帮你存着等你长大该娶媳妇儿了你要房子给你买要车给你买只要他有的尽量都会满足你。下面是这句话的代码解释:

<divid="app">
    <son></son>
</div>

let Son = Vue.extend({
  template: '<h2>son</h2>',
  inject: {
    house: {
      default: '没房'
    },
    car: {
      default: '没车'
    },
    money: {
      // 长大工作了虽然有点钱// 仅供生活费,需要向父母要default: '¥4500'
    }
  },
  created () {
    console.log(this.house, this.car, this.money)
    // -> '房子', '车子', '¥10000'
  }
})

new Vue({
  el: '#app',
  provide: {
    house: '房子',
    car: '车子',
    money: '¥10000'
  },
  components: {
    Son
  }
})

其他方式通信

除了以上五种方式外,其实还有:

  • EventBus

思路就是声明一个全局Vue实例变量 EventBus , 把所有的通信数据,事件监听都存储到这个变量上。这样就达到在组件间数据共享了,有点类似于 Vuex。但这种方式只适用于极小的项目,复杂项目还是推荐 Vuex。下面是实现 EventBus 的简单代码:

<divid="app">
   <child></child>
</div>

// 全局变量
let EventBus = new Vue()

// 子组件
let Child = Vue.extend({
  template: '<h2>child</h2>',
  created () {
    console.log(EventBus.message)
    // -> 'hello'
    EventBus.$emit('received', 'from child')
  }
})

new Vue({
  el: '#app',
  components: {
    Child
  },
  created () {
    // 变量保存
    EventBus.message = 'hello'// 事件监听
    EventBus.$on('received', function (val) {
      console.log('received: '+ val)
      // -> 'received: from child'
    })
  }
})
  • Vuex

官方推荐的,Vuex 是一个专为 Vue.js 应用程序开发的状态管理模式。

  • $parent

父实例,如果当前实例有的话。通过访问父实例也能进行数据之间的交互,但极小情况下会直接修改父组件中的数据。

  • $root

当前组件树的根 Vue 实例。如果当前实例没有父实例,此实例将会是其自己。通过访问根组件也能进行数据之间的交互,但极小情况下会直接修改父组件中的数据。

  • broadcast / dispatch

他俩是 vue@1.0 中的方法,分别是事件广播 和 事件派发。虽然 vue@2.0 里面删掉了,但可以模拟这两个方法。可以借鉴 Element 实现。有时候还是非常有用的,比如我们在开发树形组件的时候等等。


给 props 属性设置多个类型

这个技巧在开发组件的时候用的较多,为了更大的容错性考虑。比如一个 <my-button> 上暴露了一个 width 属性:

1
2
3
4
5
6
7
// my-button.vue
export default {
props: {
width: [String, Number],
default: '100px'
}
}

我们既可以传 100px,也可以传 100

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
<!-- my-button.vue -->
<template>
<button :style="computedWidth">width: {{ computedWidth }}</button>
</template>

<script>
export default {
props: {
width: [String, Number],
default: '100px'
},
computed: {
computedWidth () {
let o = {}
if (typeof this.width === 'string') o.width = this.width
if (typeof this.width === 'number') o.width = this.width + 'px'
return o
}
}
}
</script>

使用:

1
2
3
4
5
6
<!-- 在其他组件中使用 -->
<template>
<my-button width="100px"></my-button>
<!-- or -->
<my-button width="100"></my-button>
</template>

data 初始化

因为 props 要比 data 先完成初始化,所以我们可以利用这一点给 data 初始化一些数据进去,看代码:

export default {
  data () {
    return {
      buttonSize: this.size
    }
  },
 props: {
   size: String
 }
}

除了以上,子组件的 data 函数也可以有参数,且该参数是当前实例对象。所有我们可以利用这一点做一些自己的判断。如,改写上面的代码:

export default {
  data (vm) {
    return {
      buttonSize: vm.size
    }
  },
 props: {
   size: String
 }
}

template

我们在做 v-if 判断的时候,可以把判断条件放在 template 组件上,最终的渲染结果将不包含 <template> 元素。

1
2
3
4
5
6
7
8
9
10
<template>
<div class="box">
<template v-if="isVal">
<h2>...</h2>
</template>
<template v-else>
<h2>...</h2>
</template>
</div>
</template>

v-for 也同样适用。

Lifecycle hook

生命周期钩子可以是一个数组类型,且数组中的函数会依次执行。

export default {
 ...
 created: [
   function one () {
     console.log(1)
   },
   function two () {
     console.log(2)
   }
 ]
 ...
}

没什么用,知道就行了。事实上生命周期钩子还可以作用于 DOM 元素上,利用这一点,我们可以用父组件中的方法来初始化子组件的生命周期钩子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
<!-- Child.vue -->
<template>
<h3>I'm child!</h3>
</template>

<!-- Parent.vue -->
<template>
<child @hook:created="handleChildCreated"></child>
</template>

<script>
import Child from './child.vue'
export default {
components: [ Child ],
methods: {
handleChildCreated () {
console.log('handle child created...')
}
}
}
</script>

其他钩子雷同,不再赘述。

v-for 和 v-if 一起使用

由于 v-forv-if 渲染优先级更高,所以有时候可以一起使用。下面两种常见的情况下会倾向于把 v-forv-if 放在同一个标签上使用:

  • 筛选一些不想显示的条目
  • 为了避免渲染本应该被隐藏的列表

举个栗子:

1
2
3
4
5
<template>
<ul class="items">
<!-- 只有激活的用户才可以显示 -->
<li v-for="(user, index) in users" v-if="user.isActive" :key="user.id">{{ user.name }}</li>
</template>

关于以上两点不明白的地方可以参见 Vue 风格指南

混合

如果好多组件都共用到一些像 propsdatamethods 等,可以单独抽出来放到 mixins 混合器中。

// paging-mixin.vue
export default {
  props: {
    pageSize: 1,
    pageLength: 10,
    currentPage: 1
    total: 20
  },
  methods: {
    /**
     * 上一页
     */
    prevPage (page) {
      ...
    },
    /**
     * 下一页
     */
    nextPage (page) {
      ...
    }
    /**
     * 跳转到当前页
     */
    currentPage (page) {
      ...
    }
  }
}

比如在用户管理列表使用:

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
<!-- Users.vue -->
<template>
<div class="user-model">
<my-table :data="users"></my-table>
<my-paging
:page-length="pageLength"
:page-size="pageSize"
:current-page="currentPage"
:total="total">
</my-paging>
</div>
</template>

<script>
import PagingMixin from '../mixins/paging-mixin.vue'
export default {
mixins: [PagingMixin],
data () {
return {
users: [],
pageLength: 10,
pageSize: 1,
currentPage: 1,
total: 20
}
}
}
</script>

不用每个页面都写一遍 propsmethods 了。

render 函数

下面是一段简单的 template 模板代码:

1
2
3
4
5
6
<template>
<div class="box">
<h2>title</h2>
this is content
</div>
</template>

我们用渲染函数来重写上面的代码:

1
2
3
4
5
6
7
8
export default {
render (h) {
let _c = h
return _c('div',
{ class: 'box'},
[_c('h2', {}, 'title'), 'this is content'])
}
}

事实上,Vue 会把模板(template)编译成渲染函数(render) 。上面的 template 模板会被编译成如下渲染函数:

1
2
3
4
5
let render = function () {
return _c('div',
{staticClass:"box"},
[_c('h2', [_v("title")]), _v("this is content")])
}

是不是很像? 正如官方说的,渲染函数比 template 更接近编译器。如果用一个流程图来解释的话,大概是这个样子:

1
2
3
4
5
6
7
template

预编译工具(vue-loader + vue-template-compile)

render

resolve vnode

具体参见 Vue声明周期图示

渲染函数用处:

  • 开发组件库,Element 源码用的都是 render
  • 封装一些高阶组件。组件里面嵌套组件就是高阶组件,前提是要满足组件三要素:propseventslot
  • 用于处理一些复杂的逻辑判断。如果我们一个组件里面有很多的 v-if 判断的话,用模板就显得不合适了,这个时候可以用渲染函数来轻松处理

errorCaptured

捕获一个来自子孙组件的错误时被调用。有时候当我们想收集错误日志,却不想把错误暴露到浏览器控制台的时候很有用。下面是个例子:

Child.vue

1
2
3
4
5
6
7
8
9
10
11
<template>
<!-- 省略一些无关代码 -->
</template>
<script>
export default {
mounted () {
// 故意把 console 写错
consol.log('这里会报错!')
}
}
</script>

Parent.vue

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
<template>
<child></child>
</template>
<script>
import Child from './Child.vue'
export default {
components: [ Child ],
/**
* 收到三个参数:
* 错误对象、发生错误的组件实例
* 以及一个包含错误来源信息的字符串。
* 此钩子可以返回 false 以阻止该错误继续向上传播。
*/
errorCaptured (err, vm, info) {
console.log(err)
// -> ReferenceError: consle is not defined ...
console.log(vm)
// -> {_uid: 1, _isVue: true, $options: {…}, _renderProxy: o, _self: o,…}
console.log(info)
// -> `mounted hook`
// 告诉我们这个错误是在 vm 组件中的 mounted 钩子中发生的

// 阻止该错误继续向上传播
return false
}
}
</script>

关于 errorCaptured 更多说明,请移步官网->

v-once

通过 v-once 创建低开销的静态组件。渲染普通的 HTML 元素在 Vue 中是非常快速的,但有的时候你可能有一个组件,这个组件包含了大量静态内容。在这种情况下,你可以在根元素上添加 v-once 特性以确保这些内容只计算一次然后缓存起来,就像这样:

1
2
3
4
5
6
<template>
<div class="box" v-once>
<h2> 用户协议 </h2>
... a lot of static content ...
</div>
</template>

只渲染元素和组件一次。随后的重新渲染,元素/组件及其所有的子节点将被视为静态内容并跳过。这可以用于优化更新性能。关于 v-once 更多介绍,请移步官网->

slot-scope

作用域插槽。`vue@2.5.0版本以前叫scope,之后的版本用slot-scope将其代替。除了 scope 只可以用于

用过 Element 组件的同学都知道,当我们在使用 <el-table> 的时候会看到如下代码:

Element@1.4.x 的版本:

1
2
3
4
5
6
7
8
9
10
11
<el-table-column label="操作">
<template scope="scope">
<el-button
size="small"
@click="handleEdit(scope.$index, scope.row)">编辑</el-button>
<el-button
size="small"
type="danger"
@click="handleDelete(scope.$index, scope.row)">删除</el-button>
</template>
</el-table-column>

但在 2.0 之后的版本替换成了 slot-scope

Element@2.0.11

1
2
3
4
5
6
7
8
9
10
11
<el-table-column label="操作">
<template slot-scope="scope">
<el-button
size="mini"
@click="handleEdit(scope.$index, scope.row)">编辑</el-button>
<el-button
size="mini"
type="danger"
@click="handleDelete(scope.$index, scope.row)">删除</el-button>
</template>
</el-table-column>

说白了,slot-scope 相当于函数的回调,我把结果给你,你想怎么处理就怎么处理,一切随你:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
function getUserById (url, data, callback) {
$.ajax({
url,
data,
success: function (result) {
callback(result)
}
})
}

// 使用
getUserById('/users', { id: 1 }, function (response) {
// 拿到数据并开始处理自己的页面逻辑
})

下面我们来简单模拟下 <el-table> 组件内部是怎么使用 slot-scope 的,看代码:

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
// 定义模板
let template = `
<ul class="table">
<li v-for="(item, index) in data" :key="index">
<!-- 我希望数据由调用者自己处理 -->
<!-- 'row' 相当于变量名,随便起名 -->
<slot :row="item">
<!-- 当使用者什么都没写的时候,默认值才会显示-->
{{ item.name }}
</slot>
</li>
</ul>
`
// 声明 `el-table` 组件
Vue.component('el-table', {
template,
props: {
data: Array,
default: []
}
})

// 根组件
new Vue({
el: '#app',
data: {
userData: [
{id: 1, name: '张三', isActived: false},
{id: 2, name: '李四', isActived: false},
{id: 1, name: '王五', isActived: true},
{id: 1, name: '赵六', isActived: false},
]
}
})

组件使用:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
<div id="app">
<el-table :data="userData">
<!-- 使用的时候可以用 template -->
<!-- `scope` 只是个形参,随便起名 -->
<template slot-scope="scope">
<template v-if="scope.row.isActived">
<span class="red">{{ scope.row.name }}</span>
</template>
<template v-else>
{{ scope.row.name }}
</template>
</template>
</el-table>
</div>

CSS:

.red {
color: red
}

我们完全可以在 <li> 中进行逻辑判断,为什么还要放到外面进行处理呢? 因为有时候我们用的不是自己开发的组件,比如上面的 <el-table> ,所以就有必要这么做了。最后,你可以狠狠的戳这里查看效果!