精读《Vuejs与实现》第 6 章(原始值响应式方案)

原始值指的是 Boolean、Number、 BigInt、String、Symbol、undefined 和 null 等类型的值
原始值是按值传递的,而非按引用传递。这意味着,如果一个函数接收原始值作为参数,那么形参与实参之间没有引用关系,它们是两个完全独立的值,对形参的修改不会影响实参。
Proxy 无法提供对原始值的代理,因此想要将原始值变成响应式数据,就必须对其做一层包裹。

6.1 引入 ref 的概念

Proxy的代理目标必须是非原始值,使我们无法拦截对原始值的操作。例如,我们无法阻止修改字符串:

let str = 'vue'
str = 'vue3' // 无法拦截这个操作

解决这个问题的方法是使用非原始值来“包裹”原始值,例如,我们可以用一个对象来包裹原始值:

const wrapper = {
  value: 'vue'
}
const name = reactive(wrapper) // 使用 Proxy 代理 wrapper,间接实现对原始值的拦截
name.value // vue
name.value = 'vue3' // 修改值可以触发响应

但这种方法存在两个问题:

  • 用户需要创建一个包裹对象来创建响应式的原始值。
  • 包裹对象的命名由用户定义,可能不规范,例如,可以使用 wrapper.value 或 wrapper.val。

为解决这些问题,我们可以封装一个函数,将包裹对象的创建工作封装在其中:

// 封装一个 ref 函数
function ref(val) {
  // 在 ref 函数内部创建包裹对象
  const wrapper = {
    value: val
  }
  // 将包裹对象变成响应式数据
  return reactive(wrapper)
}

上述代码,我们将创建 wrapper 对象的任务封装到 ref 函数内,然后使用 reactive 函数使其成为响应式数据并返回。这样,上述两个问题都得到了解决。

// 创建原始值的响应式数据
const refVal = ref(1)

effect(() => {
  // 在副作用函数内通过 value 属性读取原始值
  console.log(refVal.value)
})
// 修改值能够触发副作用函数重新执行
refVal.value = 2

上述代码如期执行,但还有一个问题,如何区分 refVal 是原始值的包裹对象还是非原始值的响应式数据?例如:

const refVal1 = ref(1)
const refVal2 = reactive({ value: 1 })

上述代码 refVal1 和 refVal2 我们需要区分它们,因为这涉及到自动脱 ref 能力。
我们可以通过在 wrapper 对象上定义一个不可枚举的属性 __v_isRef 来区分数据是否是 ref:

function ref(val) {
  const wrapper = {
    value: val
  }
  // 使用 Object.defineProperty 在 wrapper 对象上定义一个不可枚举的属性 __v_isRef,并且值为 true
  Object.defineProperty(wrapper, '__v_isRef', {
    value: true
  })

  return reactive(wrapper)
}

上述代码,我们使用 Object.defineProperty 为 wrapper 对象定义了一个不可枚举的属性 __v_isRef,其值为 true,表示此对象是一个 ref。这样,我们可以通过检查 __v_isRef 属性来判断数据是否是 ref。

6.2 响应丢失问题

ref 功能不仅可以对原始值实现响应式,还可以解决响应丢失问题,首先,我们先理解下什么是响应丢失问题:

export default {
  setup() {
    // 响应式数据
    const obj = reactive({ foo: 1, bar: 2 })

    // 暴露数据给模板
    return {
      ...obj
    }
  }
}

然后在模板中使用这些数据:

<template>
  <p>{{ foo }} / {{ bar }}</p>
</template>

但是这样做可能会导致响应丢失,即在修改响应式数据后,不会触发组件的重新渲染。这是因为我们使用了展开运算符(...)来返回数据,它实际上展开后返回的是一个普通对象,而不是响应式对象。

解决上面问题,我们可以将响应式对象的每个属性封装成一个独立的 ref 对象,这样即使在副作用函数内部通过新对象访问属性值,也能建立响应联系:

// obj 是响应式数据
const obj = reactive({ foo: 1, bar: 2 })

// newObj 对象下具有与 obj 对象同名的属性,每个属性都是一个 ref 对象
const newObj = {
  foo: {
    get value() {
      return obj.foo
    }
  },
  bar: {
    get value() {
      return obj.bar
    }
  }
}

effect(() => {
  // 在副作用函数内通过新的对象 newObj 读取 foo 属性值
  console.log(newObj.foo.value)
})

// 修改 obj.foo 会触发响应
obj.foo = 100

你可能会注意到 newObj 对象的创建过程有些重复,现在的newObj 对象下,具有与 obj 对象同名的属性,而且每个属性的值都是一个对象。
我们可以将这部分逻辑抽象出来并封装成函数:

function toRef(obj, key) {
	const wrapper = {
		get value() {
			return obj[key]
		},
		set value(val) {
			obj[key] = val
		},
	}

	Object.defineProperty(wrapper, '__v_isRef', {
		value: true,
	})

	return wrapper
}

这样,我们就可以很方便地将响应式对象的每个属性转换成一个 ref 对象。
如果响应式对象的属性非常多,我们还可以进一步封装一个批量转换函数:

function toRefs(obj) {
  const ret = {}
  for (const key in obj) {
    ret[key] = toRef(obj, key)
  }
  return ret
}

const newObj = { ...toRefs(obj) }

这种解决方案的核心思想是,将响应式数据转换成一种类似 ref 结构的数据,响应丢失问题得以彻底解决。
我们的 toRef 和 toRefs 函数返回的 ref 对象是可读写的,这是因为我们为每个属性都定义了 getter 和 setter,从而保证了响应的正确触发。例如:

const obj = reactive({ foo: 1, bar: 2 })

const newObj = { ...toRefs(obj) }
console.log(newObj.foo.value) // 1
console.log(newObj.bar.value) // 2

这就是 ref 的另一个重要作用,除了实现原始值的响应式方案,它还用来解决响应丢失问题。

6.3 自动解包 ref

toRefs 函数解决了响应丢失问题,但也引来其他问题,toRefs 会将响应式数据的第一层属性值转为 ref,因此,必须通过 value 属性访问其值:

const obj = reactive({ foo: 1, bar: 2 })
const newObj = { ...toRefs(obj) }
// 必须通过 value 访问值
newObj.foo.value // 1
newObj.bar.value // 2

这会增加用户的心智负担,因为一般情况下,用户在模板中直接访问数据:

<p>{{ foo }} / {{ bar }}</p>

用户肯定不希望编写如下代码:

<p>{{ foo.value }} / {{ bar.value }}</p>

因此,我们需要实现自动解包 ref 的功能,也就是属性访问行为。即如果读取的属性是一个 ref,则直接返回对应的 value 属性值:

newObj.foo // 1

即使 newObj.foo 是一个 ref,我们也无需通过 newObj.foo.value 来访问其值,要实现这功能,需要用到之前 __v_isRef 这个 ref 标识 和 Proxy 为 newObj 创建一个代理对象:

function proxyRefs(target) {
  return new Proxy(target, {
    get(target, key, receiver) {
      const value = Reflect.get(target, key, receiver)
      // 自动解包 ref:如果读取的值是 ref,则返回其 value 属性值
      return value.__v_isRef ? value.value : value
    }
  })
}

const newObj = proxyRefs({ ...toRefs(obj) })

上述代码我们定义了 proxyRefs 函数,该函数接收一个对象作为参数,并返回该对象的代理对象。代理对象的作用是拦截 get 操作。
当读取的属性是一个 ref 时,则直接返回该 ref 的 value 属性值,实现了自动解包 ref:

console.log(newObj.foo) // 1
console.log(newObj.bar) // 2

实际上,在编写 Vue.js 组件时,组件中的 setup 函数返回的数据会传递给 proxyRefs 函数处理:

const MyComponent = {
  setup() {
    const count = ref(0)
    // 返回的这个对象会传递给 proxyRefs
    return { count }
  }
}

这就是为什么我们可以在模板直接访问一个 ref 的值,而无需通过 value 属性访问的原因。

既然我们可以自动解包 ref 的值,那么,对应地,我们也应该能自动设置 ref 的值:

newObj.foo = 100 // 应该生效

实现这个功能很简单,只需添加相应的 set 拦截函数即可:

function proxyRefs(target) {
	return new Proxy(target, {
		get(target, key, receiver) {
			const value = Reflect.get(target, key, receiver)
			return value.__v_isRef ? value.value : value
		},
		set(target, key, newValue, receiver) {
			// 通过 target 读取真实值
			const value = target[key]
			// 如果值是 Ref,则设置其对应的 value 属性值
			if (value.__v_isRef) {
				value.value = newValue
				return true
			}
			return Reflect.set(target, key, newValue, receiver)
		},
	})
}

上述代码我们为 proxyRefs 函数返回的代理对象添加了 set 拦截函数。如果设置的属性是一个 ref,则间接设置该 ref 的 value 属性的值。

实际上,自动解包 ref 不仅存在于上述场景。在 Vue.js 中,reactive 函数也具有自动解包 ref 的能力,例如:

const count = ref(0)
const obj = reactive({ count })
obj.count // 0

尽管 obj.count 是一个 ref,但我们可以直接读取 ref 的值,无需通过 value 属性,也无需关心哪些是 ref ,哪些不是 ref,这是由于自动解包 ref 的功能。这种设计减轻了用户的心智负担。

6.4 总结

在这一章节,我们探讨了 ref 的概念。ref 在本质上是一个“封装对象”,它的存在是因为 JavaScript 的 Proxy 无法直接代理原始值,所以我们采用封装对象的方式,间接实现原始值的响应式处理。为了区分 ref 和普通的响应式对象,我们给“封装对象”定义了一个标志属性 __v_isRef,当其值为 true 时,我们便知道这是一个 ref。
此外,ref 不仅用于原始值的响应式处理,还能解决响应丢失的问题。我们通过实现 toRef 和 toRefs 这两个函数,对响应式数据进行了封装,可以理解为“访问代理”。
最后,我们讨论了自动解包 ref 的能力。为了减少用户的认知负担,我们会自动对模板中的响应式数据进行解包处理。这使得用户在模板中使用响应式数据时,无需担心哪些数据是 ref,从而简化了代码编写的复杂性。

全部评论

相关推荐

躺尸修仙中:因为很多92的也去卷中小厂,反正投递简历不要钱,面试不要钱,时间冲突就推,不冲突就面试积累经验
点赞 评论 收藏
分享
点赞 收藏 评论
分享
牛客网
牛客企业服务