Vue 3 中的 computed 和 watch 在内部实现上有什么区别?它们各自的优化策略是什么?

大家好,欢迎来到今天的 Vue 3 内部机制小课堂。今天咱们聊聊 computedwatch 这俩哥们儿,看看他们表面上的兄弟情深背后,到底藏着多少不为人知的秘密。

(清清嗓子)

首先,咱们先用大白话捋一捋 computedwatch 都是干啥的。

computed:计算属性,懒加载的乖宝宝

computed 这家伙,就像一个特别靠谱的管家。你给他一个或多个依赖,他会根据这些依赖的值,帮你计算出一个新的值。关键是,他很懒!只有在你真正需要用到这个计算结果的时候,他才会开始计算。而且,如果依赖的值没变,他就直接把上次计算的结果拿出来给你,省时省力。

watch:侦听器,时刻待命的警卫

watch 就不一样了,他更像一个尽职尽责的保安。你告诉他要盯住哪个数据,只要这个数据一发生变化,他立马跳出来,执行你预先安排好的任务。他可不像 computed 那么懒,只要盯住的数据变了,他就绝不偷懒,立马执行。

好了,有了这两个概念,咱们就可以深入到他们的内部实现了。准备好了吗?接下来就是烧脑环节,但是别怕,我会尽量讲得通俗易懂。

computed 的内部实现:依赖追踪 + 缓存

computed 的核心在于两点:依赖追踪和缓存。

  1. 依赖追踪(Dependency Tracking)

    Vue 3 使用了一种巧妙的机制来进行依赖追踪。简单来说,就是当你在 computed 函数中访问某个响应式数据时,Vue 会记录下这个 computed 和这个响应式数据之间的关系。

    举个例子:

    <template>
      <p>Full Name: {{ fullName }}</p>
    </template>
    
    <script>
    import { ref, computed } from 'vue';
    
    export default {
      setup() {
        const firstName = ref('张');
        const lastName = ref('三');
    
        const fullName = computed(() => {
          console.log('fullName 被计算了!');
          return firstName.value + lastName.value;
        });
    
        return {
          firstName,
          lastName,
          fullName,
        };
      },
    };
    </script>

    在这个例子中,fullName 依赖于 firstNamelastName。当你在模板中使用 fullName 时,Vue 会记录下 fullNamefirstNamelastName 之间的依赖关系。

    这个依赖关系是怎么建立的呢?这就要说到 Vue 3 的响应式系统了。 当 computed 的 getter 函数被执行时 (也就是计算 fullName 的时候), Vue 会创建一个 "effect"。 这个 "effect" 专门用来记录依赖关系。当你在 "effect" 中访问 firstName.valuelastName.value 时,它们的 get 拦截器会被触发,然后 Vue 就知道 fullName 依赖于 firstNamelastName 了。

    可以用伪代码来表示这个过程:

    // 伪代码
    let activeEffect = null; // 当前正在执行的 effect (也就是 computed 的 getter)
    
    function track(target, key) {
      if (activeEffect) {
        // 记录 target[key] 被 activeEffect 依赖
        dependencies.get(target).get(key).add(activeEffect);
      }
    }
    
    function trigger(target, key) {
      // 触发 target[key] 的所有依赖 (也就是重新执行 effect)
      dependencies.get(target).get(key).forEach(effect => effect());
    }
    
    const firstName = reactive({ value: '张' });
    
    activeEffect = () => {
      console.log('fullName 被计算了!');
      return firstName.value + lastName.value; // 触发 track 函数
    };
    
    firstName.value = '李'; // 触发 trigger 函数

    这段伪代码展示了 tracktrigger 函数的基本原理,它们是 Vue 3 响应式系统的核心。

  2. 缓存(Caching)

    computed 的另一个重要特性就是缓存。当 computed 的 getter 函数第一次被执行后,Vue 会将计算结果缓存起来。只有当 computed 依赖的响应式数据发生变化时,才会重新执行 getter 函数,更新缓存。

    这使得 computed 非常高效,避免了不必要的计算。

    仍然以上面的例子为例,如果你只修改了 firstName 的值,而没有修改 lastName 的值,那么 fullName 会自动更新。但是,如果你在模板中多次使用 fullName,那么 fullName 的 getter 函数只会执行一次。

    <template>
      <p>Full Name: {{ fullName }}</p>
      <p>Full Name Again: {{ fullName }}</p>
    </template>

    在这个例子中,fullName 的 getter 函数只会执行一次,即使你在模板中两次使用了 fullName

    缓存的实现也很简单,Vue 会维护一个 dirty 标志。当 computed 依赖的响应式数据发生变化时,dirty 标志会被设置为 true。当你在模板中访问 computed 的值时,Vue 会检查 dirty 标志。如果 dirty 标志为 true,则重新执行 getter 函数,更新缓存,并将 dirty 标志设置为 false。如果 dirty 标志为 false,则直接返回缓存的值。

    下面是一个简化的 computed 实现:

    function computed(getter) {
      let value;
      let dirty = true;
      let effect = () => {
        if (dirty) {
          value = getter();
          dirty = false;
        }
        return value;
      };
    
      // 当依赖发生变化时,设置 dirty 为 true
      trackDependencies(effect, getter);
    
      return {
        get value() {
          return effect();
        },
      };
    }

    trackDependencies 函数负责追踪依赖关系,当依赖发生变化时,它会将 dirty 标志设置为 true

watch 的内部实现:观察者模式 + 回调函数

watch 的实现相对简单,它基于观察者模式。

  1. 观察者模式(Observer Pattern)

    Vue 3 的响应式系统本身就基于观察者模式。当你在 watch 中指定要观察的数据时,Vue 会创建一个观察者(Watcher),并将该观察者添加到被观察数据的依赖列表中。

    当被观察数据发生变化时,Vue 会通知所有依赖于它的观察者,也就是执行 watch 的回调函数。

    <script>
    import { ref, watch } from 'vue';
    
    export default {
      setup() {
        const count = ref(0);
    
        watch(count, (newValue, oldValue) => {
          console.log(`count 的值从 ${oldValue} 变成了 ${newValue}`);
        });
    
        const increment = () => {
          count.value++;
        };
    
        return {
          count,
          increment,
        };
      },
    };
    </script>

    在这个例子中,watch 观察 count 的变化。当 count 的值发生变化时,watch 的回调函数会被执行,打印出 count 的新值和旧值。

  2. 回调函数(Callback Function)

    watch 的核心就是回调函数。当被观察数据发生变化时,Vue 会执行你提供的回调函数。你可以在回调函数中执行任何你想要执行的操作,比如更新 DOM、发送网络请求等等。

    watch 提供了多种选项,可以让你更灵活地控制回调函数的执行时机和方式,比如:

    • immediate:立即执行回调函数。
    • deep:深度监听对象的变化。
    • flush:控制回调函数的执行时机(prepostsync)。

    下面是一个使用 deep 选项的例子:

    <script>
    import { ref, watch } from 'vue';
    
    export default {
      setup() {
        const obj = ref({
          a: 1,
          b: {
            c: 2,
          },
        });
    
        watch(
          () => obj.value.b.c, //必须通过函数返回,否则初始化时就取值了,起不到监听作用
          (newValue, oldValue) => {
            console.log(`obj.b.c 的值从 ${oldValue} 变成了 ${newValue}`);
          },
          { deep: true }
        );
    
        const updateObj = () => {
          obj.value.b.c++;
        };
    
        return {
          obj,
          updateObj,
        };
      },
    };
    </script>

    在这个例子中,watch 观察 obj.b.c 的变化。即使 obj 的引用没有发生变化,只要 obj.b.c 的值发生变化,watch 的回调函数就会被执行。

    下面是一个简化的 watch 实现:

    function watch(source, cb, options = {}) {
      let getter;
      if (typeof source === 'function') {
        getter = source;
      } else {
        getter = () => traverse(source); // traverse 用于深度访问对象的所有属性
      }
    
      let oldValue;
      let newValue;
    
      const job = () => {
        newValue = effect();
        cb(newValue, oldValue);
        oldValue = newValue;
      };
    
      const effect = () => {
        activeEffect = job;
        return getter();
      };
    
      if (options.immediate) {
        job();
      } else {
        oldValue = effect();
      }
    }

    traverse 函数用于深度访问对象的所有属性,以便建立依赖关系。

computedwatch 的优化策略

既然咱们已经了解了 computedwatch 的内部实现,那么接下来就聊聊它们的优化策略。

特性 computed watch
核心机制 依赖追踪 + 缓存 观察者模式 + 回调函数
执行时机 懒加载,只有在需要时才会计算 只要被观察数据发生变化,立即执行
使用场景 用于计算派生数据,当依赖数据不变时,避免重复计算 用于监听数据的变化,执行副作用操作,例如更新 DOM、发送网络请求
返回值 返回一个只读的响应式数据 无返回值
优化策略 依赖追踪:只追踪真正用到的依赖,避免不必要的更新。缓存:缓存计算结果,避免重复计算。懒加载:只有在需要时才计算,避免不必要的计算。 节流/防抖:限制回调函数的执行频率,避免频繁执行。flush 选项:控制回调函数的执行时机,避免不必要的渲染。deep 选项:谨慎使用,深度监听会带来性能开销。
性能考量 依赖过多:如果 computed 依赖的数据过多,会导致计算时间过长。计算复杂度:如果 computed 的计算逻辑过于复杂,也会导致计算时间过长。 频繁触发:如果被观察数据频繁变化,会导致回调函数频繁执行。深度监听:深度监听会遍历整个对象,带来性能开销。
最佳实践 避免在 computed 中执行副作用操作。尽量保持 computed 的计算逻辑简单。只追踪真正需要的依赖。 避免在 watch 的回调函数中执行耗时操作。尽量使用节流/防抖来限制回调函数的执行频率。谨慎使用 deep 选项。
使用场景示例 显示格式化的日期:formattedDate = computed(() => formatDate(date.value))。计算购物车总价:totalPrice = computed(() => items.value.reduce((sum, item) => sum + item.price * item.quantity, 0)) 监听用户输入,进行实时搜索:watch(searchText, debounce(search, 300))。监听路由变化,更新页面标题:watch(() => route.path, (newPath) => document.title = getPageTitle(newPath))

computed 的优化策略

  • 依赖追踪: Vue 3 的响应式系统只会追踪你在 computed 中真正访问的依赖。如果你在 computed 中访问了一个响应式数据,但是并没有使用它的值,那么 Vue 不会追踪这个依赖。这可以避免不必要的更新。
  • 缓存: computed 的缓存机制可以避免重复计算。只有当 computed 依赖的数据发生变化时,才会重新计算。
  • 懒加载: computed 是懒加载的,只有在你真正需要用到它的值时,才会开始计算。这可以避免不必要的计算。

watch 的优化策略

  • 节流/防抖: 如果被观察数据频繁变化,会导致 watch 的回调函数频繁执行。可以使用节流(throttle)或防抖(debounce)来限制回调函数的执行频率,避免频繁执行。
  • flush 选项: flush 选项可以控制回调函数的执行时机。pre 表示在组件更新之前执行回调函数,post 表示在组件更新之后执行回调函数,sync 表示同步执行回调函数。根据不同的场景选择合适的 flush 选项,可以避免不必要的渲染。
  • deep 选项: deep 选项可以深度监听对象的变化。但是,深度监听会遍历整个对象,带来性能开销。因此,应该谨慎使用 deep 选项。

一些需要注意的点

  • 避免在 computed 中执行副作用操作: computed 应该只用于计算派生数据,不应该执行副作用操作,例如更新 DOM、发送网络请求等等。副作用操作应该放在 watch 中。
  • 尽量保持 computed 的计算逻辑简单: 如果 computed 的计算逻辑过于复杂,会导致计算时间过长。应该尽量保持 computed 的计算逻辑简单,或者将复杂的计算逻辑拆分成多个 computed
  • 避免在 watch 的回调函数中执行耗时操作: 如果 watch 的回调函数中执行耗时操作,会导致页面卡顿。应该尽量避免在 watch 的回调函数中执行耗时操作,或者将耗时操作放在 Web Worker 中执行。
  • 只监听真正需要的依赖: watch 可以监听多个依赖,但是应该只监听真正需要的依赖。监听过多的依赖会导致不必要的更新。

总结

computedwatch 都是 Vue 3 中非常重要的特性,它们可以帮助你更好地管理状态和响应数据的变化。理解它们的内部实现和优化策略,可以帮助你编写更高效的 Vue 应用。

总而言之,computed 是个懒人,擅长缓存,适合处理计算密集型任务,而 watch 是个勤劳的保安,时刻警惕,适合处理副作用操作。

希望今天的讲解对大家有所帮助!下次再见!

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注