Skip to content

L06 · 表单与 v-model:双向绑定 ​

🎯 本节目标:用 v-model 重构输入框,实现 Todo 内联编辑
📦 本节产出:支持内联编辑的 Todo App + 理解 v-model 语法糖本质
🔗 前置钩子:L05 的事件系统(@input/@click/emit)
🔗 后续钩子:L07 将用 computed 和 watch 实现筛选和统计

1. v-model 是什么 ​

1.1 没有 v-model 的写法(L03/L05 中的方式) ​

vue
<input
  :value="newTodoText"
  @input="newTodoText = ($event.target as HTMLInputElement).value"
/>

每次都要写 :value + @input,很繁琐。

1.2 v-model 语法糖 ​

vue
<!-- 普通文本输入可这样简写;v-model 还处理输入法组合状态 -->
<input v-model="newTodoText" />

v-model 的本质:

对普通文本输入,可用 :value + @input 理解数据流。实际 v-model 还处理中文输入法的组合输入、不同控件和修饰符,不能把所有情况都当成同一段事件代码。

1.3 用 v-model 重构 App.vue 的输入框 ​

vue
<!-- 改造前 -->
<input
  :value="newTodoText"
  @input="newTodoText = ($event.target as HTMLInputElement).value"
  @keydown.enter="!$event.isComposing && addTodo()"
  placeholder="添加新任务..."
/>

<!-- 改造后 -->
<input
  v-model="newTodoText"
  @keydown.enter="!$event.isComposing && addTodo()"
  placeholder="添加新任务..."
/>

2. v-model 在不同表单元素上的行为 ​

v-model 在不同表单元素上,展开为不同的属性和事件:

元素绑定属性监听事件
<input type="text">:value@input
<textarea>:value@input
<input type="checkbox">:checked@change
<input type="radio">:checked@change
<select>:value@change

2.1 Checkbox ​

vue
<script setup lang="ts">
import { ref } from 'vue'

// 单个 checkbox → boolean
const isAgreed = ref(false)

// 多个 checkbox → 数组
const selectedTags = ref<string[]>([])
</script>

<template>
  <!-- 单个 checkbox -->
  <label>
    <input type="checkbox" v-model="isAgreed" />
    同意条款
  </label>
  <p>{{ isAgreed ? '已同意' : '未同意' }}</p>

  <!-- 多个 checkbox 绑定同一个数组 -->
  <label><input type="checkbox" v-model="selectedTags" value="工作" /> 工作</label>
  <label><input type="checkbox" v-model="selectedTags" value="学习" /> 学习</label>
  <label><input type="checkbox" v-model="selectedTags" value="生活" /> 生活</label>
  <p>已选: {{ selectedTags }}</p>
  <!-- 输出:["工作", "学习"] -->
</template>

2.2 Select ​

vue
<script setup lang="ts">
import { ref } from 'vue'

const priority = ref<'low' | 'medium' | 'high'>('medium')
</script>

<template>
  <select v-model="priority">
    <option value="low">🟢 低</option>
    <option value="medium">🟡 中</option>
    <option value="high">🔴 高</option>
  </select>
</template>

3. v-model 修饰符 ​

3.1 .trim:去除首尾空格 ​

vue
<!-- 自动去除用户输入的首尾空格 -->
<input v-model.trim="newTodoText" />

<!-- 等价于 -->
<input :value="newTodoText"
  @input="newTodoText = ($event.target as HTMLInputElement).value.trim()" />

3.2 .lazy:在 change 事件时更新(而非 input) ​

vue
<!-- 默认:每输入一个字符就更新 -->
<input v-model="text" />

<!-- .lazy:改为监听 change;文本框通常在值改变后失焦时触发 -->
<input v-model.lazy="text" />

3.3 .number:自动转为数字 ​

vue
<input v-model.number="age" type="number" />
<!-- 可转换时得到 number;清空时仍是空字符串,无法转换时保留原值 -->
<!-- type="number" 本身也会启用数字转换;状态可声明为 ref<number | string>('') -->

4. 组件上的 v-model ​

4.1 在自定义组件上使用 v-model ​

v-model 不只能用在原生 <input> 上,还能用在自定义组件上。

vue
<!-- 父组件 -->
<TodoInput v-model="newTodoText" />

<!-- 等价于 -->
<TodoInput :modelValue="newTodoText" @update:modelValue="newTodoText = $event" />

4.2 defineModel()(Vue 3.4+ 推荐) ​

vue
<!-- src/components/TodoInput.vue -->
<script setup lang="ts">
// defineModel 自动处理 prop + emit
const text = defineModel<string>({ required: true })
</script>

<template>
  <input
    v-model="text"
    class="todo-input"
    placeholder="添加新任务..."
  />
</template>

父组件仍负责初始化 newTodoText = ref('')。这里使用必传 model,避免子组件自行设置默认值而父组件仍为 undefined。

4.3 多个 v-model ​

一个组件可以支持多个 v-model:

vue
<!-- 父组件 -->
<TodoEditor
  v-model:text="todo.text"
  v-model:priority="todo.priority"
/>

<!-- 子组件 TodoEditor.vue -->
<script setup lang="ts">
const text = defineModel<string>('text', { required: true })
const priority = defineModel<'low' | 'medium' | 'high'>('priority', { required: true })
</script>

5. 实战:Todo 内联编辑 ​

5.1 升级 TodoItem 支持编辑模式 ​

vue
<!-- src/components/TodoItem.vue(新增编辑功能) -->
<script setup lang="ts">
import { nextTick, ref } from 'vue'

const props = withDefaults(
  defineProps<{
    id: number
    text: string
    done?: boolean
    priority?: 'low' | 'medium' | 'high'
    createdAt?: string
  }>(),
  { done: false, priority: 'medium' }
)

const emit = defineEmits<{
  toggle: [id: number]
  delete: [id: number]
  update: [id: number, text: string]
}>()

// 编辑状态
const isEditing = ref(false)
const editText = ref('')
const editInput = ref<HTMLInputElement | null>(null)

async function startEdit() {
  if (props.done) return  // 本例不允许编辑已完成项
  editText.value = props.text
  isEditing.value = true
  await nextTick() // 等 v-if 创建输入框后再聚焦
  editInput.value?.focus()
  editInput.value?.select()
}

function saveEdit() {
  if (!isEditing.value) return // Enter 或 Esc 关闭后,忽略随后的 blur
  isEditing.value = false
  const text = editText.value.trim()
  if (text && text !== props.text) {
    emit('update', props.id, text)
  }
}

function cancelEdit() {
  isEditing.value = false
}

function handleEditKeydown(event: KeyboardEvent) {
  if (event.isComposing) return // 不把输入法确认键当作提交
  if (event.key === 'Enter') {
    event.preventDefault()
    saveEdit()
  } else if (event.key === 'Escape') {
    cancelEdit()
  }
}
</script>

<template>
  <div class="todo-item" :class="{ 'is-done': done, 'is-editing': isEditing, [`priority-${priority}`]: true }">
    <button type="button" class="toggle-btn" :aria-label="done ? '标记为未完成' : '标记为已完成'" :aria-pressed="done" @click="emit('toggle', id)">
      {{ done ? '✅' : '⬜' }}
    </button>

    <div class="todo-content">
      <!-- 编辑模式 -->
      <input
        v-if="isEditing"
        v-model.trim="editText"
        @keydown="handleEditKeydown"
        @blur="saveEdit"
        class="edit-input"
        ref="editInput"
      />

      <!-- 展示模式 -->
      <span
        v-else
        class="todo-text"
        @dblclick="startEdit"
      >
        {{ text }}
      </span>

      <span v-if="createdAt && !isEditing" class="todo-date">{{ createdAt }}</span>
    </div>

    <span v-if="!isEditing" class="priority-badge">{{ priority }}</span>
    <button v-if="!done && !isEditing" type="button" class="edit-btn" @click="startEdit">编辑</button>
    <button v-if="!isEditing" type="button" class="delete-btn" aria-label="删除任务" @click="emit('delete', id)">🗑️</button>
  </div>
</template>

<style scoped>
.todo-item {
  display: flex;
  align-items: center;
  gap: 12px;
  padding: 12px 16px;
  background: #fff;
  border-radius: 8px;
  border: 1px solid #e8e8e8;
  margin-bottom: 8px;
  transition: all 0.3s ease;
}

.todo-item.is-editing {
  border-color: #42b883;
  box-shadow: 0 0 0 2px rgba(66, 184, 131, 0.15);
}

.todo-item:hover {
  box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
}

.todo-item.is-done {
  opacity: 0.55;
  background: #fafafa;
}

.todo-item.is-done .todo-text {
  text-decoration: line-through;
  color: #999;
}

.toggle-btn,
.edit-btn,
.delete-btn {
  background: none;
  border: none;
  font-size: 1.2rem;
  cursor: pointer;
  padding: 4px;
  border-radius: 4px;
  transition: background 0.2s;
}

.toggle-btn:hover,
.edit-btn:hover,
.delete-btn:hover {
  background: #f0f0f0;
}

.delete-btn {
  opacity: 0.6;
  transition: opacity 0.2s;
}

.todo-item:hover .delete-btn,
.todo-item:focus-within .delete-btn {
  opacity: 1;
}

.todo-content {
  flex: 1;
  display: flex;
  flex-direction: column;
}

.todo-text {
  font-size: 1rem;
  color: #2c3e50;
  cursor: default;
}

.todo-text:hover {
  cursor: text;
}

.edit-input {
  font-size: 1rem;
  padding: 4px 8px;
  border: 1px solid #ddd;
  border-radius: 4px;
  outline: none;
  width: 100%;
}

.edit-input:focus {
  border-color: #42b883;
}

.todo-date {
  font-size: 0.75rem;
  color: #aaa;
  margin-top: 2px;
}

.priority-badge {
  font-size: 0.7rem;
  padding: 2px 8px;
  border-radius: 10px;
  text-transform: uppercase;
  font-weight: 600;
}

.priority-low .priority-badge { background: #e8f5e9; color: #4caf50; }
.priority-medium .priority-badge { background: #fff3e0; color: #ff9800; }
.priority-high .priority-badge { background: #ffebee; color: #f44336; }
</style>

nextTick() 等待本轮 DOM 更新,模板 ref 才能拿到新输入框。保存时先退出编辑态,避免 Enter 之后的 blur 重复提交;Esc 也不会被随后触发的 blur 改成保存。

5.2 在 App.vue 中处理更新事件 ​

这是增量片段:把函数加进现有脚本,并在 L05 的 <TransitionGroup> 内给 TodoItem 增加监听;保留其他模板和样式。

vue
<script setup lang="ts">
// ...已有代码...

function updateTodo(id: number, text: string) {
  const todo = todos.value.find(t => t.id === id)
  if (todo) {
    todo.text = text
  }
}
</script>

<template>
  <!-- 在 TodoItem 上添加 @update 监听 -->
  <TodoItem
    v-for="todo in todos"
    :key="todo.id"
    v-bind="todo"
    @toggle="toggleTodo"
    @delete="deleteTodo"
    @update="updateTodo"
  />
</template>

6. v-model 的本质:语法糖展开 ​


7. 深度专题预览:D07 · v-model 违反单向数据流了吗? ​

很多人认为 v-model 是"双向绑定",与 Vue 倡导的"单向数据流"矛盾。实际上:

在组件边界上,v-model 仍通过 prop 向下传值、通过 update:* 事件通知父组件赋值;它没有让子组件直接重写父组件的 prop。原生控件则由相应的 DOM 属性和事件同步状态。


表单与组件 model 的具体规则参见 表单输入绑定 和 组件 v-model。

8. 本节总结 ​

检查清单 ​

  • [ ] 能用 :value + @input 解释普通文本输入的数据流,并知道输入法等处理有差异
  • [ ] 知道 v-model 在 checkbox/select 上的不同行为
  • [ ] 能使用 .trim、.lazy、.number 修饰符
  • [ ] 能用 defineModel() 在组件上实现 v-model
  • [ ] 能实现 Todo 的双击编辑、Enter 保存、Esc 取消
  • [ ] 能解释 v-model 没有违反单向数据流

🐞 防坑指南 ​

坑说明正确做法
子组件 model 默认值与父组件不同步父状态为 undefined 时,子组件默认值不会自动写回初始化父状态,或要求传入 model
.number 对空值空输入框返回空字符串,?? 0 不会替换它显式检查空字符串并校验有效数字
所有字段都用 .trim有些字段需要保留首尾空格按业务规则处理;Todo 文本提交时 trim
编辑模式忘记初始化打开编辑时 editText 是空的editText.value = props.text 后再切编辑态

📐 最佳实践 ​

  1. 优先 defineModel:Vue 3.4+ 用 defineModel() 替代 props + emit 手动绑定
  2. 编辑交互:提供编辑按钮和双击入口,进入后聚焦;处理 Enter / Esc 时排除输入法组合状态
  3. 表单触发时机:.lazy 改为 change 更新,它不是防抖;搜索请求防抖需要定时器或相应 composable
  4. 多 v-model 命名:一个组件有多个独立同步字段时,用 v-model:text、v-model:priority 区分

Git 提交 ​

bash
git add .
git commit -m "L06: v-model 双向绑定 + Todo 内联编辑"

🔗 钩子连接 ​

→ 下一节:L07 · computed 与 watch:筛选 + 统计 ​

Todo 功能基本完整了(增删改查)。L07 将添加:

  • 用 computed 实现"全部/进行中/已完成"筛选
  • 用 computed 计算统计数据(完成率 / 剩余数量)
  • 用 watch 实现副作用(如自动保存提示)