引言
在Vue3中,ref
和reactive
是我们最常见的两个API,使用的方式也是非常的简单。那么这两个API是如何实现Vue3中的响应式的呢,今天我们来分析分析。
本篇统一采用vue3的
setup
语法糖 +ts
写法进行说明。
使用方法
这两个API的使用方式都非常简单:
typescript
import { ref } from 'vue'
const count = ref(0)
// const count = ref<number>(0) ts写法
function increment() {
// 在 JavaScript 中需要 .value
count.value++
}
const user = reactive({
name: 'aaa',
age: 18
})
// ts写法
// const user = reactive<{name: string; age: number}>({
// name: 'aaa',
// age: 18
// })
function setUser() {
user.name = 'bbb'
user.age = 22
}
区别在于:
ref
相当于是以下这种方式进行了处理并且赋予了变量响应式,
typescript
// 约等于
function ref(val) {
return { value: val }
}
// 约等于
function reactive(val) {
return val
}
那么我们在使用这个变量的时候,就需要使用.value
对他的值进行获取。而我们的reactive
并不需要.value
。
唯一注意的一点
const a = reactive({aaa: 123})
你不能直接替换整个a变量(这样会失去响应式),你只能修改a.aaa = 456
这种方式
那么为什么在<template>
中使用ref
变量的时候不需要.value
呢?
是因为vue在解析模板的时候,识别到你在template中 写的 count
是一个ref变量,此时,vue会自动去获取count.value
的值,所以你在template
中写的时候,不需要使用.value
。
记住!
核心区别: 是不是在 template
中使用ref
变量,如果是,则不需要写.value
,如果是在script
中使用的话,就需要写
原因: vue会对template中的ref做处理识别,但不会对script中的ref做处理。
Setup模式的区别
这里顺带提一下,<script setup>
这种模式,其实本质上就是:
- 不用再写
setup() { xxx }
,你写的所有代码 就是setup
函数中应该写的代码 - 去掉你需要return的那部分,vue会帮你return出去你定义的变量和函数
最佳实践
其实很多同学在使用这两个API的时候,并不清楚什么时候使用ref
、什么时候使用reactive
,这就像在React中不知道什么时候使用useState
和useReducer
一样。
在此处我直接给出我推荐的结论,在平时的开发中统一使用ref
。(很多同学可能会说对象的时候使用reactive
,可以但没必要)。
在你封装通用hooks或者包的时候,可以考虑 使用reactive
,参考Pinia
,在Pinia
中使用useStore之后,解构出来的变量不需要使用.value
,原因就是Pinia
对返回的变量多包了一层reactive
。热知识:用reactive
包装后的ref
变量使用的时候不需要.value
。
到此,关于ref
和reactive
的使用部分就讲完了,接下来会分析响应式的原理,感兴趣的同学可以接着往下看。
响应式原理浅析
直接看源码会有点上头,本次讲解会借鉴春阳老师的《Vue.js设计与实现》的方式,深入浅出为大家讲解响应式原理,并加上我自己的总结,不会涉及太深的源码,但是力求能让大家了解是怎么个方式实现响应式的。
前置知识:
Proxy
和Reflect
,希望大家在看本文之前就了解其作用,不然可能会跟不。
副作用
副作⽤函数指的是会产⽣副作⽤的函数,如下⾯的代码所⽰:
js
let val = 1
function effect1() {
document.body.innerText = 'hello vue3'
}
function effect2() {
val = 2
}
当我们的effect1
执行的时候,修改的DOM
的文本内容,这个操作可能会导致其他代码在获取的时候产生影响,这就是典型的副作用函数。effect2
在执行的时候,修改了全局变量,也产生了副作用。这就是我们所说的副作用函数的概念:直接或间接影响了其他函数的执行。
如果理解纯函数的同学,这时候就可以这么说:大多数副作用函数都是非纯函数(有些非纯函数可能不产生副作用,但它们可能会依赖于外部状态或随机性,这也会增加代码的不稳定性和难以测试性)。
响应式需求
我们现在有如下代码:
js
const obj = { text: 'hello world' }
function effect() {
// effect 函数的执⾏会读取 obj.text
document.body.innerText = obj.text
}
我们期望,当我们修改obj.text
的时候,body
上的DOM
内容也能同时修改,也就是调用effect
函数,如果能实现这个目的,那这种行为就是响应式。
实现最初级的响应式
那么我们如果能在改变obj.text
的时候做到调用effect
函数从而实现响应式呢?
熟悉Vue2响应式的同学可能已经给出答案了,在取值的时候设置依赖的副作用函数,在修改obj
的值的时候触发依赖的副作用函数。
拦截对象的读取和设置
那么这个时候就需要我们对 目标对象 的读取 和设置 进行拦截了。在Vue2中使用的是Object.defineProperty
,在Vue3中使用的是Proxy
,此处我们不再对两种方式的优缺点进行讨论,感兴趣的同学可以自行查阅。
保存依赖的副作用函数及读取
我们此时需要一个桶(bucket
)来保存我们的副作用函数。
js
// 存储副作用函数的桶
const bucket = new WeakMap()
// 原始数据
const data = { foo: 1 }
// 对原始数据的代理
const obj = new Proxy(data, {
// 拦截读取操作
get(target, key) {
// 将副作用函数添加到存储副作用函数的桶中
track(target, key) // bucket.add(effect)
// 返回属性值 此处应该是使用Reflect不然会有this指向的问题,此处暂时不提太多,容易混
return target[key]
},
// 拦截设置操作
set(target, key, newVal) {
// 设置属性值
target[key] = newVal
// 把副作用函数从桶里取出并执行
trigger(target, key) bucket.forEach(fn => fn())
}
})
由此,我们写了一个最小的响应式,当data
中的字段被读取时,会触发添加effect
的逻辑,设置 时会取出effect
并执行。在注释中我分别写了两行代码bucket.add
和bucket.forEach(fn => fn())
,但其实远没有这么简单,只是为了大家能够最小化的理解响应式。
和完整的响应式比缺些什么东西?
其实到此,我不准备再为大家更深度地讲解完整的响应式系统是如何实现的,因为笔者觉得意义不大,我可以大概用几句话进行总结一下:在最基础的响应式上,打补丁处理各种问题,从而形成完整的响应式系统。
只是我会大概提一下,从最小的响应式系统到完整的响应式系统还缺一些什么东西?
- 分支切换:比如
const a = b ? c : d
,如果b从true
修改成false
之后,c仍然会一直存在于a的依赖中,这是不正确的。 - 嵌套effect:如果存在父组件调用子组件,那么大概率会出现
effect
嵌套调用的情况。 - 自增导致的无限循环:比如我们有一个
obj.foo = obj.foo + 1
,同时触发读取 和设置,就会出现循环。 - 执行时机:我们都知道
watch
还可以设置post
执行,这种方式也需要处理。以至于后续的computed
、setup
其实都只是执行的调度方式不一样的effect
。 - 不仅仅只是
get
、set
:想一想我们如果for
循环这个变量的时候,又是怎么触发的,或者Object.keys
这些操作的读取都是我们需要考虑的,不仅仅只是get
、set
。 Array
、Set
、Map
的拦截......- ......
诸如此类,我也不准备列举完。完整的响应式系统十分复杂,要处理各种情况。总而言之,就是:基础响应式+打补丁=完整响应式系统。(至于各种问题怎么处理的,可以自行看书或者源码)。
回到ref
和reactive
的原理
我们先分析一下reactive
的源码实现方案(为什么先reactive
是因为ref
底层最终也是调用的reactive
的实现)
reactive
ts
export function reactive<T extends object>(target: T): UnwrapNestedRefs<T>
export function reactive(target: object) {
// if trying to observe a readonly proxy, return the readonly version.
// 如果目标对象是一个只读的响应数据,则直接返回目标对象
if (target && (target as Target).__v_isReadonly) {
return target
}
// 否则调用 createReactiveObject 创建 observe
return createReactiveObject(
target,
false,
mutableHandlers,
mutableCollectionHandlers
)
}
createReactiveObject
创建 observe。
ts
function createReactiveObject(
target: Target,
isReadonly: boolean,
baseHandlers: ProxyHandler<any>,
collectionHandlers: ProxyHandler<any>
) {
// 如果不是对象
if (!isObject(target)) {
return target
}
// 如果目标对象已经是个 proxy 直接返回
if (target.__v_raw && !(isReadonly && target.__v_isReactive)) {
return target
}
// target already has corresponding Proxy
if (
hasOwn(target, isReadonly ? ReactiveFlags.readonly : ReactiveFlags.reactive)
) {
return isReadonly ? target.__v_readonly : target.__v_reactive
}
// only a whitelist of value types can be observed.
// 检查目标对象是否能被观察, 不能直接返回
if (!canObserve(target)) {
return target
}
// 使用 Proxy 创建 observe
const observed = new Proxy(
target,
collectionTypes.has(target.constructor) ? collectionHandlers : baseHandlers
)
// 打上相应标记
def(
target,
isReadonly ? ReactiveFlags.readonly : ReactiveFlags.reactive,
observed
)
return observed
}
// 可以被观察的值类型
const isObservableType = /*#__PURE__*/ makeMap(
'Object,Array,Map,Set,WeakMap,WeakSet'
)
其实这段代码就是在为传入的数据做判断处理,核心逻辑就是此处:
ts
const observed = new Proxy(
target,
collectionTypes.has(target.constructor) ? collectionHandlers : baseHandlers
)
根据上文其实我们知道响应式是通过Proxy
进行拦截实现的,那么Vue
对Proxy
的操作进行了封装,区分了集合类型和其他类型,分别使用其对应的封装好的参数。由此实现了响应式。
ref
我们再来看看ref
的实现:
ts
export function ref(value?: unknown) {
return createRef(value)
}
/**
* @description:
* @param {rawValue} 原始值
* @param {shallow} 是否是浅观察
*/
function createRef(rawValue: unknown, shallow = false) {
// 如果已经是ref直接返回
if (isRef(rawValue)) {
return rawValue
}
// 如果是浅观察直接观察,不是则将 rawValue 转换成 reactive ,
let value = shallow ? rawValue : convert(rawValue)
// ref 的结构
const r = {
// ref 标识
__v_isRef: true,
get value() {
// 依赖收集
track(r, TrackOpTypes.GET, 'value')
return value
},
set value(newVal) {
if (hasChanged(toRaw(newVal), rawValue)) {
rawValue = newVal
value = shallow ? newVal : convert(newVal)
// 触发依赖
trigger(
r,
TriggerOpTypes.SET,
'value',
__DEV__ ? { newValue: newVal } : void 0
)
}
}
}
return r
}
// 如是是对象则调用 reactive, 否则直接返回
const convert = <T extends unknown>(val: T): T =>
isObject(val) ? reactive(val) : val
其实可以看到ref
的逻辑非常简单,createRef
先判断 value
是否已经是一个 ref
, 如果是则直接返回,如果不是接着判断是不是浅观察,如果是浅观察直接构造一个 ref
返回,不是则将 rawValue
转换成 reactive
再构造一个 ref
返回。
这里有一个点需要注意一下,Proxy
不能代理普通类型的变量 ,这也是为什么不直接将ref
中基础变量的值直接进行代理的原因,而是包装了一层{ value }
。
这就是ref
的实现了,简单来说就是:封装一层对象,转化为响应式。
结语
具体的响应式系统其实异常复杂,实现起来也需要考虑诸多问题,甚至你需要去查阅ECMA
标准才能完整实现。
本文主要是从浅析的角度去分析响应式的原理,不涉及过多实现的细节,让大家能够对响应式原理有个基本的认知。
最后,
顺颂时祺,秋绥冬禧。