返回

避免Vue监听器无限循环更新:4种方法

vue.js

防止相互触发的侦听器更新

在构建交互式应用程序时,常常需要监控数据的变化并据此进行响应。使用如 Vue.js 这类框架,watch 功能是完成此任务的关键。然而,如果多个 watch 相互依赖并尝试更新对方的数据,就会出现问题,导致无限循环更新。这类情况需要小心处理。本文探讨如何阻止相互依赖的侦听器之间调用彼此的更新。

循环更新问题

一个典型的场景是,一个侦听器监听属性 A 的变化,并在其变化时更新属性 B。同时,另一个侦听器监听属性 B 的变化,反过来又更新 A。这种相互作用会引发一系列的连锁反应,最终可能导致浏览器卡死或崩溃。例如:

const width = ref(0);
const height = ref(0);

watch(width, async (newItem, oldItem) => {
    console.log(`width: ${oldItem}->${newItem}`);
    height.value = newItem / 2;
});

watch(height, async (newItem, oldItem) => {
    console.log(`height: ${oldItem}->${newItem}`);
    width.value = newItem * 2;
});

在这段代码中,修改 width 会导致 height 更新,然后 height 的更新又会导致 width 再次更新,从而形成一个更新循环。

解决方案

以下几种方法可以解决这种循环更新问题。

使用局部变量存储值

使用一个临时局部变量,在侦听器回调内部进行数据计算和中间状态的存储,而不是直接修改响应式状态,等计算完成后,一次性地去更新状态。这个方式确保更新只在一次计算完成后进行。

const width = ref(0);
const height = ref(0);

watch(width, async (newItem) => {
    console.log(`width: changed to ${newItem}`);
    let newHeight = newItem / 2;
    height.value = newHeight;
});

watch(height, async (newItem) => {
    console.log(`height: changed to ${newItem}`);
    let newWidth = newItem * 2;
     width.value = newWidth;
});

这个例子中,使用newWidthnewHeight存储中间值。避免了侦听器中对彼此的直接更新。注意:使用 let 定义的变量属于块级作用域,可以防止内部循环执行的问题。

设置标志位进行控制

一种更为精细的方案是使用一个标志位来阻止侦听器的相互触发。在侦听器函数开始时,设置该标志,表示正在执行更新操作;完成后,清除标志。 这种方式可以精确控制数据流,只在需要时进行更新。

const width = ref(0);
const height = ref(0);
let updatingWidth = false;
let updatingHeight = false;


watch(width, async (newItem, oldItem) => {
  if (updatingWidth) return;
  updatingHeight = true;
  console.log(`width: ${oldItem}->${newItem}`);
    height.value = newItem / 2;
  updatingHeight = false;
});


watch(height, async (newItem, oldItem) => {
    if (updatingHeight) return;
  updatingWidth = true;
    console.log(`height: ${oldItem}->${newItem}`);
    width.value = newItem * 2;
    updatingWidth = false;
});

上述代码中,updatingWidthupdatingHeight 标志用于跟踪是否有侦听器正在更新数据,在函数开始前,我们先设置一个标志,这样当这个标志为 true时,就不会继续触发监听的事件了,执行完毕再清除,允许另一个监听函数工作。这样既解决了相互触发更新的问题,又能确保每次更新按顺序发生。

衍生计算属性(computed)

如果 widthheight 之间存在一个明确的转换关系(就像当前示例一样),使用计算属性可以更优雅地解决问题。计算属性可以自动跟踪其依赖的变化,仅在必要时更新其值,从而减少不必要的计算和潜在的循环更新。

const width = ref(0);
const height = ref(0);


const computedHeight = computed(()=> {
  return  width.value / 2;
})
watch(width, async(newValue, oldValue)=> {
     console.log(`width: ${oldValue} -> ${newValue}`)
    })

watch(computedHeight, async (newValue, oldValue)=> {
     console.log(`computedHeight: ${oldValue} -> ${newValue}`)
   height.value = newValue
})

const computedWidth = computed(()=> {
 return height.value *2
})

watch(height, async (newValue, oldValue)=> {
      console.log(`height: ${oldValue} -> ${newValue}`)

    })

watch(computedWidth, async (newValue, oldValue)=> {
    console.log(`computedWidth: ${oldValue} -> ${newValue}`)

    width.value = newValue
})

此方式中,我们直接修改了响应式属性的初始值,之后 computedWidth 每次都是根据当前的 height计算出来的。 这比之前依赖 watch 相互调用更加有效,结构也更加清晰。计算属性的使用有效减少了响应式的依赖。注意:computed 必须包含 return。

debounce 或者 throttle

在高频事件更新的场景下,debouncethrottle 等函数可以防止过于频繁的更新触发,控制更新频率。debounce 会在延迟时间后执行更新,而 throttle 会按指定频率执行更新,确保性能更佳,防止页面阻塞。需要配合lodash之类的库,自行实现:

  npm install lodash
import { ref } from 'vue'
import { watch,computed } from 'vue'
import _ from 'lodash';

const width = ref(0);
const height = ref(0);
const debouncedUpdateHeight = _.debounce( (value)=>{
    height.value = value
}, 500)
const debouncedUpdateWidth =  _.debounce((value)=>{
    width.value = value
}, 500)

watch(width, async (newItem, oldItem) => {
    console.log(`width: ${oldItem}->${newItem}`);
   debouncedUpdateHeight(newItem/2);

});

watch(height, async (newItem, oldItem) => {
    console.log(`height: ${oldItem}->${newItem}`);
      debouncedUpdateWidth(newItem * 2);
});

该方式可以有效限制更新次数,每次变化后都会等待500ms后再更新 height 或者 width。 避免高频率的操作频繁地触发响应式更新。 务必注意,debounce 和 throttle的执行上下文。

额外的安全建议

  • 明确数据的依赖关系,设计好数据的流向。在初期设计就考虑周全,避免后期修改导致大量问题。
  • 添加日志输出。在开发中,应始终使用 console.log 来监控数据更新,快速发现问题所在,有助于问题的及时诊断和定位。
  • 优先选择简单的状态管理。如果可以使用计算属性和更简单的逻辑结构解决,优先选用此类方案。它们通常更易于理解和维护。

通过采取上述预防措施和采用正确的解决办法,可以有效地管理响应式数据的相互依赖,确保程序顺利运行。