一次奇怪的bug理解computed的响应式原理

91 阅读2分钟
index.vue
<template>
    <div>
        <div>{{ btnText }}</div>
        <div>{{ btnText2 }}</div>
    </div>
</template>

<script setup lang="ts">
// 脚本内容
import { useConfigHook } from './hooks/useConfig';
import { ComponentInternalInstance, getCurrentInstance, nextTick, ref } from 'vue';
const instance = getCurrentInstance() as ComponentInternalInstance;

const btnText = ref('111');
setTimeout(() => {
    btnText.value = '2222';
}, 3000);

const { btnText2 } = useConfigHook(instance);

defineExpose({
    btnText,
});
</script>

useConfig.ts

import type { ComponentInternalInstance } from '@vue/runtime-core';
import { computed, watch } from 'vue';

export const useConfigHook = (vm: ComponentInternalInstance) => {
    if (!vm) {
        throw new Error('vm is required');
    }
  

    const btnText2 = computed(() => {
        console.log('btnText:', vm.exposed?.btnText?.value)
        return `${(vm.exposed?.btnText?.value as string) || ''}` + '33333';
    });
    
   watch(
        () => btnText2,
        () => {
            console.log('watch-btnText2-----', btnText2.value);
        },
        { immediate: true },
    );
    return {
        btnText2,
    };
};

当btnText的值发生变化时,在hooks里通过watch监听的值并没有同步打印,而且btnText2在组件模版中也没有同步展示。难道vm.exposed?.btnText失去了响应式?

但是,当我们把watch注释掉后,发现btnText2又恢复了响应式,在组件模版中同步更新了,为什么watch对btnText2的监听还会影响到btnText2的响应式呢?

// watch(
//     () => btnText2,
//     () => {
//         console.log('watch-btnText2-----', btnText2.value);
//     },
//     { immediate: true },
// );

这其实是computed的缓存机制造成的。具体来说,就是当computed函数执行时,返回的一个对象,如下

function computed(getter) {
  let value;
  let dirty = true;
  const effectFn = effect(getter, {
    lazy: true
  });
  const obj = {
    get value() {
      if (dirty) {
        value = effectFn();
        dirty = false;
      }
      return value;
    }
  };

  return obj;
}

我们先来看当没有watch监听btnText2时,发生了什么

  1. 当computed执行时,传入的回调getter被包裹进了effect函数中生产了一个effectFn函数,当effectFn函数执行时,会调用getter函数返回值,不过此时effectFn还没执行

  2. 因为组件template中有btnText2的值,当组件的render函数执行此处时,会去获取btnText2.value的值,这时候就会触发obj的value属性的get函数,此时dirty是true,就会执行这句代码value = effectFn();,在effectFn内部就会执行getter函数,返回${(vm.exposed?.btnText?.value as string) || ''} + '33333'的值。与此同时,comouted内部也会将render函数加入到依赖列表中。

  3. 我们在第2步执行getter函数时,调用了vm.exposed?.btnText?.value。 btnText是一个响应式数据,所以当getter函数调用vm.exposed?.btnText?.value时,btnText内部会将此时computed的副作用函数加入到自己的依赖列表中。

  4. 当btnText的值发生改变时,会依次执行它的依赖列表,当然也包括computed的副作用函数,这个函数会将dirty重新变成true,并通知执行它的依赖列表,render函数重新执行时重新调用btnText2.value, 导致obj的value属性的get函数重新执行,此时dirty为true,所以重新执行effectFn, 重新返回 ${(vm.exposed?.btnText?.value as string) || ''} + '33333'的值,页面上的btnText2的值更新

当watch监听btnText2时

  1. 第一步与上面相同
  2. 此时先执行的是watch函数,watch会去调用btnText2.value,与上面第2步类型,get函数执行,返回{(vm.exposed?.btnText?.value as string) || ''} + '33333',并将dirty置为false

注意const { btnText2 } = useConfigHook(instance);这段代码执行的时候,组件实例还没创建完,所以此时get函数调用vm.exposed?.btnText?.value时computed的副作用函数并没有被加入到btnText的依赖列表中,vm.exposed?.btnText?.value的值为undfined,btnText2.value返回33333

  1. 同没有watch时的第2步,组件的render函数执行,调用btnText2.value, 调用get函数,但是,此时dirty为false, 所以直接返回了上一步保留的执行结果33333。
  2. 当btnText值发生变化时,因为computed的副作用函数并不在它的依赖列表中,也就无法通知到btnText2,所以此时btnText2的值不会发生变化

怎么解决这个问题呢,很简单,将watch包在nextTick中就可以了,此时执行watch,get调用的vm.exposed?.btnText.value就是一个响应式数据了,computed的副作用函数就会被加入到btnText的依赖列表中了。所以根据上面的原理,如果在computed执行后,加一行打印 console.log(btnText2.value) 也会导致btnText2失去对btnText的响应式。