Vue3 全栈实战第四周:watch、nextTick、性能优化与 keep-alive 实战记录

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 后,markAsReadmarkAllAsRead 一开始还是原来"直接改数组内部元素属性"的写法,界面完全不更新:

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:整合优化实战

不学新知识点,把前四天甚至前三周的内容过一遍走查,做了三件事。

响应式选型复查

selectedMailmailStats 也从 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.vuedefineExposeemithandleInputhandleKeydown 这几处此前漏加了功能注释(见 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 让不必要的重新渲染也能跳过。这周踩的坑几乎都围绕同一个核心:改成"更浅"的响应式之后,必须同步检查所有修改这份数据的地方是不是还在用"深层修改"的老写法。

掌握的知识点自检:

  • watch vs watchEffect:要不要对比新旧值、要不要自动收集依赖
  • 防抖: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(markAsReadmarkAllAsReaddeleteMail)写单元测试
  • 组件测试:MailItem.vueMailSearch.vue 的渲染和交互测试
  • Mock:模拟 API 请求、模拟路由跳转

选在这个时间点学测试,是因为 mailStore 的增删改查逻辑和这周补的响应式选型都已经稳定,正好是练习"给已有业务代码补测试"的合适素材,而且第9周要进入全栈项目阶段,测试思维最好提前养成。


「Vue前端转全栈实战」系列持续更新,欢迎关注。 有问题欢迎评论区交流,遇到的问题和解决过程都会记录下来。

相关推荐
布兰妮甜2 小时前
Vue 大型页面性能优化:四个核心策略
vue.js·性能优化·虚拟滚动·组件缓存·按需加载
di24k24k2 小时前
多个 el-form 共用同一 ref 导致表单校验部分失效
前端·javascript·vue.js·elementui
执子念的飞鱼4 小时前
Vue 3 文件预览落地:鉴权 Blob、Worker/WASM 与内网子路径部署
vue.js·webassembly
无人生还4 小时前
从 Vue3 到 React · 快速上手系列第 11 篇:状态管理
前端·vue.js·react.js
其美杰布-富贵-李5 小时前
Vue 3 模块导入教程:import、export 与文件组织
前端·javascript·vue.js
布兰妮甜5 小时前
Vue 路由进阶:路由守卫权限控制、动态路由、懒加载、路由缓存
vue.js·懒加载·动态路由·路由守卫权限控制·路由缓存
Cobyte6 小时前
Vite & Rollup 插件开发实践
前端·javascript·vue.js
ShuiShenHuoLe6 小时前
vue如何实现国际化,它来了!
前端·javascript·vue.js
其美杰布-富贵-李6 小时前
Vue Router 实战教程
前端·javascript·vue.js