Vue 基础体系 · 第 3/70 篇。示例基于 Vue 3、Composition API、TypeScript 与现代 Vite 工具链;版本敏感能力会单独标注。

Vue 模板与渲染:指令、表达式、列表、条件和 Virtual DOM

Vue 模板不是“把字符串替换成 HTML”的模板语言。它描述的是一个函数:给定当前状态,计算出组件应当呈现的界面;当状态变化时,Vue 再计算新旧界面的差异,并把必要的变化应用到真实 DOM。理解这条链路,才能解释为什么 v-ifv-show 的成本不同、为什么列表需要 key、为什么修改 ref 后 DOM 不会立即同步,以及为什么模板中不应堆积复杂业务逻辑。

本文示例基于 Vue 3、Composition API、TypeScript 和 Vite。代码默认使用单文件组件(Single-File Component,SFC):

<script setup lang="ts">
import { computed, ref } from 'vue'

const count = ref(0)
const doubled = computed(() => count.value * 2)
</script>

<template>
  <button @click="count++">
    {{ count }} × 2 = {{ doubled }}
  </button>
</template>

<script setup> 中声明的变量会自动暴露给同一组件的模板。脚本中访问 ref 需要 .value,模板中通常会自动解包顶层 ref,因此模板可以直接写 countdoubled


一、从模板到真实 DOM:渲染的完整链路

1. 模板、渲染函数和 Virtual DOM

Vue 模板:

<template>
  <button class="primary" @click="count++">
    {{ count }}
  </button>
</template>

会被编译成等价的渲染函数。概念上类似:

import { createElementVNode, toDisplayString } from 'vue'

function render() {
  return createElementVNode(
    'button',
    {
      class: 'primary',
      onClick: () => count.value++
    },
    toDisplayString(count.value)
  )
}

实际编译结果会包含更多优化标记,并不应把上面的代码当作精确输出。它表达的是三个事实:

  1. 元素类型是 button
  2. 元素属性中包含 class 和事件处理函数。
  3. 子节点依赖当前 count 的值。

渲染函数返回的对象树通常称为 Virtual DOM,简称 VNode。VNode 是描述节点的 JavaScript 数据,而不是浏览器中的真实节点。例如概念上的 VNode 可以表示为:

{
  type: 'button',
  props: {
    class: 'primary',
    onClick: [Function]
  },
  children: '0'
}

Virtual DOM 的作用不是让 DOM 操作“消失”,而是把“如何更新 DOM”的决策交给框架。Vue 可以比较前后两次渲染结果,只执行必要的 DOM 操作。

2. 初次渲染和更新渲染

组件首次挂载时,过程可以简化为:

模板
  ↓ 编译
渲染函数
  ↓ 执行
旧 VNode:无
  ↓ 挂载
真实 DOM

响应式状态发生变化后:

state 改变
  ↓ 触发依赖该 state 的组件更新
重新执行渲染函数
  ↓
新 VNode
  ↓ 与旧 VNode 比较
执行最小必要的 DOM 更新

可以用如下流程表示:

flowchart LR
    T[Vue 模板] --> C[编译为渲染函数]
    C --> R[执行渲染函数]
    R --> V1[当前 VNode]
    V1 --> M[挂载或更新真实 DOM]

    S[响应式状态变化] --> E[触发组件更新]
    E --> R
    V1 --> D[新旧 VNode 对比]
    R --> V2[新 VNode]
    V2 --> D
    D --> P[patch:应用必要 DOM 变化]
    P --> M

这里的“只执行必要变化”有明确边界:Vue 只能根据新旧 VNode 和编译器提供的信息做判断。如果模板中的表达式每次都生成全新的复杂对象,或者列表没有稳定的 key,框架仍然需要进行更多比较,甚至可能错误地复用节点。

3. 响应式依赖如何进入渲染过程

渲染函数执行期间读取了哪些响应式值,Vue 就会记录哪些依赖。例如:

const count = ref(0)
const visible = ref(true)
<template>
  <p v-if="visible">{{ count }}</p>
</template>

当组件渲染时:

  • visible 被读取,因此它是当前渲染结果的依赖;
  • count 只有在 visible 为真时才会被读取;
  • 修改 visible 会触发组件重新渲染;
  • 修改 count 是否触发更新,取决于当前分支是否已经执行并读取了它。

这不是说 Vue 会为每个表达式单独创建一个 DOM 更新函数;通常是组件级别的渲染副作用重新执行,再由 Virtual DOM 和编译优化决定实际更新范围。

相关的 refreactivecomputed 和依赖追踪机制,是模板重新渲染的基础:

const price = ref(100)
const quantity = ref(2)

const total = computed(() => price.value * quantity.value)
<template>
  <span>{{ total }}</span>
</template>

total 只在模板中读取,模板依赖 totaltotal 又依赖 pricequantity。当任一输入变化时,计算属性失效并在下次读取时重新计算,进而触发使用它的视图更新。


二、插值和模板表达式

1. 文本插值

双大括号表示文本插值:

<p>Hello, {{ username }}</p>

如果 username 为:

const username = ref('<Admin>')

浏览器中显示的是:

Hello, <Admin>

而不是把 <Admin> 当作 HTML 标签插入。Vue 默认会对插值结果进行 HTML 转义,这是安全的默认行为。

插值可以包含表达式:

<p>{{ firstName + ' ' + lastName }}</p>
<p>{{ isOnline ? '在线' : '离线' }}</p>
<p>{{ price.toFixed(2) }}</p>

表达式的结果会被转换为文本。常见结果包括:

  • nullundefined 通常不渲染文本;
  • 字符串直接显示;
  • 数字转换为文本;
  • 布尔值通常会显示其字符串形式;
  • 对象不应直接依赖默认字符串化结果,应显式选择字段或使用 JSON.stringify 进行调试。

2. 什么是模板表达式

模板表达式是放在插值或指令值中的单个 JavaScript 表达式,例如:

{{ user.name }}
{{ items.length }}
{{ ok ? 'yes' : 'no' }}

表达式和语句不同。以下内容不是合法的模板表达式:

<!-- 错误:赋值语句 -->
{{ count = 10 }}

<!-- 错误:if 语句 -->
{{ if (ok) 'yes' }}

<!-- 错误:声明语句 -->
{{ const value = 1 }}

模板表达式在组件渲染上下文中执行。组件实例公开的数据、计算属性、方法和部分全局对象可以访问,但不能假设浏览器环境中的任意全局变量都可用。Vue 会限制可访问的全局对象集合,以降低模板执行任意代码的风险;例如 MathDate 等常见对象通常可用,但不应把模板当作任意 JavaScript 执行环境。

复杂逻辑应移到 computed 或方法中:

const firstName = ref('Ada')
const lastName = ref('Lovelace')

const fullName = computed(() => {
  return `${firstName.value} ${lastName.value}`
})
<template>
  <span>{{ fullName }}</span>
</template>

这样做不是因为模板表达式不能写得复杂,而是因为 computed 能明确表达“由哪些状态推导出什么结果”,并具备缓存语义。模板中的复杂表达式通常会在每次组件重新渲染时重新执行,难以测试,也容易混入副作用。

3. 模板表达式不应有副作用

以下写法虽然某些情况下可能执行,但不应使用:

<!-- 不应在渲染期间修改状态 -->
<p>{{ count++ }}</p>

渲染的正确模型是:

当前状态 → 计算界面

而不是:

当前状态 → 计算界面,同时修改状态 → 再次触发渲染

如果渲染期间修改依赖自身的状态,可能造成重复更新、无限更新或结果依赖执行顺序。网络请求、写入本地存储、修改其他状态等副作用,应放到事件处理器、watch、生命周期钩子或专门的业务函数中。


三、指令:把声明性条件映射为 DOM 行为

指令是带有 v- 前缀的特殊属性。它告诉 Vue:根据表达式的值,对元素、组件或子树执行某种行为。

<div v-if="visible">内容</div>

可以抽象为:

如果 visible 为真,则保留或创建该节点;
否则移除或不创建该节点。

指令通常包含:

v-name:argument.modifier="expression"

例如:

<a
  v-bind:href="url"
  v-on:click.prevent="save"
>
  保存
</a>

其中:

  • v-bind 是指令名称;
  • href 是参数;
  • prevent 是修饰符;
  • urlsave 是指令表达式。

Vue 提供了常用缩写:

<!-- v-bind:href -->
<a :href="url">链接</a>

<!-- v-on:click -->
<button @click="save">保存</button>

<!-- v-slot -->
<template #header>标题</template>

1. v-bind:绑定属性和 DOM Property

静态属性:

<img src="/logo.svg" alt="Logo">

动态绑定:

<img :src="imageUrl" :alt="imageAlt">

下面两者含义不同:

<div class="box"></div>
<div :class="className"></div>

第一个是固定字符串 box,第二个会根据 className 的当前值更新。

布尔属性

对于 disabledchecked 等布尔属性,动态绑定应传入布尔值:

<script setup lang="ts">
import { ref } from 'vue'

const submitting = ref(false)
</script>

<template>
  <button :disabled="submitting">
    提交
  </button>
</template>

submittingtrue 时按钮禁用;为 false 时,Vue 会移除或取消对应的禁用状态。不要把它误写成字符串:

<!-- 这不是推荐写法:表达的是字符串 -->
<button disabled="false">提交</button>

HTML 对布尔属性的判断主要看属性是否存在,因此 disabled="false" 仍可能表示禁用。

对象和数组形式的 class

<script setup lang="ts">
import { computed, ref } from 'vue'

const active = ref(true)
const error = ref(false)

const classObject = computed(() => ({
  active: active.value,
  error: error.value
}))
</script>

<template>
  <div :class="classObject">内容</div>
</template>

等价的数组形式:

<div :class="[
  'panel',
  active ? 'panel-active' : 'panel-inactive',
  { error }
]"></div>

数组和对象最终会被 Vue 规范化为 class 字符串。它们适合表达“状态到样式”的映射,不应把一大段字符串拼接逻辑塞进模板。

style 绑定

<script setup lang="ts">
import { ref } from 'vue'

const color = ref('tomato')
const fontSize = ref(18)
</script>

<template>
  <p :style="{ color, fontSize: `${fontSize}px` }">
    动态文本
  </p>
</template>

Vue 会处理部分样式名转换,并对多重浏览器前缀进行兼容尝试,但它不会替代 CSS 的布局和级联规则。复杂响应式样式通常应通过 class 表达,避免在模板中产生大量内联样式。

v-bind 的动态参数

参数也可以是动态的:

<script setup lang="ts">
import { ref } from 'vue'

const attributeName = ref('title')
const attributeValue = ref('提示文本')
</script>

<template>
  <button :[attributeName]="attributeValue">
    悬停查看
  </button>
</template>

动态参数必须最终解析为合法的属性名。复杂表达式、包含空格的名称或需要特殊处理的属性并不适合直接放入动态参数中。

对象展开绑定

Vue 3 支持将对象的多个属性绑定到元素:

const inputAttrs = {
  id: 'email',
  name: 'email',
  placeholder: '请输入邮箱'
}
<input v-bind="inputAttrs">

它大致等价于:

<input
  :id="inputAttrs.id"
  :name="inputAttrs.name"
  :placeholder="inputAttrs.placeholder"
>

当对象包含事件监听器、classstyle 或组件属性时,仍需明确理解这些属性最终会落在哪里,尤其是组件的透传属性和 inheritAttrs 行为。

2. v-on:绑定事件

基础写法:

<button @click="increment">增加</button>
function increment() {
  count.value++
}

也可以直接使用内联处理器:

<button @click="count++">增加</button>

如果需要事件对象:

<button @click="handleClick">点击</button>
function handleClick(event: MouseEvent) {
  console.log(event.currentTarget)
}

Vue 会把原生事件对象传给方法处理器。

事件修饰符

常用修饰符及其语义:

<form @submit.prevent="submit">
  ...
</form>

<div @click.stop="onClick">
  ...
</div>

<button @click.once="initialize">
  初始化
</button>

<div @click.self="close">
  只有点击当前元素本身才关闭
</div>
  • .prevent 调用 event.preventDefault()
  • .stop 调用 event.stopPropagation()
  • .once 让监听器只执行一次;
  • .self 只在事件目标是当前元素本身时触发。

键盘修饰符:

<input @keyup.enter="submit">
<input @keyup.esc="cancel">

鼠标按键修饰符:

<button @click.right="openMenu">右键</button>

修饰符是事件处理策略的声明式表达,但不是所有事件都适合阻止默认行为。例如在滚动相关事件上使用 .prevent,可能影响浏览器的滚动性能和行为;应结合具体交互验证。

3. v-model:表单状态的双向语法糖

v-model 将“把状态传给控件”和“监听控件变化后写回状态”组合起来:

<script setup lang="ts">
import { ref } from 'vue'

const email = ref('')
const accepted = ref(false)
const role = ref('user')
</script>

<template>
  <input v-model.trim="email" type="email">
  <input v-model="accepted" type="checkbox">

  <select v-model="role">
    <option value="user">用户</option>
    <option value="admin">管理员</option>
  </select>
</template>

常见修饰符:

  • .trim:去除文本输入首尾空白;
  • .number:尝试将输入转换为数字;
  • .lazy:改为监听 change,而不是默认的 input

v-model 不是无条件的双向数据绑定。它建立在明确的状态和事件协议上。对于原生输入,Vue 知道如何读写 valuechecked 等属性;对于组件,Vue 3 默认使用 modelValue 属性和 update:modelValue 事件:

<!-- 父组件 -->
<CustomInput v-model="name" />

概念上等价于:

<CustomInput
  :model-value="name"
  @update:model-value="name = $event"
/>

组件实现:

<script setup lang="ts">
defineProps<{
  modelValue: string
}>()

const emit = defineEmits<{
  'update:modelValue': [value: string]
}>()
</script>

<template>
  <input
    :value="modelValue"
    @input="emit(
      'update:modelValue',
      ($event.target as HTMLInputElement).value
    )"
  >
</template>

这体现了 Vue 的单向数据流:父组件持有状态,子组件通过事件请求父组件更新,而不是直接修改传入的 prop。


四、条件渲染:v-ifv-else-ifv-elsev-show

1. v-if 是结构条件

<p v-if="status === 'loading'">加载中</p>
<p v-else-if="status === 'error'">加载失败</p>
<p v-else>加载完成</p>

v-if 控制的是子树是否存在:

条件为真:
  创建并挂载节点

条件为假:
  卸载节点,移除真实 DOM,并停止其相关组件和副作用

因此,当条件频繁切换时,v-if 可能反复创建和销毁子树:

<ExpensivePanel v-if="visible" />

如果 ExpensivePanel 内部有输入状态、定时器或订阅,它的卸载和重新挂载会影响这些状态。组件是否保留状态,不应靠猜测,应该根据实际生命周期设计;需要缓存时可以考虑 <KeepAlive>,但它改变的是组件停用与激活语义,并非简单的 v-show

2. v-show 是样式条件

<div v-show="visible">面板</div>

v-show 通常在初次渲染时创建节点,之后通过切换 display 样式控制可见性:

visible = true  → 显示
visible = false → display: none

因此:

  • 初次渲染成本通常高于完全不创建的 v-if
  • 频繁切换时通常只需要修改样式;
  • 子组件不会因为 v-show 切换而被卸载;
  • 隐藏元素仍存在于 DOM 中,通常也仍保留内存和组件状态。

选择条件是资源生命周期问题,而不是单纯的编码风格:

很少显示、创建昂贵、显示时才需要存在 → v-if
频繁切换、希望保留 DOM 和组件状态    → v-show

这不是绝对的性能定律。真正成本还取决于子树规模、组件副作用、布局影响和切换频率。

3. v-ifv-for 的优先级

在 Vue 3 中,同一元素上的 v-if 优先于 v-for。因此下面的写法无法访问 item

<!-- 错误或不符合预期:item 在 v-if 表达式中不可用 -->
<li v-for="item in items" v-if="item.visible">
  {{ item.name }}
</li>

正确写法是先过滤数据:

const visibleItems = computed(() => {
  return items.value.filter(item => item.visible)
})
<li v-for="item in visibleItems" :key="item.id">
  {{ item.name }}
</li>

或者用 template 包裹循环:

<template v-for="item in items" :key="item.id">
  <li v-if="item.visible">
    {{ item.name }}
  </li>
</template>

第一种方式适合“可见列表”本身就是一个可复用的派生状态;第二种方式适合条件与列表项渲染紧密相关的情况。无论哪种方式,key 都应放在真正由 v-for 生成的结构上。


五、列表渲染:v-for、作用域和 key

1. v-for 的数据来源

数组列表:

type User = {
  id: number
  name: string
}

const users = ref<User[]>([
  { id: 1, name: 'Ada' },
  { id: 2, name: 'Grace' }
])
<ul>
  <li v-for="user in users" :key="user.id">
    {{ user.name }}
  </li>
</ul>

user 是当前循环项的局部变量,只在该模板子树中可用。

可以同时取得索引:

<li v-for="(user, index) in users" :key="user.id">
  {{ index + 1 }}. {{ user.name }}
</li>

对象遍历:

const profile = {
  name: 'Ada',
  role: 'Engineer'
}
<div v-for="(value, key, index) in profile" :key="key">
  {{ index }} - {{ key }}: {{ value }}
</div>

数字范围:

<span v-for="n in 5" :key="n">{{ n }}</span>

这会生成 15,而不是从 0 开始。

2. key 的真实含义

key 是列表节点的身份标识。Vue 使用它判断“这次渲染中的某个节点”是否对应“上次渲染中的某个节点”。

设旧列表为:

旧:A B C
新:X A B C

如果节点有稳定 key:

旧 key:a b c
新 key:x a b c

Vue 能识别出:

  • a 仍是原来的 A;
  • b 仍是原来的 B;
  • c 仍是原来的 C;
  • 只需创建并插入 x

如果没有 key,Vue 会更多地依赖位置匹配。对于纯文本列表,结果可能看起来仍然正确;但对于包含输入框、组件内部状态或 DOM 状态的列表,位置复用会导致可见错误。

3. 反例:使用索引作为 key

<li v-for="(item, index) in items" :key="index">
  <input v-model="item.name">
</li>

旧数据:

index 0 → A
index 1 → B

在头部插入 X 后:

index 0 → X
index 1 → A
index 2 → B

如果 key 是索引,Vue 可能认为:

旧节点 0 仍对应新节点 0
旧节点 1 仍对应新节点 1

于是原本绑定 A 的输入 DOM 可能被复用于 X,原本绑定 B 的 DOM 可能被复用于 A。数据文本可能最终被更新,但输入焦点、光标位置、原生 DOM 状态或子组件内部状态可能表现异常。

因此:

  • 静态、不会重排的简单展示列表,索引 key 有时不会立即暴露问题;
  • 会插入、删除、排序、过滤或包含有状态子节点的列表,应使用稳定且唯一的业务 ID;
  • 随机生成 key 也不正确,因为每次渲染都会让 Vue 认为节点全部换了。

4. key 不只是列表专用

key 也可以强制两个同类型节点被视为不同实例:

<component :is="currentComponent" :key="currentComponent" />

或重置子组件:

<Editor :key="documentId" :document="document" />

documentId 改变时,Vue 会卸载旧的 Editor 并挂载新的实例。这是明确的身份切换,而不是普通属性更新。使用它会丢失子组件内部状态,因此必须确认这种重置正是需求。


六、template:组织条件和循环而不增加 DOM

template 元素是 Vue 的模板分组容器,通常不会渲染成真实 DOM:

<template v-if="loggedIn">
  <nav>导航</nav>
  <main>内容</main>
</template>

条件成立时,navmain 会被渲染;不存在一个额外的 template DOM 节点。

循环也可以使用:

<template v-for="item in items" :key="item.id">
  <h3>{{ item.title }}</h3>
  <p>{{ item.description }}</p>
</template>

这比为了分组而增加一个无意义的 <div> 更准确,因为额外容器可能改变 CSS flex/grid 布局、语义结构和可访问性。

template 不能在任意位置替代真实元素。它主要用于:

  • 同时控制多个兄弟节点;
  • 不增加 DOM 层级地进行循环;
  • 定义插槽内容;
  • 配合条件渲染组织模板。

七、插槽、组件和模板边界

模板不仅描述原生 DOM,也描述组件树:

<UserCard :user="user">
  <template #actions>
    <button @click="removeUser">删除</button>
  </template>
</UserCard>

父组件提供:

  • user prop;
  • actions 插槽内容;
  • removeUser 事件处理逻辑。

子组件决定插槽渲染位置:

<template>
  <article>
    <h2>{{ user.name }}</h2>
    <div class="actions">
      <slot name="actions" />
    </div>
  </article>
</template>

插槽内容的作用域属于父组件,而不是子组件:

<!-- 父组件的 user 可以在这里访问 -->
<UserCard>
  {{ user.name }}
</UserCard>

子组件内部的数据不能直接被父组件插槽模板访问。若需要子组件向插槽提供数据,应使用作用域插槽:

<!-- 子组件 -->
<slot :item="item" />
<!-- 父组件 -->
<List v-slot="{ item }">
  {{ item.name }}
</List>

这条边界很重要:模板的视觉嵌套不等同于 JavaScript 作用域或数据所有权。Props 负责父到子的输入,Emits 负责子到父的事件通知,插槽负责父组件向子组件提供可插入的视图内容。


八、Virtual DOM 的比较与更新

1. 节点匹配的基本条件

为了复用旧节点,Vue 需要判断新旧 VNode 是否代表同一个节点。概念上,匹配通常至少受到以下因素影响:

节点类型相同
且 key 相同

可以写成一个简化条件:

same(a,b)=(type(a)=type(b))(key(a)=key(b))same(a,b) = (type(a)=type(b)) \land (key(a)=key(b))

其中:

  • ab 是旧、新 VNode;
  • type 可以是原生标签、组件或其他 VNode 类型;
  • key 是节点身份;
  • same = true 表示有机会复用节点并进行属性、子节点更新;
  • same = false 通常意味着卸载旧节点并创建新节点。

这个公式是理解行为的模型,不是 Vue 内部全部实现细节。

例如:

<input v-if="mode === 'email'" key="email" type="email">
<input v-else key="search" type="search">

即使两个节点都是 input,不同的 key 也会让 Vue 将它们视为不同身份。切换时旧输入被卸载,新输入被创建,输入值和焦点不会按普通更新方式保留。

2. 属性更新

旧 VNode:

{
  type: 'button',
  props: { class: 'normal', disabled: false }
}

新 VNode:

{
  type: 'button',
  props: { class: 'danger', disabled: true }
}

Vue 不需要重建整个按钮,概念上的 patch 过程是:

class:    normal → danger
disabled: false  → true
children: 未变化

因此 DOM 操作可能只是:

element.className = 'danger'
element.disabled = true

真实实现会区分 attribute、DOM property、事件监听器、class、style 等类型,并处理它们各自的更新规则。

3. 子节点更新

文本子节点:

旧:Hello
新:World

只需修改文本节点。

同构列表:

旧:A B C
新:A C

若 key 分别为 abc,Vue 可以识别出:

a 保留
b 删除
c 保留并移动或保持位置

带 key 的列表更新需要处理新增、删除和移动。Vue 3 对常见 keyed children 场景使用优化的比较策略,包括寻找可复用节点和减少移动;但不应将其简化成“任何变化都是 O(1)”或“Virtual DOM 总是比手写 DOM 快”。性能取决于节点规模、变化模式、编译优化、响应式更新范围和浏览器布局成本。

4. 编译器优化

Vue 3 的模板编译器会对静态和动态部分进行分析。例如:

<div>
  <h1>固定标题</h1>
  <p>{{ message }}</p>
</div>

h1 的内容和结构是静态的,p 的文本依赖 message。编译器可以标记动态部分,使运行时减少不必要的比较。

这解释了一个常见误区:Vue 的性能并不只来自“Virtual DOM 比较”。现代 Vue 还结合了:

  • 静态节点提升;
  • 动态节点标记;
  • 动态子树收集;
  • 组件更新边界;
  • 编译器对模板结构的预分析。

这些属于 Vue 的常见实现机制,不应依赖未公开的内部字段或假定某个优化在所有动态写法中都成立。生产代码应依赖公开模板和 API 语义,而不是手动构造内部 patch 标记。


九、一次可运行的端到端示例

下面的组件包含响应式状态、计算属性、条件渲染、列表渲染、事件、表单绑定和批量 DOM 更新后的读取。

<script setup lang="ts">
import { computed, nextTick, ref } from 'vue'

type Task = {
  id: number
  title: string
  done: boolean
}

const nextId = ref(3)
const draft = ref('')
const filter = ref<'all' | 'active' | 'done'>('all')
const tasks = ref<Task[]>([
  { id: 1, title: '理解模板表达式', done: true },
  { id: 2, title: '练习列表 key', done: false }
])

const visibleTasks = computed(() => {
  switch (filter.value) {
    case 'active':
      return tasks.value.filter(task => !task.done)
    case 'done':
      return tasks.value.filter(task => task.done)
    default:
      return tasks.value
  }
})

const remainingCount = computed(() => {
  return tasks.value.filter(task => !task.done).length
})

function addTask() {
  const title = draft.value.trim()

  if (!title) {
    return
  }

  tasks.value.push({
    id: nextId.value++,
    title,
    done: false
  })

  draft.value = ''
}

function removeTask(id: number) {
  tasks.value = tasks.value.filter(task => task.id !== id)
}

async function addAndInspect() {
  addTask()

  // Vue 会批量调度更新;此时 DOM 可能还未反映 tasks 的新值。
  await nextTick()

  console.log('DOM 已完成本轮更新')
}
</script>

<template>
  <section>
    <h1>任务清单</h1>

    <form @submit.prevent="addAndInspect">
      <input
        v-model.trim="draft"
        type="text"
        placeholder="输入任务"
      >
      <button :disabled="!draft">
        添加
      </button>
    </form>

    <p v-if="remainingCount > 0">
      还有 {{ remainingCount }} 项未完成
    </p>
    <p v-else>
      所有任务已完成
    </p>

    <label>
      筛选:
      <select v-model="filter">
        <option value="all">全部</option>
        <option value="active">未完成</option>
        <option value="done">已完成</option>
      </select>
    </label>

    <ul v-if="visibleTasks.length > 0">
      <li v-for="task in visibleTasks" :key="task.id">
        <label>
          <input v-model="task.done" type="checkbox">
          <span :class="{ done: task.done }">
            {{ task.title }}
          </span>
        </label>

        <button type="button" @click="removeTask(task.id)">
          删除
        </button>
      </li>
    </ul>

    <p v-else>没有匹配的任务</p>
  </section>
</template>

<style scoped>
.done {
  text-decoration: line-through;
  color: #888;
}
</style>

运行前提是已经创建 Vue + TypeScript 项目,例如使用 Vite:

npm create vite@latest vue-template-demo -- --template vue-ts
cd vue-template-demo
npm install
npm run dev

将组件放入 src/App.vue 即可运行。

这个示例的状态变化路径如下:

  1. 用户输入内容,v-model 将输入值写入 draft
  2. draft 变化,模板重新计算 :disabled="!draft"
  3. 提交表单时,.prevent 阻止浏览器默认刷新;
  4. addTask 向响应式数组追加任务;
  5. visibleTasksremainingCount 依赖 tasks,其缓存结果失效;
  6. Vue 调度组件更新;
  7. Virtual DOM 比较新旧列表;
  8. 新任务以其 id 作为 key 被创建;
  9. await nextTick() 等待本轮 DOM 更新完成后继续执行。

如果把 nextIdid 改成随机值,并在每次渲染时重新生成,就会破坏节点身份稳定性。若需要读取新 DOM,不能只依靠“修改状态后代码紧接着执行”这一事实。


十、更新是异步调度的:为什么需要 nextTick

下面的代码并不能保证第二行已经读到新 DOM:

count.value++
console.log(element.textContent)

Vue 通常不会在每一次状态赋值后立即同步执行所有 DOM 操作,而是将更新放入调度队列,并在当前同步任务结束后批量执行。这样连续修改多个状态时,可以合并更新:

count.value++
visible.value = true
items.value.push(newItem)

通常只需进行一次组件更新,而不是为每条赋值都完整更新一次 DOM。

如果逻辑必须在 DOM 更新完成后执行,使用 nextTick

import { nextTick } from 'vue'

count.value++

await nextTick()

// 此时与 count 相关的 DOM 更新通常已经应用

“通常”在这里指 Vue 的公开调度语义:nextTick 用于等待下一轮 DOM 更新完成。它不等同于等待浏览器完成布局、绘制、动画或网络请求。如果要测量布局,仍需考虑浏览器渲染时序,例如在必要时配合 requestAnimationFrame


十一、原始 HTML:v-html 的能力和安全边界

插值不会解析 HTML:

<div>{{ htmlContent }}</div>

如果:

const htmlContent = ref('<strong>加粗</strong>')

页面显示的是文本 <strong>加粗</strong>

v-html 才会将字符串作为 HTML 插入:

<div v-html="trustedHtml"></div>

它适用于来自可信来源、已经经过严格清洗的 HTML。错误示例:

<!-- 不要把任意用户输入直接放入 v-html -->
<div v-html="commentFromUser"></div>

攻击者可能提交包含脚本、事件属性或恶意 URL 的内容,造成 XSS。Vue 默认转义插值是重要的安全边界;一旦使用 v-html,应用必须负责:

  • 判断 HTML 来源是否可信;
  • 在服务端或客户端使用经过审计的 HTML sanitizer;
  • 清除脚本、事件处理属性和危险 URL;
  • 避免将未清洗内容传给第三方富文本渲染器。

v-html 还会绕过 Vue 对内部模板的正常编译流程。插入的 HTML 不会自动获得当前 Vue 模板中的响应式绑定、事件监听或组件能力。


十二、常见错误与诊断路径

1. 把属性绑定写成普通属性

<button disabled="isDisabled">提交</button>

这会得到字符串 "isDisabled",而不是读取变量。应写:

<button :disabled="isDisabled">提交</button>

诊断方法是检查浏览器 DOM 和 Vue Devtools 中的实际值,区分“静态字符串”与“动态表达式”。

2. 忘记 key 或使用不稳定 key

<div v-for="item in items">
  <input v-model="item.name">
</div>

应先检查列表是否会插入、删除、排序或过滤,再检查子节点是否含有输入状态、焦点或组件内部状态。若答案为是,应使用:

<div v-for="item in items" :key="item.id">
  <input v-model="item.name">
</div>

若页面出现输入值“跑到别的行”、焦点错位或组件状态串行,首先检查 key 的稳定性和唯一性。

3. 直接修改 prop

子组件中:

const props = defineProps<{
  value: string
}>()

// 不应这样做:props.value = 'new value'

Props 是父组件传入的输入,子组件直接修改会破坏数据流,也通常会触发 Vue 的只读警告。应通过 emits 通知父组件:

const emit = defineEmits<{
  change: [value: string]
}>()

emit('change', 'new value')

对于双向模型,使用 v-model 对应的 update:modelValue 协议。

4. 期待修改数组索引后 Vue 不响应

Vue 3 的响应式系统基于 Proxy,以下操作通常可以被追踪:

tasks.value.push(task)
tasks.value[0].done = true
tasks.value = tasks.value.filter(...)

但必须注意 ref.value 边界:

const tasks = ref<Task[]>([])

tasks.value.push(task) // 正确
tasks.push(task)       // TypeScript 通常会报错,也不是数组本身

如果使用 reactive

const state = reactive({
  tasks: [] as Task[]
})

则直接访问:

state.tasks.push(task)

refreactive 都能参与模板更新,但它们的访问方式、替换整个对象的能力和解构行为不同。对响应式对象进行解构时,如果直接写:

const { count } = reactiveState

可能丢失响应式连接;需要根据场景使用 toRefs 或保持通过对象访问。

5. 在模板中调用会修改状态的方法

<p>{{ loadData() }}</p>

如果 loadData 内部发起请求或修改状态,那么每次渲染都可能再次触发它,形成重复请求或更新循环。用于显示的函数应是纯函数:

function formatDate(value: string): string {
  return new Date(value).toLocaleDateString()
}

副作用应放在事件处理器或 watch 中:

watch(userId, async (id) => {
  // 根据 id 请求数据,并处理竞态和错误
})

异步 watch 还要处理竞态:旧请求可能晚于新请求返回,覆盖更新后的数据。实际项目中应使用请求取消、递增请求序号或其他明确的失效策略,而不能假定响应顺序与发起顺序一致。


十三、声明性渲染的边界:什么时候不应依赖模板

模板适合表达:

状态 → 界面结构、属性、事件和样式

它不适合承担以下内容:

  • 多步骤业务事务;
  • 复杂数据转换;
  • 网络请求和重试;
  • 权限判断的完整策略;
  • 跨组件状态协调;
  • 需要取消和竞态处理的异步流程;
  • 直接操作第三方 DOM 库。

例如权限展示可以在模板中表达:

<button v-if="canDelete">删除</button>

canDelete 的计算应由明确的权限模型提供,而不是在模板中拼出复杂条件:

const canDelete = computed(() => {
  return user.value?.role === 'admin' &&
    record.value.ownerId === user.value.id &&
    record.value.status !== 'archived'
})

同时,前端条件渲染只是界面层控制,不能替代服务端授权。隐藏按钮并不能阻止用户直接调用接口。


十四、规范保证、实现机制与经验判断的区分

理解 Vue 模板时,应区分三种层次。

规范保证是应用可以依赖的公开语义,例如:

  • v-if 控制条件子树;
  • v-show 通过样式控制显示;
  • v-for 为每个数据项生成模板实例;
  • :key 参与节点身份识别;
  • nextTick 用于等待 Vue 的下一轮 DOM 更新;
  • 插值默认按文本处理,不直接解析 HTML。

常见实现包括:

  • 模板被编译为渲染函数;
  • 渲染函数返回 VNode;
  • 响应式读取建立组件更新依赖;
  • 更新阶段通过 patch 比较新旧 VNode;
  • Vue 3 编译器生成静态提升和动态节点标记。

这些实现解释了性能和行为,但内部函数、VNode 字段和优化标记不应作为业务代码 API。

经验判断则包括:

  • 频繁切换的面板通常更适合 v-show
  • 复杂派生值通常适合 computed
  • 可重排列表通常应使用业务 ID 作为 key;
  • 大量业务逻辑不应堆在模板表达式中。

经验必须结合子树大小、交互状态、组件生命周期和数据变化方式验证,不能脱离实际场景机械套用。


结语

Vue 模板的核心不是 HTML 的另一种写法,而是对渲染关系的声明:

响应式状态
  → 模板表达式求值
  → 渲染函数
  → Virtual DOM
  → 真实 DOM

指令负责把状态映射到属性、事件、条件和列表;表达式提供局部计算,但应保持纯粹;v-ifv-show 分别控制结构存在与可见性;v-for 负责重复生成节点,而稳定 key 负责维护节点身份;Virtual DOM 则在更新阶段比较新旧描述并应用必要的变化。

当页面出现状态错位、DOM 未及时更新、列表输入串行或条件切换丢失状态时,应沿着这条链路诊断:先确认响应式状态是否正确,再确认模板表达式和指令语义,接着检查列表 key、组件数据流和更新时序,最后才判断是否需要优化渲染结构。


系列导航与关联阅读

官方资料

本文依据 Vue、Vite 与生态项目官方文档重新梳理;正文与示例由 WR BLOG 编写。