Vue3手写系列之reactiveEffect
hello 大家好,🙎🏻♀️🙋🏻♀️🙆🏻♀️
我是一个热爱知识传递,正在学习写作的作者,ClyingDeng
凳凳!
好久不见哈!
今天要给大家带来的是 vue3 的 effect 手写实现。
今天我们要实现的就是属性变化,reactiveEffect
中关联的属性发生相应变化。其实也是类似 vue2 中的依赖收集和触发。
基础 effect 实现
首先咱们得先有一个effect功能函数吧,再者effect里面得有一个ReactiveEffect类,实例化之后才能去执行effect的方法吧。是的!那就安排🤔🤔🤔~
// effect.ts文件
class ReactiveEffect {
constructor(public fn) { }
run() {
}
}
// 对外暴露的effect方法
export function effect(fn) {
const _effect = new ReactiveEffect(fn)
_effect.run()
}
知道有一个 effect 功能函数,那么咱们就开始填充功能逻辑啰!
大概逻辑是这样的:effect 函数会默认先执行一次(run(), active默认为true)属性和函数关联起来(通过ReactiveEffect类 扩展fn(数据发生变化重新执行该函数),将activeEffect挂载到全局,在get时就可以读到其函数)执行完成最后清空activeEffect。
// effect.ts 文件
export let activeEffect = undefined
class ReactiveEffect {
public active = true
constructor(public fn) { } // fn 用户自定义回调函数
run() {
if (!this.active) return this.fn() // 不是激活状态 只需要执行用户传入的回调函数 不需要依赖收集
try {
activeEffect = this // 将effect和稍后渲染的属性关联在一起
this.fn() // 可以获取到全局的activeEffect
} finally {
activeEffect = null // 退出当前effect函数 清空activeEffect
}
}
}
export function effect(fn) {
const _effect = new ReactiveEffect(fn)
_effect.run()
}
将之前的reactive
和现在的effect
整合到一起。在index.ts文件中一起对外暴露:
export { reactive } from './reactive'
export { effect } from './effect'
说明
在此,打包构建工具使用的是esbuild
,包管理器使用的是pnpm。目录结构如下:
在html中引用effect.ts
中对外暴露的effect函数。
<div id="app"></div>
<script src="./reactivity.global.js"></script>
<script>
const { reactive, effect } = VueReactivity
const obj = {
name: 'dy',
age: 25,
get fn() {
return this.age // 25
}
}
const state = reactive(obj)
effect(() => {
document.getElementById('app').innerHTML = state.name + '年龄' + state.age
})
</script>
我们可以看到在完成effect的基本功能(执行用户自己的回调函数)后,我们想要的结果这下就在页面中出现了~
嵌套的 effect
effect大家应该都知道使用的时候其实是允许多层effect嵌套的,用户在自己的回调中再次使用effect。就比如这样:
effect(() => {
// console.log(state.name); // 对应的函数e1 parent = null activeEffect = e1
effect(() => {
// console.log(state.fn); // 对应函数e2 parent = e1 activeEffect = e2
effect(() => {
// console.log(state.fn); // 对应函数e3 parent = e2 activeEffect = e3
})
})
// 对应函数e1
// console.log(state.name); // 此fn应该和第一个fn是同一个 activeEffect = this.parent
})
每层的effect的activeEffect
都需要记录。最容易想到的就是可以通过栈的方式去记录获取当前层级各自的activeEffect
。
栈结构在此就不细说了,咱们主要来说说另一种用法🤪🤪🤪。
在Vue3.0之前就是使用的是栈结构去记录存储当前层级上一层的activeEffect
。其实,我们还可以参照Vue3.2
版本,通过树的结构去记录上一层级的 activeEffect
。
在上面的代码中,其实我也有注释。在每一层嵌套的时候,我们可以通过parent这个属性去记录上一层effect的 activeEffect
。然后再在每一层退出的时候,将其parent
上的值赋值给当前的activeEffect
。这样当我们退出e2时,返回到e1层级当前的activeEffect
就是e1。
export let activeEffect = undefined
let index = 0 // 测试使用,记录层级
class ReactiveEffect {
public active = true
public parent = null
constructor(public fn) { } // fn 用户自定义回调函数
run() {
if (!this.active) return this.fn() // 不是激活状态 只需要执行用户传入的回调函数 不需要依赖收集
try {
this.parent = activeEffect // 将effect和稍后渲染的属性关联在一起
activeEffect = this
console.log('当前activeEffect,通过用户自己回调函数区分:', ++index, activeEffect.fn);
this.fn() // 可以获取到全局的activeEffect
} finally {
console.log('退出循环,当前activeEffect', index--, activeEffect.fn);
activeEffect = this.parent // 退出当前effect函数 清空activeEffect
this.parent = null
}
}
}
查看effect运行结果:
可以看到每次退出当前层级时,都会将 parent
上记录的值赋值给当前的 activeEffect
。到最后一层时parent置空即可。
这样我们需要使用时,就可以获取到每层相对应的effect函数。
我们也可以在源码中看到:
当然此时我们还没有考虑到effect
的嵌套深度,依赖重复等情况,所以我截取了源码中与我们此次相关的部分代码逻辑。
effect 的依赖收集
effect 的依赖收集是通过track这个功能函数来实现的。track核心的逻辑是这样的:
- 先通过一个weakMap存储建立依赖收集的关系。即一个对象的某个属性对应多个
effect(map结构{target:depsMap(set结构 key:{dep: new Set() })})
; - 反向记录。属性
dep
添加 记录effect
。effect
记录当前依赖属性dep
。
参照源码目录文件,我们先将proxy核心的set和get方法提取到baseHandler.ts
文件中。
// baseHandler.ts
import { activeEffect, effect, track } from "./effect"
export const enum ReactiveFlag {
IS_REACTIVE = 'is_reactive'
}
export const baseHandler = {
get(target, key, receiver) {
if (key === ReactiveFlag.IS_REACTIVE) return true
// activeEffect
// 哪个属性对应的effect是哪个
track(target, 'get', key) // 依赖收集
return Reflect.get(target, key, receiver)
},
set(target, key, value, receiver) {
const result = Reflect.set(target, key, value, receiver)
return result
}
}
// reactive.ts
import { isObject } from "@vue/shared";
import { baseHandler, ReactiveFlag } from './baseHandler'
const reactiveMap = new WeakMap()
export function reactive(target) {
if (!isObject(target)) return
if (target[ReactiveFlag.IS_REACTIVE]) return target // 如果存在_v_isReactive属性,则表示该对象为代理对象
const exisitingProxy = reactiveMap.get(target)
if (exisitingProxy) return exisitingProxy
const proxy = new Proxy(target, baseHandler)
reactiveMap.set(target, proxy)
return proxy
}
我们在属性被读取时进行相关的依赖收集,在set时进行相应的依赖触发。在上述代码中我们可以看到在get中,我加入了track这个函数。没错,我们现在需要做的就是将track这个函数依赖收集的功能补充完整!
按照上面简述的依赖收集逻辑,不难发现我们先需要的是一个 WeakMap
来作为存储工具。WeakMap
的结构如下注释的集合:一个对象对应一个depsMap集合,depsMap集合中是当前对象的属性与dep集合(属性相关的effect集合)。
// effect.ts
const targetMap = new WeakMap() // {key: { key: Set()}} weakMap{key: target, value:depsMap({key: key, value: dep( set(effect))})}
在收集依赖的时候我们需要先判断当前的activeEffect
是否存在,如果不存在或者不需要收集时可以直接返回(与源码中不同的是🤔:源码直接判断需要收集并且activeEffect
存在的情况)。
简版track功能先安排上🤙🤙🤙~
// effect.ts 文件
class ReactiveEffect {
...
// 添加deps属性
public deps = [] // 记录依赖的哪些属性
...
...
}
let targetMap = new WeakMap()
export function track(target, type, key) {
// 收集effect中 属性对应的effect
if (!activeEffect) return
let depsMap = targetMap.get(target)
if (!depsMap) {
targetMap.set(target, (depsMap = new Map()))
}
let dep = depsMap.get(key)
if (!dep) { depsMap.set(key, (dep = new Set())) }
// 判断去重 set中是否存在activeEffect
let shouldTrack = !depsMap.has(activeEffect) // 不存在
if (shouldTrack) {
dep.add(activeEffect) // 属性记录effect
// 反向记录 effect记录哪些属性收集过
activeEffect.deps.push(dep) // 让activeEffect 记录住对应的dep 稍后清理会用到
}
}
在需要收集的情况下,我们肯定需要在ReactiveEffect
类中添加一个deps数组属性,用来记录依赖的哪些属性。
存储的工具准备好了,接下来我们就需要到track
这个函数中看看,它是怎么实现属性与effect
关联收集的。
其次先排除没有effect
的情况,如果没有的话,我们直接返回即可,不需要处理。但是我们遇到的肯定不会这么简单的吖。
在使用effect的情况下,我们先判断存储的变量 targetMap
中的key是否存在当前对象,如果不存在的话,就将当前的对象存储到targetMap
中。
targetMap
结构我们可以通过浏览器输出查看其结构:
如果对象存在于targetMap中,那么我们就接着判断当前对象对应的value值中是否存在当前的属性。
查找 depsMap
中是否存在当前对象属性存在的键名(比如:'name')。如果不存在,那么跟上面逻辑一样,没有就设置啊,初始化一个(depsMap
内就会存在一个属性对应dep的集合)。在dep
中存放当前属性相关的effect。其中涉及到一个去重问题,在此我们就简单的判断depsMap
中是否存在当前的activeEffect
,如果不存在,我们就将当前的effect
记录收集。
在此,我们就已经完成了一个属性收集对应多个effect
的依赖功能。
But,一个属性可能会存在多个effect
,同样,一个effect
内部也可能使用多个属性。
没错,属性和effect
是多对多的关系,此时我们就需要将当前的effect与当前effect
相关的依赖相关联,做一个反向记录。
那就请看这段代码:activeEffect.deps.push(dep)
。 在属性记录effect的同时,在activeEffect
中添加一个deps属性用来存放对应的依赖。
我们可以看下源码中的track
:
export function track(target: object, type: TrackOpTypes, key: unknown) {
// 是否需要收集 并且activeEffect存在
if (shouldTrack && activeEffect) { // 需要收集
let depsMap = targetMap.get(target)
if (!depsMap) {
targetMap.set(target, (depsMap = new Map()))
}
let dep = depsMap.get(key)
if (!dep) {
depsMap.set(key, (dep = createDep()))
}
const eventInfo = __DEV__
? { effect: activeEffect, target, type, key }
: undefined
// effect 收集
trackEffects(dep, eventInfo)
}
}
源码中也是通过一个 targetMap
弱引用,来存储effect
和属性的关系。与我们不同的是trackEffects
这个函数。
源码中处理考虑的场景以及收集的方法与上述简版还是有点不同的。我们可以大致看下:
export function trackEffects(
dep: Dep,
debuggerEventExtraInfo?: DebuggerEventExtraInfo
) {
let shouldTrack = false // 默认不需要收集
// 什么时候不是最新的 需要标识最新?
// 第一次收集的就不是最新的,此时 dep.n 为 0 ,我们标识为本层的 bit 位 而初始化的依赖,第一次没被收集过,需要收集。
// 什么时候时最新的,不需要标识最新 同一层中已经被标识过
if (effectTrackDepth <= maxMarkerBits) {
if (!newTracked(dep)) {
// 开始跟踪的时候吧 dep.n 标识为本层的bit位
dep.n |= trackOpBit // set newly tracked
// 什么时候不是最新的
// 没被收集过
// 被收集过 错级的场景 computed嵌套
shouldTrack = !wasTracked(dep) // 某个 effect 是否是已经被收集 可以规避重复收集
}
} else {
// Full cleanup mode.
shouldTrack = !dep.has(activeEffect!)
}
if (shouldTrack) {
dep.add(activeEffect!) // 属性记录effect
activeEffect!.deps.push(dep) // 反向记录 effect记录哪些属性收集过
if (__DEV__ && activeEffect!.onTrack) {
activeEffect!.onTrack({
effect: activeEffect!,
...debuggerEventExtraInfo!
})
}
}
}
大概的意思就是:当我们进入第一层effect是,我们代表层数的 bit 位应该是 10
,进入第二层的effect,我们的 bit 位就变成了 100
,同理第三层嵌套的 effect 就是 1000
。那么已经收集了的依赖,我们就把对应层的 bit 位赋值给当前的dep.w
,当某个依赖最新在哪一层收集了,同样也将对应层的 bit 位给 dep.n
。
源码中通过dep.n
、dep.w
两个属性来表示是否最新收集的和是否已被收集的。我们可以看下相关的wasTracked
、newTracked
这两个方法:
// 两个工具函数
// trackOpBit 表示正在操作层数的 bit 位
// 某个 effect 是否是已经被收集 可以规避重复收集
export const wasTracked = (dep: Dep): boolean => (dep.w & trackOpBit) > 0
// 是否是最新收集的 effect 判断是否在当层收集的
export const newTracked = (dep: Dep): boolean => (dep.n & trackOpBit) > 0
因为进行 &
操作时,当我们进入了第二层effect时,当前的bit 位为 100
,而当前的依赖在该层已经收集过了,那么 100 & 100 === 100 > 0
,意味着 wasTracked() === true
,表示当前已经被收集过。
effect 的依赖触发
接下来,我们看看这个依赖是如何触发的:
// baseHandler.ts
export const mutableHandlers = {
get(target, key, receiver) {
// 依赖收集
。。。
},
set(target, key, value, receiver) {
let oldValue = target[key]
let result = Reflect.set(target, key, value, receiver)
if (oldValue !== value)
// 值不同更新
trigger(target, 'set', key, value, oldValue)
return result
}
}
原来在值发生更改的时候,会触发相关依赖进行一个更新值操作🤔。同样,我们就需要补充完成基本的 trigger 功能函数。
基本功能需求:当我们数据发生变化的时候,通过对象属性查找对应的effect集合,找到后,遍历当前的effects执行每个effect中的run函数。
// effect.ts
export function trigger(target, type, key, value, oldValue) {
// 判断targetMap是否存在target
// 不存在 直接返回
// 存在 取depsMap中对应key的effect 执行run
const depsMap = targetMap.get(target)
if (!depsMap) return
const effects = depsMap.get(key)
effects && effects.forEach(effect => {
// 在执行effect时,又要执行自己,需要屏蔽自己的effect
if (effect !== activeEffect)
effect.run()
});
}
在此,我们基本的trigger
依赖触发的功能函数就完成了~
终于,我们的effect
简陋版终于告一段落,来欣赏一下我们的成果吧🔜🔜🔜
在测试页,设置一个定时器,隔段时间改变当前的某个属性,我们可以看到页面上的值发生了变化。
当然,这只是reactiveEffect
的皮毛而已,我们还有很多情况没有考虑到,比如一个effect
不能快速执行n次,嵌套深度等场景。大家有时间可以自己去看源码中的effect
是如何使用 错级位运算 提高依赖收集效率、如何解决其他场景问题的等源码内部一些优秀的方法逻辑。
感兴趣的朋友可以关注 手写vue3系列 专栏或者点击关注作者我哦(●'◡'●)!。 如果不足,请多指教。
转载自:https://juejin.cn/post/7145866067724730405