码上问学问代码之理,学编程之道

第 4 章 · 条件渲染与列表渲染

上一章你会用插值把数据「印」到页面上了,但真实页面里的数据从来不是一五一十地全铺出来:登录了才显示用户菜单、有数据才渲染列表、没数据要显示「暂无」、推荐的才加个角标。用 v-if / v-show 决定「显示什么」,用 v-for 决定「重复渲染什么」——这两组指令,撑起了「古典文学网」几乎所有页面的骨架。

为什么页面需要「条件」和「循环」

先把动机说清
先讲为什么想一想你用过的任何 App:没登录时看不到「我的收藏」,列表空了会显示「暂无内容」,会员才有那个金色小标。这些都不是「数据不同」,而是「同一个页面对不同状态给出不同长相」。
再用原生 JS 想想你要写多少行:createElement、appendChild、删数据时还要自己 removeChild——一个列表两处改动,代码就开始失控。Vue 把这些「根据数据决定长什么样」的工作,压缩成了两条指令。

v-if / v-else-if / v-else:真正销毁与重建

三态渲染最常用的一招
先讲为什么任何一次「从后端拿数据」都要经历三个阶段:还在加载 → 请求成功显示数据 → 请求失败显示错误。如果三种情况都用元素堆在页面上、靠 CSS 显示隐藏,你得自己维护一堆开关。v-if 允许你像写 if / else if / else 一样,把它们并列写出来,条件为假的那一段干脆不渲染。
动手做给作品详情页写「加载中 / 出错 / 正常」三个分支。注意 v-else-if 和 v-else 必须紧挨着上一个带 v-if 的元素,中间不能插别的东西。
ArticleDetail.vuevue
<script setup>
import { ref } from 'vue'

// 三种状态:加载中 / 出错 / 加载完成
const loading = ref(true)
const error = ref('')
const article = ref(null)

// 真实项目里这里会请求接口,本章先手动把状态改好,专心看渲染
loading.value = false
article.value = { id: 1, title: '静夜思', author: '李白', dynasty: '唐' }
</script>

<template>
  <p v-if="loading" class="loading">加载中……</p>
  <p v-else-if="error" class="err">{{ error }}</p>
  <div v-else class="detail">
    <h2>{{ article.title }}</h2>
    <p>{{ article.author }} · {{ article.dynasty }}</p>
  </div>
</template>
页面效果组件渲染结果
运行结果: 浏览器显示效果(loading = false,error 为空时): 静夜思 李白 · 唐 -- 三个分支永远只会渲染其中一个 -- 条件为假的分支,在 DOM 里根本不存在,F12 元素面板也搜不到
讲透
  • 「真正销毁 / 重建」是 v-if 最关键的特征:条件从真变假,Vue 把这个元素(连同它的子节点)从 DOM 上摘下来;再变真,Vue 重新创建一遍。所以元素里的输入框内容、组件内部的 state,都会重新开始。
  • v-if 是惰性的:初始条件为假,这段内容一开始就不会被渲染,等于「省了初始化开销」。
  • 可以在 <template> 上用 v-if,用来一次控制多个并列元素,不额外产生一层 DOM。
  • 条件里能写任意 JS 表达式:v-if="list.length === 0"、v-if="user && user.isLogin"、v-if="item.likes > 50" 都行。
避坑不要把 v-if 和 v-for 写在同一个元素上,比如 <li v-for="item in list" v-if="item.isRecommend">。Vue 3 里 v-if 的优先级高于 v-for,此时 item 还不存在,控制台会直接报 Property "item" was accessed during render but is not defined。正确写法是在外面套一层 <template v-for>,把 v-if 放到内层元素上。

v-show:只切 display 的另一种思路

频繁切换就该用它
v-if 真销毁、v-show 真隐藏:切换频率决定用哪个v-if:条件渲染条件为假:DOM 里没有这个元素(真·销毁,事件监听、组件状态一并清掉)✓ 切换少、条件分支大段内容✗ 频繁切换(销毁/重建开销大)v-show:CSS 显隐条件为假:只加 display:none(元素一直都在,只是看不见)✓ 频繁切换(Tab、悬浮面板)✗ 首次就要渲染全部内容注意:v-if 可以用在 template 上包裹多元素;v-show 不支持 template
先讲为什么首页轮播图旁边那个「展开更多」按钮,用户可能一秒钟点五下。v-if 每点一次都要真实地销毁、重建一整棵 DOM 子树,代价不低。如果只是「藏起来再拿出来」,何必每次重新造一遍?就像书签夹在书里——翻到那一页很快,不用每次重新写一遍内容。这就是 v-show:元素始终在 DOM 里,只把 display 在 none 和原值之间来回切。
动手做同一个「显示 / 隐藏轮播图」的功能,先用 v-show 写一遍,再用 v-if 写一遍,然后打开 F12 看 DOM 的差别。
BannerShow.vuevue
<script setup>
import { ref } from 'vue'

// 用户点一下切一次,切换非常频繁 —— 这种场景优先 v-show
const showBanner = ref(true)
</script>

<template>
  <div v-show="showBanner" class="banner">首页轮播图</div>
  <button @click="showBanner = !showBanner">显示 / 隐藏轮播图</button>
</template>
页面效果组件渲染结果
运行结果: 浏览器显示效果: 首页轮播图 [ 显示 / 隐藏轮播图 ] -- 点一次按钮,轮播图消失;F12 元素面板里这个 div 还在,只是变成了: -- <div class="banner" style="display: none;">首页轮播图</div>
BannerIf.vuevue
<script setup>
import { ref } from 'vue'

const showBanner = ref(true)
</script>

<template>
  <div v-if="showBanner" class="banner">首页轮播图</div>
  <button @click="showBanner = !showBanner">显示 / 隐藏轮播图</button>
</template>
页面效果组件渲染结果
运行结果: 浏览器显示效果: 首页轮播图 [ 显示 / 隐藏轮播图 ] -- 点一次按钮,F12 里这个 div 连同子节点整块消失; -- 再点一次,Vue 重新创建它:如果里面有个 <input>,用户输入的内容会被清空
讲透:到底该选哪个
  • 渲染机制不同:v-if 是「摘掉 / 造出来」,会触发真实的 DOM 增删;v-show 只改一个 CSS 属性,元素一直在。
  • 初始开销:v-if 初始为假时最省(什么都不渲染);v-show 无论真假都要先把元素渲染出来,初始开销更高。
  • 切换开销:v-if 每次切换都要销毁 / 重建;v-show 只是改样式,几乎无成本。
  • 选型口诀:频繁切换用 v-show,条件很少变或初始就不该渲染用 v-if。所以「登录 / 未登录」「加载中 / 加载完成」这种整块页面级的分支,用 v-if;「展开 / 收起」「Tab 切面板」用 v-show。
  • 两个限制:v-show 不能配 v-else(它没有「否则」的概念);也不能用在 <template> 上(因为要改的是元素的 style,而 template 本身不产生元素)。

v-for:把数组变成一串元素

列表页的全部秘密
先讲为什么「作品列表」里有 6 篇作品,你不可能手写 6 个 <li>;就算你写了,第 7 篇怎么办?后端一天不发新作品?数据的条数是可变的,模板的写法必须是固定的。v-for 就是解决这个矛盾的:写一遍 <li>,Vue 按数组长度替你复制出 N 份。数组是 3 条就渲染 3 行,是 100 条就渲染 100 行,你的模板一个字都不用改。
动手做把「古典文学网」的作品数组渲染成列表,顺便给每行加上序号;再用同一套语法遍历一个对象(分类信息),看看 (value, key) 的顺序。
ArticleList.vuevue
<script setup>
import { ref } from 'vue'

const articles = ref([
  { id: 1, title: '静夜思', author: '李白' },
  { id: 2, title: '春望', author: '杜甫' },
  { id: 3, title: '陋室铭', author: '刘禹锡' }
])

// 对象也能遍历:(值, 键) —— 注意「值」在前,「键」在后
const category = ref({ id: 1, name: '唐诗', sort: 1 })
</script>

<template>
  <h3>作品(共 {{ articles.length }} 篇)</h3>
  <ul>
    <li v-for="(item, index) in articles" :key="item.id">
      {{ index + 1 }}. {{ item.title }} —— {{ item.author }}
    </li>
  </ul>

  <h3>分类信息</h3>
  <p v-for="(value, key) in category" :key="key">{{ key }}:{{ value }}</p>
</template>
页面效果组件渲染结果
运行结果: 浏览器显示效果: 作品(共 3 篇) 1. 静夜思 —— 李白 2. 春望 —— 杜甫 3. 陋室铭 —— 刘禹锡 分类信息 id:1 name:唐诗 sort:1
讲透
  • 语法是 v-for="(item, index) in 数组":item 是当前项,index 是从 0 开始的下标。只用 item 时可以简写成 v-for="item in articles"。
  • 遍历对象是 (value, key, index):顺序和数组完全不同,「值」排在第一位——这是最容易写反的地方。对象遍历顺序按 Object.keys(),不要指望它帮你排序。
  • in 和 of 在这里等价(v-for="item of articles" 也合法),团队里统一用一种即可,本节统一用 in。
  • 还能遍历数字和字符串:v-for="n in 5" 渲染 5 次、n 从 1 到 5;v-for="ch in '唐宋元'" 逐字渲染。
  • 落到项目里:作品列表、书评及它的 children 回复(嵌套 v-for)、分类下拉框、轮播图,全都是这一条指令。

:key 为什么必须有

列表增删时状态串位的元凶
:key 的作用:Vue 靠 key 认出「谁是谁」,才能精准移动而不是整列重建旧列表key=1 静夜思key=2 春望key=3 望岳头部插入「key=0 关雎」新列表(复用节点)key=0 关雎 新建key=1 静夜思 原样复用key=2 春望 原样复用key=3 望岳 原样复用❌ 用 index 当 key:插入一条后,后面所有元素的 index 都变了 → Vue 以为全变了 → 状态串位✓ key 用数据里稳定的唯一 id(比如作品的 id)
先讲为什么想象一列站队的人,你把他们按「第 1 个、第 2 个、第 3 个」编号。第 1 个人走了,后面所有人往前挪一格——按位置编号的名单全乱了,每个人手里拿的东西还留在原地。
Vue 渲染列表时也面临「怎么复用已有元素」的问题:给了 :key,它就能认出「这是同一行」,把状态(输入框内容、组件内部状态、动画)跟着数据走;不给或者用下标当 key,它就只按位置复用,数据一增删就串位。而且不给 key,控制台会一直提示。
动手做每行放一个输入框,故意用 index 当 key,然后在第 1 行输入点东西、再点「删掉第一条」,看那个输入框内容去哪儿了。
KeyBug.vue(反例)vue
<script setup>
import { ref } from 'vue'

const list = ref([
  { id: 101, title: '静夜思' },
  { id: 102, title: '春望' },
  { id: 103, title: '陋室铭' }
])

function removeFirst() {
  list.value.splice(0, 1)
}
</script>

<template>
  <!-- ❌ 用 index 当 key:数据一删,位置和身份的对应关系就断了 -->
  <div v-for="(item, index) in list" :key="index" class="row">
    <span>{{ item.title }}</span>
    <input placeholder="写点读书笔记">
  </div>
  <button @click="removeFirst">删掉第一条</button>
</template>
页面效果组件渲染结果
运行结果: 浏览器显示效果: ① 初始,在三行的第 1 个输入框里敲了「好诗」: 静夜思 [好诗 ] 春望 [ ] 陋室铭 [ ] ② 点「删掉第一条」后(:key="index"): 春望 [好诗 ] ← 笔记串位了!它本该跟着「静夜思」一起被删掉 陋室铭 [ ] ③ 把 :key 改成 :key="item.id" 再重复一次: 春望 [ ] 陋室铭 [ ] ← 输入框内容跟着正确的那一行一起消失
讲透
  • key 是「这一行的身份证」,它的作用是告诉 Vue「新旧两个列表里,哪些是同一个元素」,从而决定「复用还是重建」。
  • 要求:唯一、稳定、可预测。最理想的 key 就是后端给的业务主键——「古典文学网」里就是 item.id(作品表主键)。
  • 为什么 index 不靠谱:index 会随数组变动而变。删掉第 1 条后,原来的第 2 条 index 从 1 变成 0,Vue 以为「还是那一行」,于是复用了旧 DOM,页面上的输入内容就跟着留了下来。列表只做「尾部追加」时 index 才不会出事,但你没理由赌这一点。
  • 必须是「兄弟之间」唯一:不同列表之间重复没关系,同一层 v-for 内不能重复。所以别用 {{ item.title }} 当 key——两篇同名作品就会撞车。
避坑三个高频错误:① 把 :key 写成 key——少了冒号就变成「给元素加一个名叫 key 的 HTML 属性」,Vue 收不到,等于没写;② 用 index 当 key,可删可增的列表上必出状态串位;③ 同一层出现重复 key(比如两条数据 id 都是 0),控制台会报 Duplicate keys found during update,渲染结果也不可信。

数组更新检测:改了,界面为什么不动

响应式到底盯着什么
先讲为什么「我明明改了数据,页面怎么不动?」这是每个 Vue 新手都会撞一次的墙。原因是:Vue 的响应式不是魔法,它只能「看见」它监听到的那条路径。你改的对象如果不是响应式代理本身,或者绕过了它,Vue 就完全不知情,自然也不重渲染。
所以这一节要解决的不是语法,而是搞清楚:哪些写法会被 Vue 看见,哪些不会。
动手做先看两种「改了但界面不动」的写法,再对照正确的四种改法。
BadUpdate.vue(反例)vue
<script setup>
import { ref, shallowRef } from 'vue'

const list = ref([
  { id: 1, title: '静夜思' },
  { id: 2, title: '春望' }
])

// ❌ 反例一:漏了 .value
//    这行不是"改数组第 0 项",而是给 ref 包装对象加了个叫 0 的属性,
//    list.value 这个真数组一个字节都没变,界面当然不动
list[0] = { id: 3, title: '陋室铭' }

// ❌ 反例二:用了浅层响应式 shallowRef
//    它只跟踪 .value 整体被替换,数组内部的 push、下标赋值通通不触发更新
const tabs = shallowRef(['唐诗', '宋词'])
tabs.value.push('元曲')
</script>
页面效果组件渲染结果
运行结果: 浏览器显示效果: 作品列表 静夜思 春望 -- 依然是两行,而且第一行还是「静夜思」 -- 反例一里那个 { id: 3, title: '陋室铭' } 只是挂在了 ref 对象上,根本没进数组 -- 反例二里「元曲」确实进了数组,但 shallowRef 不跟踪内部变化,界面不会重渲染
GoodUpdate.vue(正例)vue
<script setup>
import { ref } from 'vue'

const list = ref([
  { id: 1, title: '静夜思' },
  { id: 2, title: '春望' }
])

// ✅ 正例一:变更方法(会改动原数组,也能触发更新)
//    push / pop / shift / unshift / splice / sort / reverse
list.value.push({ id: 3, title: '陋室铭' })

// ✅ 正例二:要替换某一项,就老老实实写 list.value[0]
list.value[0] = { id: 4, title: '声声慢' }

// ✅ 正例三:splice(起始下标, 删除几个, 要插入的值)——原地替换,语义最清楚
list.value.splice(1, 1, { id: 5, title: '天净沙·秋思' })

// ✅ 正例四:非变更方法返回新数组,整体重新赋值回去
//    filter / map / concat / slice 都不会动原数组,所以要自己赋回去
list.value = list.value.filter(item => item.id !== 2)

// ✅ 正例五:想清空,「重新赋一个空数组」永远是最稳的写法
// list.value = []
</script>
页面效果组件渲染结果
运行结果: 逐步执行后 list.value 的内容: 初始 静夜思 / 春望 push 后 静夜思 / 春望 / 陋室铭 list.value[0] 替换后 声声慢 / 春望 / 陋室铭 splice(1, 1, ...) 后 声声慢 / 天净沙·秋思 / 陋室铭 filter 后 声声慢 / 天净沙·秋思 -- 每一步界面上都立刻跟着变,这才是「响应式」该有的样子
讲透:老教程里那句「arr[0] = x 不生效」,到底怎么理解
  • Vue 2 时代确实不生效:那会儿用 Object.defineProperty 给每个属性装 getter/setter,只能拦截「事先声明过的属性」。数组下标和 length 拦不到,所以必须用 splice、或者 Vue.set / this.$set。
  • Vue 3 换了 Proxy,情况变了:数组的下标赋值、length 修改现在都能被监听到。但前提是——你操作的是响应式代理本身。list.value[0] = x 有效,list[0] = x 无效——差别就在这个 .value。
  • 所以 splice 在今天仍然值得推荐:不是「不这么做就不生效」,而是它的语义最清楚(原位替换第几项),也不会踩漏 .value 的坑。同理,「重新赋值一整个新数组」永远是最不容易出错的写法。
  • 老配方要清掉:Vue.set(list, 0, x)、this.$set、Vue.delete 在 Vue 3 里都已经移除,见到就换成上面这些写法。
  • 两类方法记牢:变更方法(push / pop / shift / unshift / splice / sort / reverse)改原数组;非变更方法(filter / map / concat / slice)返回新数组,必须自己赋回去。
避坑在 <script setup> 里操作 ref 数据,忘写 .value 是头号 bug。判断方法很简单:如果 x.push(...) 之类的调用没报错、界面也没反应,就回去看看是不是漏了 .value(严格模式下 undefined.push 会直接炸,但 list[0] = x 这种赋值是静默失败的,最难查)。另外注意:模板里写 {{ list }} 不需要 .value,Vue 会自动解包——这也是最容易搞混的地方,第 5 章会把这条规则讲透。

综合:作品列表页的三态 + 推荐角标

把本章串起来

现在把 v-if、v-show、v-for、:key 拼成一个「古典文学网」作品列表页:有数据就循环渲染、没数据显示「暂无作品」、推荐的加一个角标。这是你后面每一章都会反复打磨的那一个页面。

ArticleListView.vuevue
<template>
  <div class="article-list">
    <h2>作品列表</h2>

    <!-- 空状态:一条数据都没有时给用户一句交代,别留一片白 -->
    <p v-if="list.length === 0" class="empty">暂无作品,换个分类试试~</p>

    <!-- 有数据:循环渲染,key 用后端的作品 id -->
    <ul v-else>
      <li v-for="item in list" :key="item.id" class="card">
        <!-- 角标只在推荐作品上出现 -->
        <span v-if="item.isRecommend" class="badge">推荐</span>
        <b>{{ item.title }}</b>
        <span class="meta">{{ item.author }} · {{ item.dynasty }}</span>
        <span v-show="item.views > 300" class="hot">🔥 热门</span>
      </li>
    </ul>
  </div>
</template>
页面效果组件渲染结果
运行结果: 浏览器显示效果: 作品列表 [推荐] 静夜思 李白 · 唐 🔥 热门 春望 杜甫 · 唐 [推荐] 念奴娇·赤壁怀古 苏轼 · 宋 🔥 热门 -- list 为空数组时,整个 ul 不渲染,页面上只留「暂无作品,换个分类试试~」

再补上后半个「三态」——加载中、加载失败、空、有数据,四个分支一次写清。注意顺序:先判断加载中,再判断错误,再判断空,最后才是正常列表。

ArticleListView.vue(完整三态)vue
<template>
  <p v-if="loading">加载中……</p>
  <p v-else-if="error" class="err">{{ error }}</p>
  <p v-else-if="list.length === 0">暂无作品,换个分类试试~</p>
  <ul v-else>
    <li v-for="item in list" :key="item.id">
      <span v-if="item.isRecommend" class="badge">推荐</span>
      {{ item.title }} —— {{ item.author }}
    </li>
  </ul>
</template>
页面效果组件渲染结果
运行结果: 四种情况下页面的样子: ① 请求进行中 加载中…… ② 请求失败 request failed: 500 ③ list 为空 暂无作品,换个分类试试~ ④ 有 3 条数据 [推荐] 静夜思 —— 李白 春望 —— 杜甫 [推荐] 念奴娇·赤壁怀古 —— 苏轼 -- 对应的接口返回(GET /api/article/list,统一响应格式 {code, msg, data}): { "code": 200, "msg": "success", "data": { "list": [ { "id": 1, "title": "静夜思", "author": "李白", "dynasty": "唐", "category_name": "唐诗", "cover": "", "summary": "床前明月光……", "views": 328, "likes": 42, "created_at": "2026-10-12 10:00:00" } ], "total": 6, "page": 1, "page_size": 10 } } -- data.list 直接喂给 v-for 就行;data.list 为空数组时自动落到「暂无作品」那一支
讲透:这个页面的骨架你以后会写几十遍四态(加载中 / 出错 / 空 / 有数据)+ 列表循环 + key 用业务主键 + 局部用 v-if 做小分支——这就是一个列表页的标准模板。第 5 章我们会把「请求数据 → 管理 loading 和 error」这段逻辑抽成一个可复用的组合式函数;第 6 章再把 <li> 里那一坨拆成独立的 ArticleCard.vue 组件。你现在写的这几十行,会一路长成整个前端项目。
动手练习
1给作品详情页写「加载中 / 加载失败 / 拿到数据」三个分支,并说明:为什么这里该用 v-if,而不是 v-show?
2作品列表每行前面要显示序号。有同学写成 <li v-for="(item, index) in list" :key="index">,删掉第一条后输入框里的读书笔记串到了下一行。请解释原因,并给出正确写法。
3迁移练习:「红影赏析」有个电影列表,数据是 movies 数组,每项形如 { id, title, genre, rating }。请写出这个列表的渲染:空数组时显示「暂无电影」;评分不低于 8.5 的电影在标题后加一个「高分」角标;整份列表按评分从高到低显示。
参考答案
练习答案(按题分块).vuevue
<!-- 第 1 题 -->
<script setup>
import { ref } from 'vue'

const loading = ref(true)
const error = ref('')
const article = ref(null)
</script>

<template>
  <p v-if="loading">加载中……</p>
  <p v-else-if="error" class="err">{{ error }}</p>
  <div v-else>{{ article.title }}</div>
</template>
<!-- 用 v-if 的理由:这三种状态是整块互斥的,同一时刻只该有一种存在。
     而且详情数据没拿到之前,正常分支根本不该被渲染(连骨架都不该建)。
     v-show 会先把三个分支都渲染出来再靠 display 藏,纯属浪费。 -->

<!-- 第 2 题 -->
<!-- 原因:index 是「位置」,不是「身份」。删掉第一条后,
     原来的第 2 条 index 从 1 变成 0,Vue 以为还是同一行 → 复用旧 DOM,
     输入框里已输入的内容就留在了原位置,于是串到了下一行。
     正确写法:用后端给的业务主键当 key —— :key="item.id" -->

<!-- 第 3 题(红影赏析,把作品换成电影,套路完全一样) -->
<script setup>
import { ref, computed } from 'vue'

const movies = ref([
  { id: 1, title: '流浪地球', genre: '科幻', rating: 8.4 },
  { id: 2, title: '星际穿越', genre: '科幻', rating: 9.3 },
  { id: 3, title: '让子弹飞', genre: '剧情', rating: 9.0 }
])

// 排序用计算属性:不改原数组,只产出一份「排好序的副本」
const sorted = computed(() => [...movies.value].sort((a, b) => b.rating - a.rating))
</script>

<template>
  <p v-if="movies.length === 0">暂无电影</p>
  <ul v-else>
    <li v-for="movie in sorted" :key="movie.id">
      <span v-if="movie.rating >= 8.5" class="badge">高分</span>
      {{ movie.title }} —— {{ movie.rating }}
    </li>
  </ul>
</template>
页面效果组件渲染结果
运行结果: -- 第 1 题:三种状态下分别显示「加载中……」/ 错误提示 / 作品标题,只有一个分支在 DOM 里 -- 第 2 题:改成 :key="item.id" 后,删除第一条,笔记跟着「静夜思」一起消失,不再串位 -- 第 3 题(红影赏析)浏览器显示效果: [高分] 星际穿越 —— 9.3 [高分] 让子弹飞 —— 9.0 流浪地球 —— 8.4 -- movies 为空数组时,页面上只显示「暂无电影」 -- 排序用小技巧:[...movies.value] 先复制一份再 sort, -- 因为 sort 是「变更方法」,直接排序会改动 movies 本身

第 3 题的关键:换的是「数据字段和业务规则」,不变的是「v-if 判空 + v-for 渲染 + :key 用主键 + 角标用 v-if」这套骨架。另外注意 sort() 是变更方法,别直接对原数组下手。

本章小结