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 后再切编辑态 |
📐 最佳实践
- 优先 defineModel:Vue 3.4+ 用
defineModel()替代 props + emit 手动绑定 - 编辑交互:提供编辑按钮和双击入口,进入后聚焦;处理 Enter / Esc 时排除输入法组合状态
- 表单触发时机:
.lazy改为 change 更新,它不是防抖;搜索请求防抖需要定时器或相应 composable - 多 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实现副作用(如自动保存提示)