关于vue的响应式数据,你可能有很多疑惑。
比如为什么要改用proxy?
比如为什么有reactive和ref两个api?
比如vue是如何实现响应式的?
其实这些在源码中,都能找到答案。
在第一篇vue3有哪些升级中,我也提了一下使用proxy的好处,以及Object.defineProperty的缺陷。但是今天,我想换个角度,让chatGPT来回答一下这个问题。
嗯,我感觉比我总结的好。
那继续下一个问题。
这个回答感觉就比较官方了,没有回答到我想要的答案。
理由其实很简单,因为proxy方法不能代理值类型,只能代理对象。所以需要一个额外的方法,来处理值类型的数据。
当然你也可以一个ref走天下,vue源码做了兼容会自动进行转换
有了这些理解之后,我们来看今天的重头戏,自己仿照vue3源码,实现一个响应式系统。
相关代码可以点击查看,根据文章的标题选择不同的分支即可。
实现reactive
在前文我们也介绍了,reactive其实用的是proxy代理对象。
我们可以使用proxy实现一个简单的代理函数reactive。
function reactive(target) {
const isObject = (val) => val !== null && typeof val === 'object'
if (!isObject(target)) {
console.warn(`数据必须是对象: ${String(target)}`)
return target
}
const proxy = new Proxy(target, {
get: (target, key, receiver) => {
console.log('get', key)
const res = Reflect.get(target, key, receiver)
// track(target, key)
// 这句很关键,不然嵌套数据,里面的不会响应
if (isObject(res)) {
return reactive(res)
}
return res
},
set: (target, key, value, receiver) => {
console.log('set', key, value)
const result = Reflect.set(target, key, value, receiver)
// trigger(target, key, value)
return result
},
deleteProperty: () => {
const result = Reflect.deleteProperty(target, key)
return result
}
})
return proxy
}
const person = reactive({
name: '',
age: 18,
like: ['apple']
})
person.name = 'vue test'
注意:Reflect
Reflect.get(target, key)跟直接访问target[key]是有一点差别的。
当代理对象中,有get,set等this指向的时候,可以重定向this。
举个例子:
const person = new Proxy({
name: "vue test",
age: 18,
get info() {
return 'name is :' + this.name + ' age is:' + this.age
}
}, {
get: (target, key, receiver) => {
console.log('get', key)
// return target[key]
return Reflect.get(target, key, receiver)
}
})
console.log(person.info)
使用Reflect,我们在访问name, age的时候均可以触发。
改成target之后,则只会在info触发一次。
return target[key]
实现ref
在vue3中,ref是通过get和set来实现的。
跟上面类似,也还是先声明一个函数,然后通过get,set来访问数据。
function ref(value) {
return new RefImpl(value)
}
class RefImpl {
constructor(value) {
// 如果值是对象,则用reactive处理
this._value = isObject(value) ? reactive(value) : value
// 记录一下初始值
this._rawValue = value
}
get value() {
// trackRefValue(this)
return this._value
}
set value(newVal) {
if (!Object.is(newVal, this._rawValue)) {
// 更新原始数据
this._rawValue = newVal
// 更新 .value 的值
this._value = isObject(newVal) ? reactive(newVal) : value
// triggerRefValue(this)
}
}
}
源码也很直观的解释了,为什么ref的使用一定要用.value的方式使用,因为get/set就是通过value来设置的。
添加依赖收集与触发
数据代理我们已经完成了,但是数据更改之后,怎么通知组件实现双向绑定呢?
答案就是依赖收集与触发,也就是当触发get的时候,我把触发get的这个条件【函数】给保存起来,当触发set的时候,重新执行一下触发一下这个条件【函数】不就行了吗。
还是来看代码,我加一个track收集的方法,加一个target触发的方法。(也就是上面代码片段注释掉的两行代码)
除此之外,还需要一个effect函数来管理触发函数。
let activeEffect = null
const targetMap = new WeakMap()
// 依赖收集/触发
function track(target, key) {
let depsMap = targetMap.get(target)
if (!depsMap) {
targetMap.set(target, (depsMap = new Map()))
}
let dep = depsMap.get(key)
if (!dep) {
dep = new Set()
}
dep.add(activeEffect)
depsMap.set(key, dep)
}
function trigger(target, key, value) {
const depsMap = targetMap.get(target)
if (!depsMap) {
return
}
const deps = depsMap.get(key)
if (!deps) {
return
}
deps.forEach(effectFn => {
if (effectFn.scheduler) {
effectFn.scheduler()
} else {
effectFn()
}
})
}
function effect(fn,options = {}) {
const effectFn = () => {
try {
activeEffect = effectFn
return fn()
} catch (error) {
activeEffect = null
}
}
if (!options.lazy) {
effectFn()
}
effectFn.scheduler = options.scheduler
return effectFn
}
const person = reactive({
name: "hello world"
})
effect(() => {
console.log('effect person', person.name)
})
setTimeout(() => {
person.name = 'setTimeout world'
}, 2000)
activeEffect用来存触发的条件函数。
targetMap用来存放依赖字典,格式如下
{
target: {
key: []
}
}
输出结果为hello world,2秒之后,重新执行依赖性函数,输出setTimeout world
总结
数据的代理并不复杂,就是在proxy的基础上,加上一些边界处理。而要实现响应式则需要加上依赖收集,依赖触发的实现。
effect是一个很重要的函数,很多api是基于这个函数开发的,比如useEffect, watch。组件的更新也是基于effect函数,这个后续还会提到。
如果不太理解effect,可以梳理一下执行顺序。
- 1.调用effect函数,并传入参数fn
- 2.申明一个effectFn函数,并执行,将函数保存为activeEffect
- 3.执行fn,读取person.name
- 4.走proxy的get代理
- 5.收集依赖,将activeEffect保存的函数存到全局map中
- 6.此时,effect函数执行完毕,等待数据更新
- 7.2s后,走proxy的set代理
- 8.执行全局map中保存的函数,重新执行effect函数,再次回到步骤1
相关代码可以点击查看,选择lesson3分支即可。
文章相关代码,可在vue/examples中查看,仿vue实现版本可在packages/reactivity中查看。