Vue3 全栈实战第四周:watch/nextTick/性能优化/keep-alive 完整记录
「Vue前端转全栈实战」系列第六篇。这周完成了 Vue3 进阶特性的全部核心内容:响应式数据的高级监听方式、DOM 更新时序处理、组件性能优化、路由组件缓存,外加一次整周的整合优化。这篇把知识点、修改的文件、踩坑全部记录下来。
目录
- 本周项目结构
- [Day1:watch / watchEffect](#Day1:watch / watchEffect "#day1watch--watcheffect")
- [Day2:nextTick + defineExpose](#Day2:nextTick + defineExpose "#day2nexttick--defineexpose")
- [Day3:组件性能优化(v-memo / shallowRef / markRaw)](#Day3:组件性能优化(v-memo / shallowRef / markRaw) "#day3%E7%BB%84%E4%BB%B6%E6%80%A7%E8%83%BD%E4%BC%98%E5%8C%96v-memo--shallowref--markraw")
- [Day4:keep-alive 缓存组件](#Day4:keep-alive 缓存组件 "#day4keep-alive-%E7%BC%93%E5%AD%98%E7%BB%84%E4%BB%B6")
- Day5:整合优化实战
- 响应式优化工具对比
- 本周总结
- 下周计划
本周项目结构
bash
src/
components/
MailSearch.vue ← 新增:搜索框组件,支持父组件调用清空/聚焦方法
stores/
mailStore.ts ← 修改:新增 watch/watchEffect 联动,mails/selectedMail/mailStats 改用 shallowRef
components/
MailList.vue ← 修改:v-memo 缓存列表项
MailItem.vue ← 修改:inject 主题模式,背景色跟随主题
views/
InboxView.vue ← 修改:防抖搜索、watchEffect 状态日志、使用 MailSearch、onActivated/onDeactivated 恢复滚动位置
App.vue ← 修改:RouterView 用 v-slot 包 keep-alive,只缓存 InboxView
Day1:watch / watchEffect
为什么需要 watch / watchEffect
computed 只能同步返回一个值,遇到"数据变化时要顺带做点别的事"(打日志、改标题栏、发请求、记历史)就不够用了,需要专门的监听机制:
computed → 算出一个新值,用来展示
watch / watchEffect → 数据变化时执行一段副作用逻辑,不一定要返回值
两者的区别
typescript
// watch:手动指定监听目标,能拿到新旧值,默认不立即执行
watch(selectedMail, (newMail, oldMail) => {
console.log('旧邮件:', oldMail?.subject)
console.log('新邮件:', newMail?.subject)
})
// watchEffect:自动收集函数内用到的所有响应式依赖,没有旧值,立即执行一次
watchEffect(() => {
console.log('当前统计:', mails.value.length)
})
判断标准:需要对比新旧值、或者只想监听某个特定数据源 → watch;只是想"数据变了就重新算一遍"、不关心具体是哪个依赖触发的 → watchEffect。
修改文件:src/stores/mailStore.ts(watch 相关部分)
typescript
/**
* 功能:监听邮件列表变化,自动统计各状态数量
* 场景:邮件增删时自动更新统计,不需要手动调用
*/
const mailStats = shallowRef({
total: 0,
unread: 0,
read: 0,
})
// watchEffect:自动追踪 mails 依赖,mails 变化时自动重新计算
watchEffect(() => {
mailStats.value = {
total: mails.value.length,
unread: mails.value.filter(m => !m.isRead).length,
read: mails.value.filter(m => m.isRead).length
}
console.log('[watchEffect] 邮件统计更新:', mailStats.value)
})
/**
* 功能:监听选中邮件变化,记录查看历史
* 场景:用户切换邮件时记录浏览轨迹
*/
const viewHistory = ref<number[]>([])
watch(selectedMail, (newMail: MailV2 | null, oldMail: MailV2 | null) => {
console.log('[watch] 选中邮件变化')
console.log('旧邮件:', oldMail?.subject ?? '无')
console.log('新邮件:', newMail?.subject ?? '无')
if(newMail && !viewHistory.value.includes(newMail.id)) {
viewHistory.value.push(newMail.id)
}
})
/**
* 功能:监听未读数量变化,控制浏览器标签页标题
* 场景:有未读邮件时在标题显示数量提醒
*/
watch(unreadCount, (newCount) => {
if (newCount > 0) {
document.title = `(${newCount})邮件客户端`
} else {
document.title = '邮件客户端'
}
}, { immediate: true }) // 立即执行一次,不等数据变化
修改文件:src/views/InboxView.vue(防抖搜索部分)
搜索框还实现了防抖:输入停止 300ms 后才真正触发过滤计算,避免每敲一个字符都重新跑一遍数组过滤。
typescript
// 防抖搜索:输入停止 300ms 后才真正过滤
// 避免每次输入都触发计算
let debounceTime: ReturnType<typeof setTimeout> | null = null
const debouncedKeyword = ref('')
watch(keyword, (newVal) => {
if(debounceTime) clearTimeout(debounceTime)
debounceTime = setTimeout(() => {
debouncedKeyword.value = newVal
console.log('[watch] 防抖搜索触发:', newVal)
}, 300)
})
// 用 debouncedKeyword 过滤,而不是直接用 keyword
const filteredMails = computed(()=>{
if (!debouncedKeyword.value) return mails.value
return mails.value.filter(mail =>
mail.subject.includes(debouncedKeyword.value) ||
mail.from.includes(debouncedKeyword.value) ||
mail.body.includes(debouncedKeyword.value)
)
})
// watchEffect 示例:自动追踪多个依赖
watchEffect(() => {
console.log(
'[watchEffect] 当前状态:',
`列表${mails.value.length}封`,
`过滤后${filteredMails.value.length}封`,
`搜索词「${debouncedKeyword.value}」`
)
})
Day2:nextTick + defineExpose
为什么需要 nextTick
Vue 的 DOM 更新是异步的:改了响应式数据之后,DOM 不会立刻同步更新,而是要等到下一个"tick"才会真正渲染出来。
ini
isExpanded.value = true
→ 数据变了,但 DOM 还没更新
→ 这时候直接操作 DOM(比如 focus 输入框)会失败,因为它还不存在
→ 必须 await nextTick(),等 DOM 真正更新完,才能安全操作
为什么需要 defineExpose
<script setup> 默认对外部是封闭的:父组件通过 ref 拿到子组件实例,访问不到任何内部变量和函数,必须显式 defineExpose 才能暴露出去。
typescript
// 子组件暴露
defineExpose({ clear, focus, keyword })
// 父组件调用
const searchRef = ref<InstanceType<typeof MailSearch> | null>(null)
searchRef.value?.clear()
新增文件:src/components/MailSearch.vue
vue
<script setup lang="ts">
import { ref, nextTick } from 'vue'
/**
* 功能:邮件搜索框组件
* 场景:收件箱顶部搜索框,支持父组件调用清空和聚焦方法
*/
const keyword = ref('')
const inputRef = ref<HTMLInputElement | null>(null)
const isExpanded = ref(false)
/**
* 功能:展开搜索框并自动聚焦
* 场景:点击搜索图标时展开,等 DOM 渲染后再聚焦
* 注意:必须用 nextTick,展开是异步的,直接 focus 无效
*/
async function expand() {
isExpanded.value = true
// 等 DOM 更新后再聚焦,不等的话 input 还没渲染
await nextTick()
inputRef.value?.focus()
}
/**
* 功能:清空搜索框并聚焦
* 场景:点击清空按钮,清空后自动聚焦方便继续输入
*/
async function clear() {
keyword.value = ''
await nextTick()
inputRef.value?.focus()
}
/**
* 功能:关闭搜索框
* 场景:点击外部或按 ESC 关闭
*/
function collapse() {
isExpanded.value = false
keyword.value = ''
}
/**
* 功能:手动聚焦
* 场景:父组件需要让搜索框聚焦时调用
*/
function focus() {
inputRef.value?.focus()
}
/**
* 功能:暴露给父组件的方法和数据
* 场景:父组件通过 ref 拿到 InboxView 里的搜索框实例,调用清空/聚焦/关闭
*/
defineExpose({
clear,
focus,
collapse,
keyword, // 父组件可以读取当前关键词
isExpanded, // 父组件可以读取展开状态
})
/**
* 定义可以发出的事件
* search:输入内容变化,通知父组件重新过滤列表
* clear:点击清空按钮,通知父组件重置过滤状态
*/
const emit = defineEmits<{
search: [keyword: string]
clear: []
}>()
/**
* 功能:处理输入事件
* 场景:每次输入触发,通知父组件用新关键词过滤邮件
*/
function handleInput() {
emit('search', keyword.value)
}
/**
* 功能:处理键盘事件
* 场景:按 ESC 键快速关闭搜索框
*/
function handleKeydown(e: KeyboardEvent) {
if (e.key === 'Escape') {
collapse()
}
}
</script>
<template>
<div style="display:flex;align-items:center;gap:8px">
<!-- 未展开:显示搜索图标按钮 -->
<button
v-if="!isExpanded"
style="padding:6px 10px;cursor:pointer;border:1px solid #ddd;border-radius:6px"
@click="expand"
>
🔍 搜索
</button>
<!-- 展开:显示搜索框 -->
<div v-else style="display:flex;align-items:center;gap:6px">
<input
ref="inputRef"
v-model="keyword"
placeholder="搜索主题、发件人、内容..."
style="padding:6px 10px;border:1px solid #1890ff;border-radius:6px;outline:none;width:220px"
@input="handleInput"
@keydown="handleKeydown"
>
<!-- 有内容时显示清空按钮 -->
<button
v-if="keyword"
style="padding:6px 8px;cursor:pointer;border:1px solid #ddd;border-radius:6px"
@click="clear(); emit('clear')"
>
✕
</button>
<!-- 关闭搜索框 -->
<button
style="padding:6px 8px;cursor:pointer;border:1px solid #ddd;border-radius:6px;color:#999"
@click="collapse"
>
关闭
</button>
</div>
</div>
</template>
在 InboxView.vue 里使用
vue
<script setup lang="ts">
import MailSearch from '@/components/MailSearch.vue';
// 搜索组件的 ref,用来调用子组件方法
const searchRef = ref<InstanceType<typeof MailSearch> | null>(null)
// 父组件主动清空搜索框(演示 defineExpose)
async function handleResetSearch() {
searchRef.value?.collapse()
keyword.value = ''
debouncedKeyword.value = ''
// nextTick 等 DOM 更新后打印日志
await nextTick()
console.log('[nextTick] 搜索框已重置,当前关键词:', searchRef.value?.keyword)
}
</script>
<template>
<!-- 用 MailSearch 组件替换原来的搜索框 -->
<MailSearch
ref="searchRef"
@search="keyword = $event"
@clear="keyword = ''"
/>
<button @click="handleResetSearch">重置搜索</button>
</template>
@search="keyword = $event" 把 MailSearch 内部的 keyword 同步给父组件的 keyword,再经过防抖变成 debouncedKeyword 去真正过滤列表------三层数据(组件内输入 → 父组件关键词 → 防抖后关键词)职责分得很清楚。
Day3:组件性能优化(v-memo / shallowRef / markRaw)
为什么需要这三个工具
三者的判断标准都是同一个问题:这块数据/DOM 值不值得被响应式系统"盯着"?
需要缓存 DOM、跳过不必要的重新渲染 → v-memo
数据整体从外部拉取/整体替换,不改内部属性 → shallowRef
彻底不需要响应式追踪的静态数据/第三方实例 → markRaw
修改文件:src/stores/mailStore.ts(响应式选型部分)
typescript
/**
* 功能:收件箱邮件列表
* 场景:收件箱页面展示所有邮件
* 用 shallowRef:邮件列表只整体替换,不局部修改内部属性,
* 减少深度追踪的性能开销
*/
const mails = shallowRef<MailV2[]>([])
/**
* 功能:邮件客户端静态配置
* 场景:API 地址、超时时间、分页大小等不会变化的配置
* 用 markRaw 标记:不需要响应式追踪,节省性能
*/
const mailClientConfig = markRaw({
apiBaseUrl: import.meta.env.VITE_API_BASE_URL,
pageSize: 20,
maxRetry: 3,
timeout: 10000,
supportedFormats: ['pdf', 'doc', 'docx', 'jpg', 'png'],
})
/**
* 功能:标记邮件为已读
* 场景:点开邮件详情时自动标记已读
*/
function markAsRead(mailId: number) {
// shallowRef 检测不到内部属性变化,必须整体替换成新数组
mails.value = mails.value.map(m =>
m.id === mailId ? {...m, isRead: true} : m
)
}
/**
* 功能:标记所有邮件为已读
* 场景:工具栏「全部已读」按钮
*/
function markAllAsRead() {
mails.value = mails.value.map(m => ({...m, isRead: true}))
}
修改文件:src/components/MailList.vue(v-memo 部分)
vue
<script setup lang="ts">
import MailItem from './MailItem.vue';
import type { MailV2 } from '@/types/mail';
/**
* 功能:邮件列表组件
* 场景:收件箱页面展示所有邮件,支持选中状态
*/
interface Props {
mails: MailV2[]
selectedId?: number | null
loading?: boolean
}
const props = withDefaults(defineProps<Props>(), {
selectedId: null,
loading: false
})
/**
* 透传 MailItem 的事件给父组件
* MailList 自己不处理点击,交给 InboxView 处理
*/
const emit = defineEmits<{
mailClick: [id: number]
mailMarkRead: [id: number]
}>()
</script>
<template>
<div>
<p v-if="props.loading" style="color:#999">加载中...</p>
<p v-else-if="props.mails.length === 0" style="color:#999">
暂无邮件
</p>
<ul v-else style="list-style:none;padding:0">
<!--
v-memo 依赖:邮件已读状态、主题、是否被选中
三者都不变时跳过这一行的重新渲染,减少列表大时的渲染开销
注意:不能依赖 mail.createdAt/from 等只读不变的字段,加了也没意义
-->
<MailItem
v-for="mail in mails"
:key="mail.id"
v-memo="[mail.isRead, mail.subject, props.selectedId === mail.id]"
:id="mail.id"
:subject="mail.subject"
:from="mail.from"
:is-read="mail.isRead"
:created-at="mail.createdAt"
:is-selected="props.selectedId === mail.id"
@click="emit('mailClick', $event)"
@mark-read="emit('mailMarkRead', $event)"
/>
</ul>
</div>
</template>
三者的关系可以列个表,放在文末响应式优化工具对比里统一看。
Day3 踩坑记录
坑:shallowRef 之后忘记同步适配已有函数
mails 改成 shallowRef 后,markAsRead、markAllAsRead 一开始还是原来"直接改数组内部元素属性"的写法,界面完全不更新:
typescript
// ❌ shallowRef 检测不到内部属性变化
function markAsRead(mailId: number) {
const mail = mails.value.find(m => m.id === mailId)
if (mail) { mail.isRead = true }
}
改成 map 生成新数组整体替换才解决(见上面"修改文件"里的最终版本)。deleteMail 因为本来就用 filter 返回新数组,不需要改。
Day4:keep-alive 缓存组件
为什么需要 keep-alive
Vue Router 默认离开页面组件就销毁,回来重新创建,之前输入的搜索关键词、滚动位置全部丢失:
进入 InboxView → 搜索框输入内容 → 点击邮件跳转详情页
→ InboxView 组件销毁,搜索框状态丢失
点击返回 → InboxView 重新创建
→ 搜索框是空的,之前的输入没了
keep-alive 把组件缓存在内存里,切走再切回来直接复用实例,不重新创建。
修改文件:src/App.vue
vue
<script setup lang="ts">
import { ref, provide } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { themeModeKry } from './types/injectionKeys';
import type { ThemeMode } from './types/injectionKeys';
/**
* 功能:应用根组件
* 场景:提供全局主题配置,包含导航栏和路由出口
*/
const router = useRouter()
const route = useRoute()
const themeMode = ref<ThemeMode>('light')
provide(themeModeKry, themeMode)
function toggleTheme(){
themeMode.value = themeMode.value === 'light' ? 'dark' : 'light'
}
</script>
<template>
<div :style="{ display: 'flex', height: '100vh' }">
<!-- 左侧导航栏(略) -->
<!-- 右侧内容区 -->
<div style="flex:1;padding:20px;overflow-y:auto">
<!--
RouterView 必须用 v-slot 形式才能把组件引用交给 keep-alive,
写成简写 <RouterView /> 拿不到组件引用,keep-alive 缓存不了。
include 里的字符串必须和目标组件 defineOptions({ name }) 声明的名字完全一致。
-->
<router-view v-slot="{ Component }">
<keep-alive :include="['InboxView']">
<component :is="Component" />
</keep-alive>
</router-view>
</div>
</div>
</template>
只缓存 InboxView:搜索状态、滚动位置值得保留;MailDetailView 等其他页面每次都该显示最新内容,不缓存。
修改文件:src/views/InboxView.vue(onActivated / onDeactivated 部分)
typescript
defineOptions({
name: 'InboxView' // keep-alive 的 include 靠这个名字匹配
})
/**
* 功能:记录收件箱列表的滚动容器
* 场景:离开页面前记录滚动位置,返回时恢复
*/
const listContainerRef = ref<HTMLElement | null>(null)
let savedScrollTop = 0
/**
* 功能:组件从缓存中被重新激活时触发
* 场景:从详情页返回收件箱,恢复之前的滚动位置
*/
onActivated(() => {
console.log('[InboxView] 重新激活,恢复滚动位置:', savedScrollTop)
if (listContainerRef.value) {
listContainerRef.value.scrollTop = savedScrollTop
}
})
/**
* 功能:组件进入缓存、暂时离开视图时触发
* 场景:点击某封邮件跳转详情页前,记录当前滚动位置
*/
onDeactivated(() => {
if (listContainerRef.value) {
// savedScrollTop 是普通 let 变量,不是 ref:
// 只在两个钩子之间临时存一下数值,不出现在模板里,不需要响应式
savedScrollTop = listContainerRef.value.scrollTop
console.log('[InboxView] 进入缓存,保存滚动位置:', savedScrollTop)
}
})
被缓存的组件有两个专属生命周期钩子,onMounted 只在第一次创建时触发一次,之后再次进入走的是 onActivated------数据初始化逻辑如果只写在 onMounted 里,缓存后再进入页面不会重新执行,这类"每次进入都要做的事"必须放进 onActivated。
Day5:整合优化实战
不学新知识点,把前四天甚至前三周的内容过一遍走查,做了三件事。
响应式选型复查
把 selectedMail、mailStats 也从 ref 改成了 shallowRef(两者都是整体替换模式,符合 shallowRef 的适用条件):
typescript
/**
* 功能:当前选中的邮件
* 场景:点击邮件列表某一行,右侧展示邮件详情
* 用 shallowRef:selectMail 每次都是整体替换成新对象,不修改内部属性
*/
const selectedMail = shallowRef<MailV2 | null>(null)
viewHistory 保持 ref 不变,因为用到了 push 做内部追加,shallowRef 检测不到这种修改:
typescript
watch(selectedMail, (newMail) => {
if (newMail && !viewHistory.value.includes(newMail.id)) {
// push 是往数组内部追加元素,不是重新赋值 .value。
// 如果这里改成 shallowRef,push 之后 Vue 检测不到变化,界面不会更新------
// 这个必须留着 ref。这也是判断标准的反向验证:
// 只要用到 push/splice/直接改属性这类"内部修改"的写法,就不能用 shallowRef。
viewHistory.value.push(newMail.id)
}
})
性能优化验证:v-memo 真的生效了吗
用 onUpdated 生命周期钩子验证,过程踩了两层坑,值得完整记一下。
第一阶段:日志放错了位置
一开始直接在 MailItem.vue 的 <script setup> 顶层写 console.log,标记邮件已读之后测试,发现只有组件第一次挂载时打印过一次,之后不管怎么操作都不再打印。
原因:<script setup> 里的代码只在组件创建 的时候执行一次,之后组件重新渲染只会重新执行 <template> 部分,不会重新跑一遍 <script setup> 的代码------这行 console.log 根本验证不出"重新渲染了没有"。改用 onUpdated(每次组件重新渲染完成后才触发)才是正确的验证方式:
typescript
import { onUpdated } from 'vue'
// 临时调试:每次组件重新渲染完成后触发,验证完删掉
onUpdated(() => {
console.log('[MailItem] 重新渲染了:', props.subject)
})
第二阶段:验证场景选错了
换成 onUpdated 之后,先用"切换主题"来测试,结果发现每个 MailItem 都打印了日志,看起来 v-memo 完全没拦住任何重新渲染。
排查后发现:MailItem.vue 内部用 inject 直接订阅了 themeMode:
typescript
const themeMode = inject(themeModeKry, ref('light'))
v-memo 管的是"父组件 MailList 要不要重新 diff 这个子组件节点",但 themeMode 变化时,每个 MailItem 实例是自己 通过 inject 直接订阅了这个响应式数据源,跳过了父组件的渲染流程直接触发自身更新------这完全在 v-memo 的管辖范围之外,是组件自己的独立更新路径。
这次"重新渲染"是必须发生的正确行为(背景色要跟着主题变化),不是 bug,只是选错了验证场景。换成标记已读这种真实业务场景测试:已读状态变化时,只有对应那一封邮件的 MailItem 重新渲染,其余邮件不受影响,v-memo 是生效的。
代码注释走查
补齐了几处遗漏------v-memo 依赖数组之前完全没有注释说明选了哪三个值、为什么(见 Day3 修好之后的样子);MailSearch.vue 里 defineExpose、emit、handleInput、handleKeydown 这几处此前漏加了功能注释(见 Day2 补全之后的样子)。
clearInbox 的注释和代码实际行为对不上
typescript
/**
* 功能:批量更新($patch 示例) ← 这句和实际代码不符
* 场景:同时修改多个 State,比分别赋值性能好
*/
function clearInbox(){
mails.value = []
selectedMail.value = null
}
想顺手改成真正的 $patch 用法练习,结果报错:
scss
找不到名称"$patch"。ts(2304)
原因是 $patch、$reset 这些方法是 Pinia 在 setup 函数执行完 、根据 return 内容生成 Store 实例之后才挂上去的,Composition 风格 Store 内部的函数拿不到自己所在的实例。$patch 只能在组件里 通过 mailStore.$patch(...) 调用:
typescript
// ✅ Store 内部就用普通赋值,注释改成符合实际代码行为的说法
/**
* 功能:清空收件箱数据
* 场景:切换文件夹前先清空,避免短暂显示旧数据
* 注意:Composition 风格 Store 内部拿不到 $patch(那是给外部调用者用的)
*/
function clearInbox(){
mails.value = []
selectedMail.value = null
}
// ✅ $patch 是给组件里的调用者用的
// SomeComponent.vue
mailStore.$patch({ mails: [], selectedMail: null })
响应式优化工具对比
这周学完之后,Vue3 响应式性能优化的完整图谱:
| 工具 | 作用层面 | 最外层是否响应式 | 内部是否响应式 | 典型场景 |
|---|---|---|---|---|
ref |
数据 | 是 | 是(深度代理) | 频繁修改内部字段的数据 |
shallowRef |
数据 | 是(.value 替换触发) |
否 | 大数组/对象整体替换 |
markRaw |
数据 | 否 | 否 | 第三方库实例、静态配置 |
v-memo |
DOM 渲染 | --- | --- | 列表项渲染开销大,依赖不变跳过重渲染 |
keep-alive |
组件实例 | --- | --- | 路由切换间保留组件状态(滚动位置、输入内容) |
本周总结
最大的收获:理解了"响应式不是越深越好"
前三周一路用 ref/reactive 默认深度代理,这周才第一次系统想清楚:不是所有数据都需要被"盯着内部每一层",整体替换的数据用 shallowRef 能省掉不必要的代理开销;彻底不变的配置用 markRaw 直接跳过响应式;DOM 层面同理,v-memo 让不必要的重新渲染也能跳过。这周踩的坑几乎都围绕同一个核心:改成"更浅"的响应式之后,必须同步检查所有修改这份数据的地方是不是还在用"深层修改"的老写法。
掌握的知识点自检:
-
watchvswatchEffect:要不要对比新旧值、要不要自动收集依赖 - 防抖:
watch里用setTimeout+clearTimeout实现输入防抖 -
nextTick:DOM 更新是异步的,操作 DOM 前要等它 -
defineExpose:<script setup>默认封闭,暴露方法/数据给父组件需要显式声明 -
v-memo:依赖数组不变时跳过整个节点的重新渲染 -
shallowRef:只在整体替换时触发更新,改内部属性要用map/展开运算符生成新对象 -
markRaw:彻底跳过响应式,适合第三方库实例、静态配置 -
keep-alive+RouterView v-slot:缓存指定组件,需要显式defineOptions({ name })才能被include匹配 -
onActivated/onDeactivated:缓存组件专属生命周期,替代普通场景下的onMounted/onUnmounted -
<script setup>顶层代码只在组件创建时执行一次,验证"是否重新渲染"要用onUpdated -
inject是组件自己的独立响应式依赖,不受父组件层面的v-memo管辖 -
$patch只能在组件里通过 Store 实例调用,Composition 风格 Store 内部访问不到
下周计划
第五周:Vitest 单元测试
- 测试环境搭建(Vitest + Vue Test Utils)
- 给
mailStore.ts的核心 actions(markAsRead、markAllAsRead、deleteMail)写单元测试 - 组件测试:
MailItem.vue、MailSearch.vue的渲染和交互测试 - Mock:模拟 API 请求、模拟路由跳转
选在这个时间点学测试,是因为 mailStore 的增删改查逻辑和这周补的响应式选型都已经稳定,正好是练习"给已有业务代码补测试"的合适素材,而且第9周要进入全栈项目阶段,测试思维最好提前养成。
「Vue前端转全栈实战」系列持续更新,欢迎关注。 有问题欢迎评论区交流,遇到的问题和解决过程都会记录下来。