「作品列表页一打开,数据就自己出现了」——这背后是两件事:知道「什么时候该去发请求」(生命周期钩子),以及把「请求列表」这套有状态的逻辑打包成一个能到处复用的函数(组合式函数)。学完这一章,你写的就不再是一个个孤立的页面,而是能拼装起来的逻辑积木。
<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>
<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>
<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>
<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>
<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>
<!-- 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>
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 }
}
然后在组件里用——注意只是「一行调用」,原来那 30 行全部消失:
<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 负责「什么时候请求」,页面组件只负责「长什么样」。这就是「古典文学网」列表页的正式形态。
<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>
最后把这一章涉及的文件放进项目骨架里看一眼——页面、组件、逻辑、工具各归各位,这是 Vue 3 项目最标准的目录结构。
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/ # 无状态工具函数:日期格式化、防抖等
<!-- 第 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 })
· 「什么时候加载、多久刷一次」属于页面策略,不属于逻辑本身
为什么这样分:组合式函数负责「怎么拿到并管理数据」,
页面组件负责「什么时候拿、拿完怎么用」。定时器由谁起,就由谁停。 -->
第 3 题的重点不是「写出定时器」,而是想清楚「什么属于逻辑、什么属于策略」。逻辑进组合式函数,策略留在页面——这条线画对了,你的代码会一直好维护。