Vue3:watch 的使用场景及常见问题

前言

在使用 Vue3 提供的 watch API

  • 有时会遇到监听的数据变了,但是不触发 watch 的情况;

  • 有时修改数据会触发 watch,重新赋值无法触发;

  • 有时重新赋值能触发 watch,但是修改内部数据又不触发;

  • 再或者监听外部传入的数据时,是否和直接监听组件内部数据时的行为一致?

面临这些问题,决心通过下面的应用场景一探究竟!避免重复踩坑,对应不同的问题,找到合适的解决方案。

本文已收录在 Github: github.com/beichensky/… 中,欢迎 Star,欢迎 Follow!

一、Vue3 中响应式数据的两种类型

  • 使用 reactive 声明的响应式数据,类型是 Proxy

  • 使用 ref 声明的响应式数据,类型是 RefImpl

  • 使用 computed 得到的响应式数据,类型也属于 RefImpl

  • 使用 ref 声明时,如果是引用类型,内部会将数据使用 reactive 包裹成 Proxy

二、Watch API

watch(source, callback, options)

  • source: 需要监听的响应式数据或者函数

  • callback:监听的数据发生变化时,会触发 callback

    • newValue:数据的新值

    • oldValue:数据的旧值

    • onCleanup:函数类型,接受一个回调函数。每次更新时,会调用上一次注册的 onCleanup 函数

  • options:额外的配置项

    • immediateBoolean类型,是否在第一次就触发 watch

    • deepBoolean 类型,是否开启深度监听

    • flushpre | post | sync

      • pre:在组件更新前执行副作用

      • post:在组件更新后运行副作用

      • sync:每个更改都强制触发 watch

    • onTrack:函数,具备 event 参数,调试用。将在响应式 propertyref 作为依赖项被追踪时被调用

    • onTrigger:函数,具备 event 参数,调试用。将在依赖项变更导致副作用被触发时被调用。

三、watch 监听 reactive 声明的响应式数据

1. 监听 reactive 声明的响应式数据时

  • 当监听的 reactive 声明的响应式数据时,修改响应式数据的任何属性,都会触发 watch

    const state = reactive({
      name: '张三',
      address: {
        city: {
          cityName: '上海',
        },
      },
    });
    
    watch(
      state,
      (newValue, oldValue) => {
        console.log(newValue, oldValue);
      },
      {
        deep: false,
      }
    );
    
    setTimeout(() => {
      state.name = '李四';
    }, 1000);
    
    setTimeout(() => {
      state.address.city.cityName = '北京';
    }, 2000);
    

可以发现,namecityName 发生变化时,都会触发 watch。但是,这里会发现两个问题:

  1. 无论是修改 name 或者 cityName 时,oldValuenewValue 的值是一样的;

  2. 尽管我们将 deep 属性设置成了 false,但是 cityName 的变化依然会触发 watch

这里得出两个结论:

  1. 当监听的响应式数据是 Proxy 类型时,newValueoldValue 由于是同一个引用,所以属性值是一样的;

  2. 当监听的响应式数据是 Proxy 类型时,deep 属性无效,无论设置成 true 还是 false,都会进行深度监听。

2. 监听 Proxy 数据中的某个属性时

由于在业务开发中,定义的数据中可能属性比较多,我们指向监听其中某一个属性,那我们看看该如何操作

当监听的属性是基本类型时

  • 如果只想监听 name 属性时,由于 name 是个基本类型,所以 source 参数需要用回调函数的方式进行监听:

    watch(
      () => state.name,
      (newValue, oldValue) => {
        console.log(newValue, oldValue);
      }
    );
    
    setTimeout(() => {
      state.name = '李四';
    }, 1000);
    
    setTimeout(() => {
      state.address.city.cityName = '北京';
    }, 2000);
    

这是可以看到,newValue张三oldValue李四,并且在修改 cityName 时,不会再触发 watch

当监听的属性为引用类型时

  • 监听 address 属性时,我们也可以使用回调函数的方式进行监听

    watch(
      () => state.address,
      (newValue, oldValue) => {
        console.log(newValue, oldValue);
      }
    );
    
    setTimeout(() => {
      state.name = '李四';
    }, 1000);
    
    setTimeout(() => {
      state.address.city.cityName = '北京';
    }, 2000);
    

豁。。。发现控制台现在一次日志 都不打印了,按道理说,修改 name 时,不触发 watch是正常的,但是修改 cityName 时,是想要触发的啊。

先看一下现在这种情况,如何触发 watch

watch(
  () => state.address,
  (newValue, oldValue) => {
    console.log(newValue, oldValue);
  }
);

setTimeout(() => {
  state.name = '李四';
}, 1000);

setTimeout(() => {
  state.address = {
    city: {
      cityName: '北京',
    },
  };
}, 2000);

这个时候,发现 1秒 和 2秒 之后,控制台出现打印结果了。那我们知道了,需要修改 address 属性,才能触发监听,修改更深层的属性,触发不了,这个时候明白了,应该是没有深度监听,Ok,那我们把 deep 属性设置为 true 试试:

watch(
  () => state.address,
  (newValue, oldValue) => {
    console.log(newValue, oldValue);
  },
  {
    deep: true,
  }
);

setTimeout(() => {
  state.name = '李四';
}, 1000);

setTimeout(() => {
  state.address.city.cityName = '杭州';
}, 3000);

setTimeout(() => {
  state.address = {
    city: {
      cityName: '北京',
    },
  };
}, 2000);

果不其然,控制台中,正常的打印了两次日志,说明,无论直接修改 address 还是修改 address 内部的深层属性,都可以正常的触发 watch

好的,到这里,可能有些同学说了:那我直接监听 state.address 不就可以了吗?这样 deep 属性也不用加。

那我们演示一下看看会不会存在问题:

watch(state.address, (newValue, oldValue) => {
  console.log(newValue, oldValue);
});

setTimeout(() => {
  state.address.city.cityName = '杭州';
}, 3000);

setTimeout(() => {
  state.address = {
    city: {
      cityName: '北京',
    },
  };
}, 2000);

在控制台,只打印了第一次修改 cityName 时的日志,第二次修改 address 时,无法触发 watch

好,现在把上面两次修改调换一下位置:

watch(state.address, (newValue, oldValue) => {
  console.log(newValue, oldValue);
});

setTimeout(() => {
  state.address = {
    city: {
      cityName: '北京',
    },
  };
}, 2000);

setTimeout(() => {
  state.address.city.cityName = '杭州';
}, 3000);

控制台里,一次日志都没有了,也就意味着,修改 address 时,无法触发监听,并且之后,由于 address 的引用发生变化,导致后续 address 内部的任何修改也都触发不了 watch 了。这是一个致命问题。

这里也得出了两个结论:

  1. 当指向监听响应式数据的某一个属性时,需要使用函数的方式设置 source 参数:

    • 如果属性类型是基本类型,可以正常监听,并且 newValueoldValue ,可以正常返回;

    • 如果属性类型是引用类型,需要将 deep 设置为 true 才能进行深度监听。

  2. 如果属性类型时引用类型,并且没有用函数的方式注册 watch,那么在使用时,一旦重新对该属性赋值,会导致监听失效。

四、watch 监听 ref 声明的响应式数据

1. ref 声明的数据为基本类型时

ref 声明的数据为基本类型时,直接使用 watch 监听即可

const state = ref('张三');

watch(state, (newValue, oldValue) => {
  console.log(newValue, oldValue);
});

setTimeout(() => {
  state.value = '李四';
}, 1000);

1秒 后,在控制台可以看到,打印出了 李四张三

众所周知,ref 声明的数据,都会自带 value 属性。所以下面这种写法效果同上:

const state = ref('张三');

watch(() => state.value, (newValue, oldValue) => {
  console.log(newValue, oldValue);
});

setTimeout(() => {
  state.value = '李四';
}, 1000);

2. ref 声明的数据为引用类型时

ref 声明的数据为引用类型时,内部会接入 reactive 将数据转化为 Proxy 类型。所以该数据的 value 对应的是 Proxy 类型。

const state = ref({
  name: '张三',
  address: {
    city: {
      cityName: '上海',
    },
  },
});

watch(state, (newValue, oldValue) => {
  console.log(newValue, oldValue);
});

setTimeout(() => {
  state.value = {
    name: '李四',
    address: {
      city: {
        cityName: '上海',
      },
    },
  };
}, 1000);

setTimeout(() => {
  state.value.address.city.cityName = '北京';
}, 2000);

1秒后,控制台打印出了日志,但是 2秒后,却没有日志再出现了,这又是什么原因呢,我们把上面的代码转个形。在 ref 声明的数据为基本类型时,这段里说过,监听 state() => state.value ,效果是一样的,那我们看一下转换后的代码:

watch(() => state.value, (newValue, oldValue) => {
  console.log(newValue, oldValue);
});

上面说了,当 ref 声明的数据是引用类型时,内部会借助 reactive 转化为 Proxy 类型。那这段代码是不是感觉似曾相识?哈哈,不就是将 deep 属性设置为 true 就可以了么。

const state = ref({
  name: '张三',
  address: {
    city: {
      cityName: '上海',
    },
  },
});

watch(
  state,
  (newValue, oldValue) => {
    console.log(newValue, oldValue);
  },
  {
    deep: true,
  }
);

setTimeout(() => {
  state.value = {
    name: '李四',
    address: {
      city: {
        cityName: '上海',
      },
    },
  };
}, 1000);

setTimeout(() => {
  state.value.address.city.cityName = '北京';
}, 2000);

加上 deep 之后,可以看到,在 1秒及 2秒后,都会在控制台打印出日志。说明此时,无论是修改 statevalue,还是修改深层属性,都会触发 watch

有些同学可能说了,我直接函数返回 state 行不行:

const state = ref({
  name: '张三',
  address: {
    city: {
      cityName: '上海',
    },
  },
});

watch(
  () => state,
  (newValue, oldValue) => {
    console.log(newValue, oldValue);
  }
);

setTimeout(() => {
  state.value = {
    name: '李四',
    address: {
      city: {
        cityName: '上海',
      },
    },
  };
}, 1000);

setTimeout(() => {
  state.value.address.city.cityName = '北京';
}, 2000);

好的,这里我帮大家试过了,跟上面的效果有些区别:

  • deepfalse 时,修改 value 或者深层属性,都不会触发 watch

  • 而设置deeptrue 时,修改 vaue 或者深层属性,都会触发 watch

五、watch 监听传入的 prop 时

1. Proxy 作为 prop 传递时

既然是 Proxy 类型的数据,那么我们直接按照之前演示的方式,直接使用不就好了么:

App 组件

<script setup>
import { reactive } from 'vue';
import Child from './Child.vue';

const state = reactive({
  name: '张三',
  address: {
    city: {
      cityName: '上海',
    },
  },
});

setTimeout(() => {
  state.name = '李四';
}, 1000);

setTimeout(() => {
  state.address.city.cityName = '北京';
}, 2000);
</script>

<template>
  <Child :data="state" />
</template>

Child 组件

<script setup>
import { watch } from 'vue';

const props = defineProps(['data']);

watch(props.data, (newValue, oldValue) => {
  console.log(newValue, oldValue);
});
</script>

好的,在 1秒 和 2秒之后,可以看到控制台打印出的有两次日志。Ok,乍一看感觉没有问题哈,那我们修改一下 App 组件里的数据传递:

App 组件

<script setup>
import { reactive, ref } from 'vue';
import Child from './Child.vue';

const state = reactive({
  name: '张三',
  address: {
    city: {
      cityName: '上海',
    },
  },
});

const otherState = reactive({
  name: '李四',
});

const flag = ref(true);

setTimeout(() => {
  flag.value = false;
}, 500);

setTimeout(() => {
  state.name = '李四';
}, 1000);

setTimeout(() => {
  state.address.city.cityName = '北京';
}, 2000);
</script>

<template>
  <Child :data="flag ? otherState : state" />
</template>

有些同学可能就问,flag ? otherState : state 这里用 computed 包装一下不行吗?当然可以,但是这里不是为了演示问题嘛,一切写法皆有可能对吧。

修改完 App 组件之后,按道理应该会打印三次日志,但是惊讶的发现:无论多久,控制台里都不会有日志打印,也就是说,data 属性的变化根本没有触发 watch。这是为啥呢?又该怎么处理呢?

  • 因为在 App 组件中,我们切换了要传递给 Child 组件的数据,所以 watch 监听的 prop 不是同一个了

  • 所以需要使用函数的方式监听 prop

Child 组件

<script setup>
import { watch } from 'vue';

const props = defineProps(['data']);

watch(() => props.data, (newValue, oldValue) => {
  console.log(newValue, oldValue);
});
</script>

确实哈,修改完之后,控制台里打印了一次日志,而且新旧值不同,说明切换数据的时候监听到了,但还是不对,还少了两次。

到了这里,还记得我们上面讨论过的,使用函数作为 source 监听时,想监听深层的属性,那就需要添加 deep 属性为 true 才可以。

Child 组件

<script setup>
import { watch } from 'vue';

const props = defineProps(['data']);

watch(
  () => props.data,
  (newValue, oldValue) => {
    console.log(newValue, oldValue);
  },
  {
    deep: true,
  }
);
</script>

好的,添加了 deep: true 之后,控制台中分别在 500ms、1秒、2秒后打印出了日志。此时达到了想要的效果。很棒!

2. ref 定义的数据作为 prop 传递时

当 ref 定义的数据作为 prop 进行传递时,会进行脱 ref 的操作,也就是说,基本类型会直接将数据作为 prop 传递,引用类型会作为 Proxy 传入

ref 定义数据为基本类型时

直接使用函数作为 source 参数,进行监听即可:

App 组件

<script setup>
import {  ref } from 'vue';
import Child from './Child.vue';

const state = ref('张三');

setTimeout(() => {
  state.value = '李四';
}, 1000);

</script>

<template>
  <Child :data="state" />
</template>

Child 组件,此时由于 ref 定义的是基本数据类型,所以也不存在是否需要深度监听的问题

<script setup>
import { watch } from 'vue';

const props = defineProps(['data']);

watch(
  () => props.data,
  (newValue, oldValue) => {
    console.log(newValue, oldValue);
  }
);
</script>

当 ref 定义数据为引用类型时

上面说过,ref 作为 prop 传递时,会脱 ref,也就意味着,传给子组件的就是 Proxy 类型的数据,用法及可能遇到的问题,请参照 proxy 作为 prop 传递时 里的代码和示例。

六、watch 监听 provide 提供的数据时

1. 提供的数据为 Proxy 时

proxy 作为 prop 传递时,请参照 proxy 作为 prop 传递时 里的代码和示例。

2. 提供的数据为 ref 时

provide API 提供的数据为 ref 时,不会进行脱 ref 操作,同 四、watch 监听 ref 声明的响应式数据,请参照 四、watch 监听 ref 声明的响应式数据 里的代码和示例

3. 提供的数据不是响应式数据时

可以在 watch 中使用函数的方式进行监听,前提是需要将 deep 设置 true 哦,这样对象内部如果包含了响应式的数据,也是可以触发监听的。

七、监听多个数据

在实际开发过程中,可能会需要同时监听多个值,我们看一下多个值的情况,watch 是如何处理以及响应的:

import { reactive, ref, watch } from 'vue';

const state = reactive({
  name: '张三',
  address: {
    city: {
      cityName: '上海',
    },
  },
});

consotherState = reactive({
  name: '李四',
});

const flag = ref(true);

watch([state, () => otherState.name, flag], (newValue, oldValue) => {
  console.log(newValue, oldValue);
});

setTimeout(() => {
  flag.value = false;
}, 500);

setTimeout(() => {
  otherState.name = '李四';
}, 1000);

setTimeout(() => {
  state.address.city.cityName = '北京';
}, 2000);

可以再控制台看到,三次变化都会输出日志,并且 newValueoldValue 都是一个数组,里面值的顺序对应着 source 里数组的顺序。

八、竞态问题

在业务开发的过程中,时常面临这样的需求:监听某个数据的变化,当数据发生变化时,重新进行网络请求。下面写一段代码,来模拟这个需求:

<script setup>
import { reactive, ref, watch } from 'vue';

let count = 2;
const loadData = (data) =>
  new Promise((resolve) => {
    count--;
    setTimeout(() => {
      resolve(`返回的数据为${data}`);
    }, count * 1000);
  });

const state = reactive({
  name: '张三',
});

const data = ref('');

watch(
  () => state.name,
  (newValue) => {
    loadData(newValue).then((res) => {
      data.value = res;
    });
  }
);

setTimeout(() => {
  state.name = '李四';
}, 100);
setTimeout(() => {
  state.name = '王五';
}, 200);
</script>

<template>
  <div>{{ data }}</div>
</template>

可以看到界面上展示的结果是:返回的数据为李四,显然这不是我们想要的结果。最后一次是将 name 修改为了王五,所以肯定是希望返回的结果为王五。那出现这个异常的原因是什么呢?

数据每次变化,都会发送网络请求,但是时间长短不确定,所以就有可能导致,后发的请求先回来了,所以会被先发的请求返回结果给覆盖掉。

那么该如何解决呢?上面提到过,watchcallback 中具备第三个参数 onCleanup,我们来尝试着用一下:

watch(
  () => state.name,
  (newValue, oldValue, onCleanup) => {
    let isCurrent = true;
    onCleanup(() => {
      isCurrent = false;
    });
    loadData(newValue).then((res) => {
      if (isCurrent) {
        data.value = res;
      }
    });
  }
);

此时,在浏览器上,只会出现:返回的数据为王五

onCleanup 接受一个回调函数,这个回调函数,在触发下一次 watch 之前会执行,因此,可以在这里,取消上一次的网络请求,亦或做一些内存清理及数据变更等任何操作。

九、watchEffect

上面说了很多 watch 的应用场景和常见问题。在需要监听多个数据时,可以使用数组作为 source。但是多个数据,如果是很多个呢,可能比较负责的逻辑,其中使用了较多的响应式数据,这个时候,使用 watch 去监听,显然不太适合。

这里可以使用新的 API:watchEffect

1. watchEffect API

watchEffect(effect, options)

  • effect: 函数。内部依赖的响应式数据发生变化时,会触发 effect 重新执行

    • onCleanup:形参,函数类型,接受一个回调函数。每次更新时,会调用上一次注册的 onCleanup 函数。作用同 watch 中的 onCleanup 参数。
  • options

    • flushpre | post | sync

      • pre:在组件更新前执行副作用;

      • post:在组件更新后运行副作用,可以使用 watchPostEffect 替代;

      • sync:每个更改都强制触发 watch,可以使用 watchSyncEffect 替代。

    • onTrack:函数,具备 event 参数,调试用。将在响应式 propertyref 作为依赖项被追踪时被调用

    • onTrigger:函数,具备 event 参数,调试用。将在依赖项变更导致副作用被触发时被调用。

2. watchEffect 用法

import { reactive, ref, watchEffect } from 'vue';

const state = reactive({
  name: '张三',
});

const visible = ref(false);

watchEffect(() => {
  console.log(state.name, visible.value);
});

setTimeout(() => {
  state.name = '李四';
}, 1000);

setTimeout(() => {
  visible.value = true;
}, 2000);

2秒之后,查看控制台,发现打印了三次日志:

  • 第一次是初始值

  • 第二次是修改 name 触发的监听

  • 第三次是修改 visible 触发的监听

  • 而且每次打印的都是当前最新值

由此可以看出:

  1. watchEffect 默认监听,也就是默认第一次就会执行;

  2. 不需要设置监听的数据,在 effect 函数中,用到了哪个数据,会自动进行依赖,因此不用担心类似 watch 中出现深层属性监听不到的问题;

  3. 只能获取到新值,由于没有提前指定监听的是哪个数据,所以不会提供旧值。

3. watchEffect 可能会出现的问题

在上面的用法中,感觉 watchEffect 使用起来还是很方便的,会自动依赖,而且还不用考虑各种深度依赖的问题。那 watchEffect 会不会有什么陷阱需要注意呢?

import { reactive, ref, watchEffect } from 'vue';

const state = reactive({
  name: '张三',
});

const visible = ref(false);

watchEffect(() => {
  setTimeout(() => {
    console.log(state.name, visible.value);
  })
});

setTimeout(() => {
  state.name = '李四';
}, 1000);

setTimeout(() => {
  visible.value = true;
}, 2000);

这次在 effect 函数中添加了异步任务,在 setTimeout 中使用响应式数据,会发现,控制台一直都只展示一个日志:第一次进入时打印的。

也就是说,在异步任务(无论是宏任务还是微任务)中进行的响应式操作,watchEffect 无法正确的进行依赖收集。所以后面无论数据如何变更,都不会触发 effect 函数。

如果真的需要用到异步的操作,可以在外面先取值,再放到异步中去使用

watchEffect(() => {
  const name = state.name;
  const value = visible.value;
  setTimeout(() => {
    console.log(name, value);
  });
});

修改之后,在控制台中可以正常的看到三次日志。

十、总结

  1. wacthsourceProxy 类型时:

    • deep 属性失效,强制进行深度监听;

    • 新旧值指向同一个引用,导致内容是一样的。

  2. watchsourceRefImpl 类型时:

    • 直接监听 state 和 监听 () => state.value 是等效的;

    • 如果 ref 定义的是引用类型,并且想要进行深度监听,需要将 deep 设置为 true

  3. watchsource 是函数时,可以监听到函数返回值的变更。如果想监听到函数返回值深层属性的变化,需要将 deep 设置为 true

  4. 如果想监听多个值的变化,可以将 source 设置为数组,内部可以是 Proxy 对象,可以是 RefImpl 对象,也可以是具有返回值的函数;

  5. 在监听组件 props 时,建议使用函数的方式进行 watch,并且希望该 prop 深层任何属性的变化都能触发,可以将 deep 属性设置为 true

  6. 使用 watchEffect 时,注意在异步任务中使用响应式数据的情况,可能会导致无法正确进行依赖收集。如果确实需要异步操作,可以在异步任务外先获取响应式数据,再将值放到异步任务里进行操作。

十一:相关链接

写在后面

如果有写的不对或不严谨的地方,欢迎大家能提出宝贵的意见,十分感谢。

如果喜欢或者有所帮助,欢迎 Star,对作者也是一种鼓励和支持。

猜你喜欢

转载自juejin.im/post/7126364048886595598