Vue 自定义组件双向绑定完全指南
大约 8 分钟
Vue 自定义组件双向绑定完全指南
📚 目录
核心概念
什么是双向绑定?
双向绑定 = 数据流动是双向的
父组件的数据 ←→ 子组件的数据
- 父 → 子:通过
props传递数据 - 子 → 父:通过
$emit触发事件
v-model 的本质
v-model 是一个语法糖,它简化了双向绑定的写法:
<!-- 简写 -->
<my-component v-model="value"></my-component>
<!-- 完整写法(等价) -->
<my-component
:value="value"
@input="value = $event">
</my-component>
为什么不会死循环?
🔍 关键点:Vue 的更新机制
重要: Vue 只在值真正改变时才会触发更新!
详细流程分析
// 初始状态
父组件: form.userIds = "1,2,3"
子组件: props.value = "1,2,3"
// 用户在子组件中修改选择
// 步骤1: 子组件触发 input 事件
this.$emit('input', '1,2,3,4'); // 新值
// 步骤2: 父组件接收事件,更新数据
form.userIds = '1,2,3,4'; // 父组件的值改变了
// 步骤3: Vue 检测到父组件数据变化,传递给子组件
// 子组件的 props.value 从 "1,2,3" 变为 "1,2,3,4"
// 步骤4: 子组件的 watch 监听到 value 变化
watch: {
value(newVal, oldVal) {
// newVal = "1,2,3,4"
// oldVal = "1,2,3"
// 值确实变了,更新内部状态
this.internalValue = newVal;
}
}
// 关键:子组件不会再次触发 $emit('input')
// 因为这次变化是从父组件传来的,不是用户操作导致的
🎯 不会死循环的原因
原因1:值相等时不触发更新
// Vue 的内部机制
if (newValue === oldValue) {
return; // 不触发更新
}
示例:
// 第一次
父组件: "1,2,3" → 子组件: "1,2,3" ✅ 触发更新
// 子组件 emit
子组件: emit("1,2,3,4") → 父组件: "1,2,3,4" ✅ 触发更新
// 父组件传回子组件
父组件: "1,2,3,4" → 子组件: "1,2,3,4" ✅ 触发更新
// 子组件不会再次 emit(因为值没变)
子组件: "1,2,3,4" === "1,2,3,4" ❌ 不触发
原因2:事件触发是主动的
// 子组件只在特定时机触发 emit
handleConfirm() {
// 只有用户点击"确定"按钮时才触发
this.$emit('input', newValue);
}
// 不会在 watch 中触发 emit
watch: {
value(newVal) {
// 只更新内部状态,不触发 emit
this.internalValue = newVal;
// ❌ 不要在这里 emit!
}
}
原因3:单向数据流
// ✅ 正确的流程
用户操作 → 子组件状态变化 → emit 事件 → 父组件更新 → props 传递 → 子组件接收
// ❌ 不会发生的流程
props 变化 → 自动 emit → 父组件更新 → props 变化 → 自动 emit → ...(死循环)
v-model 原理
标准实现
子组件(接收方)
<template>
<div>
<input :value="value" @input="handleInput">
</div>
</template>
<script>
export default {
name: 'MyInput',
props: {
value: {
type: String,
default: ''
}
},
methods: {
handleInput(event) {
// 触发 input 事件,传递新值
this.$emit('input', event.target.value);
}
}
}
</script>
父组件(使用方)
<template>
<my-input v-model="message"></my-input>
<!-- 等价于 -->
<my-input
:value="message"
@input="message = $event">
</my-input>
</template>
<script>
export default {
data() {
return {
message: 'Hello'
}
}
}
</script>
完整数据流
┌─────────────────────────────────────────────────────────┐
│ 父组件 │
│ data: { message: 'Hello' } │
│ │
│ <my-input v-model="message"> │
│ ↓ :value="message" ↑ @input="message=$event" │
└────┼────────────────────────┼─────────────────────────┘
│ │
│ props传递 │ 事件触发
↓ ↑
┌────┼────────────────────────┼─────────────────────────┐
│ ↓ ↑ 子组件 │
│ props: { value: 'Hello' } ↑ │
│ ↑ │
│ <input :value="value" ↑ │
│ @input="$emit('input', $event.target.value)"> │
│ │
└─────────────────────────────────────────────────────────┘
完整实现步骤
步骤1:定义 props
export default {
name: 'UserTreeSelect',
props: {
// 接收父组件传递的值
value: {
type: String,
default: ''
}
}
}
步骤2:在模板中使用 props
<template>
<div>
<!-- 显示 props.value -->
<el-input :value="displayValue" readonly></el-input>
</div>
</template>
<script>
export default {
computed: {
displayValue() {
// 基于 props.value 计算显示值
return this.value;
}
}
}
</script>
步骤3:触发 input 事件
export default {
methods: {
handleChange(newValue) {
// 当值改变时,触发 input 事件
this.$emit('input', newValue);
}
}
}
步骤4:父组件使用 v-model
<template>
<user-tree-select v-model="form.userIds"></user-tree-select>
</template>
<script>
export default {
data() {
return {
form: {
userIds: ''
}
}
}
}
</script>
常见问题
Q1: 为什么不直接修改 props?
// ❌ 错误:直接修改 props
props: ['value'],
methods: {
handleChange() {
this.value = 'new value'; // 报错!
}
}
// ✅ 正确:通过 emit 通知父组件
props: ['value'],
methods: {
handleChange() {
this.$emit('input', 'new value');
}
}
原因: Vue 遵循单向数据流原则,props 只能由父组件修改。
Q2: 需要在子组件内部维护状态吗?
情况1:简单组件(不需要)
// 直接使用 props.value
export default {
props: ['value'],
computed: {
displayValue() {
return this.value; // 直接使用
}
}
}
情况2:复杂组件(需要)
// 需要内部状态
export default {
props: ['value'],
data() {
return {
internalValue: this.value // 复制到内部状态
}
},
watch: {
// 监听 props 变化,同步到内部状态
value(newVal) {
this.internalValue = newVal;
}
},
methods: {
handleChange() {
// 修改内部状态
this.internalValue = 'new value';
// 通知父组件
this.$emit('input', this.internalValue);
}
}
}
Q3: watch 中可以 emit 吗?
// ⚠️ 谨慎使用
watch: {
value(newVal) {
// 如果在这里 emit,可能导致问题
this.$emit('input', newVal); // 危险!
}
}
问题: 可能导致不必要的更新循环。
正确做法:
watch: {
value(newVal) {
// 只更新内部状态,不 emit
this.internalValue = newVal;
}
}
Q4: 什么时候触发 emit?
// ✅ 在用户操作时触发
methods: {
handleConfirm() {
// 用户点击确定按钮
this.$emit('input', this.selectedValue);
},
handleSelect(item) {
// 用户选择项目
this.$emit('input', item.id);
}
}
// ❌ 不要在 watch/computed 中触发
watch: {
value(newVal) {
this.$emit('input', newVal); // 不要这样做!
}
}
最佳实践
1. 使用计算属性处理显示逻辑
export default {
props: ['value'],
computed: {
// 基于 props 计算显示值
displayValue() {
if (!this.value) return '';
// 处理逻辑...
return this.value;
}
}
}
2. 只在必要时维护内部状态
export default {
props: ['value'],
data() {
return {
// 只在需要复杂交互时才维护内部状态
internalValue: this.value,
isEditing: false
}
},
watch: {
value(newVal) {
// 同步外部变化到内部
if (!this.isEditing) {
this.internalValue = newVal;
}
}
}
}
3. 提供额外的 change 事件
methods: {
handleConfirm() {
const newValue = this.getSelectedValue();
// input 事件:用于 v-model
this.$emit('input', newValue);
// change 事件:提供额外信息
this.$emit('change', {
value: newValue,
detail: this.getDetailInfo()
});
}
}
4. 使用 .sync 修饰符(可选)
<!-- 父组件 -->
<my-component :visible.sync="dialogVisible"></my-component>
<!-- 等价于 -->
<my-component
:visible="dialogVisible"
@update:visible="dialogVisible = $event">
</my-component>
// 子组件
export default {
props: ['visible'],
methods: {
close() {
this.$emit('update:visible', false);
}
}
}
实战案例
案例1:简单输入框组件
<!-- MyInput.vue -->
<template>
<div class="my-input">
<input
:value="value"
@input="handleInput"
:placeholder="placeholder">
</div>
</template>
<script>
export default {
name: 'MyInput',
props: {
value: {
type: String,
default: ''
},
placeholder: {
type: String,
default: '请输入'
}
},
methods: {
handleInput(event) {
this.$emit('input', event.target.value);
}
}
}
</script>
使用:
<template>
<my-input v-model="username" placeholder="请输入用户名"></my-input>
</template>
<script>
export default {
data() {
return {
username: ''
}
}
}
</script>
案例2:复杂选择器组件(UserTreeSelect)
<!-- UserTreeSelect.vue -->
<template>
<div class="user-tree-select">
<!-- 输入框:显示选中的用户名称 -->
<el-input
:value="displayNames"
readonly
@click.native="openDialog">
</el-input>
<!-- 对话框:选择用户 -->
<el-dialog :visible.sync="dialogVisible">
<el-tree
ref="tree"
:data="treeData"
show-checkbox
node-key="id">
</el-tree>
<div slot="footer">
<el-button @click="dialogVisible = false">取消</el-button>
<el-button type="primary" @click="handleConfirm">确定</el-button>
</div>
</el-dialog>
</div>
</template>
<script>
export default {
name: 'UserTreeSelect',
props: {
// 接收父组件的值(用户ID,逗号分隔)
value: {
type: String,
default: ''
}
},
data() {
return {
dialogVisible: false,
treeData: [],
userMap: {} // ID -> 名称的映射
}
},
computed: {
// 计算显示的用户名称
displayNames() {
if (!this.value) return '';
const ids = this.value.split(',');
const names = ids.map(id => this.userMap[id] || '').filter(n => n);
return names.join(', ');
}
},
watch: {
// 监听 props 变化
value: {
handler(newVal) {
if (newVal) {
// 如果需要,加载用户数据
this.loadUserData();
}
},
immediate: true
}
},
methods: {
openDialog() {
this.dialogVisible = true;
// 设置已选中的节点
if (this.value) {
const ids = this.value.split(',');
this.$nextTick(() => {
this.$refs.tree.setCheckedKeys(ids);
});
}
},
handleConfirm() {
// 获取选中的节点
const checkedNodes = this.$refs.tree.getCheckedNodes();
const userIds = checkedNodes.map(node => node.id);
const value = userIds.join(',');
// 触发 input 事件(v-model)
this.$emit('input', value);
// 触发 change 事件(额外信息)
this.$emit('change', {
value: value,
ids: userIds,
nodes: checkedNodes
});
// 关闭对话框
this.dialogVisible = false;
},
loadUserData() {
// 加载用户数据,构建 userMap
// ...
}
}
}
</script>
使用:
<template>
<el-form-item label="设备主人" prop="userIds">
<user-tree-select
v-model="form.userIds"
@change="handleUserChange">
</user-tree-select>
</el-form-item>
</template>
<script>
export default {
data() {
return {
form: {
userIds: ''
}
}
},
methods: {
handleUserChange(data) {
console.log('选中的用户:', data.ids);
console.log('选中的节点:', data.nodes);
}
}
}
</script>
案例3:带验证的输入框
<!-- ValidatedInput.vue -->
<template>
<div class="validated-input">
<input
:value="internalValue"
@input="handleInput"
@blur="handleBlur">
<span v-if="error" class="error">{{ error }}</span>
</div>
</template>
<script>
export default {
name: 'ValidatedInput',
props: {
value: String,
rules: Array // 验证规则
},
data() {
return {
internalValue: this.value,
error: ''
}
},
watch: {
value(newVal) {
this.internalValue = newVal;
}
},
methods: {
handleInput(event) {
this.internalValue = event.target.value;
this.error = '';
// 实时更新父组件
this.$emit('input', this.internalValue);
},
handleBlur() {
// 失焦时验证
this.validate();
},
validate() {
if (!this.rules) return true;
for (const rule of this.rules) {
if (rule.required && !this.internalValue) {
this.error = rule.message || '此字段必填';
return false;
}
if (rule.pattern && !rule.pattern.test(this.internalValue)) {
this.error = rule.message || '格式不正确';
return false;
}
}
this.error = '';
return true;
}
}
}
</script>
使用:
<template>
<validated-input
v-model="email"
:rules="emailRules">
</validated-input>
</template>
<script>
export default {
data() {
return {
email: '',
emailRules: [
{ required: true, message: '邮箱不能为空' },
{ pattern: /^[\w-]+@[\w-]+\.\w+$/, message: '邮箱格式不正确' }
]
}
}
}
</script>
调试技巧
1. 添加日志
export default {
props: ['value'],
watch: {
value(newVal, oldVal) {
console.log('props.value 变化:', { oldVal, newVal });
}
},
methods: {
handleChange(newValue) {
console.log('触发 emit:', newValue);
this.$emit('input', newValue);
}
}
}
2. 使用 Vue DevTools
- 查看组件的 props
- 查看组件的 data
- 查看事件触发历史
3. 检查更新循环
let emitCount = 0;
export default {
methods: {
handleChange(newValue) {
emitCount++;
console.log('emit 次数:', emitCount);
if (emitCount > 10) {
console.error('可能存在死循环!');
return;
}
this.$emit('input', newValue);
}
}
}
总结
核心要点
- v-model = :value + @input
- 子组件通过 $emit('input') 通知父组件
- 不会死循环,因为 Vue 只在值真正改变时更新
- 不要在 watch 中 emit,只在用户操作时 emit
- 遵循单向数据流原则
数据流向
用户操作
↓
子组件内部状态变化
↓
$emit('input', newValue)
↓
父组件接收事件
↓
父组件数据更新
↓
props 传递给子组件
↓
子组件接收新的 props
↓
更新显示(不再 emit)
记忆口诀
- 父传子:用 props
- 子传父:用 emit
- 双向绑:v-model
- 值不变:不更新
- 用户操作:才 emit