Vue 自定义组件双向绑定完全指南

lishihuan大约 8 分钟

Vue 自定义组件双向绑定完全指南

📚 目录

  1. 核心概念
  2. 为什么不会死循环
  3. v-model 原理
  4. 完整实现步骤
  5. 常见问题
  6. 最佳实践
  7. 实战案例

核心概念

什么是双向绑定?

双向绑定 = 数据流动是双向的

父组件的数据 ←→ 子组件的数据
  • 父 → 子:通过 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);
    }
  }
}

总结

核心要点

  1. v-model = :value + @input
  2. 子组件通过 $emit('input') 通知父组件
  3. 不会死循环,因为 Vue 只在值真正改变时更新
  4. 不要在 watch 中 emit,只在用户操作时 emit
  5. 遵循单向数据流原则

数据流向

用户操作
  ↓
子组件内部状态变化
  ↓
$emit('input', newValue)
  ↓
父组件接收事件
  ↓
父组件数据更新
  ↓
props 传递给子组件
  ↓
子组件接收新的 props
  ↓
更新显示(不再 emit)

记忆口诀

  • 父传子:用 props
  • 子传父:用 emit
  • 双向绑:v-model
  • 值不变:不更新
  • 用户操作:才 emit

参考资源