Code前端首页关于Code前端联系我们

Vue3 watch初始值要不要立即触发?initial参数怎么用才不踩坑?

terry 11小时前 阅读数 130 #Vue

最近在帮几个刚从Vue2转过来的朋友做项目复盘,发现一个特别高频的问题:明明Vue2里watch加了immediate就会在组件挂载第一时间跑回调,怎么到Vue3官方文档又提了个initial参数?这俩到底有啥区别?是不是直接换个名字就行?还有人说initial是computed专属的?今天咱们就把这些问题掰扯清楚,从watch和immediate的基本逻辑讲起,再深挖initial的实际用途,最后给大家列几个新手最容易踩的坑,连真实项目里的避坑方案都准备好了。

搞懂Vue3 watch的三个核心:回调时机、依赖追踪、参数变化

在讲initial之前,咱们得先把Vue3的watch核心逻辑理明白——不然光记参数名和用法,换个场景又懵了,watch在Vue3里本质是一个“依赖响应式数据变化的执行器”,它的工作流程大概是这样的:

  1. 首次渲染/挂载前的准备阶段:Vue会解析watch里传入的源(比如ref、reactive对象的属性、计算属性、getter函数),建立源和回调函数的依赖关系,但这时候默认不会触发回调,哪怕源有初始值。
  2. 依赖变化后的触发阶段:只要源的响应式引用或者深层值发生了变化(如果开了deep),Vue就会把回调丢到微任务队列里,下一次DOM更新前执行。
  3. 特殊情况:immediate的介入:如果给watch传了immediate: true,那准备阶段就直接同步执行第一次回调,这时候依赖还没完全追踪完吗?不会,Vue会先处理源的依赖收集,保证第一次回调执行时用到的源属性变化后还能继续触发。

举个小例子,你就能明白默认情况和immediate的区别: 假设你写了一个登录表单组件,有个userInfo的reactive对象,里面存了usernametoken,你需要在token变化时去请求用户的个人中心信息:

// 默认情况
watch(() => userInfo.token, (newToken, oldToken) => {
  console.log('token变了,请求个人中心');
  // 这里的oldToken第一次触发时是undefined
})
// 页面刚打开时,假设你从localStorage里取了token赋值给userInfo.token
userInfo.token = localStorage.getItem('token');
// 这时候才会打印,第一次的oldToken是undefined,newToken是localStorage里的值

如果加了immediate: true:

watch(() => userInfo.token, (newToken, oldToken) => {
  console.log('token变了,请求个人中心');
  // 第一次触发时oldToken是undefined,不管有没有赋值过localStorage
  if (newToken) {
    // 避免第一次token为空时请求
    fetchUserInfo(newToken);
  }
}, { immediate: true })
// 页面准备阶段就会直接打印,不管userInfo.token初始值是什么
// 如果页面挂载前已经把localStorage的token赋给了userInfo,那这次请求就能正常发

这个场景很典型吧?是不是新手刚转Vue3或者用Vue3做新项目时经常遇到的?那initial参数到底是干啥的呢?它和computed、watch都有关系吗?

别再搞混了!initial到底是谁的参数?

首先要给大家纠正一个误区:**initial不是watch的直接参数,而是computed的可选项,也是watchEffect的第三个参数里的flush?不对不对,再回忆下——哦对了,官方文档里提到的“watch initial值”,一般是指两种情况:一种是用computed的initial选项配合watch(或者computed本身)来处理初始逻辑,另一种是watchEffect里的onCleanup函数配合setup的执行时机,或者watch的immediate,但更准确的说法是,有一个比较新的Vue3.3+的实验性或者说更常用的watch“变种”写法?哦不,应该说,大家混淆initial可能是因为几个API都涉及到“初始值处理”:

  1. computed的initial选项(Vue3.2+正式版):这个选项是给computed的getter函数提供“第一次计算前的临时值”的,当computed还没完成首次依赖收集和计算时,访问computed的value会返回initial。
  2. watch的immediate选项(Vue3所有版本):这个刚才讲过了,是让watch的回调在依赖准备完成后同步触发第一次。
  3. watchEffect的“初始执行”特性(默认行为):watchEffect和watch不一样,它默认会在组件准备阶段同步执行第一次,自动追踪回调里用到的所有响应式数据,之后数据变化时再触发。

为什么很多人会把initial说成watch的参数呢?因为有时候我们需要用computed的initial来辅助watch处理更复杂的初始逻辑,旧初始值”的问题——等下,刚才watch immediate的例子里,第一次回调的oldToken是undefined对吧?如果我们想要第一次回调的oldToken是我们预设的某个值,或者是localStorage里的旧值?这时候initial就有用了。

computed的initial选项怎么配合watch用?真实场景来了!

刚才提到的“第一次watch immediate的old值是undefined”,这个坑很多人踩过——比如你做一个搜索组件,有个搜索框输入的内容叫searchInput(ref),还有个防抖后的搜索关键词叫debouncedSearch(computed加防抖或者用lodash debounce),你需要监听debouncedSearch的变化去请求接口,同时你希望第一次请求时能区分“用户第一次打开页面没有输入”和“用户输入了又清空”两种情况: 如果只用watch immediate:

import { ref, computed, watch } from 'vue';
import { debounce } from 'lodash-es';
export default {
  setup() {
    const searchInput = ref('');
    // 用computed加防抖不太对,防抖应该放在watch里?不对,两种写法都有,先看用computed的
    // 或者直接用watch的防抖选项?哦对了,Vue3.4+还加了watch的debounce选项,但我们先看需要initial的情况
    // 假设这里的debouncedSearch是普通的计算属性,但要模拟初始搜索场景
    const debouncedSearch = ref(''); // 哦对,用ref加watch debounce searchInput更常见
    const handleInput = debounce((val) => {
      debouncedSearch.value = val;
    }, 500);
    watch(searchInput, (newVal) => {
      handleInput(newVal);
    });
    // 现在监听debouncedSearch去请求
    watch(debouncedSearch, (newKey, oldKey) => {
      console.log('请求搜索,新关键词:', newKey, '旧关键词:', oldKey);
      if (newKey === oldKey) return; // 避免重复请求
      // 但第一次请求时oldKey是undefined,newKey如果是空字符串的话,没法区分“初始空”和“清空后空”
      fetchSearchResults(newKey);
    }, { immediate: true });
    // 现在模拟用户的操作:
    // 1. 页面刚打开:debouncedSearch是空字符串,watch immediate触发,newKey空,oldKey undefined → 发请求(但可能你不想初始空发)
    // 2. 用户输入“vue”:500ms后debouncedSearch变成“vue”,newKey vue,oldKey 空 → 发请求
    // 3. 用户清空搜索框:500ms后debouncedSearch变成空,newKey 空,oldKey vue → 发请求(这个是你想要的)
    // 那怎么区分1和3?
    return { searchInput };
  }
}

这时候computed的initial选项就派上用场了?或者不用computed,用ref的初始值配合一个变量?但用computed initial更优雅,而且更符合响应式的逻辑——等下,我们把debouncedSearch改成computed试试:

import { ref, computed, watch } from 'vue';
import { debounce } from 'lodash-es';
export default {
  setup() {
    const searchInput = ref('');
    // 这里我们用一个ref来存“上一次有效的搜索关键词”?或者用computed的initial来存初始的old值?
    // 哦对了,有个更好的办法:用Vue3的watch的“自定义比较函数”?不对,自定义比较函数是用来判断是否触发回调的,不是用来改old值的。
    // 那用computed的initial来做一个“保存初始旧值的容器”,然后用watchEffect或者watch来处理?
    // 等下,换个更直接的例子,说明computed initial的必要性:
    // 假设你有一个主题切换的功能,主题初始值是从localStorage里取的,如果没有的话就用系统主题,你需要在主题变化时(包括初始设置时)去修改document.body的class,同时你还需要记录“上一次的主题”,方便用户切换回来:
    const getSystemTheme = () => window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
    // 这里我们不用watch immediate,而是用computed initial配合watchEffect?或者用watch加computed initial存上一次主题?
    // 先看不用initial的情况:
    const currentTheme = ref(localStorage.getItem('theme') || getSystemTheme());
    const lastTheme = ref(''); // 第一次肯定是空,没法记录初始的另一个主题
    watch(currentTheme, (newTheme, oldTheme) => {
      document.body.className = newTheme;
      localStorage.setItem('theme', newTheme);
      // 第一次watch immediate触发时,oldTheme是undefined,没法给lastTheme赋值
      if (oldTheme) {
        lastTheme.value = oldTheme;
      }
    }, { immediate: true });
    // 这时候用户第一次点击切换主题按钮,lastTheme还是空,没法正确切换回初始主题
    const toggleTheme = () => {
      if (lastTheme.value) {
        currentTheme.value = lastTheme.value;
      } else {
        // 只能硬编码,或者重新取系统主题,但如果用户第一次设置的是localStorage里的主题,就不对了
        currentTheme.value = currentTheme.value === 'dark' ? 'light' : 'dark';
      }
    };
    // 这时候就可以用computed的initial选项来解决lastTheme的初始值问题!
    // 或者,换个思路,用computed来生成lastTheme,initial设为另一个初始可能的主题:
    const getReverseTheme = (theme) => theme === 'dark' ? 'light' : 'dark';
    const initialCurrent = localStorage.getItem('theme') || getSystemTheme();
    // 先把currentTheme用ref存初始值
    const currentThemeRef = ref(initialCurrent);
    // 用computed生成lastTheme,initial设为reverse的初始值
    const lastThemeComp = computed(() => {
      // 这里的getter函数会在currentThemeRef第一次变化后执行,但初始访问时返回initial
      return getReverseTheme(currentThemeRef.value);
    }, { initial: getReverseTheme(initialCurrent) });
    // 现在用watch监听currentThemeRef,不用immediate?不对,还是需要immediate来设置初始的class
    watch(currentThemeRef, (newTheme) => {
      document.body.className = newTheme;
      localStorage.setItem('theme', newTheme);
      // 这里不用管lastTheme,因为它是computed,自动更新
    }, { immediate: true });
    // 切换按钮直接用lastThemeComp.value
    const toggleTheme = () => {
      currentThemeRef.value = lastThemeComp.value;
    };
    // 完美!现在不管用户第一次打开页面时的主题是啥,lastThemeComp初始访问时都是reverse的,第一次点击就能正确切换
    // 而且这个computed的initial选项,只有在computed的getter还没执行过的时候才会生效,一旦currentThemeRef变化了一次,getter就会正常执行,initial就失效了

这个场景够真实了吧?是不是很多做主题切换的组件都会遇到?而且用computed initial解决的话,代码非常简洁,没有冗余的变量,也符合响应式的“数据驱动视图和逻辑”的原则。

Vue3.4+新增的watch选项?有没有和initial相关的?

刚才提到了Vue3.4+新增了watch的debounce和throttle选项,还有一个选项叫once,但其实和initial相关的更重要的更新是在Vue3.2+正式把computed的initial选项放出来,还有Vue3.3+对watchPostEffectwatchSyncEffect的优化——不过这些都是细节,我们再回到大家最关心的问题:什么时候用watch的immediate?什么时候用computed的initial?什么时候用watchEffect?

三个API的适用场景对比

我们给大家列一个清晰的表格,不过因为是文章,我们用文字分点说:

  1. watch + immediate
    • 适用场景:你需要明确监听某个或某几个特定的响应式数据,数据变化时触发逻辑,而且组件挂载/准备阶段就需要执行一次这个逻辑(比如初始化请求、初始化DOM修改);
    • 优点:依赖明确,不会误触发,第一次回调的new值是源的当前值;
    • 缺点:第一次回调的old值是undefined,除非用其他方式处理;
  2. computed + initial
    • 适用场景:你需要一个依赖响应式数据的计算值,而且在这个计算值还没首次计算前(比如组件渲染的极早期,或者在setup的前半部分)需要访问它,或者需要给计算值提供一个初始的“默认旧值”来辅助其他逻辑
    • 优点:符合响应式逻辑,初始值只在首次未计算时生效,之后自动更新;
    • 缺点:只能用在computed上,不能直接用在watch上;
  3. watchEffect
    • 适用场景:你需要自动追踪回调里用到的所有响应式数据,数据变化时触发逻辑,而且组件准备阶段就需要执行一次
    • 优点:不用手动指定依赖,代码更简洁;
    • 缺点:依赖不明确,可能会误触发(比如回调里不小心用了一个无关的响应式数据),没有old值,只有当前值。

新手用watch(和initial相关的逻辑)最容易踩的三个坑

刚才讲了这么多用法,现在给大家列几个新手100%会踩的坑,连避坑方案都准备好了:

坑1:watch监听reactive对象的整个对象,immediate触发后old值和new值一样

这个坑太经典了!很多新手刚转Vue3时会这么写:

import { reactive, watch } from 'vue';
export default {
  setup() {
    const userInfo = reactive({
      username: '张三',
      age: 18
    });
    watch(userInfo, (newInfo, oldInfo) => {
      console.log('userInfo变了', newInfo, oldInfo);
      // 你会发现newInfo和oldInfo的引用是一样的!修改其中一个另一个也会变
      // 而且第一次immediate触发时,虽然oldInfo是undefined?不对,如果监听整个reactive对象,不加deep的话,只有替换整个对象才会触发,加deep的话,修改属性会触发,但oldInfo和newInfo引用一样
    }, { immediate: true, deep: true });
    // 测试一下:
    setTimeout(() => {
      userInfo.age = 19;
    }, 1000);
    return { userInfo };
  }
}

为什么会这样呢?因为reactive对象是一个Proxy,它的引用是不变的,不管你怎么修改里面的属性,Proxy的引用都是同一个,所以watch拿到的newInfo和oldInfo其实是同一个对象的引用,打印出来当然一样(哪怕你在控制台展开看,因为是异步打印,引用的内容已经变了)。 避坑方案

  • 如果你只需要监听reactive对象的某个属性,就监听这个属性的getter函数:watch(() => userInfo.age, (newAge, oldAge) => { ... }),这时候newAge和oldAge是基本类型,不会有引用问题;
  • 如果你需要监听整个reactive对象的变化,并且需要old值,就用structuredClone或者JSON.parse(JSON.stringify())先深拷贝一份,但要注意性能问题,只在必要时用:
    watch(
    () => structuredClone(userInfo),
    (newInfo, oldInfo) => {
      console.log('userInfo变了', newInfo, oldInfo);
    },
    { immediate: true, deep: true }
    );

    坑2:computed的initial选项用了响应式数据,导致initial不会更新

    刚才讲computed initial的例子里,我们用的都是非响应式的初始值,比如getReverseTheme(initialCurrent),initialCurrent是ref的.value(基本类型),不是响应式的ref本身——但如果新手不小心用了响应式数据作为computed的initial,会发生什么呢?

    import { ref, computed } from 'vue';

export default { setup() { const count = ref(0); const doubleCount = computed(() => { return count.value 2; }, { initial: count.value 3 }); // 这里用了count.value,但count是响应式的?不,initial是在computed创建时就固定下来的,不会追踪count的变化 console.log('初始doubleCount:', doubleCount.value); // 0*3=0 count.value = 1; console.log('count变了后的doubleCount:', doubleCount.value); // 第一次访问,getter执行,变成2,initial失效 // 你以为count变了后initial会变成3?不会!initial是静态的,只在computed创建时赋值一次 return { count, doubleCount }; } }

**避坑方案**:computed的initial选项**只能用非响应式的静态值**,不能用ref、reactive或者computed本身,如果你需要一个“动态的初始值”,要么在computed创建前先把响应式数据的.value取出来赋值给一个非响应式变量,要么就不用initial,直接在setup的前半部分先给相关的ref赋值。
#### 坑3:watchEffect的回调里用了异步函数,导致onCleanup失效或者依赖追踪错误
这个坑虽然和initial没有直接关系,但也是和watch的初始执行、回调时机相关的,新手也很容易踩:
```javascript
import { ref, watchEffect } from 'vue';
import { fetchUserInfo } from '@/api';
export default {
  setup() {
    const userId = ref(1);
    watchEffect(async (onCleanup) => {
      // 这里的异步函数会导致问题:
      // 1. watchEffect的依赖追踪是同步的,异步函数里的userId.value不会被追踪到!
      // 2. onCleanup函数需要在异步函数执行前注册,否则第一次执行时onCleanup不会生效
      const controller = new AbortController();
      onCleanup(() => {
        controller.abort();
        console.log('取消上一次请求');
      });
      // 这里的userId.value是在异步函数外面吗?不,刚才的写法是在外面取吗?哦,如果在外面取userId.value,就会被追踪到:
      const id = userId.value;
      const res = await fetchUserInfo(id, { signal: controller.signal });
      console.log('用户信息', res);
    });
    // 测试一下:
    setTimeout(() => {
      userId.value = 2;
    }, 500);
    // 如果刚才的userId.value是在await后面取的,那这次修改不会触发watchEffect!
    return { userId };
  }
}

避坑方案

  • watchEffect的依赖追踪是同步的,所以所有需要追踪的响应式数据都要在异步函数执行前访问
  • onCleanup函数要在异步函数执行前注册,这样每次watchEffect重新执行时,都会先调用上一次的onCleanup。

initial到底是啥,怎么用才对?

最后咱们再总结一遍,把所有知识点串起来:

  1. initial不是watch的直接参数,它是Vue3.2+正式版computed的可选项,用来给computed提供“首次计算前的临时静态值”;
  2. watch的初始执行靠immediate,但immediate第一次回调的old值是undefined,如果需要初始old值,可以用computed的initial配合ref;
  3. 三个API的适用场景要分清:明确依赖用watch+immediate,需要临时初始计算值用computed+initial,自动追踪依赖用watchEffect;
  4. 避坑要记住三个点:监听reactive对象属性用getter函数,computed initial用静态值,watchEffect的依赖和onCleanup要放异步前。

好了,今天关于Vue3 watch initial的所有内容就讲完了,如果你还有其他问题,欢迎在评论区留言讨论!

版权声明

本文仅代表作者观点,不代表Code前端网立场。
本文系作者Code前端网发表,如需转载,请注明页面地址。

热门