Vue 响应式进阶:effectScope 与调度

《Vue 响应式进阶:effectScope 与调度》

要点

入门阶段 reactive、ref、watch 用着顺手,直到两件事打破舒适区:组合式函数泄漏(组件卸了,watch 和定时器还活着),以及”改了数据为什么没更新 / 为什么更新了两次”。这篇讲四件进阶装备——effectScope 管资源释放、watch 的 flush 时机管调度、customRef 管输入节流、浅层 API 管大对象——全部围绕一个主题:什么时候该由我亲手接管响应式的生命周期与触发时机。四件装备互相独立,可以按需取用;验收清单是按”每个 composable 都能过”的标准写的。

本文不做:Vapor Mode 与响应式源码解析、reactive 数组的全部陷阱合集(那是参考字典篇的职责)、状态管理库。组件测试的骨架在Vue 组件测试入门篇,本文用它验证”卸载后资源真的释放了”。

effectScope:给组合式函数装电源开关

先说结论:在组件 setup 的同步调用链里创建的 watch、computed、事件监听,Vue 会自动挂到组件作用域,组件卸载时一并清理——所以入门阶段你从没手动清理过。一旦离开这条链,自动清理就失效了,三个典型现场:

  • setup 里第一个 await 之后创建的 effect:异步边界之后 Vue 不再跟踪当前实例;
  • 独立于组件创建的 effect:模块级 store、被外部手动启停的服务;
  • 轮询、WebSocket、addEventListener 这类自带生命周期的资源:本来就不归 Vue 管。

effectScope 是官方给的开关——写组合式函数而不清理资源,几乎是每个团队自制 composable 阶段的必翻之车,这个 API 就是给这种情况准备的:

import { effectScope, onScopeDispose, ref } from 'vue';

export function useTicker(intervalMs = 1000) {
  const scope = effectScope();
  const ticks = scope.run(() => {
    const n = ref(0);
    const timer = setInterval(() => n.value++, intervalMs);
    onScopeDispose(() => clearInterval(timer));
    return n;
  })!;

  return { ticks, dispose: () => scope.stop() };
}

scope.run 里创建的所有响应式副作用(watch、computed、订阅)都被收进这个作用域,scope.stop() 一键全停,不用逐个记句柄。onScopeDispose 等价于组件里的 onUnmounted,但挂在”当前作用域”上:这个 composable 在组件 setup 里被调用,就随组件卸载触发;在独立作用域里被调用,就随 scope.stop() 触发。

await 边界的坑值得单独强调:组合式函数内部一旦有 await,await 之后创建的 watch 就成了”孤儿”。我的做法是要么把 effect 的创建全部挪到 await 之前,要么在函数开头先把 scope 建好、await 之后的一切包进 scope.run——两种写法都能过验收。

模块级场景是 effectScope 的主场。登录后启动、退出后清干净的服务,把 stop 挂进登出流程,启动时重建作用域:

// store/presence.ts —— 独立于任何组件
const scope = effectScope(true);          // detached:不随父作用域一起停
scope.run(() => {
  watch(onlineUsers, syncPresence);
});

function stopPresence() {
  scope.stop();                           // watch、订阅全部释放
}

嵌套作用域默认随父级一起停,detached 是例外——只认自己的 stop()。副作用跨会话累积的泄漏,多数是忘了这一条。另有一个常见误解要澄清:stop 过的作用域不能再 run——作用域是一次性的,”停止后重启”的正确姿势是新建一个 scope 重新装配,重启逻辑里别复用旧实例。

验收:组件 mount 后推进 3 秒假时钟,unmount 后再推进,断言 interval 回调不再执行。用 vi.useFakeTimers 推进时间的具体写法见组件测试入门篇。

watch 的 flush:pre / post / sync

watch 默认不是”一改就执行”,而是被排进调度队列:flush: ‘pre’(默认)表示回调在组件更新之前执行,同一轮里多次改值只触发一次回调;flush: ‘post’ 在 DOM 更新之后;flush: ‘sync’ 完全同步,改一次跑一次。

watch(selectedId, async (id) => {
  detail.value = await loadDetail(id);      // pre:默认,组件更新前
});

watch(selectedId, () => {
  listEl.value?.scrollTo({ top: 0 });       // post:DOM 已更新,ref 已可用
}, { flush: 'post' });

watch(hitCount, () => log(hitCount.value), { flush: 'sync' }); // 每次变更立刻执行

三条经验规则。第一,默认 pre 够用,它和组件更新合并成一次批处理,这是性能友好的一半。第二,需要”更新后的 DOM”(量高度、滚动、focus)一律 post,它等价于”改数据后 nextTick 再操作”,只是少写一个 nextTick。第三,sync 只在极少数场景用,比如要在同一段同步代码里精确统计变更次数;把表单输入的 watch 配成 sync 是事故——用户每敲一个字符触发一次回调,防抖都救不回调度成本。

还有一条排雷规则:watch 的回调里改它自己监听的源,会把自己再排进下一轮队列,没有收敛条件就是死循环(好在 Vue 有告警拦着)。需要”自己更新自己”的场景,先想清楚收敛条件,或者把逻辑放进 computed。另外,flush 的三个取值对 watchEffect 同样适用,默认同样是 pre——这一节的规则不止作用于 watch。

3.5 给 watch 补了两个实用件,值得点名。once: true 只触发一次就自动停,一次性初始化订阅不用再手动记停止句柄:

watch(ready, init, { once: true });

onWatcherCleanup 注册”本轮回调的清理动作”,竞态问题从此一行注册:下一次触发或停止时,上一轮的清理函数自动执行:

import { onWatcherCleanup } from 'vue';

watch(id, async (nid) => {
  const ctrl = new AbortController();
  onWatcherCleanup(() => ctrl.abort());    // 取消上一轮请求
  detail.value = await fetchDetail(nid, ctrl.signal);
});

验收:在 post 回调里打印 listEl.value?.clientHeight,断言拿到的是新列表渲染后的高度,而不是旧值。

customRef:把防抖写进 ref

搜索框防抖是每个项目的刚需。常见写法是输入框 v-model 绑普通 ref,再对它 watch 加防抖。customRef 提供更干净的路线:把”收集依赖、触发更新”的时机拿在自己手里:

import { customRef, type Ref } from 'vue';

export function useDebouncedRef<T>(value: T, delay = 300): Ref<T> {
  let timer: ReturnType<typeof setTimeout> | undefined;
  let inner = value;
  return customRef((track, trigger) => ({
    get() { track(); return inner; },          // track 必须调,依赖收集靠它
    set(nv: T) {
      if (nv === inner) return;
      inner = nv;                              // 输入框即时回显,不跳字
      clearTimeout(timer);
      timer = setTimeout(trigger, delay);      // trigger 延迟,下游节流
    },
  }));
}

get 里的 track() 和 set 里的 trigger() 就是响应式的手动挡:track 声明”这次读取要建立依赖”,trigger 声明”现在通知所有依赖方”。这个实现的取舍点在 set:立即更新 inner,受控输入框不跳字;延迟 trigger,下游的 watch 和请求节流生效——输入体验与请求频率两个诉求各拿各的。和”watch 加防抖”的方案比,这个写法把节流收进数据源本身,任何消费者(不只 watch)都自动获得节流语义,代价是要理解 track/trigger 的模型,团队不熟时注释要写足。

验收:快速输入十六个字符,下游触发搜索的 watch 只执行一次(回调里推计数器数组,断言长度为 1);delay 调成 0,行为退化成普通 ref,这条对照用例值得留着。

浅层 API:大对象别深层代理

reactive 与 ref 默认深层代理:一个上百条记录的列表,每条记录、每个字段都被 Proxy 包住。功能没问题,但两类数据上不值得:服务端返回的大列表(整进整出,没有字段级修改)和第三方实例(图表、地图、播放器——它们有自己的状态机,被代理后内部 this 引用会出问题)。

import { shallowRef, triggerRef } from 'vue';

const rows = shallowRef<Row[]>([]);            // 只代理 .value 本身

async function reload() {
  rows.value = await fetchRows();              // 整体替换,正常触发更新
}

function patchRow(id: number, patch: Partial<Row>) {
  const next = rows.value.map((r) =>
    r.id === id ? { ...r, ...patch } : r
  );
  rows.value = next;                           // 依旧整体替换
}

浅层 ref 的纪律只有一条:永远整体替换 .value,不做 rows.value[3].done = true 这种字段级修改——浅层代理盯不到内层,改了也不触发。如果确实要就地改(比如第三方库直接改了数组内容),triggerRef(rows) 手动补一次通知——它是浅层世界的”手动触发器”,与 track/trigger 的手动挡是同一思想。第三方实例的姿势是 markRaw:

import { markRaw } from 'vue';
const chart = markRaw(initChart(el));          // 图表实例免代理

顺带把 shallowReactive 说清:它只代理第一层,第一层字段可响应、更深层不代理——介于 reactive 与完全浅层之间,适合”顶层结构常变、深层只读”的数据。浅层家族的思想是一致的:把代理的深度限定在你真正会修改的范围内。

三种容器的选择线,一张对照:

  • reactive:字段级修改频繁、需要深层响应——表格式表单、草稿编辑器;
  • shallowRef:整进整出的大列表、大数组,替换引用触发;
  • markRaw 或浅层装实例:第三方对象,一次创建、自己管自己。

浅层 API 不是性能优化万能药:数据”整进整出”才用;列表交互里有大量字段级编辑(表格式表单),深层 reactive 反而省心。先看数据的更新形态,再选代理深度。深 watch 也要留意:watch(rows, …) 在深层代理上隐式做依赖遍历,大列表要么监听具体字段,要么换 shallowRef 后只盯引用变化。

常见坑

  • await 之后创建 watch:组件作用域已丢失,effect 成孤儿,卸载后照常运行。effectScope 或前移创建解决。
  • 浅层 ref 改内层没反应:rows.value[0].done = true 不触发渲染,整体替换或 triggerRef。
  • customRef 忘了 track:get 里不调 track(),模板不建立依赖,值更新了页面不更新。
  • flush: ‘sync’ 滥用:一次同步流程改三次值触发三次回调,等于亲手关掉批处理。
  • scope 建了忘 stop:模块级作用域反复创建不释放,副作用越积越多,泄漏比组件级更隐蔽。
  • 把 markRaw 的对象当响应式用:markRaw 之后改它任何字段都不会更新 UI——这是声明的”免代理”,不是 bug。
  • 在浅层 ref 上用 v-for 局部更新:模板里直接改 items.value[i].xxx 同样是字段级修改,触发不了渲染;更新动作收敛到整体替换的函数里。

验收清单

  • 每个组合式函数在组件卸载后零副作用:mount、推进假时钟、unmount、再推进,断言无回调执行。
  • 含 await 的组合式函数,await 之后创建的 effect 同样被 scope 收口,unmount 后无残留。
  • 搜索框接 useDebouncedRef:快速输入 16 字符,下游请求恰触发 1 次。
  • 大列表换 shallowRef 后人工过一遍全部交互等价;代码里不存在对浅层 ref 的字段级修改。
  • 一次性订阅(ready 后 init)用 once: true 落地,grep 不到”watch 里自己 stop 自己”的土办法。
  • 需要更新后 DOM 的 watch 全部标 flush: ‘post’,代码里没有散落的裸 nextTick 补丁。
  • 竞态用例:连续触发两次 watch 回调,旧请求被 onWatcherCleanup 取消,页面只呈现最后一轮结果。
  • grep 项目里的 setInterval 与 addEventListener,每一个都能找到对应的清理调用。

这套装备的优先级排序:effectScope 与 await 边界是正确性问题,第一天就该管;flush 时机是”为什么没更新”类 bug 的标准答案;customRef 与浅层 API 是体验与性能的选择题。API 速查与逐条语义回参考字典篇,怎么把这些行为写成自动化的回归用例,回组件测试入门篇。

本文数据核对时间:2025-05-26:Vue 3.5.x(3.5 于 2024-09-01 发布,2025-05 处于 3.5 补丁线;effectScope、customRef、shallowRef、triggerRef、flush 均为 3.x 稳定 API);行为口径以 vuejs.org 官方文档当日版本为准。

暂无评论

发送评论 编辑评论


				
|´・ω・)ノ
ヾ(≧∇≦*)ゝ
(☆ω☆)
(╯‵□′)╯︵┴─┴
 ̄﹃ ̄
(/ω\)
∠( ᐛ 」∠)_
(๑•̀ㅁ•́ฅ)
→_→
୧(๑•̀⌄•́๑)૭
٩(ˊᗜˋ*)و
(ノ°ο°)ノ
(´இ皿இ`)
⌇●﹏●⌇
(ฅ´ω`ฅ)
(╯°A°)╯︵○○○
φ( ̄∇ ̄o)
ヾ(´・ ・`。)ノ"
( ง ᵒ̌皿ᵒ̌)ง⁼³₌₃
(ó﹏ò。)
Σ(っ °Д °;)っ
( ,,´・ω・)ノ"(´っω・`。)
╮(╯▽╰)╭
o(*////▽////*)q
>﹏<
( ๑´•ω•) "(ㆆᴗㆆ)
😂
😀
😅
😊
🙂
🙃
😌
😍
😘
😜
😝
😏
😒
🙄
😳
😡
😔
😫
😱
😭
💩
👻
🙌
🖕
👍
👫
👬
👭
🌚
🌝
🙈
💊
😶
🙏
🍦
🍉
😣
Source: github.com/k4yt3x/flowerhd
颜文字
Emoji
小恐龙
花!
上一篇
下一篇