Vue 3 派生状态与副作用:computedwatch、生命周期和组合式函数

系列导航:Vue 3 现代开发指南
上一篇:Vue 3 响应式基础
下一篇:组件契约:Props、Emits、defineModel 与插槽

响应式状态解决“数据变化后视图如何更新”,但应用还需要派生数据、异步请求、订阅和资源清理。Vue 对这些职责做了明确区分:用 computed 描述纯派生状态,用 watchwatchEffect 执行副作用,用生命周期钩子连接浏览器或第三方资源。

一、优先用 computed 表达派生状态

计算属性根据响应式依赖生成新值,并按依赖缓存:

<script setup lang="ts">
import { computed, ref } from 'vue'

const firstName = ref('Ada')
const lastName = ref('Lovelace')

const fullName = computed(
  () => `${firstName.value} ${lastName.value}`,
)
</script>

<template>
  <p>{{ fullName }}</p>
</template>

只要 firstNamelastName 没变,多次读取 fullName 不会重复计算。计算 getter 应保持纯净:不要在里面发请求、写其他状态、操作 DOM 或记录依赖执行次数。

需要双向转换时,可以创建可写计算属性:

const fullName = computed({
  get: () => `${firstName.value} ${lastName.value}`,
  set: (value: string) => {
    const [first = '', ...rest] = value.trim().split(/\s+/)
    firstName.value = first
    lastName.value = rest.join(' ')
  },
})

可写计算属性适合格式转换,不应被用来掩盖复杂的多状态同步。

二、watch 能监听哪些数据

watch() 的数据源可以是:

  • 一个 ref 或计算属性;
  • 一个响应式对象;
  • 返回待监听值的 getter;
  • 由上述数据源组成的数组。

监听 ref

const page = ref(1)

watch(page, (newPage, oldPage) => {
  console.log({ newPage, oldPage })
})

监听对象的某个基本类型属性时,必须使用 getter:

const filters = reactive({ keyword: '', category: 'all' })

watch(
  () => filters.keyword,
  (keyword) => {
    console.log(keyword)
  },
)

监听多个数据源:

watch(
  [page, () => filters.category],
  ([newPage, newCategory], [oldPage, oldCategory]) => {
    console.log({ newPage, newCategory, oldPage, oldCategory })
  },
)

不要写 watch(filters.keyword, ...),因为这会把调用当下的字符串传进去,而不是一个可监听的数据源。

三、深度监听与 Vue 3.5 的数字深度

直接监听 reactive 对象时,Vue 会深度遍历它:

const settings = reactive({
  theme: 'dark',
  editor: { fontSize: 16 },
})

watch(settings, () => {
  console.log('设置发生变化')
})

深度监听中的 newValueoldValue 可能指向同一个对象,因为内部属性变化并没有替换根对象。需要可靠快照时,应监听具体字段,或在边界处自行创建快照。

Vue 3.5 起,deep 可以是最大遍历深度:

watch(
  () => settings,
  () => {
    console.log('最多检查两层')
  },
  { deep: 2 },
)

大型对象的无限深度监听成本很高。优先监听真正会触发业务动作的字段,不要用 deep: true 代替状态设计。

四、常用监听选项

watch(source, callback, {
  immediate: true,
  once: true,
  flush: 'post',
})
  • immediate:创建监听器时立即执行一次,首次旧值为 undefined
  • once:Vue 3.4+,在数据第一次变化后自动停止;
  • flush: 'pre':默认值,在组件 DOM 更新前执行;
  • flush: 'post':需要读取更新后的组件 DOM 时使用;
  • flush: 'sync':同步触发,容易造成重复执行,应谨慎使用。

watch() 返回的句柄可以暂停、恢复或停止:

const handle = watch(page, loadPage)

handle.pause()
handle.resume()
handle.stop()

在组件 setup() 同步阶段创建的监听器,会随组件卸载自动停止。异步回调里延迟创建的监听器则需要自行管理。

五、异步请求一定要清理过期副作用

搜索词快速变化时,旧请求可能比新请求更晚返回,导致界面显示过期结果。Vue 3.5 的 onWatcherCleanup() 可以取消上一轮请求:

import { onWatcherCleanup, ref, watch } from 'vue'

interface SearchResult {
  id: number
  title: string
}

const keyword = ref('')
const results = ref<SearchResult[]>([])
const loading = ref(false)

watch(keyword, async (value) => {
  const normalized = value.trim()
  if (!normalized) {
    results.value = []
    loading.value = false
    return
  }

  const controller = new AbortController()
  onWatcherCleanup(() => controller.abort())

  loading.value = true
  try {
    const response = await fetch(
      `/api/search?q=${encodeURIComponent(normalized)}`,
      { signal: controller.signal },
    )
    if (!response.ok) throw new Error(`HTTP ${response.status}`)
    results.value = await response.json()
  } catch (error) {
    if (!(error instanceof DOMException && error.name === 'AbortError')) {
      throw error
    }
  } finally {
    if (!controller.signal.aborted) loading.value = false
  }
})

onWatcherCleanup() 必须在监听回调的同步执行阶段调用,不能放在第一个 await 之后。旧版本或需要把清理注册逻辑传给其他函数时,也可以使用回调第三个参数 onCleanup

六、watchEffect 适合自动收集依赖

watchEffect() 会立即执行,并自动跟踪同步执行期间读取的响应式依赖:

const width = ref(window.innerWidth)
const compact = ref(false)

watchEffect(() => {
  compact.value = width.value < 768
})

这个例子实际上更适合写成计算属性:

const compact = computed(() => width.value < 768)

这体现了选择原则:

  • 能返回一个值的纯计算,使用 computed
  • 需要明确来源、新旧值和触发条件,使用 watch
  • 副作用依赖很多,且自动收集更清楚时,使用 watchEffect

异步 watchEffect 只会追踪第一个 await 之前同步读取的依赖,不能假设整个异步函数里的读取都会被收集。

七、生命周期钩子

Composition API 常用生命周期如下:

import {
  onBeforeMount,
  onMounted,
  onBeforeUpdate,
  onUpdated,
  onBeforeUnmount,
  onUnmounted,
} from 'vue'

日常最常见的是:

  • onMounted():首次 DOM 已创建后访问 DOM 或初始化只接受真实元素的库;
  • onBeforeUnmount() / onUnmounted():移除事件、断开连接和销毁第三方实例;
  • onUpdated():任意响应式更新后触发,通常不应在这里继续修改状态。

如果只想等待本轮状态对应的 DOM 更新,使用 nextTick(),通常比全局的 onUpdated() 更精确:

import { nextTick, ref } from 'vue'

const open = ref(false)

async function openPanel() {
  open.value = true
  await nextTick()
  document.querySelector<HTMLElement>('[data-panel]')?.focus()
}

八、把资源生命周期封装进组合式函数

组合式函数应自己管理它创建的资源:

// composables/useWindowWidth.ts
import { onMounted, onUnmounted, ref } from 'vue'

export function useWindowWidth() {
  const width = ref(0)

  function update() {
    width.value = window.innerWidth
  }

  onMounted(() => {
    update()
    window.addEventListener('resize', update)
  })

  onUnmounted(() => {
    window.removeEventListener('resize', update)
  })

  return { width }
}

这样每个使用者只关心返回值,不需要记住额外的清理步骤。若应用会 SSR,浏览器全局对象应只在挂载后访问,或先检查运行环境。

小结

派生值用 computed,有明确来源的副作用用 watch,适合自动收集依赖的副作用用 watchEffect。异步监听要取消过期任务,资源要在卸载时清理。Vue 3.5 的 onWatcherCleanup() 和数字 deep 能让这些边界更明确,但仍不能替代良好的状态设计。

官方资料

系列导航:目录 · 上一篇 · 下一篇