模板语法与指令:Vue 的"视图层注解"
先立一个心智模型
后端开发者看 Vue 模板时,最容易把它和 Thymeleaf / JSP / FreeMarker 画等号。这个类比一半对一半错:
- 对的部分:都是"在 HTML 里写表达式和条件循环",最终渲染出 DOM
- 错的部分:Thymeleaf 在服务器上把模板渲染成 HTML 字符串后返回给浏览器;Vue 模板在浏览器里运行,数据一变,DOM 就精准更新(不是重新生成整页 HTML)
一句话:
Vue 模板是运行时响应式的视图层,Thymeleaf 是一次性的字符串拼接。
理解这点,下面所有指令才不会看成"Thymeleaf 的抄袭"。
1. 插值表达式 {{ }}:基础中的基础
<template>
<p>你好,{{ username }}</p>
<p>下单总价:{{ price * count }} 元</p>
<p>大写:{{ username.toUpperCase() }}</p>
</template>
Java 对照(Thymeleaf):
<p th:text="'你好,' + ${username}"></p>
<p th:text="${price * count} + ' 元'"></p>
关键点:
{{ }}里可以写任意合法的 JS 表达式(三元、方法调用、运算),但不能写语句(if / for / let都不行——逻辑挪到computed里)- 只能出现在标签的文本内容里。想往属性里插值要用
v-bind - Vue 会自动做 HTML 转义,
{{ '<script>' }}渲染成字符串而不是真标签。类比 Thymeleaf 的th:text默认转义(th:utext才不转义)
安全提醒:想渲染原始 HTML 得用 v-html,这等于 Thymeleaf 的 th:utext,XSS 风险自担。
2. v-bind:把 JS 值绑到 HTML 属性
<template>
<!-- 完整写法 -->
<a v-bind:href="url">链接</a>
<!-- 简写(常用)-->
<a :href="url">链接</a>
<!-- 绑定多个属性:传一个对象 -->
<img :src="user.avatar" :alt="user.name" :title="user.bio" />
<!-- 布尔属性:值为 true 才渲染 -->
<button :disabled="isLoading">提交</button>
<!-- class 绑定三种写法 -->
<div :class="'card'"></div> <!-- 字符串 -->
<div :class="{ active: isActive, error: hasError }"></div> <!-- 对象:true 才加 -->
<div :class="['card', isActive && 'active']"></div> <!-- 数组 -->
<!-- style 类似 -->
<p :style="{ color: themeColor, fontSize: size + 'px' }">文本</p>
</template>
Thymeleaf 对照:
<a th:href="@{${url}}">链接</a>
<button th:disabled="${isLoading}">提交</button>
<div th:classappend="${isActive} ? 'active' : ''"></div>
看起来都差不多,但 Vue 的 class / style 对象语法非常常用——条件拼 class 比字符串拼接清爽得多。
3. v-on:事件监听
<template>
<!-- 完整写法 -->
<button v-on:click="handleClick">点我</button>
<!-- 简写(更常用)-->
<button @click="handleClick">点我</button>
<!-- 内联表达式 -->
<button @click="count++">{{ count }}</button>
<!-- 传参 -->
<button @click="remove(item.id)">删除</button>
<!-- 原生事件对象 $event -->
<input @input="onInput($event.target.value)" />
<!-- 事件修饰符(Vue 特色)-->
<form @submit.prevent="save">...</form> <!-- 阻止默认提交 -->
<div @click.stop="handle">...</div> <!-- stopPropagation -->
<button @click.once="init">只触发一次</button>
<input @keyup.enter="submit" /> <!-- 按键修饰符 -->
</template>
Java 对照:
@click≈ Swing 的ActionListener,但声明式挂在 DOM 上@submit.prevent≈ 在 Servlet 里拦截请求并跳过默认处理- 事件修饰符是 Vue 独有的"语法糖",后端生态里没有直接对应物
推荐写法:所有项目统一用简写(: 和 @),别混用。
4. v-if / v-else-if / v-else:条件渲染
<template>
<div v-if="user.role === 'admin'">管理员视图</div>
<div v-else-if="user.role === 'editor'">编辑视图</div>
<div v-else>游客视图</div>
<!-- 多个元素整体控制:用 <template> 包一下,template 不会渲染成真标签 -->
<template v-if="isLoggedIn">
<h1>欢迎回来</h1>
<p>{{ user.name }}</p>
</template>
</template>
Thymeleaf 对照:
<div th:if="${user.role == 'admin'}">管理员视图</div>
<div th:unless="${user.role == 'admin'}">非管理员视图</div>
Vue 独有的 v-show:
<div v-show="isVisible">切换显示</div>
v-if:真的增删元素(DOM 里不存在),类比 Thymeleaf 的th:ifv-show:只切换display: none,元素一直在 DOM 里
选择原则:频繁切换用 v-show,极少切换用 v-if。v-if 的初始化成本高,v-show 的首渲染成本高。
现场对比:v-if 与 v-show 的 DOM 差异
打开浏览器 DevTools 的 Elements 面板,切换两个按钮看 DOM 结构:
<script setup>
import { ref } from 'vue'
const showIf = ref(true)
const showShow = ref(true)
</script>
<template>
<div class="box">
<p>打开 DevTools 的 Elements 看下面两块的 DOM 结构</p>
<label><input type="checkbox" v-model="showIf" /> v-if (切换会增删节点)</label>
<div v-if="showIf" class="tag red">v-if 块</div>
<label><input type="checkbox" v-model="showShow" /> v-show (切换的是 display)</label>
<div v-show="showShow" class="tag blue">v-show 块</div>
</div>
</template>
<style scoped>
.box { padding: 20px; font-family: system-ui; line-height: 2; }
label { display: block; }
.tag { display: inline-block; padding: 4px 12px; border-radius: 4px; margin-top: 4px; color: white; }
.red { background: #ef4444; }
.blue { background: #3b82f6; }
</style>
5. v-for:列表渲染
<template>
<!-- 数组:(item, index) -->
<li v-for="(note, index) in notes" :key="note.slug">
{{ index + 1 }}. {{ note.title }}
</li>
<!-- 对象:(value, key, index) -->
<li v-for="(value, key) in userInfo" :key="key">
{{ key }}: {{ value }}
</li>
<!-- 范围:1 ~ 10 -->
<span v-for="n in 10" :key="n">{{ n }}</span>
</template>
Thymeleaf 对照:
<li th:each="note, iter : ${notes}" th:text="${iter.index + 1} + '. ' + ${note.title}"></li>
关键坑::key 不能省
Vue 依赖 :key 做"虚拟 DOM diff"——列表更新时靠 key 识别哪些是同一个元素,从而复用 DOM 而不是重建。
规则:
- 永远加
:key(ESLint 会警告) - 用稳定、唯一的 id(比如
note.slug/user.id) - 不要用
index做 key,除非列表完全静态(不排序、不增删)
反例(会引发奇怪 bug):
<!-- ❌ 列表顺序变化时,输入框里的值会"错位" -->
<div v-for="(item, i) in items" :key="i">
<input v-model="item.text" />
</div>
不要在同一个标签上同时写 v-for 和 v-if
Vue 3 里 v-if 优先级更高,会导致 v-if 里访问不到 v-for 的变量。正确做法是把 v-if 挪到外层 <template> 或内层子元素:
<!-- ✅ 先过滤,再渲染 -->
<li v-for="note in visibleNotes" :key="note.slug">...</li>
<script setup>
const visibleNotes = computed(() => notes.value.filter((n) => n.published))
</script>
这也是为什么 src/pages/HomePage.vue 里用了 filteredNotes 这个 computed——先筛后渲。
6. v-model:双向绑定
<template>
<!-- 文本输入 -->
<input v-model="searchQuery" placeholder="搜索" />
<!-- 复选框 -->
<input type="checkbox" v-model="agreed" />
<!-- 下拉选择 -->
<select v-model="selectedCategory">
<option v-for="c in categories" :key="c" :value="c">{{ c }}</option>
</select>
<!-- 修饰符 -->
<input v-model.trim="name" /> <!-- 自动去首尾空格 -->
<input v-model.number="age" /> <!-- 自动转 number -->
<input v-model.lazy="bio" /> <!-- change 事件才同步,而不是 input -->
</template>
v-model 本质是语法糖,等价于:
<input :value="searchQuery" @input="searchQuery = $event.target.value" />
后端没有直接类比——Thymeleaf 是单向模板渲染,没有"输入变化自动回写变量"这回事。这是 Vue / 前端 MVVM 框架的核心卖点之一。
现场感受 v-model 的修饰符
对比 .lazy / .trim / .number 的差别:
<script setup>
import { ref } from 'vue'
const live = ref('')
const lazy = ref('')
const trimmed = ref('')
const ageNum = ref(0)
</script>
<template>
<div class="box">
<label>默认(input 事件)<input v-model="live" /></label>
<p>值:「{{ live }}」</p>
<label>.lazy(change 事件,失焦才同步)<input v-model.lazy="lazy" /></label>
<p>值:「{{ lazy }}」</p>
<label>.trim(去首尾空格)<input v-model.trim="trimmed" placeholder="前后打几个空格" /></label>
<p>长度:{{ trimmed.length }}</p>
<label>.number(自动转 number)<input v-model.number="ageNum" /></label>
<p>typeof = {{ typeof ageNum }},值 = {{ ageNum }}</p>
</div>
</template>
<style scoped>
.box { padding: 20px; font-family: system-ui; line-height: 1.7; }
label { display: block; margin-top: 6px; }
input { margin-left: 8px; padding: 4px 8px; }
p { margin: 2px 0 8px; color: #475569; }
</style>
首页的搜索框就是一行 v-model="searchQuery" 完成的,Java 视角等价于:
// 伪代码:输入框的值和字段自动保持同步
@TwoWayBind
private String searchQuery;
7. v-html / v-text:较少用
<!-- v-text 等价于 {{ }},不转义也安全(文本节点)-->
<span v-text="message"></span>
<!-- v-html:渲染原始 HTML,XSS 风险自担 -->
<div v-html="note.html"></div>
v-html 只在信任数据源时用(比如自己写的 Markdown → HTML)。笔记详情页的 MarkdownRenderer 里就用了 v-html,因为内容是我们自己写的 Markdown 经 markdown-it + Shiki 渲染,没有用户输入。
速查表
| 指令 | 用途 | Thymeleaf 对照 |
|---|---|---|
{{ x }} |
文本插值 | th:text |
v-bind:foo / :foo |
绑定属性 | th:attr / th:foo |
v-on:click / @click |
事件监听 | (无直接对应) |
v-if / v-else |
条件渲染(真增删) | th:if / th:unless |
v-show |
条件显示(切 display) | (无直接对应) |
v-for |
列表渲染 | th:each |
v-model |
表单双向绑定 | (无,Thymeleaf 只读) |
v-html |
渲染原始 HTML | th:utext |
8. 原理深挖:模板不是字符串拼接,是被编译成渲染函数
Vue 模板看着像 HTML,实际上不是——它在构建期被 Vue 编译器转成纯 JS 渲染函数。
<template>
<div class="card" :class="{ active }" @click="onClick">
{{ title }}
<p v-if="show">{{ desc }}</p>
</div>
</template>
编译输出(简化版):
import { createElementBlock as _createBlock, openBlock as _openBlock,
normalizeClass as _normalizeClass, toDisplayString as _toDisplayString,
createCommentVNode as _createCommentVNode } from 'vue'
export function render(_ctx, _cache) {
return (_openBlock(), _createBlock('div', {
class: _normalizeClass(['card', { active: _ctx.active }]),
onClick: _ctx.onClick
}, [
_toDisplayString(_ctx.title),
_ctx.show
? _createBlock('p', null, _toDisplayString(_ctx.desc), 1 /* TEXT */)
: _createCommentVNode('v-if', true)
], 10 /* CLASS, PROPS */, ['onClick']))
}
关键点:
- 模板和
<script setup>在编译期合体——_ctx.active直接对应script里的 ref v-if不成立时渲染一个注释节点占位(不是真的不存在)- 末尾的
10/['onClick']是 PatchFlag 和 props 数组——下一节讲
Java 对照:和 Thymeleaf 完全不一样:
| 维度 | Thymeleaf | Vue 模板 |
|---|---|---|
| 编译时机 | 服务器请求时 | 前端构建时(vite build) |
| 编译产物 | 字符串 HTML | JS 渲染函数 |
| 数据变化 | 重新发请求 → 重新渲染整页 | 渲染函数重跑 → 局部 patch DOM |
| 是否需要 JS | 不需要 | 必须 |
调试技巧:Vue Template Explorer 在线粘模板 → 右边实时显示编译产物。看不懂某个指令的行为时,丢进去看一眼永远比文档讲得清楚。
9. 原理深挖:虚拟 DOM 和 Diff 算法
渲染函数不直接生产真 DOM,先生产虚拟 DOM(VNode)——一个描述 DOM 的纯 JS 对象树:
// 一个 VNode 大概长这样
{
type: 'div', // 标签名(或组件对象)
props: { class: 'card', onClick: fn },
children: [
{ type: 'p', children: 'hello' }
],
el: <真实 DOM 引用>, // 第一次 mount 后才有
patchFlag: 10, // 编译期标记,diff 用
}
为什么要 VNode:
- 跨平台——同一份 VNode 可以渲染到浏览器 DOM、Canvas、Native(Weex)、PDF
- 可以做 diff——新旧两棵 VNode 树对比,算出最小的 DOM 操作集合
- 批量更新——多次响应式变化先合并到下一个微任务,最后一次性 patch
Diff 的核心算法(v-for 列表场景,简化版):
旧: [A, B, C, D]
新: [A, C, B, D]
↑同
↑动
↑动
↑同
1. 头尾双指针扫描相同的部分(A 头同、D 尾同)
2. 中间剩余部分用 key 建一个 Map(C → 索引、B → 索引)
3. 遍历新列表中间部分,用 Map 找到旧位置 → 复用 DOM
4. 算出"最长递增子序列"作为不动的锚点
5. 其他元素 insertBefore 到正确位置
这就是为什么 :key 关键——key 是 diff 算法识别"同一个元素"的唯一手段。用 index 当 key 时,列表顺序变化后 key 跟着位置走,diff 认为所有 key 都没变 → 复用所有 DOM 但内容重新赋值 → 输入框光标乱跳、动画错位。
Java 对照:类似 Hibernate 的 dirty checking——不一定是字段对字段比,而是按 ID 索引对象、按字段对比变化。但 Vue 的 diff 是树形结构的、要计算最小移动操作,比 Hibernate 复杂。
10. 原理深挖:编译期优化(PatchFlag、静态提升、Block Tree)
Vue 3 相比 Vue 2 的最大革命:不再做"全量树 diff",只 diff 动态部分。靠的是编译器把信息塞进渲染函数。
10.1 PatchFlag
每个 VNode 有个 patchFlag 数字:
// 摘自 packages/shared/src/patchFlags.ts
TEXT = 1 // 文本动态
CLASS = 2 // class 动态
STYLE = 4 // style 动态
PROPS = 8 // 普通 props 动态
FULL_PROPS = 16 // 有 key 是动态的(必须全量对比)
HYDRATE_EVENTS = 32
STABLE_FRAGMENT = 64
KEYED_FRAGMENT = 128
UNKEYED_FRAGMENT = 256
NEED_PATCH = 512
DYNAMIC_SLOTS = 1024
HOISTED = -1 // 静态提升
回到第 8 节的例子,<div :class="{ active }" @click="..."> 的 patchFlag 是 2 | 8 = 10(CLASS + PROPS)。
diff 时只看这些标记的字段——别的字段编译期已知不会变,跳过。这把 diff 复杂度从 O(n) 节点 × m 字段降成 O(n) 节点 × 仅动态字段。
10.2 静态提升(Hoisting)
<template>
<div>
<h1>静态标题</h1> <!-- 永远不变 -->
<p>{{ msg }}</p> <!-- 动态 -->
</div>
</template>
编译产物:
const _hoisted_1 = createVNode('h1', null, '静态标题') // 提到模块顶层
function render(_ctx) {
return createBlock('div', null, [
_hoisted_1, // 直接复用,不重新创建 VNode
createVNode('p', null, _toDisplayString(_ctx.msg), 1 /* TEXT */)
])
}
每次重渲染时 _hoisted_1 都是同一个 VNode 对象引用——diff 阶段直接 === 命中相等,连内部都不看,跳得最干净。
10.3 Block Tree(块树)
VNode 树本来需要递归遍历找动态节点。Vue 3 编译器额外维护一个 dynamicChildren 数组,把所有动态后代直接平铺:
传统 VNode 树: Block Tree:
div div (block)
/ | \ ├─ dynamicChildren: [
h1 p ul p (动态文本),
\ / | \ ul (子 block),
li li li (静态 h1 不在这)
]
diff 时绕过整棵树,直接在 dynamicChildren 数组里挨个比——树多深都不影响性能,只看实际动态的那几个节点。
实战影响:手写 h() 函数(render function)的代码享受不到这些优化——所以模板写法不仅不慢,反而更快。这点和 React 反过来。
11. 原理深挖:v-model 的脱糖规则
第 6 节说 v-model 是语法糖。完整规则:
<!-- 原始组件 v-model(Vue 3 默认绑 modelValue) -->
<MyInput v-model="text" />
<!-- 等价于 -->
<MyInput :modelValue="text" @update:modelValue="text = $event" />
<!-- 命名 v-model -->
<MyInput v-model:title="title" v-model:content="content" />
<!-- 等价于 -->
<MyInput
:title="title" @update:title="title = $event"
:content="content" @update:content="content = $event"
/>
<!-- 原生表单元素的 v-model 会按元素类型自动选择事件 -->
<input v-model="text" /> <!-- → :value + @input -->
<input type="checkbox" v-model="x" /> <!-- → :checked + @change -->
<select v-model="x" /> <!-- → :value + @change -->
自定义组件实现 v-model:
<!-- MyInput.vue -->
<script setup>
const props = defineProps<{ modelValue: string }>()
const emit = defineEmits<{ 'update:modelValue': [v: string] }>()
</script>
<template>
<input
:value="modelValue"
@input="emit('update:modelValue', $event.target.value)"
/>
</template>
Vue 3.4+ 提供 defineModel() 简化:
<script setup>
const model = defineModel<string>()
</script>
<template>
<input v-model="model" />
</template>
defineModel 内部就是上面那一坨 props + emit 的简写。Java 对照:类似 Lombok 的 @Data 自动生成 getter/setter——纯编译期魔法。
12. 原理深挖:v-if 和 v-show 的真实代价
v-if 的代价:
- 切到 false 时,子组件 整个销毁(unmount → 触发 onUnmounted → 清理事件 → 移除 DOM)
- 切回 true 时,子组件 重新创建(setup 重跑、ref 重置、onMounted 再次触发)
v-show 的代价:
- 始终渲染,初次挂载时和真渲染一样耗时
- 切换只是改一行 CSS(
display: none / ''),便宜
选择标准(带数据):
| 场景 | 推荐 | 原因 |
|---|---|---|
| 组件首屏不显示,几乎不切换 | v-if | 省首屏渲染 |
| 切换频率 > 1 次/秒(如 tab) | v-show | 省 mount/unmount |
| 子组件有 onMounted 拉数据 | 看意图 | v-if 每次切都重拉;v-show 只拉一次 |
| 重型组件(编辑器/图表) | v-if + KeepAlive | mount 慢,但不能一直留着;KeepAlive 缓存实例 |
KeepAlive 是第三选项:v-if 切走的时候不真销毁,只是从 DOM 里抽出来缓存,再切回时跳过 setup → 直接复用:
<KeepAlive>
<component :is="currentTab" />
</KeepAlive>
被缓存的组件不走 onMounted/onUnmounted,走 onActivated/onDeactivated。
13. 原理深挖:事件修饰符的脱糖
<button @click.stop.prevent="handler">
编译后:
onClickModifiers = withModifiers(handler, ['stop', 'prevent'])
// withModifiers 内部
function withModifiers(fn, modifiers) {
return (event) => {
if (modifiers.includes('stop')) event.stopPropagation()
if (modifiers.includes('prevent')) event.preventDefault()
if (modifiers.includes('self') && event.target !== event.currentTarget) return
return fn(event)
}
}
按键修饰符 @keyup.enter 的脱糖:
withKeys(handler, ['enter'])
// 内部检查 event.key === 'Enter' 才调 handler
为什么不直接在 handler 里写 if (e.key === 'Enter'):
- 模板里更短,意图更明显
- 编译器可以做静态分析(比如这个事件没有副作用、可以缓存)
- 不同框架/不同浏览器的"Enter 是 13 还是 'Enter'"差异编译器统一处理
Java 对照:类似 Spring 的 @RequestMapping(method = POST) 注解——用注解声明意图,框架在底层做过滤。
14. 心智模型:模板 → 渲染函数 → VNode → DOM 全流程
你写的 .vue 文件
│
│ vite build / vite dev (编译器: @vue/compiler-sfc)
▼
JS 渲染函数 + 编译期优化标记
│
│ 组件首次挂载
▼
渲染函数() → VNode 树 (描述应该长什么样)
│
│ patch(null, vnode) —— 首次 mount
▼
真实 DOM (浏览器树上的节点)
│
│ 响应式依赖变化触发渲染函数重跑
▼
新 VNode 树
│
│ patch(oldVNode, newVNode) —— diff + 最小化操作
▼
DOM 增量更新
Java 对照:类似 JSP → Servlet → HttpServletResponse → 浏览器,但区别在 Vue 整个流程在浏览器里循环跑,每次响应式变化都走一遍后两步。
小练习:在 in4vue 里观察
- 打开
src/pages/HomePage.vue,找出所有用到的指令,对照速查表能一一说出作用 - 在
filteredNotes外层尝试把v-for的:key从note.slug改成index,搜索时观察卡片有没有奇怪闪烁(小列表可能看不出来,但原理要记住) - 把搜索框的
v-model="searchQuery"改成v-model.lazy="searchQuery",体会"边打字边筛" vs "失焦才筛"的差异