前端知库

effectScope 和 customRef 适合什么场景?

effectScope 把一段代码里创建的 computed、watch、watchEffect 收进同一个作用域,调用 stop() 就能一起停掉,适合在组件之外使用组合式函数或做共享状态;onScopeDispose 是它的清理钩子。customRef 让你自己决定 ref 什么时候收集依赖(track)、什么时候通知更新(trigger),典型例子是防抖 ref。

参考答案

简答

组件里写的 watch、computed,组件卸载时 Vue 会自动停掉。可要是在组件外面创建,比如在一个全局 store 里,就没人管它们的清理了。effectScope() 就是手动建一个“收纳盒”:scope.run() 里创建的所有 effect 都归它管,scope.stop() 一次全部停掉。

配套的 onScopeDispose(fn) 在当前作用域停止时调用 fn。每个组件的 setup() 本身也运行在一个作用域里,所以组合式函数用 onScopeDispose 代替 onUnmounted,在组件里和组件外都能正确清理。

customRef() 解决的是另一个问题:普通 ref 一读就收集依赖、一写就触发更新,你改不了。customRef 把 track 和 trigger 交给你,在 get 里调 track(),在 set 里决定什么时候调 trigger()。官方例子是防抖 ref,输入停下一段时间后才真正更新。

要注意,这两个都是进阶 API,业务代码很少直接用。customRef 的 get 每次返回新对象时,作为 prop 传给子组件可能引起意外的更新,官方文档专门提醒过。

effectScope 怎样把 effect 收在一起

先看官方文档的例子。scope.run() 里创建了一个 computed 和两个侦听器:

JavaScript
const scope = effectScope();

scope.run(() => {
  const doubled = computed(() => counter.value * 2);

  watch(doubled, () => console.log(doubled.value));

  watchEffect(() => console.log('Count: ', doubled.value));
});

// 停掉作用域里的所有 effect
scope.stop();

调用 scope.stop() 后,counter 再变,两个侦听器都不会再执行。为什么不直接把每个 watch 返回的停止函数存起来?可以,但 effect 一多就容易漏,而且组合式函数内部创建的 effect 调用方根本拿不到。作用域会自动收集 run 期间同步创建的所有 effect,不用一个个记。

run 会返回回调的返回值,所以可以在里面创建状态再拿出来。作用域停止后再调用 run,返回的是 undefined。

什么时候需要它

最典型的是在组件之外共享一份状态。比如多个组件共用一个“鼠标位置”的组合式函数,希望全局只监听一次 mousemove。这时可以在第一次使用时建一个作用域,把组合式函数放进去执行;最后一个使用它的组件卸载时,再 stop() 掉这个作用域。

组件里没这个需求,是因为 setup() 已经在组件的作用域里执行了,组件卸载时 Vue 会停掉它。effectScope() 还有一个 detached 参数,传 true 时新作用域不会被外层作用域收集,外层停止时它也不受影响。

onScopeDispose 和 onUnmounted 有什么不同

组合式函数里注册清理逻辑,常见写法是 onUnmounted。可是它只在组件里有效,组合式函数要是在 effectScope 里调用,就没有组件可以挂。

onScopeDispose(fn) 挂在当前作用域上。在组件里调用,当前作用域就是组件的,组件卸载时执行;在手动作用域里调用,scope.stop() 时执行。所以写通用的组合式函数时用它更合适。在没有活动作用域的地方调用会给出警告,Vue 3.5 起第二个参数传 true 可以关掉这个警告。组合式函数的写法见《组合式函数和 mixin 相比有什么维护优势?》。

customRef 怎样做一个防抖 ref

普通 ref 绑在输入框上,每敲一个字就更新一次,依赖它的搜索请求也跟着发。官方文档给的防抖 ref 是这样写的:

JavaScript
import { customRef } from 'vue';

export function useDebouncedRef(value, delay = 200) {
  let timeout;
  return customRef((track, trigger) => {
    return {
      get() {
        track();
        return value;
      },
      set(newValue) {
        clearTimeout(timeout);
        timeout = setTimeout(() => {
          value = newValue;
          trigger();
        }, delay);
      },
    };
  });
}

在组件里用 const text = useDebouncedRef('hello'),再 v-model="text" 绑到输入框上。

track() 和 trigger() 是什么?track() 告诉 Vue “有人读了这个值,记下来”,trigger() 告诉 Vue “值变了,通知读过它的地方”。这正是普通 ref 内部做的两件事,原理见《Vue 3 响应式依赖是怎样收集和触发的?》。customRef 把它们交给你,所以这里 set 没有马上触发,而是每次重新计时,停下 200 毫秒后才更新值并 trigger()。

用 customRef 要小心什么

官方文档提醒,get 每次返回一个新对象时要小心。假设这个 ref 作为 prop 传给子组件,父组件因为别的状态重新渲染,就会重新读一次它,得到一个新对象。子组件比较 prop 发现变了,于是更新;可这个 ref 的 set 根本没被调用过。所以 get 应该返回保存好的值,而不是每次现造一个。

常见追问

effectScope 能停掉 scope.run 里异步创建的 watch 吗?

作用域收集的是 run 执行期间创建的 effect。await 之后才创建的 watch,已经不在 run 的同步执行过程里了,需要在拿到作用域后再用 scope.run() 包一层。

customRef 能不能不调 track?

可以,官方说何时调用、调不调用都由你控制。不调 track(),读这个 ref 的地方就不会收集依赖,它变了也不会触发更新,相当于一个不响应的值。

怎样拿到当前的作用域?

用 getCurrentScope(),有活动作用域时返回它,否则返回 undefined。组合式函数可以用它判断自己是否运行在作用域里,再决定要不要注册 onScopeDispose。