【翻译】Vue中的防弹watcher

9 阅读4分钟

原文链接:michaelnthiessen.com/bulletproof…

作者:Michael Thiessen

你的 Vue 可能正在泄漏内存,而你甚至毫不知情。

当你创建一个执行 API 调用或设置定时器的watcher时,会发生以下情况:Vue 在响应式数据发生变化时运行监视器函数。但当数据再次变化时,Vue 会再次运行监视器函数。

在这种快速状态切换中,重叠的效果开始累积,产生虚假的网络流量和奇怪的时序边界,这些在调试过程中出人意料地难以理解。

问题出在哪里?

你的旧API请求仍在运行。

你的旧计时器仍在滴答作响:

// This creates a new timer every time searchQuery changes
watch(searchQuery, (query) => {
  setTimeout(() => {
    console.log(`Searching for: ${query}`)
  }, 1000)
})

快速修改 searchQuery 五次,就会有五个计时器同时运行;修改一百次,就会有一百个计时器。

这是 Vue 应用中资源泄漏和效果重复的常见成因。

但存在一个简单解决方案,多数开发者却不曾知晓。一旦理解其中原理,你将永远不再编写会泄漏的监听器。

认识 onCleanup:你的清理超级英雄

Vue 在watch回调的第三个参数中提供了 onCleanup。这是在监听器再次运行之前执行的清理函数。

可将其视为精确的预运行钩子,它会在下一个效果执行前同步运行,为你提供可靠的时机来拆除先前设置的内容(定时器、监听器、正在处理的请求)。

watch(searchQuery, (query, oldQuery, onCleanup) => {
  const timerId = setTimeout(() => {
    console.log(`Searching for: ${query}`)
  }, 1000)

  // This runs before the watcher runs again

  onCleanup(() => {
    clearTimeout(timerId)
  })
})

现在当 searchQuery 发生变化时,Vue 会在启动新定时器前自动取消旧定时器。

这消除了内存泄漏和重复的 API 调用,同时抑制了不必要的性能开销。

以下是 onCleanup 的幕后工作原理:Vue 在两种情况下调用清理函数——监视器即将再次运行时,以及监视器完全停止时(例如组件卸载或效果作用域终止时)。

此时正是清理副作用的最佳时机。

(若添加时间戳日志,可观察到清理操作在后续执行前立即触发,这在调试时能提供有效的合理性验证。)

实际清理示例

让我们看看一些需要清理的常见场景。

防抖搜索

这是防抖搜索的经典示例:

import { ref, watch } from 'vue'

const searchQuery = ref('')
const searchResults = ref([])

watch(searchQuery, (query, oldQuery, onCleanup) => {
  // Clear previous search results immediately
  searchResults.value = []

  const timerId = setTimeout(async () => {
    const results = await fetch(`/api/search?q=${query}`)
    searchResults.value = await results.json()
  }, 300)

  onCleanup(() => clearTimeout(timerId))
})

清理函数会在用户输入新字符时取消之前的搜索计时器。这可防止旧搜索覆盖新结果。

若无onCleanup机制,快速输入会分散多个API调用,导致响应延迟覆盖最新数据,最终造成对同一端点的反复请求。

事件监听器管理

我们还可利用onCleanup管理事件监听器:

import { ref, watch } from 'vue'

const isModalOpen = ref(false)

watch(isModalOpen, (isOpen, wasOpen, onCleanup) => {
  if (isOpen) {
    const handleEscape = (event) => {
      if (event.key === 'Escape') {
        isModalOpen.value = false
      }
    }

    window.addEventListener('keydown', handleEscape)
    onCleanup(() => {
      window.removeEventListener('keydown', handleEscape)
    })
  }
})

该模式在模态框打开时添加一个Escape键监听器。onCleanup函数会在模态框关闭或监听器再次运行时移除该监听器。

若缺少onCleanup机制,每次模态框打开都会累积事件监听器。最终按下Escape键将触发多个处理程序。这是典型的内存泄漏案例,且极易被忽视(可能需要反复打开关闭模态框才能察觉)。

Vue 3.5 升级:多重清理函数(Vue 3.5+)

让我们回到 Vue 3.4 及更早版本,回顾一下 onCleanup 的发展历程。

Vue 3.4 及更早版本存在一个限制:每个监听器只能注册一个清理函数。若多次调用 onCleanup,仅最后一次调用会生效。

// Vue 3.4 and earlier - only the last cleanup runs
watch(data, (value, oldValue, onCleanup) => {
  const timer1 = setTimeout(() => {}, 1000)
  onCleanup(() => clearTimeout(timer1)) // This gets overwritten
  
  const timer2 = setTimeout(() => {}, 2000)
  onCleanup(() => clearTimeout(timer2)) // Only this runs
})

若需清理多个副作用,则必须将它们组合到单一的 onCleanup 函数中:

watch(data, (value, oldValue, onCleanup) => {
  const timer1 = setTimeout(() => {}, 1000)
  const timer2 = setTimeout(() => {}, 2000)

  onCleanup(() => {
    clearTimeout(timer1)
    clearTimeout(timer2)
  })
})

对于这个示例来说没问题,但在更复杂的场景中会很麻烦,这极大地限制了清理逻辑的复用能力,也降低了操作体验。

不过Vue 3.5引入了onWatcherCleanup组合函数来解决这个问题。现在你可以独立注册多个清理函数:

import { watch, onWatcherCleanup } from 'vue'

watch(userId, (id) => {
  // Set up a debounced save
  const saveTimer = setTimeout(() => {
    saveUserPreferences(id)
  }, 1000)
  onWatcherCleanup(() => clearTimeout(saveTimer))

  // Set up an activity tracker
  const activityTimer = setInterval(() => {
    trackUserActivity(id)
  }, 30000)
  onWatcherCleanup(() => clearInterval(activityTimer))

  // Set up an API request
  const controller = new AbortController()
  fetchUserData(id, { signal: controller.signal })
  onWatcherCleanup(() => controller.abort())
})

每个清理函数管理其自身的副作用。当监视器再次运行时,Vue会按注册顺序调用全部三个清理函数。

onWatcherCleanup的限制(Vue 3.5+)

尽管onWatcherCleanup相较于onCleanup的单一清理限制实现了强大改进,但仍存在若干重要限制,需充分理解才能有效使用:

1. 异步屏障:仅在首次 await 前生效 onWatcherCleanup 最关键的限制在于它仅在异步监视器中的首次 await 之前生效。请将其视为硬性边界:Vue 必须在异步操作开始前完成清理函数的注册:

// ❌ This won't work - onWatcherCleanup after await
watch(dataId, async (newId) => {
  const controller = new AbortController()
  const response = await fetch(`/api/data/${newId}`, {
    signal: controller.signal,
  })
  const data = await response.json()

  // This cleanup registration will fail!
  onWatcherCleanup(() => {
    controller.abort()
  })
})

// ✅ This works - onWatcherCleanup before await
watch(dataId, async (newId) => {
  const controller = new AbortController()

  // Register cleanup before any await
  onWatcherCleanup(() => {
    controller.abort()
  })

  const response = await fetch(`/api/data/${newId}`, {
    signal: controller.signal,
  })
  const data = await response.json()
})

一旦遇到 await 语句,函数执行将被暂停,Vue 将无法可靠地追踪清理注册。这是由于 Vue 中效果作用域的工作机制所致(您将在我的课程《高级响应性》中深入了解相关内容)。

若需在 await 之后执行清理操作,必须改用 onCleanup 参数:

watch(dataId, async (newId, _oldId, onCleanup) => {
  const controller = new AbortController()
  const response = await fetch(`/api/data/${newId}`, {
    signal: controller.signal,
  })

  const data = await response.json()

  // After await: must use onCleanup parameter
  onCleanup(() => controller.abort())
})

2. 上下文依赖性

onWatcherCleanup 的另一个挑战在于,它无法在 onMounted 或其他没有监视器上下文的环境中使用。onCleanup 函数与监视器绑定,因此始终可用且始终"知道"自己关联的是哪个监视器:

watch(data, (value) => {
  setupCleanup() // Works because we're in watcher context
})

// ❌ This won't work: outside watcher context
onWatcherCleanup(() => {
  // Which watcher is this for?
})

// ✅ This works: inside watcher context
function setupCleanup() {
  const timer = setTimeout(() => {}, 1000)
  onWatcherCleanup(() => clearTimeout(timer))
}

构建可复用的清理助手

好了,现在进入正题。

当你将清理逻辑提取到辅助函数中,或者需要处理需要注册自身清理逻辑的第三方代码时,onWatcherCleanup 可组合函数便能大放异彩。

这能让你的watcher更简洁,清理代码更具复用性:

import { onWatcherCleanup } from 'vue'

export function useTimeout(callback, delay) {
  const timerId = setTimeout(callback, delay)
  onWatcherCleanup(() => clearTimeout(timerId))
  return timerId
}

export function useInterval(callback, delay) {
  const intervalId = setInterval(callback, delay)
  onWatcherCleanup(() => clearInterval(intervalId))
  return intervalId
}

export function useEventListener(target, event, handler) {
  target.addEventListener(event, handler)
  onWatcherCleanup(() => {
    target.removeEventListener(event, handler)
  })
}

export function useAbortableRequest(url, options = {}) {
  const controller = new AbortController()
  onWatcherCleanup(() => controller.abort())

  return fetch(url, {
    ...options,
    signal: controller.signal,
  })
}

在此,我们使用 onWatcherCleanup 可组合函数为每种不同的副作用注册清理函数。但我们能够将每种副作用封装在各自的辅助函数中,并为每种副作用复用相同的清理逻辑。

这使得watcher变得更加简洁:

import { watch } from 'vue'
import {
  useTimeout,
  useEventListener,
  useAbortableRequest,
} from './cleanup-helpers'

watch(currentUser, (user) => {
  // Debounced analytics tracking
  useTimeout(() => {
    trackUserView(user.id)
  }, 500)

  // Keyboard shortcuts for this user's role
  if (user.role === 'admin') {
    useEventListener(
      window,
      'keydown',
      handleAdminShortcuts
    )
  }

  // Load user preferences
  useAbortableRequest(`/api/users/${user.id}/preferences`)
    .then((response) => response.json())
    .then((preferences) => {
      applyUserPreferences(preferences)
  })
})

辅助函数负责处理所有清理细节,让监视器能专注于业务逻辑。

这种模式在库和组合器中尤为强大。第三方代码可注册自己的清理逻辑而不干扰你的清理函数,且与你的业务逻辑保持完全解耦。

为何清理工作比你想象中更重要

正确的清理不仅关乎防止内存泄漏,更关乎构建行为可预测的可靠应用程序。

缺乏清理机制会导致应用程序出现隐蔽缺陷:

  • 搜索结果显示顺序混乱。
  • 事件处理程序多次触发。
  • API响应用过期数据更新界面。

这些缺陷难以复现且更难调试,通常仅在特定时间条件下或用户持续交互后才会显现。

但通过规范清理机制,监听器将变得坚不可摧——它们能自动清理自身并阻止资源累积。

用户由此获得更快速、更可靠的体验。