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

第 5 章 · 生命周期与组合式函数

「作品列表页一打开,数据就自己出现了」——这背后是两件事:知道「什么时候该去发请求」(生命周期钩子),以及把「请求列表」这套有状态的逻辑打包成一个能到处复用的函数(组合式函数)。学完这一章,你写的就不再是一个个孤立的页面,而是能拼装起来的逻辑积木。

组件的一生:从创建到销毁

先看清时间轴
组件的一生:一条时间轴,四个最常用的钩子setup()组件实例创建初始化响应式数据onMountedDOM 已就绪在这里发请求onUpdated数据变化页面重新渲染后onUnmounted组件被卸载清理定时器/监听日常 90% 的场景只用 onMounted:进页面 → 拉数据 → 渲染收摊铁律:onMounted 里开的定时器、加的 window 监听,必须在 onUnmounted 里清掉否则组件没了「魂」还在:定时器空转、监听越积越多,页面越来越卡创建 → 挂载 → 更新 → 卸载
先讲为什么你肯定有过这种困惑:「为什么我在 setup 里写 document.querySelector('.list') 拿到的永远是 null?」
答案很简单:那一刻,元素还没被造出来。组件从被创建到被销毁,中间要经历好几个确定的阶段,每个阶段能做的事不一样。「拿不到 DOM」不是代码写错了,是时机不对。生命周期钩子就是把「我想在某个时刻做一件事」说清楚的工具。
① setup():初始化数据 ② onBeforeMount ③ 挂载:渲染成真实 DOM ④ onMounted ★ DOM 已就绪 数据变了 响应式数据发生变化 onBeforeUpdate → 重新渲染 onUpdated(DOM 更新完成) 被移除时 ⑤ onBeforeUnmount ⑥ onUnmounted ★ 做清理
动手做把 Vue 3 的六个生命周期钩子一次性列出来,先不管用不用得上。注意它们都是从 vue 里 import 进来的函数,在 <script setup> 里调用即可——没有 Vue 2 那种 mounted() {} 选项了。
LifeCycleDemo.vuevue
<script setup>
import {
  onBeforeMount, onMounted,
  onBeforeUpdate, onUpdated,
  onBeforeUnmount, onUnmounted
} from 'vue'

onBeforeMount(() => {
  console.log('挂载前:DOM 还没生成,拿不到元素')
})

onMounted(() => {
  console.log('★ 挂载完成:DOM 已就绪,可以发请求、操作元素')
})

onBeforeUpdate(() => {
  console.log('数据变了,但 DOM 还是旧的')
})

onUpdated(() => {
  console.log('DOM 更新完成,此时页面和最新数据是一致的')
})

onBeforeUnmount(() => {
  console.log('即将卸载:这是最后能访问到 DOM 的机会')
})

onUnmounted(() => {
  console.log('★ 已卸载:清定时器、解绑事件监听')
})
</script>
页面效果组件渲染结果
运行结果: 控制台输出(页面首次打开时): 挂载前:DOM 还没生成,拿不到元素 ★ 挂载完成:DOM 已就绪,可以发请求、操作元素 -- 之后每次响应式数据变化,会成对打印: 数据变了,但 DOM 还是旧的 DOM 更新完成,此时页面和最新数据是一致的 -- 组件被移除时(比如路由跳走、外层 v-if 变 false): 即将卸载:这是最后能访问到 DOM 的机会 ★ 已卸载:清定时器、解绑事件监听
讲透
  • 「挂载(mount)」是分水岭:挂载之前,组件只是一堆 JS 对象和函数;挂载之后,它才变成页面上真实存在的 DOM。所以凡是要碰 DOM、要拿元素尺寸、要发请求渲染数据的,都放在 onMounted 之后。
  • 更新阶段是一对一对出现的:onBeforeUpdate(DOM 还是旧的)→ 重新渲染 → onUpdated(DOM 已经新的)。
  • 卸载阶段也是一对:onBeforeUnmount 时组件还完整可用(能读到 DOM、能拿到状态),onUnmounted 时组件已经和页面断开,只剩「善后」这一件事可做。
  • 别死记顺序:记不住就记「先有数据 → 再有 DOM → 最后收拾干净」,需要用什么的时候回来查图就行。日常开发 90% 的情况只会用到 onMounted 和 onUnmounted。
  • 还有 onActivated / onDeactivated(配 <KeepAlive> 缓存组件时用)、onErrorCaptured(错误捕获)等,属于进阶内容,本项目基本用不到。

onMounted:DOM 就绪后再发请求

日常开发最常用的一个钩子
先讲为什么「作品列表页一打开就该有数据」——这件事的动作是「发请求」,那它该在什么时刻发生?
不是在 setup 刚开始的时候(那时组件还在半路上,请求还没回来页面就要开始渲染,逻辑容易乱),而是在组件已经挂到页面上、DOM 就绪的那一刻。onMounted 就是那个时刻的回调:此时 DOM 一定存在,拿到的数据也一定能立刻渲染出来。
动手做在 onMounted 里请求「古典文学网」的作品列表接口,把 data.list 塞进 list。回调函数可以写成 async,里面正常写 await。
ArticleListView.vuevue
<script setup>
import { ref, onMounted } from 'vue'

const list = ref([])
const total = ref(0)

onMounted(async () => {
  // 只在挂载后执行:此刻 DOM 已就绪,数据回来能立刻渲染
  const res = await fetch('http://127.0.0.1:5000/api/article/list?page=1&page_size=10')
  const json = await res.json()

  if (json.code === 200) {
    list.value = json.data.list          // 注意:这里要写 .value
    total.value = json.data.total
  }
  console.log('列表拿到了', list.value.length, '条,共', total.value, '条')
})
</script>

<template>
  <ul>
    <li v-for="item in list" :key="item.id">{{ item.title }} —— {{ item.author }}</li>
  </ul>
</template>
页面效果组件渲染结果
运行结果: 浏览器显示效果: 静夜思 —— 李白 春望 —— 杜甫 念奴娇·赤壁怀古 —— 苏轼 声声慢 —— 李清照 天净沙·秋思 —— 马致远 陋室铭 —— 刘禹锡 控制台输出: 列表拿到了 6 条,共 6 条 -- 接口实际返回(GET /api/article/list?page=1&page_size=10): { "code": 200, "msg": "success", "data": { "list": [ { "id": 1, "title": "静夜思", "author": "李白", "views": 328, "likes": 42 } ], "total": 6, "page": 1, "page_size": 10 } } -- 统一响应格式里 code 是业务状态码,200 才代表成功,所以先判 code 再取 data
讲透
  • 钩子必须「在组件初始化期间」被调用:也就是写在 <script setup> 的顶层(或顶层调用的函数里)。写在 setTimeout、写在 await 之后、写在按钮的点击回调里,Vue 就找不到「这是哪个组件的钩子」,等于没写。
  • 一个组件可以有多个 onMounted,它们按注册顺序依次执行。所以引入一个组合式函数,内部再注册一个 onMounted 是完全可以的——这正是下一节要讲的用法。
  • 请求放在 onMounted 还有一个好处:它天然是「只跑一次」的(除非组件被反复卸载重建)。不用你自己写 if (inited) 之类的开关。
  • 和项目的关联:作品列表、作品详情、书评列表、我的收藏——每一个页面的数据加载,都发生在它自己的 onMounted 里。第 7 章学了路由之后,你从列表页点进详情页,就是「列表页卸载 → 详情页挂载 → 详情页 onMounted 发请求」这一串动作。
避坑不要在 <script setup> 顶层直接写 await fetch(...)。一旦 setup 变成了 async setup,这个组件就变成了「异步组件」,必须外面套一层 <Suspense> 才能渲染——否则整页一片空白,控制台只给你一句 Component <Xxx> is missing template or render function,非常难查。要发请求,老实用 onMounted。

onUpdated 与 onUnmounted:该收的摊子

定时器和事件监听必须清理
先讲为什么你打开一个页面,里面有个每秒跳一下的倒计时;然后你跳到别的页面去了。
问题来了:那个定时器停了吗?没停。那个组件早就从页面上消失了,但它的定时器还在后台每秒跑一次,占着 CPU、还抱着已经不存在的 DOM 引用不放——这就是内存泄漏。页面切个几十次,你的应用就肉眼可见地卡了。
生命周期后半段的价值就在这:组件「死」的时候,把活着的东西都收干净。
动手做写一个带「在线时长」的组件:onMounted 里起定时器和窗口监听,onUpdated 里观察更新,onUnmounted 里一一收回。
TimerCard.vuevue
<script setup>
import { ref, onMounted, onUpdated, onUnmounted } from 'vue'

const seconds = ref(0)
let timer = null

function onResize() {
  console.log('窗口宽度变了:', window.innerWidth)
}

onMounted(() => {
  timer = setInterval(() => {
    seconds.value++
  }, 1000)
  window.addEventListener('resize', onResize)
})

onUpdated(() => {
  // 只在里面「读」数据,做日志 / 上报;改 DOM 之外的事都别干
  console.log('DOM 更新完成,当前 seconds =', seconds.value)
})

onUnmounted(() => {
  // 起了什么,就回收什么
  clearInterval(timer)
  timer = null
  window.removeEventListener('resize', onResize)
  console.log('组件已卸载,定时器与监听均已清理')
})
</script>

<template>
  <p>你在本页停留了 {{ seconds }} 秒</p>
</template>
页面效果组件渲染结果
运行结果: 浏览器显示效果: 你在本页停留了 0 秒 (每秒自动 +1,变成 1 秒、2 秒、3 秒……) 控制台输出: DOM 更新完成,当前 seconds = 1 DOM 更新完成,当前 seconds = 2 DOM 更新完成,当前 seconds = 3 -- 路由跳走 / 外层 v-if 变 false 时: 组件已卸载,定时器与监听均已清理 (之后再也不会打印 seconds 了)
讲透
  • 哪些东西必须在 onUnmounted 里清:setInterval / setTimeout、window / document 上的事件监听、WebSocket 连接、第三方的图表实例(ECharts 之类)。规矩很简单:凡是在 onMounted 里「向外」注册的东西,都要在 onUnmounted 里撤回。
  • Vue 自己管理的不用你管:模板上的 @click、响应式数据、子组件,Vue 会随组件一起销毁,不需要手动清理。要清的只有「你自己伸手到 Vue 之外」的注册。
  • onUpdated 少用,用就有讲究:它在每次重渲染后触发,非常频繁。千万不要在里面修改响应式数据——改了数据 → 触发更新 → 又进 onUpdated → 又改数据,死循环,控制台会刷屏「Maximum recursive updates exceeded」。
  • 想在「某个数据变化后」做事,别用 onUpdated,用第 3 章学的 watch:它能明确指定监听谁,比「一有更新就跑」精确得多。
避坑两个坑常一起出现:① 定时器不清理,切页面后组件已销毁,seconds.value++ 还在执行,界面不更新但你会在控制台看到一堆无效触发——项目一复杂就是性能事故;② 在 onUnmounted 里访问 DOM,此时元素已经从页面移除了,querySelector 只会返回 null,取属性就会报 Cannot read properties of null。最后一刻能碰 DOM 的是 onBeforeUnmount,不是 onUnmounted。

ref 与 reactive:到底用哪个

.value 这事得说清
先讲为什么上一章你已经被 .value 坑过一次了:list[0] = x 没反应,list.value[0] = x 才对。为什么非要这个 .value?
原因很实在:JS 里基本类型(数字、字符串、布尔)是「值传递」,一个 let n = 0 改成 1,Vue 没法感知——它没法给一个"值"装监听器,只能给"对象"装。所以 Vue 想了个办法:用一个对象把它「包」起来,{ value: 0 },然后监听这个对象的 value 属性。这就是 ref。
而对象本来就是引用类型,reactive 直接给整个对象套一层 Proxy,不用包,所以不需要 .value。
动手做同一个计数器,先用 ref 写,再用 reactive 写,感受一下 .value 的差别。
RefDemo.vuevue
<script setup>
import { ref } from 'vue'

// ref 可以装基本类型,也可以装对象 / 数组
const count = ref(0)
const user = ref({ nickname: '青莲居士' })

function add() {
  count.value++                  // script 里:必须 .value
  user.value.nickname = '李白'    // 对象内部属性也要经过 .value 才拿到代理
}
</script>

<template>
  <!-- 模板里:自动解包,直接写名字,千万别写 count.value -->
  <p>{{ count }} 次 </p>
  <p>{{ user.nickname }}</p>
  <button @click="add">加一</button>
</template>
页面效果组件渲染结果
运行结果: 浏览器显示效果: 0 次 青莲居士 [ 加一 ] -- 点两次「加一」: 2 次 李白 -- 记住这条分界线:<script> 里写 count.value,<template> 里写 count -- 模板自动解包只对「顶层的 ref」生效,所以 user.value.nickname 在模板里可以写成 user.nickname
ReactiveDemo.vuevue
<script setup>
import { reactive, toRefs } from 'vue'

// reactive 只能装对象(包含数组),不能装数字 / 字符串
const state = reactive({
  count: 0,
  keyword: ''
})

function add() {
  state.count++            // 不用 .value,直接改属性
}

// ⚠️ 直接解构会「丢掉响应性」:解出来的是普通变量,改它页面不动
const { count } = state

// ✅ 想解构又要保响应性,用 toRefs 转成一组 ref
const { keyword } = toRefs(state)
</script>

<template>
  <p>{{ state.count }} 次</p>
  <input v-model="state.keyword" placeholder="搜索作品">
</template>
页面效果组件渲染结果
运行结果: 浏览器显示效果: 0 次 [ 搜索作品 ] -- 点「加一」:state.count 变成 1,页面跟着变(不用 .value,这就是 reactive 的舒服之处) -- 在输入框里打字:state.keyword 同步变化 -- ⚠️ 那个解构出来的 count 是个快照式普通变量: -- 改 state.count,count 还是 0;改 count,页面也完全不动
讲透:选型就一句话
  • 用 ref 装基本类型(数字、字符串、布尔)——这是 reactive 做不到的,它只能装对象。
  • 用 reactive 装「一组相关的状态」,比如一个表单 { username, password, remember },写起来没有 .value,读起来更顺。
  • 团队的实用建议:统一用 ref。理由有三:① 一个规则不用切换脑子(永远 .value);② 不会犯「解构丢响应性」的错;③ 下一节要讲的组合式函数,返回 ref 是最容易组合的。本课程后续代码统一用 ref。
  • 模板里自动解包只作用于「顶层的 ref」。所以 list 能用 {{ list }};但如果 ref 装在数组里([ref(1), ref(2)]),模板里访问就得写 item.value。日常很少遇到,知道有这么回事就行。
  • 改 ref 别忘 .value 这条铁律,例外只有两个位置:模板里、以及 v-model / @click 等指令的表达式里(第 6 章讲 defineModel 时还会碰到)。
避坑两个必踩的坑:① reactive 对象不能整体替换——let state = reactive({ count: 0 }) 之后写 state = { count: 1 },变量指向的已经是个**普通对象**,不再是代理,之后改 state.count 页面完全不动。要改就改属性(state.count = 1),或改用 ref(state.value = {...} 是合法的)。② 从 reactive 里解构出来的属性会丢响应性,必须用 toRefs 转回 ref。

为什么组合式函数是 Vue 3 的精髓

从「复制粘贴」到「逻辑积木」
组合式函数:把「一段逻辑」抽成 useXxx(),数据和操作打包复用useArticleList()一段完整的列表逻辑:· loading 加载状态· list 数据列表· fetchList() 拉取函数src/composables/ 目录复用复用首页推荐模块赏文学列表页后台管理列表对比 mixin:来源不明的变量互相踩,排查困难;composable来源清晰、可传参。「逻辑积木」是组合式 API 的精髓:页面负责拼积木,积木负责具体逻辑
先讲为什么你的项目里会有:作品列表页、我的收藏页、搜索结果页、作者的其它作品……它们背后是同一段逻辑——「请求一页数据、维护加载中/失败状态、把列表存起来」。
最直接的写法是复制粘贴:每个页面贴 30 行。但代价在第 3 个月才显现:接口地址要改(改 4 处)、错误提示文案要统一(改 4 处)、发现一个 bug(修 3 处、漏 1 处)。
Vue 2 时代用 mixin 解决复用,但 mixin 的毛病是「你不知道一个变量到底从哪来的」——多个 mixin 混进来,重名覆盖、来源不明。组合式函数换了个思路:复用逻辑就是复用函数——普通函数一样调用、能看见返回值、能传参、能 IDE 跳转。这就是它被称为 Vue 3 精髓的原因。
动手做先看「复制粘贴版」有多难受:下面两个页面里,除了接口地址和变量名,逻辑几乎一模一样。
复制粘贴版(反面教材)vue
<!-- src/views/ArticleListView.vue -->
<script setup>
import { ref, onMounted } from 'vue'

const list = ref([])
const loading = ref(false)
const error = ref('')

onMounted(async () => {
  loading.value = true
  try {
    const res = await fetch('http://127.0.0.1:5000/api/article/list?page=1&page_size=10')
    const json = await res.json()
    if (json.code === 200) list.value = json.data.list
    else error.value = json.msg
  } catch (e) {
    error.value = '网络异常,请稍后再试'
  } finally {
    loading.value = false
  }
})
</script>

<!-- src/views/FavoriteView.vue -->
<script setup>
import { ref, onMounted } from 'vue'

const list = ref([])
const loading = ref(false)
const error = ref('')

onMounted(async () => {
  loading.value = true
  try {
    // 只有这一行不一样
    const res = await fetch('http://127.0.0.1:5000/api/favorite/list?page=1&page_size=10')
    const json = await res.json()
    if (json.code === 200) list.value = json.data.list
    else error.value = json.msg
  } catch (e) {
    error.value = '网络异常,请稍后再试'
  } finally {
    loading.value = false
  }
})
</script>
页面效果组件渲染结果
运行结果: 两个页面功能都正常,浏览器显示效果一致: 加载中…… → 列表出现,每行一篇作品 -- 但问题不在「能不能跑」,而在「以后改一次要改几处」: -- ① 接口地址从 127.0.0.1:5000 换成域名 → 4 个页面全改 -- ② 错误提示文案要统一 → 4 个页面全改 -- ③ 发现 catch 里漏了请求超时处理 → 修 3 个、漏 1 个 -- ④ 新同学接手:这段逻辑到底属于谁? → 没人说得清
讲透:什么是「组合式函数」
  • 定义:一个普通的 JS 函数,内部使用 Vue 的响应式 API(ref / computed / watch)和生命周期钩子,最后把一组响应式状态返回出去。它复用的不是「界面」,而是有状态的逻辑。
  • 命名约定:以 use 开头(useArticleList、useUser、useCart)。这不是语法要求,是社区约定——一看名字就知道「这是个组合式函数,得在 setup 里调用」。
  • 它和「普通工具函数」的区别:工具函数(比如格式化日期)无状态,调完就完;组合式函数有状态——调用一次就新建一套独立的 loading / list / error,两次调用互不干扰,这一点和「每个组件有自己的 data」是一模一样的。
  • 它和「组件」的区别:组件复用 UI(模板 + 逻辑一起打包);组合式函数只复用逻辑,UI 由调用方自己决定。同一个 useArticleList(),列表页渲染成卡片流、侧边栏渲染成文字链,都行。
  • 落到项目里:useArticleList、useCommentList、useUser、useFavorite……这些函数会构成你项目里的「逻辑层」。第 8 章的 Pinia,本质就是把「全局共享的组合式函数」做成了官方方案。

动手写一个 useArticleList()

目录约定 src/composables/
动手做把上一节那 30 行复制粘贴的逻辑,抽成 src/composables/useArticleList.js。约定:所有组合式函数统一放在 src/composables/ 目录下,一个函数一个文件,文件名和函数名一致。它不放在 components 里(那不是组件),也不放在 utils 里(那里放无状态的工具函数)。
src/composables/useArticleList.jsjs
import { ref } from 'vue'

const BASE_URL = 'http://127.0.0.1:5000/api'

/**
 * 作品列表的加载逻辑:内部管理 loading / list / error,
 * 并暴露一个 fetchArticles() 让组件决定「什么时候加载、带什么条件」。
 */
export function useArticleList() {
  const list = ref([])
  const total = ref(0)
  const loading = ref(false)
  const error = ref('')

  async function fetchArticles(params = {}) {
    loading.value = true
    error.value = ''

    try {
      const query = new URLSearchParams({ page: 1, page_size: 10, ...params })
      const res = await fetch(`${BASE_URL}/article/list?${query}`)
      const json = await res.json()

      if (json.code === 200) {
        list.value = json.data.list
        total.value = json.data.total
      } else {
        // 后端返回的业务错误:msg 可以直接展示给用户
        error.value = json.msg
      }
    } catch (e) {
      error.value = '网络异常,请稍后再试'
    } finally {
      loading.value = false
    }
  }

  // 返回一组 ref:调用方解构出来的仍然是响应式的
  return { list, total, loading, error, fetchArticles }
}
页面效果浏览器运行结果
运行结果: -- 这是一个纯 JS 模块,本身没有界面,不能单独「运行」。 -- 它的效果要通过组件调用后观察: 组件里调用后,loading 从 true 变 false,list 从 [] 变成 6 条数据; 若接口返回 {"code":404,"msg":"作品不存在","data":null}, error 会变成「作品不存在」,页面即可直接渲染这条提示。

然后在组件里用——注意只是「一行调用」,原来那 30 行全部消失:

ArticleListView.vuevue
<script setup>
import { onMounted } from 'vue'
import { useArticleList } from '@/composables/useArticleList'

// 一行拿到全套状态和方法(<script setup> 里解构 ref 不会丢响应性)
const { list, total, loading, error, fetchArticles } = useArticleList()

// 什么时候加载、按什么条件加载,由页面自己决定
onMounted(() => fetchArticles({ order_by: 'hot' }))
</script>

<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">{{ item.title }} —— {{ item.author }}</li>
  </ul>
</template>
页面效果组件渲染结果
运行结果: 浏览器显示效果(onMounted 里按热度加载): 加载中…… → 列表出现(按浏览量从高到低): 念奴娇·赤壁怀古 —— 苏轼 静夜思 —— 李白 声声慢 —— 李清照 春望 —— 杜甫 陋室铭 —— 刘禹锡 天净沙·秋思 —— 马致远 -- 换成 onMounted(() => fetchArticles({ dynasty: '唐' })) 只改这一个参数, -- loading / error / list 的处理逻辑一个字都不用动 -- 目录约定: src/ composables/ useArticleList.js ← 本节的成果 useCommentList.js useUser.js views/ ArticleListView.vue ← 页面组件在这里调用它
讲透:一个组合式函数该长什么样
  • 名字以 use 开头,一个文件只 export 一个组合式函数,文件名与函数名对齐(useArticleList.js ↔ useArticleList())。
  • 内部负责「状态 + 行为」:ref 定义状态,普通 async 函数定义行为。它自己不知道「什么时候该请求」——这个决定权留给调用方(组件里写 onMounted 或按钮点击)。这就是「逻辑复用」和「写死一个页面」的区别。
  • 返回一个对象,里面是一组 ref 和函数。用对象返回、调用方按需解构,比返回数组更清楚(不用记顺序)。
  • 每次调用都是独立的一份:列表页调用一次得到自己的 list,收藏页再调用一次得到另一份 list,互不影响。这是它和「全局单例」最重要的区别——如果你需要全局共享,那是第 8 章 Pinia 的活。
  • 参数化是它的价值所在:把「接口地址、筛选条件、每页条数」做成参数,一个函数就覆盖了「作品列表 / 按朝代筛 / 按热度排 / 搜索」全部场景。要改接口超时、统一错误文案,只改这一个文件。
避坑如果组合式函数内部要用生命周期钩子(比如自动在 onMounted 里加载),必须保证这个组合式函数是在组件的 setup 同步阶段被调用的。写成这样就会失效:
setTimeout(() => useArticleList(), 100)、或者在 await fetch() 之后再调用它——Vue 找不到当前组件实例,控制台会警告 onMounted is called when there is no active component instance to be associated with,钩子不生效、组件卸载时也不会清理。规矩:组合式函数只在 <script setup> 顶层(或另一个同步调用的组合式函数里)使用。

综合:进页面就加载作品列表

onMounted + composable 合体

把本章的两块拼起来:组合式函数负责「怎么请求」,onMounted 负责「什么时候请求」,页面组件只负责「长什么样」。这就是「古典文学网」列表页的正式形态。

src/views/ArticleListView.vuevue
<script setup>
import { ref, onMounted } from 'vue'
import { useArticleList } from '@/composables/useArticleList'

// ① 一行接入列表逻辑
const { list, total, loading, error, fetchArticles } = useArticleList()

// 当前选中的分类:默认「全部」
const currentCategory = ref(null)

// ② 进页面就按当前分类加载
onMounted(() => fetchArticles({ category_id: currentCategory.value }))

// ③ 切分类时重新加载(fetchArticles 是组合式函数给的,直接复用)
function switchCategory(categoryId) {
  currentCategory.value = categoryId
  fetchArticles({ category_id: categoryId })
}
</script>

<template>
  <div class="article-list">
    <h2>作品列表<span class="meta">共 {{ total }} 篇</span></h2>

    <!-- 分类切换:用第 4 章的 v-for + :key -->
    <div class="tabs">
      <button
        v-for="cat in [{ id: null, name: '全部' }, { id: 1, name: '唐诗' }, { id: 2, name: '宋词' }]"
        :key="cat.id"
        @click="switchCategory(cat.id)"
      >{{ cat.name }}</button>
    </div>

    <!-- 第 4 章的四态渲染,一行都不用改 -->
    <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>
        <b>{{ item.title }}</b> —— {{ item.author }} · {{ item.dynasty }}
      </li>
    </ul>
  </div>
</template>
页面效果组件渲染结果
运行结果: 浏览器显示效果: 作品列表 共 6 篇 [ 全部 ] [ 唐诗 ] [ 宋词 ] 加载中…… → [推荐] 静夜思 —— 李白 · 唐 春望 —— 杜甫 · 唐 [推荐] 念奴娇·赤壁怀古 —— 苏轼 · 宋 声声慢 —— 李清照 · 宋 天净沙·秋思 —— 马致远 · 元 陋室铭 —— 刘禹锡 · 唐 -- 点「唐诗」:重新请求 GET /api/article/list?category_id=1 -- 显示「共 3 篇」,列表只剩静夜思 / 春望 / 陋室铭 -- 切换过程中会短暂显示「加载中……」,因为 loading 被重新置为 true

最后把这一章涉及的文件放进项目骨架里看一眼——页面、组件、逻辑、工具各归各位,这是 Vue 3 项目最标准的目录结构。

项目目录shell
hiwen-web/
├── index.html
├── package.json
├── vite.config.js
└── src/
    ├── main.js                 # 入口:createApp(App).mount('#app')
    ├── App.vue                 # 根组件
    ├── api/                    # 接口封装(第 9 章 axios 会专门讲)
    ├── composables/            # ★ 组合式函数:有状态的逻辑放这里
    │   ├── useArticleList.js
    │   ├── useCommentList.js
    │   └── useUser.js
    ├── components/             # 通用组件:跨页面复用的 UI(第 6 章)
    │   ├── ArticleCard.vue
    │   └── ArticleList.vue
    ├── views/                  # 页面级组件:一个路由对应一个(第 7 章)
    │   ├── ArticleListView.vue
    │   └── ArticleDetailView.vue
    ├── router/                 # 路由配置(第 7 章)
    ├── stores/                 # 全局状态(第 8 章 Pinia)
    └── utils/                  # 无状态工具函数:日期格式化、防抖等
终端输出命令行执行结果
运行结果: -- 三条判断线,帮你决定一个文件该放哪: components/ 复用的「界面」 → 有模板,能被别的页面 import composables/ 复用的「有状态逻辑」 → 没有模板,导出的是 ref 和函数 utils/ 复用的「纯函数」 → 输入进、输出出,跟 Vue 没关系 -- 反例:把 useArticleList 放进 components/ 里,新同事会以为它是个组件, -- 打开却没有 <template>,一脸问号
讲透:这一章你真正学到的是「分层」页面组件里只剩下三件事:调用逻辑(一行)、声明时机(onMounted)、决定长相(模板)。数据怎么来、错误怎么处理、loading 什么时候翻——全都在 useArticleList 里。
这就是 Vue 3 组合式 API 想给你的东西:按「逻辑职责」组织代码,而不是按「选项类型」堆在一起。写多了你会发现,换了业务、换了接口,变的是组合式函数;页面组件的骨架几乎不用动。
动手练习
1下面两种写法,哪一个能正常拿到 DOM?为什么?① 在 <script setup> 顶层直接 document.querySelector('.list');② 在 onMounted(() => ...) 里做同样的事。
2参照 useArticleList(),为书评写一个 useCommentList(articleId) 组合式函数:内部管理 list / loading / error,暴露 fetchComments(),接口用 GET /api/comment/list?article_id=1&page=1&page_size=10。再写一段组件代码,在 onMounted 里加载并渲染。
3迁移练习:「红影赏析」首页有个「热映电影」区块,需要一进页面就加载数据、每隔 30 秒自动刷新一次,组件离开时要能干净收场。请说明:哪些逻辑放进 useHotMovies(),哪些留在页面组件里?定时器该在哪两个钩子里起和停?
参考答案
练习答案(按题分块).vuevue
<!-- 第 1 题 -->
<!-- 只有 ② 能拿到。
     ① 在 setup 顶层执行时,组件实例刚被创建,模板还没渲染成真实 DOM,
        document.querySelector('.list') 返回 null —— 这不是语法错误,是时机不对。
     ② onMounted 的回调在「挂载完成」后才执行,此时 .list 一定已经在页面上,
        所以拿得到。
     顺带一句:能不用 querySelector 就不用 —— 用模板 ref(<ul ref="listRef">)
     更符合 Vue 的风格,也避免了「同一页面上有多个 .list」时选错元素。 -->

<!-- 第 2 题:src/composables/useCommentList.js -->
import { ref } from 'vue'

const BASE_URL = 'http://127.0.0.1:5000/api'

export function useCommentList(articleId) {
  const list = ref([])
  const loading = ref(false)
  const error = ref('')

  async function fetchComments(page = 1) {
    loading.value = true
    error.value = ''
    try {
      const query = new URLSearchParams({ article_id: articleId, page, page_size: 10 })
      const res = await fetch(`${BASE_URL}/comment/list?${query}`)
      const json = await res.json()
      if (json.code === 200) {
        list.value = json.data.list
      } else {
        error.value = json.msg
      }
    } catch (e) {
      error.value = '网络异常,请稍后再试'
    } finally {
      loading.value = false
    }
  }

  return { list, loading, error, fetchComments }
}

<!-- 第 2 题:页面里这样用 -->
<script setup>
import { onMounted } from 'vue'
import { useCommentList } from '@/composables/useCommentList'

const { list, loading, error, fetchComments } = useCommentList(1)

onMounted(() => fetchComments())
</script>

<template>
  <p v-if="loading">加载中……</p>
  <p v-else-if="error">{{ error }}</p>
  <p v-else-if="list.length === 0">还没有书评,来写第一条吧</p>
  <ul v-else>
    <li v-for="c in list" :key="c.id">{{ c.user.nickname }}:{{ c.content }}</li>
  </ul>
</template>

<!-- 第 3 题(红影赏析) -->
<!-- 放进 useHotMovies() 的:
       · movies / loading / error 三个状态
       · fetchMovies():请求 GET /api/movie/hot
       · 如果希望「自动刷新」是这段逻辑的固有行为,也可以把
         setInterval 的起停写进组合式函数(在内部用 onMounted / onUnmounted)——
         但要注意:这样调用方就无法决定刷新频率了,所以更推荐把
         「30 秒刷新一次」留在页面组件里,由页面决定刷新策略。

     留在页面组件里的:
       · onMounted(() => { fetchMovies(); timer = setInterval(fetchMovies, 30000) })
       · onUnmounted(() => { clearInterval(timer); timer = null })
       · 「什么时候加载、多久刷一次」属于页面策略,不属于逻辑本身

     为什么这样分:组合式函数负责「怎么拿到并管理数据」,
     页面组件负责「什么时候拿、拿完怎么用」。定时器由谁起,就由谁停。 -->
页面效果组件渲染结果
运行结果: -- 第 1 题:① 打印 null(TypeError: Cannot read properties of null); -- ② 打印 <ul class="list">...</ul>,元素真实存在 -- 第 2 题:浏览器显示效果 加载中…… → 管理员:短短二十字,写尽思乡之情。 青莲居士:同感! (list 为空时显示「还没有书评,来写第一条吧」) -- 第 3 题:页面打开时显示热映列表,之后每 30 秒自动刷新一次(控制台可见日志); -- 路由跳走后组件卸载,控制台不再出现刷新日志 —— 说明定时器已清理干净

第 3 题的重点不是「写出定时器」,而是想清楚「什么属于逻辑、什么属于策略」。逻辑进组合式函数,策略留在页面——这条线画对了,你的代码会一直好维护。

本章小结