模板语法与指令: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 的首渲染成本高。
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="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 |
小练习:在 in4vue 里观察
- 打开
src/pages/HomePage.vue,找出所有用到的指令,对照速查表能一一说出作用 - 在
filteredNotes外层尝试把v-for的:key从note.slug改成index,搜索时观察卡片有没有奇怪闪烁(小列表可能看不出来,但原理要记住) - 把搜索框的
v-model="searchQuery"改成v-model.lazy="searchQuery",体会"边打字边筛" vs "失焦才筛"的差异