记一次使用 Vue3 ref 以及 ref unwrapping 的坑

146 阅读4分钟

背景

一个列表,其中数据项有属性 favorite 展示是否收藏。

点击打开详情弹框,弹框中有操作进行收藏/取消收藏。然后收藏状态需要同步到列表中。

现状

已经实现的大概逻辑:

父组件

列表数据 list = ref(xx)

点击列表项获取详情数据、打开详情弹框:

async function handleClickItem(params) {
  const detail = await getDetail(params))
  detailData.value = {
    id: curDetailId,
    isFavorite: detail.is_favorite,
  }
  isDetailVisible.value = true
}

根据子组件更新列表数据:

function handleUpdateFavorite(isFavorite) {
  detailData.value.isFavorite = isFavorite
  list.value = list.value.map((item) => {
    return {
      ...item,
      favorite: item.design_id === curDetailId ? isFavorite : item.favorite,
    }
  })
}

子组件

// 子组件收藏状态
const isFavorite = ref(props.xxx.isFavorite)

// 监听父组件传参
watchEffect(() => {
  isFavorite.value = props.xxx.isFavorite
})

// 收藏操作
function handleFavorite() {
  await updateFavorites(xxx)
  isFavorite.value = !isFavorite.value
  emits('update-favorite', isFavorite)
}

现象

假设有列表项 A、B、C、D……初始 favorite 属性都是 false。

  1. 点击 A,在详情弹框中进行收藏,能正常收藏,子组件、父组件状态都能正常改变,favorite 为 true。
  2. 关掉 A,点击 B,子组件状态正常,父组件列表 list 中的 A 的 favorite 属性变为 false。
  3. 关掉 B,点击 A,子组件和父组件又都是正确的,也就是 list 中 A 的favorite 为 true。

问题就出在 2,为什么点击 B,会改变 A 的属性呢?list 好像没有进行更新啊。

继续:

  1. 点击 B,收藏。
  2. 点击 C,B 的收藏取消了,但是 A 的并没有取消,A 的恢复了收藏状态。
  3. 点击 C,收藏。
  4. 点击 D,同5,C 的收藏取消了,但是 B、A 的收藏还在。

排查

一开始怀疑是子组件更新数据的问题,涉及到 watch watchEffect 监听数据。但是排查下来没有发现问题。

上面的现象也是我后来发现总结的。一开始发现 list 更新,就开始排查到底哪里进行了 list 的更新,第二次打开弹框是否进行了 list 更新?打开弹框进行详情数据的请求,detailData 是否会影响 list,似乎也不能发现问题。

问了 ChatGPT,贴了部分代码,完全是模式的回答,对于这种很隐蔽的 bug 完全没有意义。

原因

在请同事帮忙看了之后,很快发现了问题,原来是子组件 emits 传参错误,isFavorite 应为 isFavorite.value

这实在是一个低级错误,但是因为收藏功能没问题,一开始就忽略了这里的问题。

但是,我想要知道为什么会导致这个现象?

打开 A,收藏,正确,打开 B,为什么会更新 list?这里并不是哪里主动更新了 list,而是 A 的 favorite 被赋值为一个 ref 对象。对象是引用,也就是子组件的状态 isFavorite 被父组件 list 中 A 的属性 Favorite 引用。当打开 B 的时候,父组件传参更新了子组件的状态 isFavorite,所以此时 list 中的属性也跟着改变了。

总结

  • js 莫名其妙的数据更新问题,往往是引用的问题。此时在控制台打印也要很小心,打印对象并不能反应实时的状态。
  • Vue ref 的赋值需要注意 value。
  • 但是:
function handleUpdateFavorite(isFavorite) {
  console.log('isFavorite: ', isFavorite);
  detailData.value.isFavorite = isFavorite
  console.log('detailData.value.isFavorite: ', detailData.value.isFavorite);
}

这里的打印是:

isFavorite: {"__v_isShallow":false,"dep":{"cleanup":"Function()=>ref2.dep=void0Function()=>ref2.dep=void0","computed":null},"__v_isRef":true,"_rawValue":true,"_value":true} detailData.value.isFavorite: true

为什么?

因为 Ref Unwrapping,ref 本身作为值赋值给 reactive 对象的属性,会进行自动解包操作。

我之前很清楚 template 中的 unwrapping 以及数组的 unwrapping,却忽略了这里。

  • 上面的现象 4-7 怎么解释?

打开 A,收藏,再打开 B,A 的收藏状态被改变,收藏 B,打开 C,B 变为未收藏,而 A 是已收藏。

如果是引用的关系,不应该 A 也会被打开 C 的操作影响吗?变为未收藏吗?

父组件的回调更新:

list.value = list.value.map((item) => {
    return {
      ...item,
      favorite: item.design_id === curDetailId ? isFavorite : item.favorite,
    }
})

列表只更新了当前项,当打开 C 时,影响了 B 的 favorite 引用,但是 A 的 favorite 属性已经被赋值为 true,不会受到影响。此时 A 的赋值是 item.favorite,值为 true,而不是 isFavorite 引用。

  • 另外这个问题的源头是传参错误,为什么 TS 类型约束不起作用呢?这样修改:
const emits = defineEmits<{
  'update-favorite': [isFavorite: boolean]
}>()

这样修改之后,就可以对 emits 的传参进行类型约束。