携手创作,共同成长!这是我参与「掘金日新计划 · 8 月更文挑战」的第1天,点击查看活动详情
本篇内容基于【拆解Vue3】reactive是如何实现的?实现。
有了reactive为什么还需要ref?
学习JavaScript,最先接触到的知识就是数据类型。从广义上讲,我们可以把数据类型分为两种:
- 原始类型(7种):
number, bigint, string, boolean, null, undefined, symbol
; - 引用类型:
object
;
typeof null
的结果是object
,这是一个官方承认的错误,为了兼容性才保留了下来。
处于程序执行效率的考虑,原始类型被保存在栈空间中,而引用类型被保存在堆空间中。这在赋值时产生了显著的差异,原始类型采用了值传递的方式,而引用类型传递的是应用。举个例子来说明这点。
function add(a, b) {
a += 1
b += 1
}
let a = 1, b = 1
add(a, b)
console.log(a, b)
执行这段代码,不难发现尽管执行了add(a, b)
,但最后输出时,a
与b
的值没有发生改变,仍然都是1。值传递的方式意味着函数内的a b
与函数外的a b
实际上是相互独立的。接下来,基于这个例子,我们改为引用传递看看效果。
function add(a, b) {
a.val += 1
b.val += 1
}
let a = { val: 1 }, b = { val: 1 }
add(a, b)
console.log(a.val, b.val)
这里的a.val b.val
的值都变为了2,这说明引用传递时,函数内的a b
与函数外的a b
实际上是相同的。
上面我们简单复习了一下不同类型的非响应式的数据,在进行赋值时产生的差异。接着让我们想想,这种差异是否会对响应式产生影响?
任何一种响应式数据,必须要满足在修改时能够触发对应的副作用函数,这点对于原始类型的响应式来说也一样。为了实现这一点,我们必须让原始数据类型通过引用传递的方式进行赋值,引入reactive
对原始类型数据进行包装。在Vue3的官方文档中,我们也找到了对应的表述:
ref
会返回一个可变的响应式对象,该对象作为一个响应式的引用维护着它内部的值,这就是ref
名称的来源。
ref
还是先来看看ref
是如何使用的。
const count = ref(0)
顺着上一小节的思路,我们引入reactive
对原始类型数据进行包装,但由于reactive
基于Proxy
实现,只能给对象提供响应式能力,这里我们替开发者来做一层封装。
function ref(val) {
const wrapper = {
value: val
}
return reactive(wrapper)
}
接着,我们继续基于上一小节的例子来试试,使用ref
会有什么响应式效果。
const a = ref(0)
const b = ref(0)
effect(() => {
const c = a
const d = b
add(c, d)
function add (a, b) {
a.value += 1
b.value += 1
}
console.log('a = ', a.value, ', b = ', b.value)
})
从上图可以看到,第一次执行effect
的结果,a b
的值加上了1
.第二次通过修改a.value
再次触发effect
,也得到了预期的结果。到这里我们已经实现了ref
的基本功能。最后需要考虑一个细节问题,ref
实际上返回的是一个reactive
,但在实际使用时,我们需要有区分二者的能力。为了实现这一点,我们使用defineProperty
(属性标志和属性描述符)给ref
打一个标记__v_isRef
。
function ref(val) {
const wrapper = {
value: val
}
Object.defineProperty(wrapper, '__v_isRef', {
value: true
})
return reactive(wrapper)
}
toRef与toRefs
正如Vue3文档中所说,当我们想使用大型响应式对象的一些属性时,可能想使用ES6的解构语法来获取我们想要的属性。但这会导致属性的响应性丢失,如下面这个例子所示。
const state = reactive({
foo: 1,
bar: 2
})
let { foo } = state
这是因为解构相当于返回了一个普通属性,普通属性当然不包含响应式的能力。为了方便理解,我们给出一个例子来说明解构的实质。
let { foo } = state // (1)
let foo = state.foo // (2)
(1)处的解构语法,实质上执行的是(2)处的赋值,二者是完全等价的。到这就很清楚了,又是由值传递引发了预期之外的结果。我们沿用上一节的实现ref
的思路,通过包装,让其基于引用传递来完成响应式。
function toRef(obj, key) {
const wrapper = {
get value() {
return obj[key] // (3)
},
set value(val) {
obj[key] = val // (4)
},
}
Object.defineProperty(wrapper, '__v_isRef', {
value: true
})
return wrapper
}
这里和ref
不同的是,toRef
接收的就是响应式对象,我们只需要拦截这个响应式对象的获取和读取操作,将操作映射到响应式对象的属性上去。实现效果如下图所示。
实现了toRef
,接下来的toRefs
就很好完成了。我们只需要遍历响应式对象中的每一个属性,给它们套上toRef
就可以执行了。
function toRefs(obj) {
const ret = {}
for(const key in obj) {
ret[key] = toRef(obj, key)
}
return ret
}
const state = reactive({
foo: 1,
bar: 2
})
const obj = { ...toRefs(state) }
这里又有一个蛋疼的问题,在语义上toRef
为了和ref
保持一致,我们需要通过foo.value
才能对响应式数据的值进行读取或修改,而我们在Vue模板上使用ref
时是不需要value
就可以直接操作原始类型的响应式对象的。为了避免给用户造成不必要的心智负担,我们要想办法去掉value
。
我们的想法恰好对应了Vue3官方文档中关于Ref解包的概念。原话是,当ref
作为渲染上下文(从setup()
中返回的对象)上的属性返回并可以在模板中被访问时,它将自动浅层次解包内部值。这里再明确一下要实现的是针对从setup()
中返回的对象,去掉其value
。用proxy
拦截对象的get 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 // (5)
},
set(target, key, newValue, receiver) {
const value = target[key]
if(value.__v_isRef) { // (6)
value.value = newValue
return true
}
return Reflect.set(target, key, newValue, receiver)
}
})
}
我们之前设置的__v_isRef
标签起作用了,通过__v_isRef
标签,我们就能判断哪个属性是ref
。在(5)处,我们拦截get
操作,当读取ref
属性时,直接返回.value
的结果。同理,在(6)处,我们拦截set
操作,当修改ref
属性时,直接使用.value
去接赋值。接下来,我们验证一下proxyRefs
能否按预期执行。
const count = ref(0)
let setup = { count }
/** 上例相当于
* setup() {
* const count = ref(0)
* return {
* count
* }
* }
*/
setup = proxyRefs(setup)
在Vue组件中,
setup()
返回对象时,会自动使用proxyRefs()
进行处理,以此来达到Ref解包的目的。
参考资料
- 《Vue.js设计与实现》霍春阳
- Vue.js (vuejs.org)
- Tiny-Vue: 一个实现了 Vue 核心功能的微型前端框架。 (gitee.com)