Vue.js组件自定义事件

本章重点:自定义事件是子组件向父组件通信的核心机制。掌握$emit方法和事件监听是构建交互式组件系统的关键。

什么是组件事件?

组件事件是Vue中实现子组件向父组件通信的机制。子组件通过触发(emit)事件,父组件通过监听(listen)事件来实现组件间的数据流动。

子组件

触发事件
$emit('event-name', data)

事件系统

传递数据
事件冒泡

父组件

监听事件
@event-name="handler"

父子组件通信模式

Props向下传递

父 → 子,单向数据流

事件向上传递

子 → 父,反向通信

双向通信

组合使用Props和事件

基本用法:触发和监听事件

使用$emit方法触发事件,父组件使用v-on@语法监听事件:

基础示例

// ChildComponent.vue - 子组件
export default {
    name: 'ChildComponent',
    data() {
        return {
            message: '来自子组件的问候'
        };
    },
    methods: {
        sendMessage() {
            // 触发自定义事件 'message-sent'
            // 第一个参数:事件名称
            // 第二个参数:传递的数据
            this.$emit('message-sent', this.message);
        },

        sendCustomData() {
            this.$emit('data-update', {
                id: 1,
                text: '自定义数据',
                timestamp: new Date().toISOString()
            });
        }
    },
    template: `
        <div class="child-component">
            <button @click="sendMessage" class="btn btn-primary">
                发送消息
            </button>
            <button @click="sendCustomData" class="btn btn-secondary">
                发送数据
            </button>
        </div>
    `
};
                                    

<!-- ParentComponent.vue - 父组件 -->
<template>
    <div class="parent-component">
        <h3>父组件</h3>
        <p>接收到的消息: {{ receivedMessage }}</p>
        <p>接收到的数据: {{ receivedData }}</p>

        <!-- 监听子组件事件 -->
        <child-component
            @message-sent="handleMessage"
            @data-update="handleDataUpdate"
        />
    </div>
</template>

<script>
import ChildComponent from './ChildComponent.vue';

export default {
    components: {
        ChildComponent
    },
    data() {
        return {
            receivedMessage: '',
            receivedData: null
        };
    },
    methods: {
        handleMessage(message) {
            // 处理子组件传递的消息
            this.receivedMessage = message;
            console.log('收到消息:', message);
        },

        handleDataUpdate(data) {
            // 处理子组件传递的数据
            this.receivedData = data;
            console.log('收到数据:', data);

            // 可以在这里触发其他操作
            this.processData(data);
        },

        processData(data) {
            console.log('处理数据:', data.id, data.text);
        }
    }
};
</script>
                                    
模拟效果:
父组件状态:
等待消息...
等待数据...

事件命名规范

Vue推荐使用kebab-case(短横线分隔)命名自定义事件:

推荐命名 JavaScript中使用 HTML模板中使用
user-selected this.$emit('user-selected', user) @user-selected="handleUserSelected"
form-submitted this.$emit('form-submitted', formData) @form-submitted="handleSubmit"
item-deleted this.$emit('item-deleted', itemId) @item-deleted="deleteItem"
status-changed this.$emit('status-changed', newStatus) @status-changed="updateStatus"
注意:与props不同,事件名称没有自动的大小写转换。 触发的事件名必须完全匹配监听时使用的名称。
重要:事件名称不会被用作JavaScript变量名或属性名,所以没有理由使用camelCase。 由于HTML属性是不区分大小写的,使用camelCase命名的事件在HTML模板中也会被自动转换为kebab-case。 因此,推荐始终使用kebab-case的事件名称。

事件参数传递

可以向父组件传递多个参数,并可以在事件处理器中使用$event访问:


// 子组件:传递多个参数
methods: {
    handleAction() {
        // 传递多个参数
        this.$emit('action-completed', 'success', Date.now(), {
            userId: 123,
            action: 'login'
        });
    }
}
                            

<!-- 父组件:接收多个参数 -->
<!-- 方式1:使用内联处理器 -->
<child-component @action-completed="handleAction($event, 'extra')" />

<!-- 方式2:使用方法处理器 -->
<child-component @action-completed="handleAction" />

<script>
export default {
    methods: {
        // 接收所有参数
        handleAction(status, timestamp, data, extraParam) {
            console.log('状态:', status);
            console.log('时间戳:', timestamp);
            console.log('数据:', data);
            console.log('额外参数:', extraParam); // 仅在方式1中有效
        },

        // 或使用展开运算符接收所有参数
        handleAction(...args) {
            const [status, timestamp, data] = args;
            console.log('所有参数:', args);
        }
    }
}
</script>
                            

事件修饰符

Vue为组件事件提供了一些有用的修饰符:

.native修饰符

<!-- 监听组件根元素的原生事件 -->
<my-component @click.native="handleClick"></my-component>

<!-- 注意:Vue 3中已移除.native修饰符 -->
<!-- Vue 3中使用v-bind="$attrs"或emits选项 -->
                                

用途:监听组件根元素的原生DOM事件

.once修饰符

<!-- 事件只触发一次 -->
<my-component @custom-event.once="handleOnce"></my-component>
                                

用途:确保事件处理器只执行一次

.prevent修饰符

<!-- 阻止事件默认行为 -->
<form @submit.prevent="handleSubmit">
    <!-- 在组件中使用 -->
    <custom-input @keydown.enter.prevent="submit" />
</form>
                                

用途:阻止事件的默认行为

.sync修饰符(双向绑定)

.sync修饰符是Vue提供的语法糖,用于实现父子组件的双向绑定:


<!-- 父组件使用.sync -->
<child-component :title.sync="pageTitle"></child-component>

<!-- 等价于 -->
<child-component
    :title="pageTitle"
    @update:title="pageTitle = $event"
></child-component>
                            

// 子组件实现
export default {
    props: {
        title: String
    },
    methods: {
        updateTitle(newTitle) {
            // 触发约定格式的事件
            this.$emit('update:title', newTitle);
        }
    }
}
                            
最佳实践:
  • 使用.sync简化双向绑定逻辑
  • 事件名称必须为update:propName格式
  • 适合用于表单控件等需要双向绑定的场景
  • 在Vue 3中,.sync已被v-model参数替代

model选项(自定义v-model)

使用model选项自定义组件的v-model行为:


// CustomInput.vue - 自定义输入组件
export default {
    name: 'CustomInput',

    // 自定义v-model
    model: {
        prop: 'value',     // 绑定的prop名称
        event: 'input'     // 触发的事件名称
    },

    props: {
        value: String,     // 必须与model.prop一致
        placeholder: String
    },

    methods: {
        handleInput(event) {
            // 触发input事件,更新v-model绑定的值
            this.$emit('input', event.target.value);
        }
    },

    template: `
        <input
            type="text"
            :value="value"
            :placeholder="placeholder"
            @input="handleInput"
            class="custom-input"
        >
    `
};
                            

<!-- 使用自定义v-model -->
<custom-input v-model="username" placeholder="请输入用户名"></custom-input>

<!-- 等价于 -->
<custom-input
    :value="username"
    @input="username = $event"
    placeholder="请输入用户名"
></custom-input>
                            

完整示例:任务管理组件

创建一个完整的任务管理组件系统,展示组件事件的综合应用:


// TaskItem.vue - 任务项组件
export default {
    name: 'TaskItem',

    props: {
        task: {
            type: Object,
            required: true,
            validator: function(value) {
                return value && value.id && value.text
            }
        },
        completed: {
            type: Boolean,
            default: false
        }
    },

    data() {
        return {
            isEditing: false,
            editText: this.task.text
        };
    },

    computed: {
        statusClass() {
            return {
                'completed': this.completed,
                'editing': this.isEditing
            };
        }
    },

    methods: {
        toggleComplete() {
            // 触发任务状态切换事件
            this.$emit('toggle-complete', this.task.id);
        },

        startEdit() {
            this.isEditing = true;
            this.editText = this.task.text;
            this.$emit('edit-start', this.task.id);
        },

        saveEdit() {
            if (this.editText.trim()) {
                // 触发任务更新事件
                this.$emit('update-task', {
                    id: this.task.id,
                    text: this.editText.trim()
                });
                this.isEditing = false;
            }
        },

        cancelEdit() {
            this.isEditing = false;
            this.$emit('edit-cancel', this.task.id);
        },

        deleteTask() {
            // 触发任务删除事件
            this.$emit('delete-task', this.task.id);
        },

        // 使用.sync修饰符的双向绑定
        updateCompleted(newValue) {
            this.$emit('update:completed', newValue);
        }
    },

    template: `
        <div class="task-item" :class="statusClass">
            <div class="task-content">
                <!-- 完成状态复选框 -->
                <input
                    type="checkbox"
                    :checked="completed"
                    @change="updateCompleted(!completed)"
                    class="task-checkbox"
                >

                <!-- 显示模式 -->
                <span v-if="!isEditing" class="task-text">
                    {{ task.text }}
                </span>

                <!-- 编辑模式 -->
                <div v-else class="task-edit">
                    <input
                        type="text"
                        v-model="editText"
                        @keyup.enter="saveEdit"
                        @keyup.esc="cancelEdit"
                        class="form-control"
                        ref="editInput"
                    >
                    <button @click="saveEdit" class="btn btn-sm btn-success">
                        保存
                    </button>
                    <button @click="cancelEdit" class="btn btn-sm btn-secondary">
                        取消
                    </button>
                </div>
            </div>

            <div class="task-actions" v-if="!isEditing">
                <button @click="startEdit" class="btn btn-sm btn-outline-primary">
                    编辑
                </button>
                <button @click="deleteTask" class="btn btn-sm btn-outline-danger">
                    删除
                </button>
            </div>
        </div>
    `,

    watch: {
        isEditing(newVal) {
            if (newVal) {
                // 编辑模式下聚焦输入框
                this.$nextTick(() => {
                    this.$refs.editInput?.focus();
                });
            }
        }
    }
};
                            

// TaskList.vue - 任务列表组件
export default {
    name: 'TaskList',

    props: {
        tasks: {
            type: Array,
            default: () => []
        },
        filter: {
            type: String,
            default: 'all', // all, active, completed
            validator: value => ['all', 'active', 'completed'].includes(value)
        }
    },

    computed: {
        filteredTasks() {
            switch (this.filter) {
                case 'active':
                    return this.tasks.filter(task => !task.completed);
                case 'completed':
                    return this.tasks.filter(task => task.completed);
                default:
                    return this.tasks;
            }
        },

        stats() {
            const total = this.tasks.length;
            const completed = this.tasks.filter(t => t.completed).length;
            const active = total - completed;

            return { total, completed, active };
        }
    },

    methods: {
        handleToggleComplete(taskId) {
            this.$emit('toggle-complete', taskId);
        },

        handleUpdateTask(updatedTask) {
            this.$emit('update-task', updatedTask);
        },

        handleDeleteTask(taskId) {
            this.$emit('delete-task', taskId);
        },

        handleEditStart(taskId) {
            this.$emit('edit-start', taskId);
        },

        handleEditCancel(taskId) {
            this.$emit('edit-cancel', taskId);
        }
    },

    template: `
        <div class="task-list">
            <!-- 统计信息 -->
            <div class="task-stats">
                <span class="badge bg-primary">全部: {{ stats.total }}</span>
                <span class="badge bg-success">已完成: {{ stats.completed }}</span>
                <span class="badge bg-warning">进行中: {{ stats.active }}</span>
            </div>

            <!-- 任务列表 -->
            <div class="task-items">
                <task-item
                    v-for="task in filteredTasks"
                    :key="task.id"
                    :task="task"
                    :completed.sync="task.completed"
                    @toggle-complete="handleToggleComplete"
                    @update-task="handleUpdateTask"
                    @delete-task="handleDeleteTask"
                    @edit-start="handleEditStart"
                    @edit-cancel="handleEditCancel"
                />

                <!-- 空状态 -->
                <div v-if="filteredTasks.length === 0" class="empty-state">
                    <template v-if="filter === 'all'">
                        暂无任务,添加一个吧!
                    </template>
                    <template v-else-if="filter === 'active'">
                        没有进行中的任务
                    </template>
                    <template v-else>
                        没有已完成的任务
                    </template>
                </div>
            </div>

            <!-- 事件日志 -->
            <div class="event-log" v-if="showEventLog">
                <h6>事件日志</h6>
                <pre>{{ eventLog }}</pre>
            </div>
        </div>
    `
};
                            

高级事件模式

事件总线(Event Bus)

// 创建事件总线
const EventBus = new Vue();

// 组件A:发送事件
EventBus.$emit('global-event', data);

// 组件B:监听事件
EventBus.$on('global-event', (data) => {
    console.log('收到全局事件:', data);
});

// 组件B:取消监听
EventBus.$off('global-event');
                                

用途:非父子组件间的通信

集中式事件管理

// eventHub.js - 集中管理事件
export default {
    events: {
        USER_LOGIN: 'user-login',
        DATA_LOADED: 'data-loaded',
        ERROR_OCCURRED: 'error-occurred'
    },

    emit(event, ...args) {
        this.$emit(event, ...args);
    },

    on(event, callback) {
        this.$on(event, callback);
    },

    off(event, callback) {
        this.$off(event, callback);
    }
};
                                

用途:大型应用中的事件管理

合成事件

// 触发多个相关事件
methods: {
    handleSave() {
        // 触发主事件
        this.$emit('save-started');

        try {
            // 执行保存逻辑
            const result = this.performSave();

            // 触发成功事件
            this.$emit('save-success', result);

            // 同时触发通用的保存事件
            this.$emit('save-completed', {
                success: true,
                data: result
            });
        } catch (error) {
            // 触发错误事件
            this.$emit('save-error', error);

            // 同时触发通用的保存事件
            this.$emit('save-completed', {
                success: false,
                error: error
            });
        }
    }
}
                                

用途:复杂操作的事件序列

事件最佳实践

事件设计与使用指南
命名规范
  • 使用kebab-case命名事件
  • 事件名应描述发生了什么
  • 避免使用通用名称如clickchange
  • 使用过去式描述已完成的操作
数据传递
  • 传递最小必要数据
  • 复杂数据使用对象包装
  • 包含足够上下文信息
  • 避免传递整个组件实例
错误处理
  • 提供错误事件处理
  • 包含错误信息和上下文
  • 保持事件处理的纯净性
  • 避免在事件处理器中抛出异常
性能优化
  • 合理使用.once修饰符
  • 及时清理事件监听器
  • 避免过度频繁的事件触发
  • 使用防抖/节流优化高频事件

常见陷阱与解决方案

事件处理常见问题
事件监听器未触发

可能原因:事件名称不匹配

解决方案:检查大小写,统一使用kebab-case

事件重复触发

可能原因:未正确移除事件监听

解决方案:beforeDestroy钩子中清理

数据更新无效

可能原因:直接修改了prop数据

解决方案:始终通过事件通知父组件修改

内存泄漏

可能原因:全局事件总线未清理

解决方案:组件销毁时移除所有事件监听

事件练习

为以下购物车商品组件设计合适的事件:


// CartItem.vue - 购物车商品组件
Vue.component('CartItem', {
    props: {
        product: {
            type: Object,
            required: true
        },
        quantity: {
            type: Number,
            default: 1
        }
    },

    // TODO: 定义需要触发的事件
    // 考虑以下操作:
    // 1. 数量变化
    // 2. 删除商品
    // 3. 收藏商品
    // 4. 查看详情

    template: `
        <div class="cart-item">
            <img :src="product.image" :alt="product.name">
            <div class="product-info">
                <h4>{{ product.name }}</h4>
                <p>¥{{ product.price }}</p>
            </div>
            <div class="quantity-control">
                <button @click="decrease">-</button>
                <span>{{ quantity }}</span>
                <button @click="increase">+</button>
            </div>
            <div class="actions">
                <button @click="remove">删除</button>
                <button @click="toggleFavorite">
                    {{ isFavorite ? '取消收藏' : '收藏' }}
                </button>
                <button @click="viewDetails">详情</button>
            </div>
        </div>
    `
});