Vue3手写系列之reactiveEffect

1,782 阅读10分钟

本文为稀土掘金技术社区首发签约文章,14天内禁止转载,14天后未获授权禁止转载,侵权必究!

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。目录结构如下:

image.png

在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的基本功能(执行用户自己的回调函数)后,我们想要的结果这下就在页面中出现了~

image.png

嵌套的 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运行结果:

image.png

可以看到每次退出当前层级时,都会将 parent 上记录的值赋值给当前的 activeEffect。到最后一层时parent置空即可。

这样我们需要使用时,就可以获取到每层相对应的effect函数。

我们也可以在源码中看到:

image.png

当然此时我们还没有考虑到effect的嵌套深度,依赖重复等情况,所以我截取了源码中与我们此次相关的部分代码逻辑。

effect 的依赖收集

effect 的依赖收集是通过track这个功能函数来实现的。track核心的逻辑是这样的:

  • 先通过一个weakMap存储建立依赖收集的关系。即一个对象的某个属性对应多个effect(map结构{target:depsMap(set结构 key:{dep: new Set() })})
  • 反向记录。属性dep添加 记录effecteffect记录当前依赖属性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 结构我们可以通过浏览器输出查看其结构: image.png

如果对象存在于targetMap中,那么我们就接着判断当前对象对应的value值中是否存在当前的属性。

image.png

查找 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.ndep.w两个属性来表示是否最新收集的和是否已被收集的。我们可以看下相关的wasTrackednewTracked这两个方法:

// 两个工具函数
// 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简陋版终于告一段落,来欣赏一下我们的成果吧🔜🔜🔜

q325n-4hzfw.gif

在测试页,设置一个定时器,隔段时间改变当前的某个属性,我们可以看到页面上的值发生了变化。

当然,这只是reactiveEffect的皮毛而已,我们还有很多情况没有考虑到,比如一个effect不能快速执行n次,嵌套深度等场景。大家有时间可以自己去看源码中的effect是如何使用 错级位运算 提高依赖收集效率、如何解决其他场景问题的等源码内部一些优秀的方法逻辑。

感兴趣的朋友可以关注 手写vue3系列 专栏或者点击关注作者我哦(●'◡'●)!。 如果不足,请多指教。