模块一 · 起步与基础
模板与表单:指令、修饰符与 v-model 的完整用法
模板是 Vue 唯一的声明式入口。这一节把条件渲染、列表、class/style 绑定、事件修饰符与表单 v-model 讲成一张可查的表,并给出一套三层校验的表单做法。
读完这一节,你能做到
- 按场景选对
v-if、v-show、v-for,不再混用。 - 写出可读的 class/style 绑定,而不是拼字符串。
- 用事件修饰符替代手写
stopPropagation。 - 独立完成一个带校验的受控表单。
一、指令全景
| 指令 | 作用 | 关键点 |
|---|---|---|
v-text | 设置 textContent | 会覆盖子内容,很少直接用 |
v-html | 设置 innerHTML | 有 XSS 风险,只用于可信内容 |
v-show | 切换 display | 元素始终在 DOM 中,初次渲染成本高 |
v-if / v-else-if / v-else | 条件渲染 | 真正销毁与重建节点,会触发组件卸载 |
v-for | 列表渲染 | 必须给稳定 key |
v-bind(:) | 绑定属性/Props | 支持对象语法批量绑定 |
v-on(@) | 绑定事件 | 配合修饰符使用 |
v-model | 双向绑定语法糖 | 本质是"值 + 输入事件" |
v-once | 只渲染一次 | 适合纯静态区块 |
v-memo | 记忆化子树 | 列表项性能优化,需谨慎 |
二、条件渲染:v-if 还是 v-show
判断标准只有两条:切换频率与首次渲染成本。
| 场景 | 选择 | 原因 |
|---|---|---|
| 弹窗、抽屉、标签页 | v-show | 频繁切换,避免反复创建销毁 |
| 权限区块、首屏不展示的大组件 | v-if | 不该渲染的内容不要进 DOM |
| 需要保留输入内容 | v-show | v-if 会销毁组件,状态丢失 |
| 内含大量子组件 | v-if | 减少初始渲染与内存占用 |
v-if 是"惰性"的:初始为假时,分支内的组件不会创建,也不会执行 onMounted。v-show 只是改 display: none,组件生命周期照常执行。这一点在排查"接口为什么多请求一次"时很关键。
v-if 与 v-for 不要放在同一元素上。在 Vue 3 中 v-if 优先级更高,拿不到 v-for 的循环变量:
<!-- 错误:item 未定义 -->
<li v-for="item in list" v-if="item.active">{{ item.name }}</li>
<!-- 正确:先过滤,再渲染 -->
<li v-for="item in activeList" :key="item.id">{{ item.name }}</li>
三、列表渲染
<ul>
<li v-for="(item, index) in list" :key="item.id">
{{ index + 1 }}. {{ item.name }}
</li>
</ul>
v-for 还能遍历对象与数字:
<span
v-for="(value, key, i) in user"
:key="key"
>{{ i }}-{{ key }}:{{ value }}</span>
<span v-for="n in 5" :key="n">{{ n }}</span>
key 的规则:
- 用业务 id,不要用数组下标。
- 只在同级兄弟间唯一,不需要全局唯一。
- 列表项是组件时,
key写在组件标签上,不要写在组件内部的根元素上。
用下标做 key,在"插入到中间"时会让后续节点复用错位:输入框内容、展开状态、动画都会跑到别的行上。这个 bug 在开发时不容易发现,因为纯展示列表看起来完全正常。
四、class 与 style 绑定
<div :class="{ active: isActive, 'is-disabled': disabled }" />
<div :class="['btn', size === 'large' ? 'btn-lg' : '']" />
<div :class="[baseClass, { active: isActive }]" />
<div :style="{ color: textColor, fontSize: `${size}px` }" />
三条实践:
- 静态类写在
class上,动态类写在:class上,Vue 会自动合并。 - 复杂的类名判断抽成
computed,模板里只留一个变量。 :style的数值单位要自己补,Vue 不会自动加px(除非用 CSS 变量)。
五、事件与修饰符
<button @click="handleClick">保存</button>
<button @click="handleClick($event, row)">带参数</button>
<form @submit.prevent="onSubmit">
<a @click.stop="open">不冒泡</a>
<div @click.self="close">点空白才关闭</div>
<input @keyup.enter="search" @keyup.esc="clear" />
</form>
常用修饰符:
| 修饰符 | 等价于 | 典型场景 |
|---|---|---|
.stop | event.stopPropagation() | 阻止冒泡到父级点击区域 |
.prevent | event.preventDefault() | 表单提交、链接跳转 |
.self | 判断 event.target === event.currentTarget | 遮罩层点击关闭 |
.once | 只触发一次 | 引导、埋点 |
.capture | 捕获阶段触发 | 需要在子级之前拦截 |
.passive | 提升滚动性能 | @scroll.passive、@touchmove.passive |
按键与系统键修饰符:.enter、.tab、.esc、.space、.up/.down/.left/.right、.ctrl、.alt、.shift、.meta、.exact。
一个常见错误:在 .passive 上同时写 .prevent,浏览器会忽略 preventDefault,并给出警告。滚动拦截要用非 passive 监听或直接改布局。
六、表单:v-model 的六种形态
v-model 是"绑定值 + 监听输入事件"的语法糖,不同类型的表单元素绑定的属性不同:
| 元素 | 绑定属性 | 绑定事件 | 值类型 |
|---|---|---|---|
input[type=text] / textarea | value | input | string |
input[type=checkbox](单个) | checked | change | boolean |
input[type=checkbox](多选) | checked | change | array |
input[type=radio] | checked | change | 选中的 value |
select(单选/多选) | value | change | string / array |
<input v-model="form.name" />
<textarea v-model="form.remark" />
<input type="checkbox" v-model="form.agree" />
<input type="checkbox" v-model="form.tags" value="vue" />
<input type="radio" v-model="form.level" value="P6" />
<select v-model="form.city">
<option disabled value="">请选择</option>
<option value="sh">上海</option>
</select>
三个修饰符:
| 修饰符 | 作用 | 注意 |
|---|---|---|
.lazy | 从 input 改为 change 同步 | 输入过程中不更新 |
.number | 尝试转成数字 | 无法转换时返回原字符串 |
.trim | 去掉首尾空格 | 只处理首尾,不去中间 |
<input v-model.number="form.age" type="number" />
<input v-model.trim="form.email" />
<input v-model.lazy="form.keyword" />
七、一个完整表单案例
需求:注册表单,包含手机号、验证码、密码、同意协议;要求即时校验格式、提交前统一校验、服务端错误回填。
<script setup lang="ts">
import { reactive, computed, ref } from "vue";
const form = reactive({ phone: "", code: "", password: "", agree: false });
const errors = reactive<Record<string, string>>({});
const submitting = ref(false);
const phoneValid = computed(() => /^1[3-9]\d{9}$/.test(form.phone));
const canSubmit = computed(
() =>
phoneValid.value &&
form.code.length === 6 &&
form.password.length >= 8 &&
form.agree,
);
function validate() {
errors.phone = phoneValid.value ? "" : "手机号格式不正确";
errors.code = form.code.length === 6 ? "" : "验证码为 6 位";
errors.password = form.password.length >= 8 ? "" : "密码至少 8 位";
errors.agree = form.agree ? "" : "请先同意协议";
return !Object.values(errors).some(Boolean);
}
async function onSubmit() {
if (!validate()) return;
submitting.value = true;
try {
await register(form);
} catch (e: any) {
errors[toField(e.code)] = e.message; // 服务端错误映射到字段
} finally {
submitting.value = false;
}
}
</script>
这个例子体现了三层校验:
- 即时校验:
computed判断格式,错误提示在字段旁,不弹窗。 - 提交校验:
validate()统一跑一遍,避免遗漏与绕过。 - 服务端校验:接口错误按字段码回填,不能只依赖前端。
三个易被忽略的细节:
- 提交中禁用按钮,防止重复提交;
submitting同时用于 loading 态。 - 验证码按钮要倒计时并在组件卸载时清理定时器。
- 表单数据用
reactive时不要整体替换对象,否则丢失响应式;用Object.assign(form, next)。
八、常见坑
v-if与v-for同元素,循环变量取不到。key用下标,插入后输入框内容错位。- 复选框多选忘了给每个选项写
value,结果绑成true/false。 select没有空值占位,默认选中第一项,用户以为已选择。v-model.number在空字符串时得到""而不是0,后端校验失败。- 表单整体替换(
form = {...})后页面不更新,因为reactive的引用被换掉。 v-html渲染用户输入,造成 XSS。
九、动手任务:自写 → AI 写 → 对比 → 取舍
四步不跳:① 自己写(限时,关掉补全)→ ② 让 AI 写同一题 → ③ 逐条比对 → ④ 说出保留谁的写法。 完整动作与记录模板见《AI 协作与代码评审》。
① 自己写
- 写一个搜索栏:输入框
v-model.trim,回车触发搜索,带清空按钮。 - 写一个多选标签组件,用
v-model绑定数组,至少选一项才能提交。 - 把上面的注册表单补全错误提示、提交中禁用与验证码倒计时。
- 用
v-show与v-if各实现一次弹窗,用 DevTools 观察 DOM 与组件生命周期的差异。
② 让 AI 写同一题
把第 2 题(表单校验)交给 AI,补一句 用 v-model 修饰符,不要手写 change 事件。
③ 对比要盯的点
.number/.trim它用了吗?手写Number(e.target.value)会把空字符串变成 0。- 它有没有把
v-model直接绑在 prop 上?prop 只读,必须 emit 上去。 - 校验时机它是 blur 还是每次输入?后者用户刚敲第一个字就满屏红字。
④ 一句话取舍
哪个差异会让用户在还没输完时就被判错?
十、自测
v-if与v-show的选择标准是什么?- 为什么
v-if不能和v-for同元素? v-model在复选框上的值类型有哪两种?.lazy、.number、.trim分别改变什么?- 表单三层校验各解决什么问题?
参考答案要点:切换频率与首渲染成本;优先级导致循环变量不可用;单个为布尔、多选为数组;同步时机、类型转换、去空格;即时体验、防遗漏、防绕过。
附:表单可访问性检查清单
表单能不能用,不只取决于逻辑对不对。下面九条是上线前可以逐项打勾的最低要求:
| 检查项 | 做法 | 不做的后果 |
|---|---|---|
| 每个输入框有标签 | <label for> 或 aria-label | 读屏只念“编辑框” |
| 必填有语义 | required 或 aria-required | 用户提交后才知道必填 |
| 错误与字段关联 | aria-describedby 指向错误节点 | 读屏读不到错误原因 |
| 错误状态可感知 | aria-invalid="true" | 仅靠红色边框,色弱用户无感 |
| 错误提示可被朗读 | 容器加 role="alert" | 提交后没有反馈 |
| 按钮类型明确 | <button type="button"> | 在表单里误提交 |
| 焦点可见 | 不要 outline: none 而不给替代样式 | 键盘用户不知道焦点在哪 |
| 错误后聚焦 | 提交失败后把焦点移到第一个错误字段 | 用户要自己找 |
| 禁用与只读区分 | 不可改但需提交用 readonly | 用 disabled 会导致值不提交 |
其中“错误后聚焦”最容易被忽略。表单较长时,用户点完提交只看到页面没反应,实际上错误在屏幕外。把焦点移到第一个错误字段,并配合 scrollIntoView,体验差别很大。
另一个细节是 disabled 与 readonly 的选择:disabled 的字段不会随表单提交,也不可聚焦;readonly 可聚焦、可复制、会提交。展示“不可修改但需要提交”的信息(如订单号)时,应该用 readonly。
配套题目
★ 越多越该先背;点题目可看完整答案与深入版。
提示:读完可以顺手做几道对应题目,检验是否真的理解