公司那套内部系统的前端是我 2023 年开始接手的,从那时起才真正写起 Vue 3。头一年我还挺喜欢组合式函数,觉得比 mixin 干净;到 2025 年下半年,几个页面反复出现「数据明明变了但视图没更新」和「组件都关掉了请求还在发」这类问题,查下来根子都在 composable 的写法上。这里记三个我确实踩过的点,都附了当时的代码。

先交代环境:公司那套系统是 Vue 3.5.13 + Vite 6,2026 年 7 月初把 Vue 升到 3.5.18,重新跑了一遍测试,本文涉及的行为没有变化。类型检查用 vue-tsc,编辑器是 VS Code 加 Volar。我本职是 .NET 后端,前端是兼着做的,下面的结论如果在专业前端看来有错,欢迎写信指出来。

一、toRefs:拆出来的还是不是同一个东西

先看这段,是「工单详情」页里真实存在过的代码,变量名改过:

export function useTicketDetail(id: number) {
  const state = reactive({
    loading: false,
    ticket: null as Ticket | null,
    error: null as string | null,
  })

  async function load() {
    state.loading = true
    try {
      state.ticket = await api.getTicket(id)
    } finally {
      state.loading = false
    }
  }

  return { ...state, load }   // 问题在这里
}

// 组件里
const { ticket, loading, load } = useTicketDetail(props.id)

{ ...state } 展开的时候,ticket 拿到的是那一刻的,不是响应式引用。接口回来之后 state.ticket 变了,组件里解构出来的 ticket 还是 null。我当时的现象是页面一直停在骨架屏,Network 面板里请求是 200,来回看了半个多小时才想到是解构的问题——因为报错也没有,看着完全正常。

改法有两个。一是干脆返回 reactive 对象本身,组件里不解构;二是用 toRefs 转一道:

const state = reactive({ /* ... */ })

return { ...toRefs(state), load }

toRefs 会把 reactive 对象的每个属性转成对应的 ref,解构之后仍然是响应式的。要注意的是它只对已经存在的属性生效。我在 props 上栽过一次:把 toRefs(props) 的结果解构之后去访问一个父组件没传的可选属性,拿到的是 undefined,不是我预想的默认值,因为那个 key 在转换时压根不在对象上。

注意

toRefs 遇到本来就是 ref 的属性会原样返回,不会再包一层。我多写了一个 .value,页面上渲染出来的是 [object Object],又查了十分钟。

顺带记一下 3.5 的变化:3.5 里「响应式 props 解构」转正了,const { id } = defineProps<{ id: number }>() 这样直接解构出来的 id 在模板里依然是响应式的,编译器会帮你改写成 props.id这个我在公司那套系统上还没改:一是改动面太大,二是我没搞清楚它和 watch 一起用的时候行为是否完全一致,只在自己一个小工具页上试过。等哪天有整块时间再说。

二、watch 的依赖:写错形式不报错,只是不触发

第二个坑更隐蔽,因为它不报错。下面两种写法看着差不多,行为完全不一样:

watch(props.id, fetchDetail)          // 传的是值,不是监听源
watch(() => props.id, fetchDetail)    // 正确

第一种在 props.id 是 number 的时候,Vue 会给出 Invalid watch source 的警告,还算好查。麻烦的是传对象的情况——watch(props.filter, ...) 既不会警告,第一次往往还能触发,我就是在测试环境看到它「能跑」才放心合进代码,结果线上筛选条件改了列表不刷新,被同事叫过去看了半天。

我现在给自己定的规矩很机械:只要监听源不是 ref,一律写成 getter 函数;确实要监听整个对象就加 deep: true,同时心里有数,deep 是递归遍历的,对象大的时候有成本。

(2026-07 更正:这一节我原来写的是「watch 监听数组要加 deep」,重读文档后发现对 ref 数组并不需要,只有监听 reactive 对象时才默认深层。原来那句已经删掉,如果你印象里看过旧版本,以这里为准。)

另一件和 watch 有关的事是异步竞态。列表页按关键词搜索,用户连着输四个字就发四个请求,回来的顺序不保证,后发的可能先到,最后渲染的是旧结果。watch 回调的第三个参数 onCleanup 就是干这个的:

watch(() => props.keyword, async (kw, _old, onCleanup) => {
  let cancelled = false
  onCleanup(() => { cancelled = true })

  const list = await api.search(kw)
  if (cancelled) return
  result.value = list
})

这个写法我在两个页面上用了,效果还行。Vue 3.5 另外提供了 onWatcherCleanup(),可以在回调里直接调用而不用走第三个参数,我只在一个页面上试过,没敢大面积替换——它和 onCleanup 在组件卸载时机上的处理是否完全等价,我没有验证过,不敢下结论。

三、onScopeDispose:组件没了,定时器还在

第三个是内部用的一个轮询 hook,简化之后长这样:

export function usePolling(fn: () => void, interval = 5000) {
  const timer = setInterval(fn, interval)
  return { stop: () => clearInterval(timer) }
}

组件里我忘了在 onUnmounted 里调 stop。现象是测试环境「待办数量」那个接口的 QPS 一直下不来,后端同事找过来问我前端是不是在循环调。我数了一下,自己在页面上切了五次菜单,就有五个定时器还活着——组件销毁了,setInterval 没人管它。

后来改成在 composable 内部注册清理,而不是指望调用方记得收尾:

export function usePolling(fn: () => void, interval = 5000) {
  const timer = setInterval(fn, interval)

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

  return { stop: () => clearInterval(timer) }
}

onScopeDispose 而不是 onUnmounted 的理由:它依赖的是当前活跃的 effect scope,而不一定非得是组件。所以这个 composable 也可以在 effectScope() 手动建的作用域里用,清理同样生效。反过来说,它必须在同步执行路径里调用——如果你在 await 之后才注册,就会看到 onScopeDispose() is called when there is no active effect scope 这条警告,而且清理压根没注册上。要不要注册前先判断,可以用 getCurrentScope()

官方文档里作用域那一节我读过至少三遍,每次都觉得「懂了」,然后下次又忘记注册。最后是在 ESLint 里加了条自定义约束:新增的 composable 文件里如果出现 setInterval 或 addEventListener,就必须出现在 onScopeDispose 的清理函数里。靠记性是记不住的。

写在最后

这三个问题有个共同点:编译期不报错,大部分时候连运行时警告都没有,只在特定的操作顺序下才暴露。所以我现在能想到的办法只有把规则固化成清单和 lint 规则,而不是指望自己记住。

(2026-07 补充:第三节那个 usePolling 后来又加了 document.visibilityState 判断,页面不可见时暂停轮询,这个改动还没上线,等跑两周再说。)

还有一个我没搞清楚的地方:在 <script setup> 里用顶层 await 的组件,onScopeDispose 的注册时机到底算在 await 前还是之后。我试过一次,结果和预期不一样,后来绕开没再深究。