小Cの已经记不起来的博客

Vue 3 组合式 API 中响应式对象与 ref 的使用边界

先说结论:能用 ref 就别急着 reactive

我一开始写组合式 API 的时候,特别喜欢 reactive,原因很简单:不用到处写 .value,模板里直接 state.count,脚本里也直接 state.count++,看起来干净。后来被坑了几次才发现,这个“干净”是有代价的。

reactive 只适合对象和数组,基本类型它接不住。更关键的是,它一旦被整体替换,响应性说没就没。比如下面这种写法:

const state = reactive({ count: 0 })

// 这么写,原来的 Proxy 引用就被换掉了
state = { count: 1 }

当然,正常写代码不会这么直接赋值,因为 const 也不允许。但实际场景里很容易出现类似问题:

let form = reactive({ name: '', age: 0 })

function reset() {
  form = { name: '', age: 0 }
}

模板里还用着旧的 form,你这边已经换了一个新对象,页面不动,控制台也不报错。别问我怎么知道的。

所以我现在的习惯是:只要这个对象有可能被整体替换,就用 ref。 ref 包对象虽然脚本里要多写 .value,但它把“整体替换”和“改内部属性”这两件事都接住了:

const form = ref({ name: '', age: 0 })

form.value = { name: '张三', age: 18 }
form.value.name = '李四'

模板里还是 form.name,不用改。麻烦一点,但边界清楚。

为什么我后来大多数时候用 ref

组合式函数写多了会发现,返回 ref 比返回 reactive 更省心。比如一个简单的计数器:

function useCounter() {
  const count = ref(0)
  const increment = () => count.value++
  return { count, increment }
}

调用方解构也好,传递也好,都不容易丢响应性。你拿到的是 count 这个 ref 本身,不是某个 Proxy 上被解构出来的普通值。

但如果是下面这样:

function useUser() {
  const state = reactive({ name: '', age: 0 })
  return { ...state }
}

看着没问题,实际上返回出去的是两个普通值,响应性已经断了。要用 toRefs 才行:

function useUser() {
  const state = reactive({ name: '', age: 0 })
  return { ...toRefs(state) }
}

所以不是 reactive 不能用,是它很容易在“解构、展开、传参”这些动作里悄悄丢掉响应性。ref 也不是没坑,但它至少把值包在一个对象里,你拿到引用本身,心里更有数。

reactive 真正舒服的场景

我也不是完全不用 reactive。有一类场景它确实舒服:一组相关字段固定放在一起,只改属性,不整体替换。

比如一个弹窗状态:

const dialog = reactive({
  visible: false,
  title: '',
  loading: false
})

function openDialog(title) {
  dialog.title = title
  dialog.visible = true
}

function closeDialog() {
  dialog.visible = false
  dialog.loading = false
}

这种状态对象结构稳定,字段也不多,用 reactive 少写很多 .value,模板里也直观。再配合 toRefs 往外解构,问题不大。

但要注意,reactive 对象里的 ref 属性会自动解包,数组和集合里就不一定了。这个边界别记混。比如:

const state = reactive({
  count: ref(0)
})

console.log(state.count) // 0,不是 ref

这看起来很方便,但也容易让人误以为所有嵌套 ref 都这样。数组里的 ref、Map 里的 ref,该 .value 还是得 .value。我现在的做法是尽量别把 ref 和 reactive 混着塞来塞去,除非我很清楚这里需要自动解包。

解构是最大的分界线

reactive 最典型的坑就是解构:

const state = reactive({ count: 0 })
const { count } = state

count++ // 页面不动

因为 count 已经是普通数字了。解法要么别解构,要么用 toRefs:

const { count } = toRefs(state)
count.value++

ref 也有类似问题,但更直观:

const count = ref(0)
const { value } = count
value++ // 页面不动

你看,ref 解构的是 .value,普通值,一样丢。区别在于 ref 的 .value 太显眼了,写的时候会警觉;reactive 的属性访问太自然,反而容易忘。

所以我现在判断边界很简单:只要我打算解构,就先想清楚解构出来的是不是 ref。不是 ref,就别指望它单独响应。

整体替换对象时,ref 更省心

接口回填、表单重置、列表刷新,这些场景都容易整体替换对象。用 reactive 不是不能做,但得用 Object.assign 或者逐个字段改:

const form = reactive({ name: '', age: 0 })

// 可以
Object.assign(form, { name: '张三', age: 18 })

// 不建议,容易把响应性搞丢
form = { name: '张三', age: 18 }

用 ref 就没这个心理负担:

const form = ref({ name: '', age: 0 })
form.value = { name: '张三', age: 18 }

模板里 form.name 照常工作。唯一的代价是脚本里多写 .value。我现在的取舍是:状态对象如果存在“整体换掉”的可能,直接上 ref,别为了少写几个 .value 给自己埋雷。

模板自动解包别带进脚本

ref 在模板里会自动解包,这是 Vue 的贴心设计,但也容易让人在脚本里犯迷糊:

<script setup>
const count = ref(0)
count++ // 错,应该是 count.value++
</script>

<template>
  {{ count }} <!-- 这里不用 .value -->
</template>

reactive 在模板里直接属性访问,脚本里也是直接属性访问,看起来更统一。但它和 ref 的边界不一样:ref 在模板自动解包,reactive 在模板不解包一个“外层包装”,因为它本来就没有外层包装。

我的建议是别在模板里做复杂判断,脚本里老老实实写 .value。模板里少写 .value 是语法糖,不是让你在脚本里也忘了它。

组合式函数返回值怎么设计

我现在写组合式函数,返回值优先用 ref 和普通函数:

function usePagination() {
  const page = ref(1)
  const pageSize = ref(10)
  const total = ref(0)

  const next = () => page.value++

  return { page, pageSize, total, next }
}

调用方解构 { page, next } 也没问题,因为 page 本身是 ref。要是返回 reactive 对象,调用方一解构就很容易出问题。

如果确实想返回一组状态,又不想让调用方写太多 .value,那就返回 readonly 或者明确用 toRefs:

function useFilters() {
  const state = reactive({
    keyword: '',
    status: ''
  })

  return {
    ...toRefs(state),
    state
  }
}

但说实话,除非团队有统一约定,我现在更倾向于直接返回 ref。统一比“少写 .value”重要。

数组和集合的边界

数组也是对象,reactive([]) 可以用,但整体替换数组时同样难受:

const list = reactive([])
list = [1, 2, 3] // 不行,响应性断了

用 ref 就直观很多:

const list = ref([])
list.value = [1, 2, 3]

Vue 3 对数组索引赋值是支持的,list.value[0] = 1 也能触发更新。但如果你经常整体替换列表,ref 明显更稳。

Map、Set 这类集合,reactive 支持,但用起来限制不少。我的习惯是:如果只是内部改改,reactive 可以;如果要整体换、要跨函数传,还是 ref 包一层更省心。

我现在的实际选择清单

写业务代码时,我基本按下面这个清单选:

这些规则不是教条,但能避开大部分“页面为什么不更新”的问题。真遇到不更新,先查三件事:是不是解构了普通值,是不是整体替换了 reactive,是不是忘了 .value。这三类问题占了我在组合式 API 里踩坑的绝大多数。

边界不是教条,是让代码少出错

说到底,reactive 和 ref 不是谁替代谁的关系。reactive 适合结构稳定的对象状态,写起来顺手;ref 适合需要整体替换、需要跨函数传递、或者你不想在解构上动脑子的场景。

我现在不会为了“统一风格”强行全用 ref,也不会因为“不想写 .value”就把所有东西塞进 reactive。边界清楚比风格统一重要。代码是给人维护的,页面不更新的时候,能一眼看出问题在哪,比少写几个 .value 值多了。

评论

还没有评论。

发表评论

提交后评论将经过自动审核,审核通过后公开展示。

未在播放