给一个子组件定义自定义事件的步骤大致如下:

  • 在父组件中使用v-on绑定自定义事件,并定义回调。

    例如给一个user-info绑定自定义事件:

    <!-- 
      App.vue
     -->
    
    <template>
      <div>
        <!-- 
          通过父组件给子组件绑定一个自定义事件
          实现子组件给父组件传递数据
          使用 .once 修饰符,让事件只在第1次被触发时执行回调
         -->
        <user-info @get-name.once="getUserName"/>
      </div>
    </template>
    
    <script>
      import UserInfo from './components/user-info.vue';
    
      export default {
        name: 'app',
        components: {
          UserInfo
        },
        methods: {
          getUserName(name) {
            console.log(
              'The event get-name has be trigged.');
          },
        },
      }
    </script>
    
  • 然后在子组件中,触发该自定义事件。

    接上例:

    <!-- 
      user-info.vue
    -->
    
    <template>
      <div class="user">
        <h2>用户姓名:{{name}}</h2>
        <h2>用户年龄:{{age}}</h2>
        <h2>用户性别:{{sex}}</h2>
        <button @click="sendUserName">获取用户姓名</button>
      </div>
    </template>
    
    <script>
    
    export default {
      name: 'user-info',
      data() {
        return {
          name: '张三',
          sex: '男',
          age: 21,
        }
      },
      methods: {
        sendUserName() {
          // 触发 user-info 组件实例上的 get-name 事件
          this.$emit('get-name')
        },
      },
    }
    </script>
    

实现子组件到父组件的数据通信

通过自定义事件,可以实现子组件到父组件的数据通信。

如上例,在子组件user-info中,触发get-user-name事件的方法为this.$emit()this是组件实例,Vue实例上也有这个方法)。

this.$emit()

  1. 参数1:触发的事件名称。
  2. 参数2 ~ n:触发事件的同时,向父组件传递的数据。

子组件通过调用this.$emit()来触发事件,然后告知父组件有数据需要传递。接着通过this.$emit()的第2 ~ n个参数,将数据传递给父组件。

父组件通过事件回调函数来处理事件,并接收从子组件传递过来的数据。

例如某个子组件触发了update事件,并且将数据传递给父组件:

this.$emit("update", this.name, this.sex, this.age)

在父组件的methods中,可以这样定义回调函数:

  • 定义对应的形参:

    updateHandler(name, sex, age) {
      /* ... */
    }
    
  • 定义数量可变的形参:

    updateHandler(...params) {
      /* ... */
    }
    

修改上方的user-infoApp组件,从user-info中获取用户的姓名,并在App组件中显示欢迎消息:

<!-- 
  App.vue
 -->

<template>
  <div class="app">
    <h1>{{msg}}</h1> <hr>
    <user-info @get-name="getUserName"/>
  </div>
</template>

<script>
  import UserInfo from './components/user-info.vue';

  export default {
    name: 'app',
    components: {UserInfo},
    data() {
      return {
        userName: '',
      }
    },
    computed: {
      msg() {
        return `Hello ${this.userName}!`
      },
    },
    methods: {
      getUserName(name) {
        console.log(
          'The event get-name has be trigged.', name);
        this.userName = name
      },
    },
  }
</script>

<style>
.app {
  background-color: orange;
  padding: 5px;
}
</style>
<!-- 
  user-info.vue
 -->

<template>
  <!-- ... -->
</template>

<script>
export default {
  name: 'SiteUser',
  data() {
    /* ... */
  },
  watch: {
    name: {
      immediate: true,
      handler() {
        // 触发 user-info 组件实例上的 get-name 事件,并传递数据
        this.$emit('get-name', this.name)
      }
    }
  },
}
</script>

<style scoped>
.user {
  background-color: skyblue;
  padding: 5px;
  margin-top: 30px;
}
</style>

绑定自定义事件

事件有两种绑定方式:

  • 使用v-on指令绑定。

    如上所示的案例,都是使用v-on来绑定自定义事件。

  • 在父组件中,使用ref属性获取组件实例对象,然后通过在父组件的mounted()钩子中调用组件实例对象的$on方法绑定。

    <demo @event-name="eventHandler"/>
    

    上方对应的使用ref绑定事件的方法是:

    <demo ref="demo"/>
    
    mounted() {
      this.$ref.demo.$on('event-name', this.eventHandler)
    }
    

    使用refmounted绑定事件的好处是,自定义度高。例如可以在mounted中使用定时器来实现延迟绑定事件的效果。

    注:

    在Vue实例对象或组件实例对象上,要让绑定事件仅触发一次,可以使用this.$once()this.$once()的参数与this.$on()一致。

    mounted中,如果要在绑定事件的同时定义回调函数,应该使用Lambda表达式:

    mounted() {
      this.$ref.demo.$on('event-name', (...params) => {
        /* ... */
      })
    }
    

    这是因为,如果使用一般的function来定义,那么回调函数中的this指向的是demo的组件实例对象;而使用Lambda来定义,回调函数中的this指向的就是当前的组件实例对象。

    如果this.$ref.demo.$on()传入的回调函数是methods中定义的函数,那么这个函数需要使用function来定义。

    也就是说,在绑定自定义事件回调时,回调函数要么是配置在methods中用function定义,要么用Lambda表达式定义。

    如果子组件的this.$emit()是在immediate:truewatch中调用的,那么就不要使用ref来绑定。因为immediate:truewatch是在beforeCreate()之后created()进行第1次执行。


解绑自定义事件

解绑自定义事件使用的是this.$off()方法:

  • this.$off(event):解绑event指定的事件。event是事件的名称,字符串类型。
  • this.$off([event1, event2, ...]):解绑数组中指定的多个事件。event1event2等均是事件的名称,字符串类型。
  • this.$off():解绑所有的自定义事件。当this.$off()没有附带任何参数直接调用时,this.$off()会将实例中的所有事件解绑。

解绑自定义事件后,无论再调用多少次对应的this.$emit(),事件都不会被触发。除非在父组件中再次绑定这些自定义事件。


绑定原生事件

Vue中,在组件标签上使用v-on指令绑定的事件,对组件来说,绑定的都是自定义事件。即使绑定的事件名称是原生事件的名称,Vue也会将其识别为自定义事件。

如果要在组件上绑定原生事件,可以使用.native修饰符。

例如:

<demo @click.native="clickDemo">

全局事件总线

全局事件总线(Global Event Bus)是一种组件间通信的方式,适用于任意组件间通信。

全局事件总线是指,抽取出一个专门用来绑定和触发自定义事件的对象。所有的组件都通过在这个对象上绑定或触发自定义事件来接收或发送数据。

作为全局事件总线,需要满足以下条件:

  • 能被所有组件访问。

    可以将全局事件总线对象在Vue原型对象上,让所有组件都能访问。

  • 拥有$on$emit$off等方法。

    可以使用Vue实例或组件实例作为全局事件总线。

全局事件总线最适用于同级组件间的通信和跨越多层级的组件间的通行。

安装全局事件总线

一般情况下,是将main.js中的Vue实例对象作为全局事件总线对象,并且将Vue实例安装在Vue原型对象Vue.prototype上。

new Vue({
  /* ... */
  beforeCreate() {
    Vue.prototype.$bus = this // 安装全局事件总线
  },
  /* ... */
}).$mount('#app')

$bus只有在Vue实例创建之前进行安装,才能生效。如果在new Vue()执行结束之后安装,是无法生效的(即$bus === undefined)。

使用事件总线发送数据

this.$bus.$emit(event, this.eventHandler)

this指的是Vue组件实例(下同)。

使用事件总线接收数据

this.$bus.$on(event, value1[, value2[, ...]])

关闭数据通道

在当前组件实例中,如果要在事件总线中关闭某条数据通道(停止某个自定义事件的数据发送和接收),可以使用$bus.$off()解绑某个事件。

// 关闭单个通道
this.$bus.$off(event)

// 关闭多个通道
this.$bus.$off([event1, event2, ...])

关闭数据通道(自定义事件)的同时,需要注意该通道(自定义事件)没有被其它组件或组件实例对象所使用。如果当前组件有多个实例,但是它们有相同的数据通道,最好是不要随便去关闭通道。

销毁前解绑$bus的自定义事件:

在绑定了$bus自定义事件(调用了$bus.$on())的组件实例中,最好在beforeDestroy钩子中,将当前组件实例使用到的自定义事件从$bus上解绑。

beforeDestroy() {
  this.$bus.$off([event1, event2, ...])
},

自定义事件实现组件间数据通信案例

使用自定义事件实现一个todo-list案例,这个案例演示了如何实现组件间数据通信。

注:全局事件总线中,每条线的$bus.$on()应该在早于所有的$bus.emit()时执行。

main.js

import Vue from 'vue'
import App from './App.vue'

Vue.config.productionTip = false

new Vue({
  render: h => h(App),
  beforeCreate() {
    Vue.prototype.$bus = this // 安装全局事件总线
  },
}).$mount('#app')

todo-list-itme.vue

<template>
<li>
  <label>
    <input type="checkbox" :checked="isCompleted" @change="handleCheck"/>
    <span>{{name}}</span>
  </label>
  <button class="btn btn-danger" @click="handleDelete">删除</button>
</li>
</template>

<script>
export default {
  name: 'todo-list-item',
  props: {
    id: {
      type: String,
      required: true,
    },
    name: {
      type: String,
      required: true,
    },
    isCompleted: {
      type: Boolean,
      default: false,
    },
  },
  methods: {
    // 勾选或取消勾选
    handleCheck() {
      // 通知 App.vue 将对应的 todo 对象的 isCompleted 取反
      this.$bus.$emit('check-todo', this.id)
    },
    // 删除
    handleDelete() {
      if (confirm(`是否确定删除${this.name}?`)) {
        this.$bus.$emit('remove-todo', this.id)
      }
    },
  }
}
</script>

<style scoped>
li {
  list-style: none;
  height: 36px;
  line-height: 36px;
  padding: 0 5px;
  border-bottom: 1px solid #ddd;
}

li label {
  float: left;
  cursor: pointer;
}

li label li input {
  vertical-align: middle;
  margin-right: 6px;
  position: relative;
  top: -1px;
}

li button {
  float: right;
  display: none;
  margin-top: 3px;
}

li:before {
  content: initial;
}

li:last-child {
  border-bottom: none;
}

li:hover {
  background-color: #ddd;
}

li:hover button {
  display: block;
}
</style>

todo-main.vue

<template>
<ul class="todo-main">
  <!-- 将 checkTodo 传递给子组件 -->
  <todo-list-item
    v-for="todo in todos" 
    :key="todo.id"

    :id="todo.id"
    :name="todo.name"
    :isCompleted="todo.isCompleted"
  />
</ul>
</template>

<script>
import TodoListItem from './todo-list-item.vue'

export default {
  name: 'todo-main',
  components: {
    TodoListItem,
  },
  props: {
    // 从父组件获取一个 todos 列表
    todos: {
      type: Array,
      required: true,
    },
  },
}
</script>

<style scoped>
.todo-main {
  margin-left: 0px;
  border: 1px solid #ddd;
  border-radius: 2px;
  padding: 0px;
}

.todo-empty {
  height: 40px;
  line-height: 40px;
  border: 1px solid #ddd;
  border-radius: 2px;
  padding-left: 5px;
  margin-top: 10px;
}
</style>

todo-header.vue

<template>
<div class="todo-header">
  <!-- 输入回车键添加 Todo -->
  <input 
    type="text" 
    placeholder="请输入你的任务名称,按回车键确认"
    @keyup.enter="add"
  />
</div>
</template>

<script>
import {nanoid} from 'nanoid'

export default {
  name: 'todo-header',
  data() {
    return {
      todoName: '',
    }
  },
  methods: {
    add(e) {
      const elem = e.target

      // 校验数据
      if (!elem.value.trim()) {
        return alert('输入不能为空!')
      }

      // 将用户输入包装为 todo 对象
      const todo ={
        id: nanoid(),
        name: elem.value,
        isCompleted: false,
      }
      // 通知 App 组件添加一个 todo
      this.$emit('add-todo', todo)
      // 清空输入
      elem.value = ''
    },
  },
}
</script>

<style scoped>
.todo-header input {
  width: 560px;
  height: 28px;
  font-size: 14px;
  border: 1px solid #ccc;
  border-radius: 4px;
  padding: 4px 7px;
}

.todo-header input:focus {
  outline: none;
  border-color: rgba(82, 168, 236, 0.8);
  box-shadow: inset 0 1px 1px rgba(0, 0, 0, 0.075), 0 0 8px rgba(82, 168, 236, 0.6);
}
</style>

todo-footer.vue

<template>
<div class="todo-footer" v-show="total > 0">
  <label>
    <input type="checkbox" v-model="isCheckedAll"/>
  </label>
  <span>
    <span>已完成{{completedTotal}}</span> / 全部{{total}}
  </span>
  <button class="btn btn-danger" @click="clearAllCompleted">清除已完成任务</button>
</div>
</template>

<script>
export default {
  name: 'todo-footer',
  props: {
    /* todos: {
      type: Array,
      required: true,
    }, */
    // todo 总数
    total: {
      type: Number,
      required: true,
    },
    // 被选 todo 的总数
    completedTotal: {
      type: Number,
      required: true,
    },
  },
  computed: {
    // 计算是否全选或取消全选
    isCheckedAll: {
      get() {
        return this.completedTotal === this.total && this.total > 0
      },
      set(isChecked) {
        this.$emit('check-todos', isChecked)
      }
    }
  },
  methods: {
    clearAllCompleted() {
      if (this.completedTotal <= 0) {
        alert('没有已完成的任务')
      } else if (confirm('是否清除所有已完成的任务?')) {
        this.$emit('clear-completed-todos')
      }
    },
  },
}
</script>

<style scoped>
.todo-footer {
  height: 40px;
  line-height: 40px;
  padding-left: 6px;
  margin-top: 5px;
}

.todo-footer label {
  display: inline-block;
  margin-right: 20px;
  cursor: pointer;
}

.todo-footer label input {
  position: relative;
  top: -1px;
  vertical-align: middle;
  margin-right: 5px;
}

.todo-footer button {
  float: right;
  margin-top: 5px;
}
</style>

App.vue

<template>
<div id="root">
  <div class="todo-container">
    <div class="todo-wrap">
      <!-- 将 addTodo 函数传递给子组件 -->
      <todo-header @add-todo="addTodo"/>
      <!-- 将 todos 列表和 checkTodo 函数传递给子组件 -->
      <todo-main 
        :todos="todos" 
      />
      <!-- 将 todos 列表和 checkAllTodo 函数传递给子组件 -->
      <todo-footer 
        :total="total"
        :completedTotal="completedTotal"

        @check-todos="checkAllTodo"
        @clear-completed-todos="clearAllCompletedTodos"
      />
    </div>
  </div>
</div>
</template>

<script>
import TodoHeader from './components/todo-header.vue'
import TodoFooter from './components/todo-footer.vue'
import TodoMain from './components/todo-main.vue'

export default {
  name: 'App',
  components: {
    TodoHeader,
    TodoFooter,
    TodoMain,
  },
  data() {
    return {
      // 将 todos 列表定义在 App.vue 中
      // || 的原理是,符号左边的值结果不为真则返回符号右边的值
      todos: JSON.parse(localStorage.getItem('todos')) || [],
    }
  },
  watch: {
    todos: {
      deep: true,
      handler(value) {
        localStorage.setItem('todos', JSON.stringify(value))
      },
    },
  },
  computed: {
    // 计算被选 todo 的总数
    completedTotal() {
      return this.todos.reduce(
        (pre, todo) => pre + (todo.isCompleted ?  1 : 0), 0)
    },
    // 计算 todos 总数
    total() {
      return this.todos.length
    },
  },
  methods: {
    // 添加一个 todo
    addTodo(todo) {
      this.todos.unshift(todo)
    },
    // 勾选或取消一个 todo
    checkTodo(id) {
      this.todos.forEach(todo => {
        if (todo.id === id) {
          todo.isCompleted = !todo.isCompleted
        }
      })
    },
    // 删除一个 todo
    deleteTodo(id) {
      this.todos = this.todos.filter(todo => todo.id !== id)
    },
    // 选择所有或取消选择所有
    checkAllTodo(checked) {
      this.todos.forEach(todo => todo.isCompleted = checked)
    },
    // 清除所有已完成的 todo
    clearAllCompletedTodos() {
      this.todos = this.todos.filter(todo => !todo.isCompleted)
    },
  },
  mounted() {
    this.$bus.$on('check-todo', this.checkTodo)
    this.$bus.$on('remove-todo', this.deleteTodo)
  },
  beforeDestroy() {
    this.$bus.$off('check-todo')
    this.$bus.$off('remove-todo')
  },
}
</script>

<style>
body {
  background: #fff;
}

.btn {
  display: inline-block;
  padding: 4px 12px;
  margin-bottom: 0;
  font-size: 14px;
  line-height: 20px;
  text-align: center;
  vertical-align: middle;
  cursor: pointer;
  box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.2), 0 1px 2px rgba(0, 0, 0, 0.05);
  border-radius: 4px;
}

.btn-danger {
  color: #fff;
  background-color: #da4f49;
  border: 1px solid #bd362f;
}

.btn-danger:hover {
  color: #fff;
  background-color: #bd362f;
}

.btn:focus {
  outline: none;
}

.todo-container {
  width: 600px;
  margin: 0 auto;
}
.todo-container .todo-wrap {
  padding: 10px;
  border: 1px solid #ddd;
  border-radius: 5px;
}
</style>