在本篇文章中,将讨论在Vue
中原始值的响应式方案。原始值指的是Boolean
、Number
、String
、BigInt
、Symbol
、undefined
和null
等类型的值。在JavaScript
中,原始值是按值传递的,而非按引用传递。这意味着如果一个函数接受原始值作为参数,那么形参与实参之间没有引用关系,它们两个是完全独立的值,对形参的修改不会影响实参。另外,JavaScript
中的Proxy
无法提供对原始值的代理,因此想要将原始值变为响应式数据,就必须对其做一层包裹,也就是我们接下来要介绍的ref
。
引入 ref 的概念
由于Proxy
的代理目标必须是非原始值,所以我们没有任何手段拦截原始值的操作。例如:
1 | let str = 'hello' |
对于这个问题,我们唯一能够想到的办法就是使用一个非原始值去“包裹”原始值。例如使用一个对象去包裹原始值:
1 | const wrapper = { |
但这样做会导致两个问题:
- 用户为了创建一个响应式的原始值,不得不顺带创建一个包裹对象;
- 包裹对象由用户来定义,意味着不规范。
为了解决上面这两个问题,我们可以封装一个函数,将包裹对象的创建工作封装到这个函数中:
1 | function ref(val) { |
如上代码所示,我们把创建包裹对象的工作封装到ref
函数内部,然后使用reactive
函数将包裹对象变成响应式数据并返回,这样就解决了上面这两个问题。运行如下测试代码:
1 | // 创建原始值的响应式数据 |
上面这段代码看似没什么问题了,其实并不是这样的。我们接下来要面临的第一个问题就是如何区分 refVal 到底是原始值的包裹对象,还是一个非原始值的响应式数据呢?
1 | const refVal1 = ref(1) |
这段代码中的refVal1
和refVal2
在实现上并没有什么区别。但是我们有必要区分一个数据到底是不是 ref,因为涉及到了后面的自动脱 ref 能力。
想要区分一个数据是不是ref
很简单。怎么做呢?如下面代码所示:
1 | function ref(val) { |
我们使用Object.defineProperty
为包裹对象wrapper
定义了一个不可枚举且不可写的属性__v_isRef
,它的值为true
。代表这个对象是一个ref
,而非一个普通对象。这样我们就可以通过检查__v_isRef
属性来判断一个数据是不是ref
了。
响应丢失问题
ref
除了能够用于原始值的响应式方案之外,还可以用来解决响应丢失问题。首先,我们先来看一下什么是响应丢失问题。在编写 Vue.js 组件时,我们需要把数据暴露到模板中使用,例如:
1 | export default { |
接着我们就可以在模板中去访问从setup
中暴露出来的数据:
1 | <template> |
但是这么做的话响应会丢失,表现是,当我们修改响应式数据的时候,不会触发重新渲染:
1 | export default { |
为什么会导致响应丢失呢?这是因为展开运算符(…)导致的。实际上下面这段代码:
1 | return { |
等价于:
1 | return { |
可以发现,这其实就是返回了一个普通对象,它不具备任何响应式能力。把一个普通对象暴露到模板中使用,是不会在渲染函数与响应式数据之间建立联系的。所以我们尝试在一个定时器中修改obj.name
的值时,不会触发重新渲染。我们还可以用另一种方式来描述响应丢失问题:
1 | // obj是响应式数据 |
如上面的代码所示,首先我们创建一个响应式对象obj
,然后我们使用展开运算符得到一个新的对象newObj
,它是一个普通对象,不具有响应能力。这里的关键点在于,副作用函数内访问是普通对象newObj
,它没有任何响应能力,所以当我们尝试修改obj.age
的值时,不会触发副作用函数重新执行。
如何解决这个问题呢?换句话说有没有办法能够帮助我们实现:在副作用函数内部,即使通过普通对象newObj
来访问属性值,也能够建立响应联系呢?其实是可以的,代码如下:
1 | // obj是一个响应式数据 |
在上面这段代码中,我们修改了newObj
对象的实现方式。可以看到的是,在现在的newObj
对象下,具有与obj
对象同名的属性,而且每个属性都是一个对象,例如 name 属性的值是:
1 | { |
该对象有个访问器属性value
,当读取 value 的值时,最终读取的是响应式数据obj
下的同名属性值。也就是说,当在副作用函数内部读取newObj.age
时,等价于间接读取了obj.age
的值。这样响应式数据自然能够与副作用函数建立起响应联系。于是,当我们尝试修改obj.age
的值时,能够触发副作用函数重新执行。
观察newObj
对象,发现它的结构存在相似之处:
1 | const newObj = { |
name
和age
这两个属性的结构非常像,这启发我们将这种结构抽象出来并封装成函数,如下面的代码所示:
1 | function toRef(obj, key) { |
toRef
函数接受两个参数,第一个参数obj
是响应式数据,第二个参数key
是obj
对象的一个键。该函数会返回一个类似于ref
结构的wrapper
对象。有了toRef
函数后,我们就可以重新实现newObj
对象了:
1 | const newObj = { |
可以看到,代码变得非常简洁。但是如果响应式数据obj
的键非常多,我们还是要花费很大力气来做这一层转换。为此,我们可以封装toRefs
函数,来批量的完成转换:
1 | function toRefs(obj) { |
现在我们只需要一步操作即可完成对一个对象的转换:
1 | const newObj = { ...toRefs(obj) } |
可以使用如下代码进行测试:
1 | const obj = reactive({ name: 'zhangsan', age: 18 }) |
现在,响应丢失问题被我们彻底解决了。解决的思路是将响应式数据转换成类似于ref
结构的数据。但为了概念上的统一,我们会将通过toRef
或者toRefs
转换后得到的结果视为真正的ref
数据,为此我们需要为toRef
函数增加一段代码:
1 | function toRef(obj, key) { |
可以看到,我们使用Object.defineProperty
函数为wrapper
对象定义了__v_isRef
属性。这样一来,toRef
函数返回值就是真正意义上的ref
了。通过上面这个例子我们能注意到,ref
的作用不仅仅是实现原始值的响应式方案,它还可以用来解决响应丢失问题。
但上文中实现的toRef
函数存在缺陷,即通过toRef
函数创建的ref
是只读的,如下面代码所示:
1 | const obj = reactive({ name: 'zhangsan', age: 18 }) |
这是因为toRef
返回的wrapper
对象的value
属性只有getter
,没有setter
。为了功能的完整性,我们应该为它加上setter
函数,所以最终的实现如下:
1 | function toRef(obj, key) { |
可以看到,当设置value
属性的值时,最终设置的是响应式数据同名属性的值,这样就能正确的触发响应了。
自动脱 ref
toRefs
函数解决了响应丢失问题,但同时也带来了新问题。由于toRefs
函数会把响应式数据的第一次属性转换为ref
,因此必须通过value
属性访问值,如下代码所示:
1 | const obj = reactive({ name: 'zhangsan', age: 18 }) |
这其实增加了用户的心智负担,因为通常情况下用户是在模板中访问数据的,例如:
1 | <p>{{name}}</p> |
用户肯定不希望编写下面这样的代码:
1 | <p>{{name.value}}</p> |
因此我们需要自动脱ref
的能力,所谓的自动脱ref
,指的是属性的访问行为,即如果读取的属性是一个ref
,直接将该ref
对应的value
属性返回,例如:
1 | newObj.name //zhangsan |
可以看到,即使newObj.name
是一个 ref,也无需通过newObj.name.value
来访问它的值。要实现此功能,需要使用Proxy
为newObj
来创建一个代理对象,通过代理对象来实现最终目标,这时就用到了上文中介绍的ref
标识,即__v_isRef
属性,如下面的代码所示:
1 | function proxyRefs(target) { |
在上面这段代码中,我们定义了proxyRefs
函数,该函数接收一个对象作为参数,返回该对象的代理对象。代理对象的作用是拦截 get 操作,当读取的值是一个ref
时,则直接返回该ref
的value
属性值,这样就实现了自动脱ref
:
1 | newObj.name // zhangsan |
实际上,我们在编写vue.js
组件时,组件中的setup
函数所返回的数据会传递给proxyRefs
函数进行处理:
1 | const myComponent = { |
这也是为什么我们可以在模板直接访问一个ref
的值,而无须通过value
属性来访问:
1 | <p>{{count}}</p> |
既然读取属性的值有自动脱 ref 的能力,那么对应的,设置属性的值也应该有自动脱 ref 的能力。例如:
1 | newObj.name = 'lisi' |
实现此功能也很简单,只要添加对应的set
拦截函数即可:
1 | function proxyRefs(target) { |
如上代码所示,我们作为proxyRefs
函数返回的代理对象添加了set
拦截函数。如果设置的属性是一个 ref,则间接设置该ref
的value
属性值即可。
实际上,自动脱ref
能力不仅存在于上述场景中,在Vue.js
中,reactive
函数也有自动脱 ref 的能力,例如:
1 | const count = ref(0) |
可以看到,obj.count
是一个ref
,但是因为自动脱 ref 能力的存在,使得我们无须通过value
属性即可读取到ref
的值。这么设计旨在减轻用户的心智负担,因为大部分情况下,用户不知道一个值到底是不是ref
,有了自动脱 ref 的能力,用户在模板中使用响应式数据时,将不再需要关心哪些是ref
,哪些不是ref
。
总结
在本章中,我们首先介绍了ref
的概念。ref
本质上是一个“包裹对象”。因为JavaScript
的Proxy
无法提供对原始值的代理,所以我们需要使用一层对象作为包裹,间接实现原始值的响应式方案。由于“包裹对象”与普通对象本质上没有任何区别,因此为了区分ref
与普通响应式对象,我们还为“包裹对象”定义了一个值为true
的属性,即__v_isRef
,用它作为ref
的标识。
ref
除了能够用于原始值的响应式方案之外呢,还能用来解决响应丢失问题。为了解决该问题,我们实现了toRef
和toRefs
这两个函数。它们本质上是对响应式数据做了一层包装,或者叫做“访问代理”。
最后我们讲解了自动脱ref的能力。为了减轻用户的心智负担,我们自动对暴露到模板中的响应式数据进行脱ref
处理。这样用户在模板中使用响应式数据时,就无须关心一个值是不是ref
了。