4. Vue3 核心基础(二):计算属性、侦听器与生命周期钩子

上一章我们聊了响应式数据和模板语法,算是把 Vue3 的「手脚」活动开了。但真正写业务逻辑时,你会发现光有 ref 和 reactive 还不够——数据要加工、变化要监听、组件什么时候挂载也得心里有数。这一章,我们就来啃这三块硬骨头。

本章核心脉络:计算属性解决「衍生状态」的声明式表达;侦听器处理「副作用」的精准触发;生命周期钩子让你在组件的关键时刻「插一脚」。三者配合,才能写出健壮、可维护的看板应用。

Vue3 核心基础(二)知识体系 Vue3 组件逻辑 计算属性 computed 侦听器 watch 生命周期钩子 缓存依赖 · 自动追踪 getter / setter 分离 深层监听 · 立即执行 watchEffect 自动收集 onMounted / onUnmounted onBeforeUpdate / onUpdated 三者协作:computed 声明状态 → watch 响应变化 → 生命周期管理时机

4.1 计算属性(computed)—— 别把逻辑写在模板里

先问个问题:你会在模板里写 {{ items.filter(i => i.price > 100).length }} 吗?我以前刚学 Vue 的时候就这么干过。结果模板又长又难读,而且每次渲染都要重新计算——性能浪费不说,维护起来也头疼。

计算属性就是来解决这个问题的。它本质上是一个「派生值」,基于已有的响应式数据自动计算,并且会缓存结果。只要依赖没变,它就返回缓存,不会重复执行。

我的习惯:只要模板里出现超过一个三元表达式或者链式调用,我就立刻抽成计算属性。这不仅是性能问题,更是代码可读性的底线。

<script setup>
import { ref, computed } from 'vue'

const searchQuery = ref('')
const metrics = ref([
  { name: 'CPU 使用率', value: 78, unit: '%' },
  { name: '内存占用', value: 4.2, unit: 'GB' },
  { name: '磁盘 IO', value: 256, unit: 'MB/s' }
])

// 计算属性:过滤后的指标列表
const filteredMetrics = computed(() => {
  if (!searchQuery.value) return metrics.value
  return metrics.value.filter(m => m.name.includes(searchQuery.value))
})

// 计算属性:统计高负载指标
const highLoadCount = computed(() => {
  return metrics.value.filter(m => m.value > 80).length
})
</script>

注意,计算属性默认是只读的。你可能会想:「那我能不能直接给它赋值?」可以,但需要提供 setter。不过我在项目中很少用——计算属性的 setter 容易让数据流变得混乱。如果你发现需要给计算属性赋值,多半是设计上该用普通状态了。

避坑指南:计算属性里不要做异步操作或者修改 DOM。它必须是纯函数——输入确定,输出就确定。我曾经在计算属性里调了一个 API,结果页面卡死,排查了半天才发现是计算属性触发了无限循环。

4.2 侦听器(watch)—— 精准响应变化

计算属性适合「根据已有数据算出新数据」。但有些场景不一样——比如搜索框输入后要调接口、某个值变化后要写 localStorage、或者数据变化后要触发动画。这些「副作用」操作,就该交给侦听器。

Vue3 的 watch 比 Vue2 灵活了不少。你可以监听单个 ref、多个 ref,甚至是一个 getter 函数。我个人最常用的是监听一个计算属性,然后触发异步请求。

<script setup>
import { ref, watch } from 'vue'

const keyword = ref('')
const debounceTimer = ref(null)

// 监听单个 ref
watch(keyword, (newVal, oldVal) => {
  console.log(`搜索词从 "${oldVal}" 变为 "${newVal}"`)
  
  // 防抖处理
  clearTimeout(debounceTimer.value)
  debounceTimer.value = setTimeout(() => {
    fetchSearchResults(newVal)
  }, 300)
})

// 监听多个来源
const cpuUsage = ref(45)
const memoryUsage = ref(60)

watch([cpuUsage, memoryUsage], ([newCpu, newMem], [oldCpu, oldMem]) => {
  if (newCpu > 90 || newMem > 90) {
    triggerAlert('资源使用率过高')
  }
}, { deep: true })
</script>

这里有个细节:deep: true 用于监听对象内部的变化。但要注意,深度监听开销不小。如果你只关心某个嵌套属性,不如用 getter 函数精确监听。

watch vs watchEffect:watchEffect 会自动收集依赖,不需要手动指定监听目标。适合「只要依赖变了就重新执行」的场景,比如日志记录、数据同步。但如果你需要获取旧值,或者需要懒执行(默认不立即执行),那就用 watch。

我记得有一次做实时看板,需要监听 WebSocket 推送的数据变化来更新图表。用 watch 配合 immediate: true,组件一创建就执行一次初始化,后续每次数据变化自动更新——代码干净,逻辑清晰。

4.3 生命周期钩子——在正确的时机做正确的事

组件不是凭空出现的。它要经历创建、挂载、更新、卸载这几个阶段。Vue3 提供了组合式 API 的生命周期钩子,让你在每个阶段「插一脚」。

最常用的几个:

钩子函数 触发时机 典型用途
onMounted 组件挂载到 DOM 后 发起 API 请求、操作 DOM、初始化第三方库
onBeforeUpdate 响应式数据变化,DOM 更新前 获取更新前的 DOM 状态
onUpdated DOM 更新完成后 执行依赖新 DOM 的操作
onUnmounted 组件卸载前 清除定时器、取消订阅、释放资源
onActivated 被 <KeepAlive> 缓存的组件激活时 恢复轮询、刷新数据
<script setup>
import { ref, onMounted, onUnmounted } from 'vue'

const cpuData = ref([])
let pollingTimer = null

onMounted(() => {
  // 初始化数据
  fetchCpuData()
  
  // 启动轮询
  pollingTimer = setInterval(fetchCpuData, 5000)
})

onUnmounted(() => {
  // 清理资源——这个很容易忘
  if (pollingTimer) {
    clearInterval(pollingTimer)
    pollingTimer = null
  }
})

function fetchCpuData() {
  // 模拟 API 请求
  cpuData.value.push({
    time: Date.now(),
    value: Math.random() * 100
  })
}
</script>

我曾经踩过的坑:在 onMounted 里注册了全局事件监听,但忘了在 onUnmounted 里移除。结果组件切换了几次后,事件绑定了好几层,每次触发都执行多次回调。排查了半天才发现是事件泄漏。记住:谁注册,谁销毁。

另外,Vue3 的生命周期钩子只能在 setup 中同步调用。你不能在 setTimeout 或者异步回调里写 onMounted——那样会报错。原因很简单,Vue 需要在组件初始化时收集这些钩子,异步调用就收集不到了。

4.4 三者如何协作?一个真实场景

光讲理论没意思。我们来看一个实时看板中常见的场景:搜索过滤 + 数据轮询。

<script setup>
import { ref, computed, watch, onMounted, onUnmounted } from 'vue'

// 原始数据
const allMetrics = ref([])
// 搜索关键词
const searchText = ref('')
// 轮询间隔
const POLL_INTERVAL = 3000

// 计算属性:过滤后的数据
const displayMetrics = computed(() => {
  if (!searchText.value) return allMetrics.value
  return allMetrics.value.filter(m => 
    m.name.toLowerCase().includes(searchText.value.toLowerCase())
  )
})

// 侦听器:搜索词变化时打日志(方便调试)
watch(searchText, (val) => {
  console.log(`搜索词变化: ${val},当前显示 ${displayMetrics.value.length} 条`)
})

// 生命周期:挂载时启动轮询,卸载时清理
let timer = null

onMounted(() => {
  fetchData()
  timer = setInterval(fetchData, POLL_INTERVAL)
})

onUnmounted(() => {
  clearInterval(timer)
})

async function fetchData() {
  // 模拟 API 请求
  const res = await mockApi()
  allMetrics.value = res
}
</script>

你看,这个模式在实时看板里非常典型:computed 负责数据加工,watch 负责副作用(日志、调试),onMounted 和 onUnmounted 管理生命周期。各司其职,互不干扰。

我的建议:写组件时先想清楚「哪些是状态」「哪些是派生值」「哪些是副作用」。状态用 ref/reactive,派生值用 computed,副作用用 watch 或生命周期。这个思维模型一旦建立,写出来的代码自然就清晰了。