VUE3之响应系统
前言
最近在学习VUE3的新特性,记录一下学习成果。
副作用函数
什么是副作用函数?
会产生副作用的函数,或者说会直接或间接影响其他函数的执行结果。
举个简单的例子:一个函数改变了全局变量,这个函数就是副作用函数。
响应式数据
响应式数据的实现思路
拦截对象的读取和设置操作,在读取的时候把相关联的副作用函数存储起来,设置的时候递归执行相关联的副作用函数。
vue3用proxy和reflect实现的,用weakMap、map、set收集依赖集合(与响应式数据相关联的副作用函数)
把get拦截函数里把副作用函数收集起来的逻辑单独封装到一个track函数中,把set拦截函数里把触发副作用函数执行的逻辑单独封装到trigger函数中。
调度执行
可调度是指指的是当 trigger 动作触发副作用函数重新执行时,有能力决定副作用函数执行的时机、次数以及方式。
// 定义一个任务队列
const jobQueue = new Set()
// 使用 Promise.resolve() 创建一个 promise 实例,我们用它将一个任务添加到微任务队列
const p = Promise.resolve()
// 一个标志代表是否正在刷新队列
let isFlushing = false
function flushJob() {
// 如果队列正在刷新,则什么都不做
if (isFlushing) return
// 设置为 true,代表正在刷新
isFlushing = true
// 在微任务队列中刷新 job?ueue 队列
p.then(() => {
jobQueue.forEach(job => job())
}).finally(() => ?
// 结束后重置 isFlushing
isFlushing = false
})
}
effect(() => {
console.log(obj.foo)
}, {
scheduler(fn) {
// 每次调度时,将副作用函数添加到 jobQueue 队列中
jobQueue.add(fn)
// 调用 flushJob 刷新队列
flushJob()
}
})
obj.foo++
obj.foo++
可能你已经注意到了,这个功能有点类似于在 Vue.js 中连续多次修改响应式数据但只会触发一次更新,实际上 Vue.js 内部实现了一个更加完善的调度器,思路与上文介绍的相同。
computed
computed函数接收一个getter函数作为参数,把getter函数作为副作用函数,当响应式数据发生改变时,就会执行该副作用函数,将计算结果作为值范围。
缓存实现思路
新增两个变量:value和dirty。value是缓存上一次计算的值,dirty是一个标识,代表是否需要重新计算。
当响应式变量发生改变时,dirty = true,访问计算属性值的时候就会调用该副作用函数(getter参数),重新计算并返回值。
watch
watch其本质就是观测一个响应式数据,当数据发生变化时通知并执行相应的回调函数。
watch 的实现本质上就是利用了 effect 以及options.scheduler 选项。
effect(() =>
console.log(obj.foo)
}, {
scheduler() {
//当obj.foo的值发生变化时,执行scheduler调度函数
}
})
如果获取新旧值?
充分利用effect函数的lazy
function watch(source, cb) {
let getter
if (typeof source === 'function') {
getter = source
} else {
getter = () => traverse(source)
}
//定义新值与旧值
let oldValue, newValue
//使用effect注册副作用函数 时,开启lazy选项,并把返回值存储到effectFn中,以便后续手动调用
const effectFn = effect (
() => getter(),
{
lazy: true,
scheduler() {
//在scheduler中重新执行副作用函数,得到的是新值
newValue = effectFn()
//将旧值和新值作为回调函数的参数
cb(newValue, oldValue)
//更新旧值,不然下次会得到错误的旧值
oldValue = newValue
}
}
)
//手动调用副作用函数,拿到的值就是旧值
oldValue = effectFn()
}
回调执行的时机:flush
pre:默认值,创建时立即执行(组件更新前)
post:异步延迟执行,把副作用函数放到一个微任务(promise实现)队列中,等到dom更新结束后执行(组件更新后)
sync:同步执行
过期的副作用:
let finalData
watch(obj, async () => {
//发送并等待网络请求
const res = await fetch('/request')
//将请求结果赋值给 finalData
finalData = res
)
在上面的代码片段中,如果在第一次发送请求A的结果返回之前,改变了obj的字段,就会触发第二次请求B的发送,这个时候请求A的结果就是过期的副作用。为了避免竟态问题导致的错误结果,需要一个让副作用过期的手段,具体解决思路如下:
watch(obj, async (newValuw, oldValue, onInvalidate) => {
//定义一个标志,代表当前副作用函数是否过期,默认为false,代表没有过期
let expired = false
//调用onInvalidate() 函数注册一个过期回调
onInvalidate(() => {
//过期时,将expired设置为true
expired = true
})
//发送网络请求
const res = await fetch ('/request')
//只有当该副作用函数的执行没有过期时,才会执行后续操作
if (!expired) {
finalData = res
}
})
非基本数据类型的响应式reactive
在vue3中,响应式数据是基于Proxy和Reflect实现的,它允许我们拦截并重新定义一个对象的基本操作。
Proxy
const obj = {
foo: 'Secret',
get secret() {
return this.foo;
}
};
const p = new Proxy(obj, {
get(target, key, recevier){
return key + '11'
},
})
console.log(p.foo) //输出foo11
proxy可以拦截读取或者设置的方法。
拦截方法:
obj.*:用的是get方法拦截
key in obj:用的是has方法拦截,内部方法是hasProperty
for…in: 用的是ownKeys拦截
delete:用的是deleteProperty拦截
Reflect
const obj = {
foo: 'Secret',
get secret() {
return this.foo;
}
};
// 使用 Reflect.get() 获取 secret 属性的值,并传递 obj 作为 receiver
const secretValue = Reflect.get(obj, 'secret', obj);
console.log(secretValue); // 输出: 'Secret'
// 如果没有传递 receiver,或者 receiver 不是 obj,getter 函数中的 this 可能不会指向正确的对象
const incorrectSecretValue = Reflect.get(obj, 'secret', {});
console.log(incorrectSecretValue); // 输出: undefined,因为 _private 属性在 {} 上不存在
Reflect.get(target, key, receiver) 中的receiver可以简单理解为函数调用中的this,this由原始对象obj变成第三个参数receiver。
reactive代码片段
function reactive (obj) {
return new Proxy (obj, {
get (target, key, reveiver) {
//代理对象通过raw属性访问原始数据
if (key === 'raw') {
return target
}
track(target, key)
return Reflect.get (target, key, reveiver)
},
set (target, key, receiver) {
const oldVal = target[key]
const type = Object.prototype.hasOwnProperty.call(target, key) ? 'SET' : 'ADD'
const res = Reflect.set(target, key, newVal, receiver)
//target === receiver.raw 说明receiver就是target的代理对象
if (target === receiver.raw) {
if (oldVal !== newVal && (oldVal === oldVal || newVal === newVal)) {
trigger(target, key, type)
}
}
return res
}
})
}
只有当receiver是target的代理对象时才触发更新,这样就能屏蔽由原型引起的更新,从而避免不必要的更新操作。
浅响应和深响应
深响应:在get拦截函数里面加一个判断
const res = Reflect.get(target, key, receiver)
if (typeof res === 'object') && res !== null) {
//调用reactive将结果包装成响应式数据并返回
return reactive(res)
}
并非所有情况都需要深响应的,这就催生了shallowReactive,即浅响应。
//封装createReactive 函数,接受一个参数isShallow,代表是否浅响应,默认为false,即深响应
function createReactive(obj, isShallow = false) {
return new Proxy(obj, {
get(target, key, receiver) {
//省略部分逻辑
//新增一个判断:如果是浅响应,则直接返回原始值
if (isShallow) {
return res
}
}
})
}
只读和浅只读
只读:拦截set和deleteProperty函数,如果是只读,则打印警告信息并返回。拦截get函数,并在get函数内递归调用readonly将数据包装成只读的代理对象,并将其返回。如果一个数据是只读,那么就没有必要为只读数据建立响应联系了。也不需要调用track函数追踪响应,不需要调用trigger函数执行想关联的副作用函数了。
function createReactive (obj, isShallow = false, isReadonly = false) {
return new Proxy(obj, {
get (target, key, receiver) {
//省略其他逻辑的代码
if (!isReadonly) {
track(traget, key)
}
const res = Reflect.get (target, key, receiver)
if (isShallow) {
return res
}
if (typeof res === 'object' && res !== null) {
//如果为只读,则调用readonly对值进行包装
return isReadonly ? readonly(res) : reactive(res)
}
},
set(target, key, receiver) {
//省略其他逻辑代码
if (isReadonly) {
console.warn(`属性${key}是只读的`)
return true
}
},
deleteProperty(target, key) {
//省略其他逻辑代码
if (isReadonly) {
console.warn(`属性${key}是只读的`)
return true
}
}
})
}
//深只读
fuunction readonly (obj) {
return createReactive(obj, false, true)
}
浅只读:shallowReadonly,只需要修改createReadonly的第二个参数即可
function shallowReadonly(obj) {
return createReactive(obj, true, true)
}
基本数据类型的响应式ref
javaScript中的proxy无法提供对基本数据类型的代理,对于基本数据类型的代理的解决办法:
用一个对象去包裹基本数据类型。
let str = '123' //无法代理str
//可以使用proxy代理wrapper,间接实现对基本数据类型的拦截
const wrapper = {
str: '123'
}
const name = reactive(wrapper)
但是这样用户每创建一个响应式的基本数据类型,就要创建一个包裹对象。为此,我们可以封装ref函数
function ref(val) {
const wrapper = {
value: val
}
Object.defineProperty(wrapper, '__v_isRef', {
value: true
})
return reactive(wrapper)
}
使用Object.defineProperty为包裹的wrapper定义一个不可以枚举且不可以写的属性__v_isRef,它的值为true,代表这是一个ref对象,而非普通对象。
响应丢失问题
用reactive定义的响应式数据,解构赋值后会变成普通数据,就会产生响应丢失的问题。
可以封装toRefs函数来解决此问题
//obj是响应式数据, key 是键值
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
}
function toRefs(obj) {
const ret = {}
//使用for...in 循环遍历对象
for (const key in obj) {
//调用toRef函数完成数据的转换
ret[key] = toRef(obj, key)
}
return ret
}
ref数据模板自动解包
ref数据需要通过value属性访问值,在vue的模板中,会自动解包,可以直接调用。
实现思路:在get拦截函数中加一个判断,用__v_isRef属性判断,是ref数据就返回value.value
function proxyRefs(target) {
return new Proxy(target, {
get(target, key ,receiver) {
const vlaue = Reflect.get(target, key, receiver)
return value.__v_isRef ? value.value : value
},
set(target, key, receiver) {
//通过target读取真实值
const value = target[key]
//如果是ref,则设置其对应的value属性
if (value.__v_isRef) {
value.value = newValue
return true
}
return Reflect.set(target, key, newValue, receiver)
}
})
}
在编译模板的时候,组件中的setup函数返回的数据会传递给proxyRefs函数进行处理。