任务清单的新验收单上多了四项要求:没有任务时显示空状态,可以切到“只看未完成”,允许删除记录,还要支持拖动换序。它们看起来都在描述界面,背后却指向同一个问题:DOM 树应该怎样重组,又怎样在重组时保住每条记录的身份?
第 3 章已经解决了“变化怎样被发现”:模板读取状态时留下依赖,脚本写入同一个响应式入口后,变化就会沿着 trigger 发出通知。本章接着处理通知之后的工作:空状态出现时,列表节点应当离开页面;切换筛选时,暂时不可见的任务不能从源数据中消失;删除时,只该移除目标节点;拖动换序后,任务自己的输入内容、焦点和组件状态还得跟着那条任务走。
这意味着难点已经从“数据会不会变”转向“变化应该得到哪棵 DOM 树”。如果继续用原生 JavaScript,我们要亲自调用 document.createElement()、remove() 和节点移动 API,还要一直核对数据记录与页面节点有没有错位。Vue 则让我们直接描述某份状态应该得到什么界面,再由模板编译出的渲染逻辑完成必要的创建、移动、复用和删除。
本章的四个核心工具恰好对应这次升级:v-if 决定结构是否存在,v-show 在保留节点的前提下切换可见性,v-for 把数据映射成一组节点,key 为每个节点标明稳定身份。它们不是运行时字符串替换,也不是定时扫描页面,而是 Vue 据以组织和更新 DOM 树的结构信息。
无论组件脚本按组合式 API 还是 Options API 组织,v-if、v-show、v-for 和 key 表达的模板规则都相同;旧项目里即使状态与方法换了摆放位置,也可以沿用本章的判断方式。
先给整章建立一张简单的心智地图。后面每个细节都可以放回这四个问题里检查:
key 表达身份。你会发现,写模板的重点不是挑一个“看起来能用”的指令。先把这四个问题说清楚,指令往往会自己浮现出来:一个互斥状态用条件分支,一组同构记录用循环,记录身份用稳定 id,展示结果用计算属性。
先从一个很小的需求开始:用户没有登录时显示登录提示,登录后显示欢迎卡片。
用原生 JavaScript 实现,我们必须亲自决定什么时候创建节点、把它插到哪里、什么时候再删除:
const host = document.querySelector('#account')
let isLoggedIn = false
function renderAccount() {
host.replaceChildren()
const message = document.createElement('p')
message.textContent = isLoggedIn ? '欢迎回来,小林' : '请先登录'
host.append(message)
}
renderAccount()
document.querySelector('#toggle').addEventListener('click', () => {
isLoggedIn = !isLoggedIn
renderAccount()
})这个版本能工作,但 replaceChildren() 会把容器里的内容全部清掉。等卡片里有头像、按钮、表单和子组件时,我们还得自己想办法少改一点 DOM,并处理旧节点上的事件和临时状态。
在 Vue 里,我们直接把互斥关系写进模板:
<!-- App.vue -->
<script setup>
import { ref } from 'vue'
const isLoggedIn = ref(false)
const userName = ref('小林')
</script>
<template>
<main class="account-card">
<button type="button" @click
初始页面只有“模拟登录”按钮和“请先登录”的提示。点击按钮后,提示节点被移除,欢迎卡片被创建并插入;再次点击,欢迎卡片又会被销毁。这里不是两个区域一直躺在页面里轮流变透明,而是当前只存在符合条件的那一个分支。
v-if 读取的是真假性v-if="condition" 会计算表达式,再按 JavaScript 的真假性决定是否渲染。false、0、空字符串、null、undefined 和 NaN 都会进入假分支;非空字符串和对象通常会进入真分支。
这会带来一个常见误判。接口如果返回字符串 'false',它仍然是非空字符串,所以会被当成真值:
<p v-if="'false'">这段内容仍然会出现</p>遇到这种问题,不要怀疑 v-if 失效,先在控制台确认值的类型。真正需要布尔值时,应在数据进入组件时就把它整理成 true 或 false,不要把类型修补塞进模板。
v-else-if 和 v-else 必须紧跟前一个分支多个互斥状态可以写成一条分支链:
<p v-if="score >= 90">掌握得很稳</p>
<p v-else-if="score >= 60">已经入门,可以继续练习</p>
<p v-else>先回到示例再走一遍</p>Vue 会从上到下判断,命中第一个分支后便不再检查后面的分支。v-else-if 或 v-else 必须紧挨着前面的条件节点。若中间插入另一个元素,这条分支链就断了:
<!-- 错误:说明文字隔断了条件分支 -->
<p v-if="isReady">可以开始</p>
<small>状态说明</small>
<p v-else>还没准备好</p>普通的换行和注释通常不会成为页面元素,但为了让关系一眼可见,仍建议把分支连续写在一起,把补充说明放进各自分支内部。
<template> 同时控制多个相邻节点指令需要挂在一个节点上,可我们常常希望标题、正文和按钮一起出现,又不想为了指令多套一层无意义的 div。这时可以使用 <template>:
<template v-if="hasPermission">
<h2>课程管理</h2>
<p>你可以新增、排序或删除课程。</p>
<button type="button">新建课程</button>
</template>
<p v-else>当前账号没有管理权限。</p>这里的 <template> 是编译期的分组容器,不会成为最终 DOM 中的一层标签。条件成立时,三个相邻节点会一起出现。v-else-if 和 v-else 也能挂在 <template> 上。
把模板理解成“会被编译的界面声明”很有帮助。Vue 不是把 template 当字符串反复替换,而是把其中的静态结构和动态分支编译成渲染函数。数据变化后,渲染器比较新旧虚拟节点,再把必要操作落到真实 DOM 上。
刚开始写条件渲染时,我们很容易为每块内容准备一个布尔值:isLoading、hasError、isEmpty、isReady。四个开关单独看都很直白,组合起来却可能出现互相矛盾的状态,例如加载动画和错误提示同时出现,或者数据已经为空却仍然显示旧列表。
如果这些状态本来就互斥,使用一个明确的状态值通常更安全:
<script setup>
import { ref } from 'vue'
const pageState = ref('loading')
function showState(nextState) {
pageState.value = nextState
}
</script>
<template>
<div class="state-switcher">
<button type=
这里任意时刻只有一个 pageState,所以四个分支不会同时成立。这种写法还有一个调试上的好处:开发者工具里看到 'error',就能直接知道页面应该落在哪个分支,而不用再计算几个布尔值之间的组合。
真实接口通常会先进入 loading,成功后根据数组长度进入 ready 或 empty,捕获异常后进入 error。条件渲染只负责把状态翻译成界面;请求、异常处理和状态转换放在 JavaScript 中。这样模板不会变成一大串难以核对的逻辑表达式。
还有一个容易忽略的细节:加载、错误和空状态本身也是产品界面,不是“没有内容时随便塞一句话”。加载时可以给状态区域加 aria-live="polite",让辅助技术在内容变化后感知提示;错误分支应该给用户下一步动作;空状态则要说明为什么为空,以及怎样创建第一条数据。v-if 只决定节点结构,清晰的文字和可操作入口仍要由我们设计。

template 是无形的分组容器,展开后不会留在真实页面结构中。v-if 与 v-show 的差别要去 DOM 里看两段内容肉眼都能消失,不代表浏览器经历了同一件事。
v-if="open" 在条件为假时不渲染对应节点,切换时会创建或销毁其中的元素、事件监听与子组件。v-show="open" 则会先把元素渲染出来,之后只切换元素的内联 display 样式。条件为假时,节点还在 DOM 中,只是布局不再显示它。
下面这个组件故意放了两个没有绑定 v-model 的输入框,方便观察真实 DOM 自己保存的临时值:
<!-- App.vue -->
<script setup>
import { ref } from 'vue'
const showIfPanel = ref(true)
const showDisplayPanel = ref(true)
</script>
<template>
<main class="compare-page">
<section class="panel">
分别在两个输入框中输入内容,再隐藏、显示:
v-if 对应的输入框会被删除,重新显示时得到的是一个新节点,所以浏览器保存在旧节点上的输入值消失了。v-show 对应的输入框只是 display: none,重新显示的仍是原节点,输入值还在。如果输入框使用 v-model 绑定响应式数据,即使 v-if 重建节点,新的输入框也会从那份响应式数据读取值。不要因此反推“节点没有重建”;保留下来的是数据,不是原来的 DOM 节点。
需要频繁开合的下拉菜单、帮助提示和简单工具面板,通常适合 v-show。它承担一次初始渲染成本,后面切换只改 CSS。
登录后才有资格创建的管理区、很少打开的大型详情、包含昂贵子组件的可选区域,通常更适合 v-if。初始条件为假时,这个分支是惰性的,不会先创建再藏起来。
这不是一条只看切换次数的机械公式。还要问一个更具体的问题:隐藏以后,节点、子组件和内部状态是否应该继续存在?如果答案是“应该销毁并从头开始”,v-if 的语义更准确;如果答案是“暂时看不见,但回来还要接着用”,v-show 更自然。
打开浏览器开发者工具的 Elements 面板,搜索示例中的 label。关闭 v-if 时,对应元素会从树中消失,原位置可能只剩框架用于定位分支的注释节点;关闭 v-show 时,对应元素仍能找到,并能看到 style="display: none;"。这是排查“看不见”和“不存在”最可靠的第一步。
v-show 不适合挂在 <template> 上,也没有 v-else 分支。因为它的工作方式是修改一个真实元素的 display,而 <template> 本身不会成为真实元素。
如果条件块里放的是一个子组件,差别不只体现在 Elements 面板。v-if 关闭时,子组件会卸载;它在内部建立的事件监听、定时器和副作用清理逻辑也应该随卸载执行。再次打开时,组件重新挂载,局部 ref 从初始值开始。
v-show 下的子组件仍保持挂载。它的局部状态、计时进度和缓存数据不会因为不可见而自动停止。如果一个隐藏的面板内部还有定时器或持续计算,display: none 并不会替你暂停这些工作。此时要么由组件根据可见状态主动暂停,要么改用 v-if 让整个实例卸载。
所以“频繁切换就用 v-show”只是起点。真正做决定时,要把创建成本、隐藏期间的运行成本、局部状态是否保留一起考虑。一个很轻的小提示反复开合,v-show 很合适;一个关闭后就应该停止工作的实时图表,即使用户可能频繁打开,也可能更适合 v-if,或者需要额外的暂停逻辑。

v-if 会改变页面结构,v-show 只改变元素的可见性。v-for 把一份数据映射成一组节点列表渲染的基本问题很朴素:数组里有三项,页面就应该出现三个结构相同、内容不同的节点。原生 JavaScript 通常这样写:
const lessons = [
{ id: 101, title: '模板语法' },
{ id: 102, title: '响应式数据' },
{ id: 103, title: '条件与列表' }
]
const list = document.querySelector('#lesson-list')
for (const lesson of lessons) {
const item = document.createElement('li'
第一次渲染并不难,难的是数据新增、删除或换序以后怎么更新。最省事的原生做法是清空整个列表再重建,但输入焦点、滚动位置和节点内部状态都可能受影响。更精细的写法则要自己维护数据记录与 DOM 节点的对应关系。
Vue 的 v-for 把这种映射直接写进模板:
<!-- App.vue -->
<script setup>
import { ref } from 'vue'
const lessons = ref([
{ id: 101, title: '模板语法', minutes: 24 },
{ id: 102, title: '响应式数据', minutes: 36 },
{ id: 103, title: '条件与列表', minutes: 42 }
])
</script>
<
v-for="lesson in lessons" 中,lessons 是数据源,lesson 是当前这一轮的局部变量。写成 (lesson, index) in lessons 时,第二个变量是从 0 开始的索引。它很适合显示序号或做调试信息,但它描述的是当前位置,不是这条记录的身份。
v-for 的局部作用域很像 JavaScript 回调函数的作用域。循环内部能访问外层的变量,外层却不能访问循环里的 lesson:
lessons.forEach((lesson, index) => {
console.log(lesson, index)
})
// 这里不能访问 lesson数组是业务页面最常见的数据源,v-for 也可以遍历对象属性:
<li v-for="(value, key, index) in profile" :key="key">
{{ index + 1 }}. {{ key }}:{{ value }}
</li>三个局部变量的顺序是“值、属性名、索引”。对象属性的展示顺序来自 JavaScript 枚举规则;如果业务明确要求固定顺序,更稳妥的做法是把字段整理成一个有序数组。
传入整数时,v-for="page in 5" 会得到 1 到 5,不是 0 到 4:
<button v-for="page in 5" :key="page" type="button">
第 {{ page }} 页
</button><template> 上重复一组兄弟节点一个课程需要同时渲染标题、说明和分隔线,但你不希望每项多出一层容器,可以把 v-for 放到 <template>:
<dl>
<template v-for="lesson in lessons" :key="lesson.id">
<dt>{{ lesson.title }}</dt>
<dd>{{ lesson.minutes }} 分钟</dd>
</template>
</dl>最终 DOM 中不会出现 <template> 标签。注意 key 要放在 <template> 上,因为这一整组相邻节点才是一次循环产生的单元。
课程下面还有若干小节时,可以嵌套两个 v-for。内层能访问自己的 chapter,也能访问外层的 course:
<section v-for="course in courses" :key="course.id">
<h2>{{ course.title }}</h2>
<ol>
<li v-for="chapter in course.chapters" :key="chapter.id">
{{ course.title }} · {{ chapter.title }}
</li>
</ol>
</section>两层循环各自需要 key,但“唯一”的范围是同一组兄弟节点。不同课程下的小节 id 如果只在课程内部唯一,也可以组合出稳定身份:
<li
v-for="chapter in course.chapters"
:key="`${course.id}-${chapter.id}`"
>
{{ chapter.title }}
</li>不要为了方便,把外层和内层的局部变量都命名成 item。语法上允许遮蔽,阅读时却很难确认 item.id 指哪一层。course、chapter、task 这类业务名字能让作用域边界直接显现在代码里。
嵌套循环还会放大节点数量。十个课程、每个一百节,页面就要处理一千个列表项。v-for 能正确渲染,不代表一次把所有节点放进 DOM 就一定合适。数据非常多时,应考虑分页、按需展开或虚拟列表。这里的瓶颈来自浏览器需要维护大量真实节点,不是把循环语法换个写法就能消失。

v-for 把同一份结构依次套用到数组的每一项数据上。key 不是索引装饰,而是节点的身份证很多人第一次看到 :key="item.id",会把它记成“写 v-for 时顺手加一下,性能更好”。这个记法太轻了,遇到列表换序时很容易写出 :key="index",然后面对一串像玄学一样的错位问题。
我们换个角度:Vue 更新列表时,面对的是新旧两组虚拟节点。它必须判断“新列表里的这一项,是旧列表里的哪一项”。key 就是这道身份匹配题的答案。
假设任务按顺序是:
位置 0:任务 A,id = a17
位置 1:任务 B,id = b42
位置 2:任务 C,id = c08现在把任务 C 移到最前面。位置变成了 0,但任务 C 仍然是 c08。如果 key 使用 id,Vue 能知道旧节点 C 应该被移动到前面;如果 key 使用索引,Vue 看到的仍是 0、1、2,便更倾向于就地修改这些位置上的节点内容。
纯文本列表里,两种策略表面上可能都显示正确。一旦列表项包含未受控输入框、正在播放的媒体、获得焦点的元素或有自身状态的子组件,位置和身份混在一起,临时状态就可能留在旧位置,看起来像“任务已经移动,输入框内容却没跟过去”。
下面的例子很适合亲手验证。先在任意任务的备注框里输入文字,再点击“倒序”:
<!-- App.vue -->
<script setup>
import { ref } from 'vue'
const tasks = ref([
{ id: 'task-17', title: '读条件渲染' },
{ id: 'task-42', title: '练习列表过滤' },
{ id: 'task-68', title: '检查 key' }
])
function reverseTasks() {
tasks.value = [...tasks.value].reverse()
因为 task.id 没有随着位置改变,DOM 节点会连同输入框的临时值一起移动。把它故意改成 :key="index",并在循环中取出 index,再重复操作,就能看到身份错误为什么危险。
key 要满足什么key。key。索引只适用于真正静态、不会插入、删除、筛选、换序,且内部没有状态的简单展示。一旦你不能肯定这些条件一直成立,就使用数据 id。
Math.random() 也不是稳定身份。它每次渲染都变,等于告诉 Vue“这些全是新节点”,旧节点难以复用,内部状态也会丢失。title、name 只有在业务保证唯一且不变时才能做 key,仅仅“目前没重复”不算保证。
key 的目的不是让循环通过语法检查,也不是单纯追求更快。它声明的是数据记录与节点之间的身份关系。只要列表项会持有状态或改变顺序,就应该把这层关系写准确。
key 也守着局部状态后面学到组件时,你会经常看到这样的结构:
<TaskCard
v-for="task in tasks"
:key="task.id"
:task="task"
/>每个 TaskCard 可能有自己的展开状态、编辑草稿或加载进度。父列表换序时,稳定 key 告诉 Vue 哪个组件实例仍然对应哪条任务,局部状态便能跟着业务记录移动。
如果故意改变某个组件的 key,Vue 会把它视为另一个身份,旧实例被卸载,新实例重新创建。这有时可以用来明确地重置表单或重新播放过渡,但它是一项有成本、有语义的操作。把 key 绑定到每次都会变的值,相当于每次更新都要求组件重新开始,既破坏状态,也浪费创建工作。
你可以用三个问题检查一个 key:这条记录挪到别处以后它变不变?界面重新渲染但记录没变时它变不变?两条不同记录会不会得到同一个值?答案应该依次是“不变、不变、不会”。

key 能让每一项保持身份。Vue 能追踪响应式数组的常见变更。使用 push()、pop()、shift()、unshift()、splice()、sort()、reverse() 修改原数组时,依赖这份数组的模板会更新:
const tasks = ref([])
tasks.value.push({ id: 1, title: '阅读示例' })
tasks.value.splice(0, 1)
tasks.value.reverse()在 <script setup> 的 JavaScript 区域,tasks 是一个 ref,所以读写数组要使用 tasks.value。模板会自动解包,写 tasks.length 即可,不需要 tasks.value.length。
filter()、slice()、concat() 不修改原数组,它们会返回一个新数组。如果你打算永久删除数据,要把结果赋回 ref:
function removeTask(id) {
tasks.value = tasks.value.filter((task) => task.id !== id)
}只写下面这一行不会改变 tasks:
tasks.value.filter((task) => task.id !== id)新数组确实计算出来了,但没有被保存。页面不更新并不是 Vue 检测不到,而是源数据根本没变。
“只看未完成任务”不是把已完成任务从数据里删除,而是从完整数据中派生出一个展示结果。这个场景应该使用 computed:
这里会提前用到 computed,暂时把它当作“展示清单的派生容器”就够了;它的缓存方式、依赖关系以及为什么计算过程中不该夹带副作用,会在第 7 章完整展开。
<script setup>
import { computed, ref } from 'vue'
const status = ref('all')
const tasks = ref([
{ id: 1, title: '理解 v-if', done: true },
{ id: 2, title: '理解 key', done: false },
{ id: 3, title: '完成练习', done: false }
])
visibleTasks 是派生状态:它由 status 和 tasks 计算得到。依赖没变时,computed 会复用已有结果;依赖变化后才重新计算。更重要的是,它把“怎样筛选”留在 JavaScript,把模板保持为“怎样展示”。
计算属性的 getter 应该只负责计算和返回,不要在里面请求接口、修改 DOM,也不要顺手改源数据。尤其要小心 sort() 和 reverse(),它们会修改原数组。派生排序应先复制:
const sortedTasks = computed(() => {
return [...tasks.value].sort((a, b) => a.createdAt - b.createdAt)
})如果直接 tasks.value.sort(...),只是想展示另一个顺序,却永久改变了源数组。后面再切回“默认顺序”时,你会发现原顺序已经不在了。
有人会因为担心性能,执意用 splice(),不敢把 filter() 得到的新数组赋回去。他们想象 Vue 一看到数组引用变了,就会把所有旧节点扔掉。实际更新并不是按“数组是不是同一个对象”这么简单地决定。
渲染器会比较新旧列表里的节点类型和 key。新数组中仍然存在、身份也相同的记录,对应 DOM 可以继续复用;真正被过滤掉的记录才需要移除。稳定 key 在这里再次发挥作用:数组容器可以换,记录身份仍然清楚。
原地修改和替换数组都可以是正确写法,选择标准是数据操作是否清楚:
// 在原数组尾部新增一项,push 很直接
tasks.value.push(newTask)
// 按 id 删除,filter 的意图很直白
tasks.value = tasks.value.filter((task) => task.id !== id)
// 只展示排序结果,复制后排序,不碰源数组
const orderedTasks = computed(() => {
return [...tasks.value].sort((a, b) => a.minutes - b.minutes)
})数组里的对象也是响应式数据。task.done = true 能触发相关界面更新,不必为了修改一个字段把整条对象和整份数组全部深拷贝。反过来,如果你拿到的是计算属性返回的新排序数组,其中的任务对象通常仍然指向源数据里的那些响应式对象;修改 task.done 仍是在修改源任务。理解“新数组”和“新对象”不是一回事,能避免很多不必要的复制。
如果列表循环 visibleTasks,空状态却判断 tasks.length,它表达的是“源数据是否为空”,不一定是“当前有没有可见项”。完整看板故意同时判断两者,是为了区分“清单真的空了”和“筛选没有结果”。
普通页面如果不需要区分原因,列表和空状态最好依赖同一份派生数组:
<ul v-if="visibleTasks.length">
<li v-for="task in visibleTasks" :key="task.id">
{{ task.title }}
</li>
</ul>
<p v-else>当前没有可显示的任务。</p>统计文字也要明确统计对象。tasks.length 是全部数量,visibleTasks.length 是筛选后的数量,completedCount 是完成数量。变量名写清楚,页面文案就不容易把几个数字混为一谈。

v-if 和 v-for 放在同一个元素上下面这段代码看起来像“遍历任务,只显示未完成项”:
<!-- 错误写法 -->
<li v-for="task in tasks" v-if="!task.done" :key="task.id">
{{ task.title }}
</li>问题在于同一个节点上,v-if 的处理优先级高于 v-for。计算 !task.done 时,v-for 还没有建立当前这一轮的 task 变量,于是会出现属性未定义等错误。
不要把它背成一个孤立的优先级结论。先问清楚你真正想表达哪一种语义。
如果需求是“只显示未完成项”,用计算属性先得到 activeTasks:
<script setup>
import { computed, ref } from 'vue'
const tasks = ref([
{ id: 1, title: '读代码', done: true },
{ id: 2, title: '写练习', done: false }
])
const activeTasks = computed(() => {
return tasks.value.filter((task)
循环拿到的每一项都确定要展示,模板里没有互相打架的结构指令。计数也能直接使用 activeTasks.length,不会出现“源数组有 10 项,页面只显示 3 项,却仍然写总数 10”的语义混乱。
如果需求是“用户关闭列表后,一项都不要渲染”,把 v-if 放在列表容器上:
<ul v-if="showTasks">
<li v-for="task in tasks" :key="task.id">
{{ task.title }}
</li>
</ul>
<p v-else>任务列表已收起。</p>这样 v-if 控制整块结构,v-for 只负责列表内部的重复,意图非常清楚。
少数情况下,每项条件不适合提炼成派生列表,可以用 <template v-for> 建立循环作用域,再在内部判断:
<ul>
<template v-for="task in tasks" :key="task.id">
<li v-if="task.canRead">
{{ task.title }}
</li>
</template>
</ul>此时 task 已经存在,内部的 v-if 可以访问它。不过普通筛选仍优先使用 computed,因为派生列表更容易复用、测试和计数。
现在把条件渲染、列表渲染、稳定 key、筛选和数组更新放进同一个组件。这个看板支持新增任务、按状态筛选、切换完成状态、删除、把任务移到最前面,并在没有匹配结果时显示空状态。
把下面内容保存为项目中的 src/App.vue,在标准 Vue 3 项目中可以直接运行:
<!-- src/App.vue -->
<script setup>
import { computed, ref } from 'vue'
const draft = ref('')
const keyword = ref('')
const status = ref('all')
const nextId = ref(5)
const tasks = ref([
首次打开时,页面显示四条任务、完成计数和剩余分钟数。选择“只看未完成”,visibleTasks 重新计算,已完成任务不再进入循环;源数组 tasks 仍保留全部数据,所以切回“全部任务”时它会回来。
在搜索框输入“key”,列表只留下标题匹配的任务。若没有任何匹配项,visibleTasks.length 变成 0,任务列表的 v-if 分支被销毁,空状态的 v-else 分支出现。清空搜索词,列表节点再按稳定的 task.id 与数据匹配。
点击“置顶”时,代码先用 splice() 取出目标记录,再用 unshift() 放到数组开头。它的索引变了,id 没变,所以 Vue 移动对应节点。点击“删除”时,filter() 返回新数组,我们把新数组赋回 tasks.value,相应节点随之移除。
当最后一条任务也被删除时,空状态又细分出两个原因:源数据确实为空,提示“添加第一条任务”;源数据不为空但筛选无结果,则建议修改筛选条件。用户看到的是同样的“列表空白”,组件却用条件链表达了不同状态。
以勾选任务为例,用户点击复选框后,v-model 把新的勾选状态写入对应任务的 done。completedCount 和 remainingMinutes 都读取过任务状态,它们的依赖发生变化,于是得到新结果。模板再次生成所需的虚拟节点,Vue 把完成数量、剩余分钟和任务文字样式的变化更新到真实 DOM。
这条链路中,我们没有手动寻找统计数字节点,也没有手动给标题添加删除线。事件负责改源数据,计算属性负责派生,模板负责声明界面,渲染器负责 DOM。职责分开以后,一个动作影响三处界面也不会多出三段手动更新代码。
再看删除:点击按钮时传入稳定的 task.id,removeTask 用 filter() 生成新数组并赋回 tasks.value。visibleTasks、两个统计计算属性都依赖源数组,因此一起重新计算。列表比较新旧身份后,只移除对应节点,其他任务仍然是原来的记录。
“置顶”的链路稍有不同。splice() 和 unshift() 修改的是同一响应式数组,Vue 同样能收到变化。task.id 没有改变,渲染器知道这是已有节点移动到开头,不是一条旧任务消失、另一条新任务诞生。正因为模板中写了准确的身份,更新策略才有可靠依据。
搜索时,keyword 每输入一个字符都会改变。visibleTasks 读取它,先 trim() 再转为小写,返回匹配记录。搜索不会删除源任务,所以清空关键词后所有记录重新进入结果。这里不要再维护一份 searchResults,否则新增、删除、勾选和搜索时都要记得同步两份数组,很容易产生旧数据。
这也是整个例子真正想展示的东西:条件和循环不是互不相干的模板技巧。它们站在响应式数据链的末端,把当前状态翻译成节点结构。只要源数据、派生状态和身份三层保持清楚,页面变化就能顺着链路解释。

条件与列表问题很容易被笼统地说成“页面没更新”。真正排查时,按下面的顺序会快很多。
先在 Vue 开发者工具里看 tasks、status 和 visibleTasks,或临时在事件函数中打印它们。删除按钮点击后,如果 tasks.value 长度完全没变,问题发生在数据操作;如果数据正确而页面不对,再检查模板关系。
一个典型错误是忘记接住非变更方法的返回值:
// 没有保存 filter 的结果
tasks.value.filter((task) => task.id !== id)
// 把新数组交回响应式引用
tasks.value = tasks.value.filter((task) => task.id !== id)打开 Elements 面板找目标节点。节点不存在,通常沿着 v-if 的条件查;节点存在并带 display: none,沿着 v-show 和 CSS 查;节点存在、没有隐藏样式但仍看不到,再检查尺寸、定位、透明度和遮挡。
这一步能避免在 v-if 上反复改 CSS,或者在 CSS 上浪费时间追一个根本没有创建的节点。
key 是否真的稳定换序后文字正确、输入值或子组件状态错位,优先检查 key。把每条数据的 id 临时显示在页面上,比盯着索引更直观:
<small>调试:id={{ task.id }}</small>确认是否有重复 id,是否用了 index,是否在渲染期间临时生成随机值。若 <template v-for> 产生一组节点,确认 key 写在 <template> 上。
控制台出现无法识别 v-else 的警告时,查看它前一个兄弟节点是不是 v-if 或 v-else-if。不要只看缩进,浏览器和编译器关心的是节点关系。
computed 应返回派生结果。如果你在 getter 内部修改 tasks,它可能在计算期间再次触发依赖变化,让数据流很难追。筛选使用 filter();排序先复制再 sort();永久修改放进明确命名的事件函数。
如果真实页面里有请求、路由、弹窗和十几个组件,直接盯着整页很难判断是哪一层出错。可以临时复制出三条固定数据,只保留一个按钮和一个循环,观察问题是否还能出现。
怀疑 key 时,只保留换序按钮、两条记录和未受控输入框;怀疑 v-if 时,只保留一个布尔值和目标节点;怀疑筛选时,把计算属性的结果暂时渲染成 JSON 或简单标题列表。最小实验不是逃避真实业务,而是把变量减少到足以验证一个机制。
复现以后一次只改一个条件。例如先保持数据不变,只把 :key="index" 改成 :key="task.id"。如果错位消失,就有直接证据说明问题来自身份;不要同时重写数据结构和组件样式,否则即使现象消失,也不知道哪一项修改真正起效。
以后开始写组件测试时,v-if 与 v-show 也应该用不同方式验证。对 v-if,要检查节点是否存在;对 v-show,节点会存在,应检查它是否可见。把两者都写成“找不到文字”会掩盖真实差别。
列表测试不只看渲染数量。可以先给某条记录输入临时内容,再换序,确认内容仍跟着相同 id;可以删除中间记录,确认剩余 id 的顺序;可以切换筛选,确认源数组没有被破坏。测试关注身份与状态,比只截一张静态页面更能发现列表更新错误。
一个好用的排查口诀是:先看源数据,再看派生数据,接着看真实 DOM,最后看节点身份。四层逐一核对,大多数“列表怎么乱了”的问题都会落到一个具体位置。
v-show 只是让元素不显示,内容仍然存在于 DOM 中。它不能承担安全权限。真正敏感的数据和操作权限必须由服务端控制,前端条件渲染只负责界面表达。
模板里写长串条件,或者把 v-if 与 v-for 堆在同一元素上,短期看似紧凑,后面统计数量、增加搜索、复用结果时会迅速变乱。派生列表放进命名清楚的 computed,模板只循环结果。
visibleTasks 是从 tasks 算出来的临时快照。不要对它 push() 或 splice() 来试图永久修改任务。要改变业务数据,就更新 tasks;依赖改变后,visibleTasks 会自然得到新结果。
JavaScript 中空数组 [] 仍是真值,所以 v-if="tasks" 不能判断列表是否为空。应该明确写 v-if="tasks.length" 或 v-if="tasks.length > 0"。
不要在模板表达式中写 tasks.sort(...) 或 tasks.reverse()。它们会修改源数组,而且模板重新求值时可能反复改变顺序。把排序写进计算属性并先复制数组。
index + 1 很适合显示“第几项”,却不适合删除定位和 key。筛选后索引会重排,删除 index = 1 未必还指向源数组里的同一条记录。事件参数和 key 都传稳定 id。
复用节点不是偷懒,而是更新策略的一部分。只要 key 准确,Vue 会在保留身份的前提下更新内容和顺序。如果业务想强制把某个区域当成全新实例,可以有意识地改变它的 key,但这意味着旧状态会被销毁,不能当通用刷新按钮滥用。
很多已有项目会把状态写进 data(),把派生列表写进 computed,把操作写进 methods。你看到的 JavaScript 组织形式会与 <script setup> 不同,但模板中的条件、循环和身份规则完全相同:
<script>
export default {
data() {
return {
status: 'all',
tasks: [
{ id: 1, title: '阅读旧组件', done: true },
{ id: 2, title: '整理数据流', done: false }
]
}
},
computed: {
visibleTasks() {
if (this.status ===
读这种代码时,把 this.tasks 对应到组合式写法中的 tasks.value,把 computed 选项对应到 computed(() => ...),把 methods 里的函数对应到 <script setup> 的普通函数,就能继续沿用本节的思路。不要同时承担“理解列表更新”和“立刻重构整份旧组件”两项任务,先确认数据链路,再决定是否迁移写法。
本节选择组合式 API,是因为相关状态、派生结果和操作函数能靠近放置,任务看板变复杂后仍容易沿着业务功能阅读。这不是说 Options API 的条件渲染会失效,也不是要求见到旧语法就重写。实际维护中,保持一个组件内部风格一致,通常比机械追求某种形式更重要。
模板允许写 JavaScript 表达式,于是有人会把权限、状态、数量和关键词全部塞进一个 v-if:
<section
v-if="user && user.active && tasks.length > 0 && status !== 'archived'"
>
...
</section>这段代码不一定立刻出错,却很难回答“为什么没有显示”。更清楚的做法是在脚本中创建有业务名字的计算属性,例如 canViewActiveTasks,并把判断拆成能逐项检查的变量。模板写 v-if="canViewActiveTasks",读者看到的是界面规则;具体条件则在脚本里集中说明。
简单表达式留在模板没有问题,例如 tasks.length > 0、task.done。当表达式已经需要停下来数括号、反复确认运算优先级,或者相同逻辑出现两次,就应该提炼。模板越接近一张结构图,条件分支和列表关系越容易维护。
命名时也尽量写出业务含义。flag、data2、listTemp 只能说明它们是某种变量,无法说明页面为什么依赖它们。isLoggedIn 表达登录条件,visibleTasks 表达当前可见记录,hasSearchResult 表达搜索结果是否存在。好的名字会让 v-if 像一句可以直接读懂的话,也会让后来的调试少猜一层。
如果一个条件暂时说不清,不用急着继续叠指令。先在纸上写出可能状态,再确认哪些可以同时出现、哪些必须互斥;接着确定唯一的源数据,最后才写模板。很多看似复杂的渲染问题,真正的根源是状态定义含糊。模板忠实地呈现了混乱的数据,自然也只能得到混乱的界面。
你也可以把每个分支写成一句普通中文来检查:“正在加载时显示等待提示”“加载失败时显示重试按钮”“有可见任务时渲染列表”“没有匹配项时解释筛选结果为空”。如果一句话需要很多“并且”和“或者”才能成立,往往说明它应该被拆成命名清楚的派生状态。先让规则在人话里顺畅,再把它翻译成 Vue,代码通常会简单得多。
一个聊天窗口的表情面板每天会开合很多次,关闭后再次打开时,希望保留用户刚才滚动到的位置。你会使用 v-if 还是 v-show?说明你判断时关注的 DOM 行为。
下面的模板为什么可能报错?请用计算属性改写。
<li v-for="student in students" v-if="student.present" :key="student.id">
{{ student.name }}
</li>一个可拖拽排序的商品列表使用 :key="index"。每个商品行里有一个未提交的备注输入框。拖拽后备注跑到了另一件商品旁边。请解释原因并给出修复方向。
请补全删除函数,并说明为什么需要赋值:
function removeLesson(id) {
// 在这里完成删除
}给完整看板增加“按预计时长从短到长”和“恢复默认顺序”两个选项。要求不破坏 tasks 的原始顺序,不在模板里调用 sort()。
你不必把所有指令背成表格,但应该能用自己的话回答下面几件事:
v-if 为假时,真实 DOM 中为什么找不到对应节点;v-show 为假时,为什么仍然能找到。v-show,而一个很少出现、需要销毁内部状态的分支更适合 v-if。v-for 的当前项和索引分别表达什么,为什么索引通常不能承担身份。key 如何帮助节点、输入状态与业务记录继续对应。filter() 的结果需要赋回 ref,而 push() 可以直接修改响应式数组。computed,以及排序前为什么常常要复制数组。v-if 和 v-for 不该放在同一个节点上,整表显隐与逐项过滤又该分别怎么写。现在,这张任务看板已经能忠实地把状态翻译成界面:该出现的分支会出现,清单会按派生结果展开,删除或换序后节点仍能认出各自的身份。但它还缺少真正把人接进来的那一段——一次点击、一次表单提交或一次按键,究竟怎样沿着 DOM 路径进入组件脚本,再由处理函数改变响应式状态?浏览器事件,究竟怎样接入这条已经搭好的状态—渲染链路?