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 = falsefunction flushJob() {// 如果队列正在刷新,则什么都不做
if (isFlushing) return// 设置为 true,代表正在刷新isFlushing = true// 在微任务队列中刷新 job?ueue 队列p.then(() => {jobQueue.forEach(job => job())}).finally(() => ?// 结束后重置 isFlushingisFlushing = 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 getterif (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')//将请求结果赋值给 finalDatafinalData = res
)
在上面的代码片段中,如果在第一次发送请求A的结果返回之前,改变了obj的字段,就会触发第二次请求B的发送,这个时候请求A的结果就是过期的副作用。为了避免竟态问题导致的错误结果,需要一个让副作用过期的手段,具体解决思路如下:
watch(obj, async (newValuw, oldValue, onInvalidate) => {//定义一个标志,代表当前副作用函数是否过期,默认为false,代表没有过期let expired = false//调用onInvalidate() 函数注册一个过期回调onInvalidate(() => {//过期时,将expired设置为trueexpired = 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 作为 receiverconst 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 = newValuereturn true}return Reflect.set(target, key, newValue, receiver)}})
}
在编译模板的时候,组件中的setup函数返回的数据会传递给proxyRefs函数进行处理。