《Vue进阶教程》(12)ref的实现详细教程
1 为什么需要ref
由于proxy
只能代理引用类型
数据(如: 对象, 数组, Set, Map...), 需要一种方式代理普通类型
数据(String, Number, Boolean...)
设计ref
主要是为了处理普通类型
数据, 使普通类型
数据也具有响应式
除此之外, 通过reactive
代理的对象可能会出现响应丢失的情况. 使用ref
可以在一定程度上解决响应丢失问题
2 初步实现
1) 包裹对象
既然proxy
不能代理普通类型
数据, 我们可以在普通类型
数据的外层包裹一个对象
用proxy
代理包裹的对象(wrapper). 为了统一, 给包裹对象定义value
属性, 最后返回wrapper
的代理对象
function ref(value) {
const wrapper = {
value: value,
}
return reactive(wrapper)
}
测试用例
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Document</title>
<script src="./reactive.js"></script>
</head>
<body>
<script>
function ref(value) {
const wrapper = {
value: value,
}
return reactive(wrapper)
}
// count是一个proxy对象
const count = ref(1)
effect(() => {
// 访问proxy对象的属性 触发 getter 收集依赖
console.log(count.value)
})
setTimeout(() => {
count.value = 2
}, 1000)
</script>
</body>
</html>
2) 添加标识
按照上面的实现, 我们就无法区分一个代理对象是由ref
创建, 还是由reactive
创建, 比如下面的代码
ref(1)
reactive({value: 1})
为了后续能够对ref
创建的代理对象自动脱ref
处理, 即不用.value
访问.
考虑给ref
创建的代理对象添加一个标识
示例
function ref(value) {
const wrapper = {
value: value,
}
// 给wrapper添加一个不可枚举, 不可写的属性__v_isRef
Object.defineProperty(wrapper, '__v_isRef', {
value: true,
})
return reactive(wrapper)
}
在Vue3源码中, 虽然不是按上述方式实现的, 但是可以这样去理解
3 响应丢失问题
将reactive
定义的代理对象赋值给其它变量时, 会出现响应丢失问题
赋值主要有如下三种情况:
- 如果将
reactive
定义的代理对象的属性赋值给新的变量, 新变量会失去响应性 - 如果对
reactive
定义的代理对象进行展开操作. 展开后的变量会失去响应性 - 如果对
reactive
定义的代理对象进行解构操作. 解构后的变量会失去响应性
1) 赋值操作
示例
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Document</title>
<script src="./reactive.js"></script>
</head>
<body>
<script>
const obj = reactive({ foo: 1, bar: 2 })
// 将reactive创建的代理对象的属性赋值给一个新的变量foo
let foo = obj.foo // foo此时就是一个普通变量, 不具备响应性
effect(() => {
console.log('foo不具备响应性...', foo)
})
foo = 2
</script>
</body>
</html>
obj.foo
表达式的返回值是1- 相当于定义了一个普通变量
foo
, 而普通变量是不具备响应性的
2) 展开操作
示例
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Document</title>
<script src="./reactive.js"></script>
</head>
<body>
<script>
const obj = reactive({ foo: 1, bar: 2 })
// 展开运算符应用在proxy对象上, 会从语法层面遍历源对象的所有属性
// ...obj ===> foo:1, bar:2
const newObj = {
...obj,
}
// 此时的newObj是一个新的普通对象, 和obj之间不存在引用关系
console.log(newObj) // {foo:1, bar:2}
effect(() => {
console.log('newObj没有响应性...', newObj.foo)
})
// 改变newObj的属性值, 不会触发副作用函数的重新执行
// 此时, 我们就说newObj失去了响应性
newObj.foo = 2
</script>
</body>
</html>
说明
这里对proxy对象展开会经历如下过程
- proxy对象属于异质对象(exotic object)
- 当没有自定义proxy对象的[[GetOwnProperty]]内部方法时, 会使用源对象的方法, 获取所有属性
- 调用GetValue, 获取属性值
参考文献
ECMAScript规范2022-对象初始化
2) 解构操作
示例
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Document</title>
<script src="./reactive.js"></script>
</head>
<body>
<script>
const obj = reactive({ foo: 1, bar: 2 })
// 对proxy对象进行解构操作后, foo和bar就是普通变量, 也失去了响应性
let { foo, bar } = obj
effect(() => {
console.log('foo不具备响应性', foo)
})
// 给变量foo赋值, 不会触发副作用函数重新执行
foo = 2
</script>
</body>
</html>
对proxy
对象解构后, foo
就是一个普通变量, 也失去了跟obj
的引用关系.
因此, 对foo
的修改不会触发副作用函数重新执行
4 toRef与toRefs
1) 基本使用
为了解决在赋值过程中响应丢失问题, Vue3提供了两个API
- toRef: 解决赋值问题
- toRefs: 解决展开, 解构问题
使用演示
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Document</title>
<script src="https://unpkg.com/vue@3.2.41/dist/vue.global.js"></script>
</head>
<body>
<script>
const { reactive, effect, toRef, toRefs } = Vue
// obj是reactive创建的响应式数据(proxy代理对象)
const obj = reactive({ foo: 1, bar: 2 })
effect(() => {
console.log('obj.foo具有响应性:', obj.foo)
})
// 使用toRef定义, 取代基本赋值操作 foo = obj.foo
const foo = toRef(obj, 'foo')
effect(() => {
console.log('foo.value具有响应性:', foo.value)
})
</script>
</body>
</html>
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Document</title>
<script src="https://unpkg.com/vue@3.2.41/dist/vue.global.js"></script>
</head>
<body>
<script>
const { reactive, effect, toRef, toRefs } = Vue
// obj是reactive创建的响应式数据(proxy代理对象)
const obj = reactive({ foo: 1, bar: 2 })
// 使用toRefs解构赋值 取代 {foo, bar} = obj
const { foo, bar } = toRefs(obj)
effect(() => {
console.log('bar.value具有响应性', bar.value)
})
</script>
</body>
</html>
2) toRef的实现
基本实现
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
}
在Vue3中, 将wrapper抽象成了ObjectRefImpl
类的实例, 大致的实现如下
class ObjectRefImpl {
constructor(_obj, _key) {
this._obj = _obj
this._key = _key
this.__v_isRef = true
}
get value() {
return this._obj[this._key]
}
set value(newVal) {
this._obj[this._key] = newVal
}
}
function toRef(obj, key) {
return new ObjectRefImpl(obj, key)
}
源码解读
- 源码中的
toRef
实现了默认值的功能 - 源码中的
toRef
对要转换的数据做了判断, 如果已经是ref
类型就直接返回
class ObjectRefImpl {
// 支持默认值
constructor(_object, _key, _defaultValue) {
this._object = _object
this._key = _key
this._defaultValue = _defaultValue
this.__v_isRef = true
}
get value() {
const val = this._object[this._key]
return val === undefined ? this._defaultValue : val
}
set value(newVal) {
this._object[this._key] = newVal
}
}
// 1. 支持默认值
function toRef(object, key, defaultValue) {
const val = object[key]
// 2. 如果要转换的对象已经是ref类型, 直接返回
// eg: state = reactive({foo: ref(1)}) state.foo已经是ref类型, 直接返回ref(1)
return isRef(val) ? val : new ObjectRefImpl(object, key, defaultValue)
}
测试用例
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Document</title>
<script src="./reactive.js"></script>
</head>
<body>
<script>
// obj是响应式数据
const obj = reactive({ foo: 1, bar: 2 })
const foo = toRef(obj, 'foo')
effect(() => {
console.log('foo.value具备响应性:', foo.value)
})
</script>
</body>
</html>
3) toRefs的实现
基本实现
function toRefs(obj) {
const ret = {}
for (const key in obj) {
ret[key] = toRef(obj, key)
}
return ret
}
原码解读
- 源码中对
obj
的类型做了判断
-
- 如果不是
reactive
类型的对象, 提示警告 - 支持代理是数组的情况
- 如果不是
function toRefs(object) {
// 如果传入的对象不具备响应性, 提示警告
if (!isProxy(object)) {
console.warn(
`toRefs() expects a reactive object but received a plain one.`
)
}
// 支持代理是数组的情况
// - 对象的情况: toRefs(reactive({foo: 1, bar: 2})) => {foo: ref(1), bar: ref(2)}
// - 数组的情况: toRefs(reactive(['foo', 'bar'])) => [ref('foo'), ref('bar')]
const ret = isArray(object) ? new Array(object.length) : {}
for (const key in object) {
ret[key] = toRef(object, key)
}
return ret
}
测试用例
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Document</title>
<script src="./reactive.js"></script>
</head>
<body>
<script>
// obj是响应式数据
const obj = reactive({ foo: 1, bar: 2 })
// 解构赋值
const { foo, bar } = toRefs(obj)
effect(() => {
console.log('foo.value具备响应性:', foo.value)
})
</script>
</body>
</html>
5 自动脱ref
1) 什么是自动脱ref
所谓自动脱ref, 就是不写.value
对于ref
类型数据, 每次在访问时, 需要加.value
才能触发响应式.
但是这样做无疑增加了心智负担, 尤其是在写模板时, 不够优雅
为此, Vue3提供一个API: proxyRefs
对传入的ref
类型对象进行代理, 返回proxy
对象
个人理解: 有点类似toRefs的逆操作??
2) 基本使用
使用演示
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Document</title>
<script src="./vue.js"></script>
</head>
<body>
<script>
const { ref, proxyRefs, effect } = Vue
const count = ref(0)
// 1.模拟setup的返回对象
// setup函数返回的对象会经过proxyRefs处理
// 这样在模板中就不用写.value了
const setup = proxyRefs({
count,
})
// 2.模拟页面渲染
effect(() => {
console.log('不用通过.value访问', setup.count)
})
</script>
</body>
</html>
3) proxyRefs的实现
基本实现
function proxyRefs(objectWithRefs) {
return new Proxy(objectWithRefs, {
get(target, key, receiver) {
// 使用Reflect读取target[key]
const obj = Reflect.get(target, key, receiver)
// 如果obj是ref类型, 返回obj.value; 否则, 直接返回
return obj.__v_isRef ? obj.value : obj
},
set(target, key, newVal, receiver) {
const obj = target[key]
if (obj.__v_isRef) {
obj.value = newVal
return obj
}
return Reflect.set(target, key, newVal, receiver)
},
})
}
源码解读
- 源码对传入参数加强了判断
-
- 如果objectWithRefs已经是
reactive
类型, 就直接使用
- 如果objectWithRefs已经是
- 源码按功能进一步细化, 可读性更高
-
unref
函数可以复用: 如果是ref
返回.value
; 否则直接返回- 将proxy的handler提取成
shallowUnwrapHandlers
函数 - 在set时, 加入了新旧值类型的判断, 更严谨
function unref(ref) {
return isRef(ref) ? ref.value : ref
}
const shallowUnwrapHandlers = {
get: (target, key, receiver) => unref(Reflect.get(target, key, receiver)),
set: (target, key, value, receiver) => {
const oldValue = target[key]
if (isRef(oldValue) && !isRef(value)) {
oldValue.value = value
return true
} else {
return Reflect.set(target, key, value, receiver)
}
},
}
function proxyRefs(objectWithRefs) {
return isReactive(objectWithRefs)
? objectWithRefs
: new Proxy(objectWithRefs, shallowUnwrapHandlers)
}
6 改造
按照vue的源码进行改造
function isObject(val) {
return typeof val === 'object' && val !== null
}
function toReactive(value) {
return isObject(value) ? reactive(value) : value
}
class RefImpl {
constructor(value) {
this.dep = new Set()
this._rawValue = value
this.__v_isRef = true
this._value = toReactive(value)
}
get value() {
trackEffects(this.dep)
return this._value
}
set value(newValue) {
if (this._rawValue != newValue) {
this._rawValue = newValue
this._value = toReactive(newValue)
triggerEffects(this.dep)
}
}
}
function ref(val) {
return new RefImpl(val)
}