模板语法与指令:Vue 的"视图层注解"

先立一个心智模型

后端开发者看 Vue 模板时,最容易把它和 Thymeleaf / JSP / FreeMarker 画等号。这个类比一半对一半错

一句话:

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>

关键点:

安全提醒:想渲染原始 HTML 得用 v-html,这等于 Thymeleaf 的 th:utextXSS 风险自担


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 对照

推荐写法:所有项目统一用简写(:@),别混用。


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-show,极少切换用 v-ifv-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 而不是重建。

规则

反例(会引发奇怪 bug):

<!-- ❌ 列表顺序变化时,输入框里的值会"错位" -->
<div v-for="(item, i) in items" :key="i">
  <input v-model="item.text" />
</div>

不要在同一个标签上同时写 v-forv-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']))
}

关键点

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

  1. 跨平台——同一份 VNode 可以渲染到浏览器 DOM、Canvas、Native(Weex)、PDF
  2. 可以做 diff——新旧两棵 VNode 树对比,算出最小的 DOM 操作集合
  3. 批量更新——多次响应式变化先合并到下一个微任务,最后一次性 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 的代价:

v-show 的代价:

选择标准(带数据)

场景 推荐 原因
组件首屏不显示,几乎不切换 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')

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 里观察

  1. 打开 src/pages/HomePage.vue,找出所有用到的指令,对照速查表能一一说出作用
  2. filteredNotes 外层尝试把 v-for:keynote.slug 改成 index,搜索时观察卡片有没有奇怪闪烁(小列表可能看不出来,但原理要记住)
  3. 把搜索框的 v-model="searchQuery" 改成 v-model.lazy="searchQuery",体会"边打字边筛" vs "失焦才筛"的差异

延伸阅读