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

Vue3官方不推荐的强制更新还有必要学吗?会不会出问题?

terry 1小时前 阅读数 26 #Vue

很多刚从Vue2转来的开发者,第一次遇到数据好像没响应式触发视图更新的情况,第一反应都是找this.$forceUpdate(),结果翻遍setup里的文档都找不到对应写法,更头疼的是,Vue3官方文档里还特意强调了“尽量避免使用强制更新,优先排查响应式数据的问题”——那既然官方都这么说了,强制更新的知识还有必要啃吗?真要用的话会不会踩什么大雷?今天咱们就把这个事儿掰扯清楚。

为什么Vue3官方不推荐强制更新?先搞懂响应式失效的核心

先别急着找“能让页面立刻刷新的代码”,得先明白官方为什么反对,毕竟强制更新本质是“绕过响应式系统的自动检测,手动触发组件的render和patch流程”,相当于给正常运转的机器按下“跳过质检直接打印”的按钮,偶尔用一次没问题,天天按肯定会出故障。

那Vue3里明明写了ref/reactive包裹数据,为什么还是会失效?先回忆几个高频踩坑场景——这些90%的情况下才是你需要解决的根本问题,强制更新只是临时救急的补丁: 第一个坑,组合式API里直接替换了整个reactive对象或者给reactive顶层属性赋值非响应式数据?不对不对,组合式API里给reactive直接替换整个对象会丢失响应式,因为reactive是Proxy代理的原对象,你把引用换了,新对象没有代理,自然不会触发视图,比如写let data = reactive({ count: 0 }),后来不小心改成data = { count: 10 },这时候count变了但页面不动,根本不是强制更新能彻底解决的,下次再替换还会犯; 第二个坑,用索引直接修改数组元素,或者修改数组的length属性?哦对,这个虽然Vue2里也有坑,但Vue3的Proxy其实已经能处理大部分数组操作了?等下等下,别搞错了,Proxy在浏览器里确实能拦截arr[0] = xxxarr.length = xxx,但旧版的Edge(比如EdgeHTML内核的Edge)不支持,不过现在Edge都改Chromium内核了,这个坑是不是没了?其实还有延伸的——比如你给reactive数组里塞了一个普通对象,然后修改这个普通对象的属性,会不会触发?比如let list = reactive([{ name: '小明' }]),然后list[0] = { name: '小红' }没问题,Proxy能拦截;但如果let temp = { name: '小刚' }; list.push(temp); temp.name = '小丽'呢?这时候temp是普通对象,没有被Vue3的响应式系统递归代理,所以修改它的属性不会触发list相关的视图,这时候得用toRefs或者直接给list里的元素赋值成reactive包裹的才行; 第三个坑,ref或者reactive包裹的数据层级太深,导致嵌套对象的属性更新没被正常捕获?这个概率其实很低,Vue3的Proxy是递归代理的,只要你整个初始对象是被reactive/ref包裹的,不管多少层的普通属性(注意是普通属性,不是Symbol属性或者不可枚举属性)修改都会触发视图;除非你用了shallowReactive或者shallowRef,那浅层以下的更新就不会自动触发了,这时候其实是你自己选的性能优化,得配合强制更新或者手动处理深层数据; 第四个坑,在setup之外的地方修改了响应式数据?比如在setTimeout、Promise.then、DOM事件监听的回调里,如果是用了旧的Vue2的写法(比如挂载到window上的data),或者是组合式API里把ref/reactive传到了非setup的普通函数里但没正确解包?不对不对,传普通函数里ref会自动解包吗?不会,普通函数里要用.value才能拿到和修改ref的值,reactive不用,这点新手很容易搞混,比如const count = ref(0); function add() { count++; },这时候add是setup里定义的吗?如果是,没问题;如果不是,比如add是挂载到window上的全局函数,那count++等于修改了ref的引用本身(?不对,ref是个对象,count++是给ref.value赋值吗?不,JavaScript里++不能直接作用于对象,所以会报错,但如果不小心写成const count = ref(0); function add() { count = 1; },那不管add在哪,都是替换了count的引用,setup里模板绑定的还是原来的那个ref对象,自然不会动; 第五个坑,用了v-once或者memo导致组件/节点被缓存了?v-once是只渲染一次,之后不管数据怎么变都不动;v-memo是Vue3.2新增的,只有当依赖的数组变化时才更新,如果你写错了依赖数组,比如漏写了某个关键变量,那就算变量变了也不会触发更新,这时候强制更新能临时解决,但最好还是改v-memo的依赖。

哦对了,还有一个容易忽略的——模板里绑定的数据是用解构赋值从reactive里取出来的普通变量?比如let data = reactive({ count: 0 }); let { count } = data,这时候count是个普通的number,修改它当然不会触发视图,这时候得用toRefslet { count } = toRefs(data),这样count就是个ref对象了,修改count.value或者data.count都会触发更新。

把这些坑都排查一遍,99%的响应式失效问题都能解决,根本不需要强制更新——这就是官方不推荐的核心原因:强制更新是治标不治本的,甚至会掩盖你在响应式数据使用上的错误,导致代码后期维护越来越难,性能也越来越差(因为每次强制更新都会让整个组件重新渲染,不管有没有必要的节点变化,不像响应式系统只渲染变化的部分)。

什么时候真的必须用强制更新?别瞎用,只有这几个场景

虽然官方不推荐,但也没有完全删掉这个功能,说明还是有极少数场景下非用不可的,我整理了一下,目前开发中遇到的合理场景只有这三个: 第一个场景,使用了非响应式的第三方库数据,这些数据不在Vue的响应式系统里,但你修改之后需要更新视图,比如你用了一个专门处理3D模型的库Three.js,你把Three.js的模型实例存到了reactive里,但Three.js修改模型位置、颜色的属性(比如model.position.x = 100)是直接修改原生对象的,不会触发Vue的Proxy拦截,这时候你可以在修改完之后强制更新组件; 第二个场景,使用了shallowReactive或者shallowRef做性能优化,但偶尔需要更新深层的数据,比如你有一个超级大的列表,每个列表项都是一个有很多属性的对象,你只需要更新列表项的顺序或者是否选中这种浅层属性,这时候用shallowReactive包裹整个列表可以节省很多性能(因为不用递归代理每个深层属性),但如果偶尔需要修改某个列表项的深层属性(比如修改用户的地址里的街道),这时候就可以强制更新一次组件; 第三个场景,测试或者调试的时候,临时验证某个逻辑是否正确,比如你不确定是不是响应式系统的问题,还是你业务逻辑的问题,可以先加个强制更新看看页面会不会动,如果动了,那肯定是响应式数据的问题,然后再去排查之前说的那些坑;如果没动,那就是业务逻辑的问题,比如数据根本没改对。

除了这三个场景,其他时候请一定先排查响应式数据的问题,别上来就用强制更新当“万能药”。

Vue3里强制更新有哪些方法?每个方法的坑都得记牢

OK,现在假设你真的遇到了必须用强制更新的场景,那Vue3里有哪些写法呢?组合式API和选项式API不一样,得分开说,而且每个方法都有对应的适用范围和坑,别乱用。

选项式API的写法:保留了Vue2的this.$forceUpdate()

这个应该是老转新的开发者最熟悉的了,选项式API里还是和Vue2一样,直接在方法里调用this.$forceUpdate()就行,不过要注意,这个方法只会强制更新当前组件本身,不会更新它的子组件——除非子组件的props或者插槽内容变化了,或者子组件自己也触发了强制更新。 举个简单的例子,比如你在选项式API里用了一个非响应式的计数器:

export default {
  data() {
    return {
      // 故意不用响应式,测试强制更新
      normalCount: 0
    };
  },
  methods: {
    add() {
      this.normalCount++;
      // 强制更新当前组件
      this.$forceUpdate();
    }
  }
};

模板里绑定normalCountadd方法,点击按钮就能看到数字变化了,不过这个例子完全是为了测试,正常情况下你应该用count: 0包裹在data里(data里的属性默认是响应式的,除非你用Object.freeze冻结了)。

组合式API的第一种写法:使用getCurrentInstance().proxy.$forceUpdate()

组合式API里没有this,那怎么调用类似$forceUpdate的方法呢?可以用Vue3提供的getCurrentInstance()钩子,这个钩子能获取当前组件的实例对象,然后通过实例对象的proxy属性访问到类似选项式API里this的所有方法和属性,包括$forceUpdate()。 不过这里有两个大坑,一定要记牢: 第一个大坑,getCurrentInstance()只能在setup函数或者其他组合式钩子(比如onMountedcomputedwatch)里调用,不能在普通的异步回调(比如setTimeoutPromise.thenDOM事件监听的回调)里直接调用,否则会返回null,那如果异步回调里需要强制更新怎么办?可以在setup里先把proxy存到一个变量里,然后在异步回调里用这个变量调用$forceUpdate(); 第二个大坑,这个方法是Vue3内部API,没有对外公开文档!哦对,这点非常重要,官方没有把getCurrentInstance().proxy作为正式的API对外公布,所以未来Vue3的小版本更新可能会修改或者删除这个属性,导致你的代码突然报错,所以除非你是在做Vue3的插件或者库,否则尽量不要用这个方法。

举个组合式API用getCurrentInstance()的例子,不过这个例子不推荐在生产环境用:

import { getCurrentInstance, onMounted } from 'vue';
export default {
  setup() {
    // 先在setup里获取实例和proxy
    const instance = getCurrentInstance();
    const proxy = instance?.proxy;
    // 故意不用响应式
    let normalCount = 0;
    const add = () => {
      normalCount++;
      // 调用proxy的$forceUpdate
      proxy?.$forceUpdate();
    };
    // 异步回调里的正确用法:先存proxy
    onMounted(() => {
      setTimeout(() => {
        normalCount = 10;
        proxy?.$forceUpdate();
      }, 1000);
    });
    return {
      normalCount,
      add
    };
  }
};

组合式API的第二种写法:使用key属性重置组件

这个方法是官方推荐的替代强制更新的方法之一,虽然它不是“强制更新现有组件”,而是“销毁旧组件,创建新组件”,但效果是一样的——整个组件都会重新渲染,包括它的子组件,而且这个方法是对外公开的正式API,未来不会有兼容性问题。 具体怎么用呢?很简单,给需要强制更新的组件绑定一个key属性,这个key属性可以是一个ref或者reactive包裹的变量,当你需要强制更新的时候,修改这个key的值就行(比如加1,或者换成一个新的字符串/数字)。 为什么修改key会重置组件?因为Vue的虚拟DOM diff算法是通过key来识别节点的,如果两个节点的key不一样,Vue就会认为它们是完全不同的节点,会直接销毁旧节点,创建新节点,这样新节点的所有状态都会被重置,包括data、computed、watch、生命周期钩子等等。 这个方法的适用范围比前两个广,不仅能更新当前组件,还能更新子组件;而且是正式API,不会有兼容性问题,不过它也有一个坑:重置组件会丢失组件的所有内部状态!比如你有一个表单组件,用户已经填了一半的内容,你修改了key重置组件,那用户填的内容就会全部清空——所以这个方法适合用来重置组件的状态,或者更新那些不需要保留内部状态的组件,比如Three.js的3D渲染组件、图表组件(比如ECharts组件,有时候重新创建比更新视图更简单)。 举个用key属性重置组件的例子,比如你有一个ECharts组件,需要在修改完非响应式的图表配置之后重新渲染:

<template>
  <div>
    <!-- 给ECharts组件绑定ref的key -->
    <MyECharts :chart-option="chartOption" :key="chartKey" />
    <button @click="updateChart">更新图表</button>
  </div>
</template>
<script setup>
import { ref } from 'vue';
import MyECharts from './MyECharts.vue';
// 图表配置(假设是从非响应式的第三方库或者接口拿到的,修改之后不会自动触发MyECharts的更新)
const chartOption = ref({ { text: '初始图表' },
  xAxis: { data: ['周一', '周二', '周三', '周四', '周五'] },
  yAxis: {},
  series: [{ type: 'bar', data: [5, 20, 36, 10, 10] }]
});
// 绑定到key上的ref变量
const chartKey = ref(0);
const updateChart = () => {
  // 修改非响应式的图表配置(假设chartOption是从非响应式的地方来的,这里为了演示直接用ref,其实ref修改会自动触发,但如果是普通对象就不会)
  chartOption.value.title.text = '更新后的图表';
  chartOption.value.series[0].data = [10, 30, 20, 40, 50];
  // 修改key的值,重置MyECharts组件
  chartKey.value++;
};
</script>

组合式API的第三种写法:使用一个“空的”响应式变量触发watchEffect/computed,间接触发视图更新?不对不对,还有一个更简单的,Vue3.2新增了triggerRef和自定义ref里的trigger?哦对,还有一个专门给组合式API用的、公开的、安全的强制更新方法——使用一个不做任何事的响应式变量,当你需要强制更新的时候,修改这个变量的值,Vue的响应式系统会自动检测到这个变量的变化,然后触发组件的render和patch流程。

这个方法是目前组合式API里最推荐的“轻量级强制更新”方法,因为它是公开的API,不会有兼容性问题;而且它只会更新当前组件,不会更新子组件(除非子组件也依赖了这个变量);更重要的是,它不会销毁组件的内部状态,比用key属性更安全。 具体怎么用呢?很简单,在setup里定义一个ref或者reactive包裹的“空变量”,比如const forceUpdateFlag = ref(0),然后在模板里不需要绑定这个变量(或者绑定也可以,不影响),当你需要强制更新的时候,修改forceUpdateFlag.value++就行——虽然模板里没有绑定这个变量,但Vue的响应式系统会记录所有在setup或者组合式钩子里被访问过的响应式变量吗?不对不对,等下,这里可能有个误区:Vue的响应式系统只会触发那些模板里绑定了的响应式变量所在的组件的更新,或者computed/watch/watchEffect里依赖了的响应式变量所在的组件的更新,哦对,我刚才说的方法有个前提:你得让Vue的响应式系统知道这个变量是当前组件依赖的,那怎么让它知道呢?可以在setup里随便访问一下这个变量,或者在模板里加一个隐藏的元素绑定这个变量,或者用一个空的watchEffect来依赖这个变量。 举个正确的例子:

<template>
  <div>
    <!-- 非响应式的计数器 -->
    <p>普通计数器:{{ normalCount }}</p>
    <button @click="add">增加</button>
    <!-- 隐藏的元素绑定forceUpdateFlag,让Vue知道当前组件依赖这个变量 -->
    <span v-show="false">{{ forceUpdateFlag }}</span>
  </div>
</template>
<script setup>
import { ref } from 'vue';
// 非响应式的计数器
let normalCount = 0;
// 轻量级强制更新用的ref变量
const forceUpdateFlag = ref(0);
const add = () => {
  normalCount++;
  // 修改forceUpdateFlag的值,触发组件更新
  forceUpdateFlag.value++;
};
</script>

或者不用隐藏元素,用一个空的watchEffect:

import { ref, watchEffect } from 'vue';
// 非响应式的计数器
let normalCount = 0;
// 轻量级强制更新用的ref变量
const forceUpdateFlag = ref(0);
// 空的watchEffect,只依赖forceUpdateFlag
watchEffect(() => {
  // 随便访问一下forceUpdateFlag.value,让Vue知道这个变量是当前组件依赖的
  forceUpdateFlag.value;
});
const add = () => {
  normalCount++;
  forceUpdateFlag.value++;
};

这个方法的优点很多:公开API、安全、不会丢失状态、只会更新当前组件;缺点几乎没有,唯一的缺点就是你得定义一个额外的变量,但这个变量占的内存可以忽略不计。

哦对了,刚才提到的Vue3.2新增的triggerRef是干嘛的?triggerRef是专门用来手动触发shallowRef的更新的,不管shallowRef.value有没有变化,比如你用shallowRef包裹了一个对象,修改了这个对象的深层属性,这时候你可以用triggerRef来触发shallowRef的更新,从而触发组件的更新——这个其实也算一种“半强制更新”,因为它只触发依赖了这个shallowRef的组件的更新,比刚才的空变量方法更精准。 举个triggerRef的例子:

<template>
  <div>
    <!-- shallowRef包裹的用户信息 -->
    <p>用户名:{{ user.name }}</p>
    <p>街道:{{ user.address.street }}</p>
    <button @click="updateStreet">更新街道</button>
  </div>
</template>
<script setup>
import { shallowRef, triggerRef } from 'vue';
// shallowRef包裹的用户信息,只代理user本身,不代理深层的address
const user = shallowRef({
  name: '小明',
  address: {
    street: '中关村大街1号'
  }
});
const updateStreet = () => {
  // 修改深层的address.street,shallowRef不会自动触发更新
  user.value.address.street = '中关村大街2号';
  // 手动触发shallowRef的更新
  triggerRef(user);
};
</script>

这个方法适合用来配合shallowRef或者自定义ref做性能优化,也是公开的正式API,推荐使用。

强制更新的正确打开方式

咱们来总结一下今天讲的内容:

  1. 优先排查响应式失效的坑,不要上来就用强制更新,99%的问题都能通过正确使用ref/reactive/toRefs/shallowReactive/shallowRef解决;
  2. 只有三个场景真的必须用强制更新:非响应式的第三方库数据、shallowReactive/shallowRef的深层更新、测试/调试临时验证;
  3. 强制更新的方法优先级
    • 配合shallowRef用:triggerRef(最精准,公开API);
    • 组合式API轻量级更新:空的响应式变量+隐藏元素/watchEffect(最常用,公开API,不丢状态);
    • 需要重置组件状态:绑定key属性(公开API,会丢状态);
    • 选项式API:this.$forceUpdate()(老代码兼容用,只会更新当前组件);
    • 组合式API临时用:getCurrentInstance().proxy.$forceUpdate()(尽量别用,内部API);
  4. 不管用哪种方法,都要尽量少用,强制更新会影响性能,还会掩盖响应式数据的使用错误。

好了,今天关于Vue3强制更新的内容就讲到这里了,希望能帮到大家,如果还有其他Vue3的问题,欢迎在评论区留言讨论。

版权声明

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

热门