05|条件渲染:该拆台时拆台,该拉帘时拉帘
第一章概览 / 05。目标:按切换成本、初始成本和状态生命周期选择
v-if或v-show,而不是拿它们当同义词。
第一个例子是可独立运行的 src/App.vue,环境为 Vue 3.5.x + Vite + TypeScript。切换状态时同时看页面与 Elements 面板:节点消失和节点带着 display: none 留在 DOM,是两条不同证据。
一句话决策表
| 问题 | 倾向选择 | 原因 |
|---|---|---|
| 条件大多数时候为假、出现时才需创建 | v-if | 未满足时不创建子树,也不会运行其中组件生命周期 |
| 内容已存在、短时间高频显示/隐藏 | v-show | 元素保留在 DOM,只切换 CSS display |
| 需要保存输入框/组件内部状态 | v-show 或把状态上提 | v-if 切走后会卸载并重新创建 |
| 需要按条件延迟加载昂贵组件 | v-if(配合异步组件) | 先避免创建,再谈网络与代码分割 |
基础分支:让状态名称说人话
<!-- src/App.vue -->
<script setup lang="ts">
import { computed, ref } from 'vue'
type LoadState = 'idle' | 'loading' | 'success' | 'error'
const state = ref<LoadState>('idle')
const error = ref<Error | null>(null)
const hasUnreadTips = computed(() => state.value === 'success')
function retry() {
error.value = null
state.value = 'loading'
}
</script>
<template>
<section aria-live="polite">
<p v-if="state === 'idle'">还没开始,咖啡也还没凉。</p>
<p v-else-if="state === 'loading'">正在加载…</p>
<p v-else-if="state === 'error'" role="alert">
{{ error?.message ?? '加载失败,请重试。' }}
<button @click="retry">重试</button>
</p>
<p v-else>数据已就绪,仪表盘可以渲染。</p>
</section>
<!-- 已挂载的提示面板,可频繁开关。 -->
<aside v-show="hasUnreadTips">你有新的学习提示。</aside>
</template>v-else-if / v-else 必须紧挨着对应的 v-if / v-else-if;中间插一个别的元素,Vue 就会失去剧情接续能力。需要包裹多项条件内容时,用 <template v-if="…">,它本身不会额外生成 DOM。
生命周期差异:不是“看不见”那么简单
<script setup lang="ts">
import { ref } from 'vue'
import SearchPanel from './SearchPanel.vue'
const useLazyPanel = ref(true)
const visible = ref(false)
</script>
<template>
<button @click="visible = !visible">切换面板</button>
<!-- 每次 visible 从 false 变 true 都会创建;切回 false 会卸载。 -->
<SearchPanel v-if="useLazyPanel && visible" />
<!-- 首次渲染时即创建;之后只是 display:none / 显示。 -->
<SearchPanel v-show="!useLazyPanel && visible" />
</template>| 维度 | v-if | v-show |
|---|---|---|
| 首次条件为假 | 不创建内容 | 创建内容但隐藏 |
| 切换 | 挂载 / 卸载 | 改 display |
| 组件内部状态 | 卸载后丢失(除非外部保存) | 留在内存与 DOM 中 |
| 适用 | 稀少、昂贵、需要真正离场 | 高频切换、保留输入/焦点状态 |
| 限制 | 切换有创建成本 | 不支持 <template>,也不能和 v-else 链搭配 |
深水区:先画状态生命周期,再选指令
条件渲染最容易犯的错,是先问“哪条指令更快”,再补理由。更可靠的顺序是先写出这块 UI 的生命周期:它何时第一次出现、关掉后是否该保留草稿、后台工作是否应继续、下次打开是恢复现场还是重新开始。答案自然会指向 v-if 或 v-show。
| 场景 | 关闭后该发生什么 | 更自然的选择 |
|---|---|---|
| 首次使用引导 | 彻底结束,不再保留内部步骤 | v-if,或由上层状态决定是否再创建 |
| 常用筛选面板 | 保留输入和滚动位置,快速再次打开 | v-show |
| 危险操作确认框 | 每次打开都是一段新流程 | v-if,并在关闭时让内部状态卸载 |
| 隐藏但仍需后台持续更新的监控面板 | DOM 与组件继续存在 | v-show,同时明确资源开销 |
v-show 隐藏的组件仍在运行它自己的定时器、watcher 和请求逻辑;它不是暂停按钮。v-if 卸载组件也不等于所有外部副作用自动消失——例如你手写的全局事件监听器仍应在组件清理阶段被正确移除。指令决定的是组件树的存在与显示,资源管理仍是代码的责任。
条件渲染最适合表达“状态”,不是为了省标签
加载、空数据、权限不足、失败和成功是不同的产品状态,应该有可辨认的名称和可访问的呈现。把它们揉成 items.length ? … : … 的多层三元表达式,页面一开始很短,后来却很难补重试、说明和焦点策略。
<script setup lang="ts">
import { computed, ref } from 'vue'
type ResultState = 'loading' | 'error' | 'ready'
const state = ref<ResultState>('loading')
const lessons = ref<string[]>([])
const hasLessons = computed(() => lessons.value.length > 0)
</script>
<template>
<section aria-live="polite">
<p v-if="state === 'loading'">正在读取课程…</p>
<p v-else-if="state === 'error'" role="alert">加载失败,请重试。</p>
<p v-else-if="!hasLessons">还没有课程,先创建第一条吧。</p>
<ul v-else>
<li v-for="lesson in lessons" :key="lesson">{{ lesson }}</li>
</ul>
</section>
</template>这里的 v-if 链回答的是“用户现在处于什么阶段”;列表中的 v-for 只回答“该阶段要展示哪些项”。把两个问题拆开,才能避免又在同一元素上混用 v-if 和 v-for。
分支身份与 key
Vue 3会为 v-if/v-else 分支自动生成不同key,因此下面的邮箱与手机分支即使删掉手工key,切换时也会重建。若自己写key,必须保证各分支不同;不能通过相同key强迫Vue3复用分支。这个规则与后文同一 v-for 列表按key匹配条目不同。
<script setup lang="ts">
import { ref } from 'vue'
const mode = ref<'email' | 'phone'>('email')
</script>
<template>
<label v-if="mode === 'email'" key="email-field">
邮箱 <input type="email" autocomplete="email" />
</label>
<label v-else key="phone-field">
手机号 <input type="tel" autocomplete="tel" />
</label>
</template>容易混淆的交叉口:不要把 v-if 和 v-for 写在同一元素
它们同时出现时 v-if 的优先级更高,表达式还拿不到 v-for 的局部变量。更重要的是,把过滤逻辑写进模板会让身份、性能和可读性一起挠头。先用计算属性生成展示列表,再只渲染它。
<script setup lang="ts">
import { computed, ref } from 'vue'
type Task = { id: string; title: string; done: boolean }
const tasks = ref<Task[]>([])
const activeTasks = computed(() => tasks.value.filter((task) => !task.done))
</script>
<template>
<!-- 好:过滤是派生数据,渲染只负责逐项展示。 -->
<li v-for="task in activeTasks" :key="task.id">{{ task.title }}</li>
</template>排错路线:切换后数据去哪儿了
- 确认条件值。 条件表达式是否真的是布尔语义?加载状态、权限状态与“用户手动关闭”最好不要混在一个含糊的变量里。
- 确认节点是否仍存在。 在 Elements 面板中,
v-if关闭后节点会消失,v-show仍在但带有隐藏样式;这决定了 ref、焦点与内部状态的命运。 - 检查分支身份。 Vue3条件分支已自动具有不同身份;若要保留数据,保存的是业务状态,而不是试图让不同分支共用key。
- 把列表逻辑带离模板。 当条件涉及筛选一组项时,先用
computed生成集合,再使用单纯的v-for渲染。
翻车博物馆
| 现象 | 真相 | 修复 |
|---|---|---|
| 展开高级筛选后输入全没了 | 用了 v-if,组件已经卸载 | 用 v-show,或把草稿状态提升到父层 |
| 隐藏面板仍在跑计时器/请求 | v-show 只是隐藏 | 需要停止工作时用 v-if 或由组件显式暂停 |
v-else 不生效 | 与 v-if 不是相邻兄弟 | 调整结构,必要时用 <template> 包组 |
列表筛选写成 v-for + v-if | 优先级与维护性都糟糕 | 计算属性先筛选 |
练习:给设置页选对开关
创建 src/App.vue、src/components/HelpDrawer.vue 和 src/components/DeleteConfirm.vue。实验功能初始关闭,第一次打开才创建;帮助抽屉内有一个本地搜索草稿,关闭再打开必须保留;危险确认框内要求输入 DELETE,取消再打开必须清空。
为三块分别选择 v-if 或 v-show,并写下“初始是否创建、关闭后局部状态是否保留、内部资源是否继续运行”的推导。不要把局部草稿都放到 App.vue,否则无法观察卸载与隐藏的真实差异。
参考答案:查看第一章练习答案中的第 05 题。
验收清单:如何判断练习完成
- 首屏 Elements 中没有实验功能与危险确认框;帮助抽屉节点存在但隐藏。
- 帮助抽屉输入
key后关闭再打开仍保留;确认框输入DELETE后取消再打开为空。 - 每个
v-if/v-show选择都能用创建成本、切换频率、状态生命周期和资源是否继续运行来解释。 - 列表过滤先在
computed中完成,没有把v-if和v-for放在同一元素上赌优先级。
通关自检:你能用“创建成本、切换频率、状态生命周期”三个词解释选择,而不是只说“这两个都能隐藏”。