Skip to content

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

@beichensky

Description

@beichensky

前言

在使用 Vue3 提供的 watch API

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

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

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

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

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

本文已收录在 Github: github.com/beichensky/Blog 中,欢迎 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,对作者也是一种鼓励和支持。

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions