Vue3手写系列之reactiveEffect(2)

1,974 阅读7分钟

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

hello 大家好,🙎🏻‍♀️🙋🏻‍♀️🙆🏻‍♀️

我是一个热爱知识传递,正在学习写作的作者,ClyingDeng 凳凳!

今儿,咱们接着分析这个 reactiveEffect 其他的功能方法。比如:在响应式数据中,不渲染的数据不在页面展示是否还需要再次渲染;响应数据能否自己手动暂停等。

分支切换

问:在响应式数据中,不渲染的数据不在页面展示是否还需要再次渲染?🙋🏼‍♀️🙋🏼‍♀️🙋🏼‍♀️

答:不展示在页面的数据,发生变化应该不会重新渲染。🙅🏼‍♀️🙅🏼‍♀️🙅🏼‍♀️

举个栗子🌰:

<script src="../../../../node_modules/@vue/reactivity/dist//reactivity.global.js"></script>
<script>
        const { reactive, effect } = VueReactivity
        const obj = {
            flag: true,
            name: 'dy',
            age: 25,
            get fn() {
                return this.age //    25
            }
        }
        const state = reactive(obj)
        // 分支切换
        effect(() => {
            // 每次执行effect的时候都需要清理依赖重新收集 
            console.log('render');
            document.getElementById('app').innerHTML
                = state.flag ? '姓名' + state.name : '年龄' + state.age
        })
        setTimeout(() => {
            state.flag = false
            setTimeout(() => {
                console.log('修改name,应该不更新');// 执行后应该不再输出render
                state.name = 18
            }, 1000);
        }, 1000)
</script>

使用vue3自身的effect,我们可以看出,在执行完flag值变化后,再次修改name后,页面并没有重新渲染。

1.gif

很好,没有👀!
vue3能实现,那么我自己也来搞一个呗~

先看我们之前实现的能不能直接使用:

2.gif

原本不要更新的,又再次执行了effect。很显然,是我们想多了-_-!

肯定需要功能补充的啊!

我们可以看出,在数据发生变化后都会重新渲染一次页面,但其实我们只需要监听页面需要渲染的属性。

源码中是这样判断的:

// effect文件 run()函数
try {
      // 将effect和稍后渲染的属性关联在一起
      this.parent = activeEffect
      activeEffect = this
      shouldTrack = true
      // 到某一层就左移
      trackOpBit = 1 << ++effectTrackDepth // effectTrackDepth当前effect递归的层数
      // 递归层数没有超过最大深度30
      if (effectTrackDepth <= maxMarkerBits) {
        // 收集依赖
        initDepMarkers(this)
      } else {
        // 否则清除
        cleanupEffect(this)
      }
      return this.fn() // 执行用户自己回调
} finally {
  // 退出effect嵌套 finally 在return前操作 所以退出时进行了去重依赖的操作
  if (effectTrackDepth <= maxMarkerBits) {
    // 清除重复依赖
    finalizeDepMarkers(this)
  }
  // 某一层执行完毕右移
  trackOpBit = 1 << --effectTrackDepth
  activeEffect = this.parent
  shouldTrack = lastShouldTrack
  this.parent = undefined // 递归到最外层置空parent
  if (this.deferStop) {
    this.stop() // 停止收集
  }
}

在不超过最大嵌套深度的情况下,每个属性的依赖,通过位运算做区分(上篇文中🈶️提及)。在超出最大嵌套深度的时候,就直接清除相关属性的依赖。

简单点,我们可以这样:在执行用户回调函数时,我们先清空当前依赖,然后再重新收集。这里直接采用的是源码中超出最大嵌套深度后的cleanupEffect方法。直接清空当前属性依赖,然后重新收集。

function cleanupEffect(effect: ReactiveEffect) {
  const { deps } = effect // deps 里面装的是name对应的effect
  if (deps.length) {
    for (let i = 0; i < deps.length; i++) {
      deps[i].delete(effect) // 解除相关effect 重新依赖收集
    }
    deps.length = 0 // 如果直接将deps置空,只是清空数组,但是set中的name依旧存在
  }
}

cleanupEffect通过传入的effect中的deps,去遍历相关属性依赖的deps,并将其effect清除。

在依赖触发的时候,去重新收集依赖。

export function trigger(target, type, key, value, oldValue) {
    // 判断targetMap是否存在target
    // 不存在 直接返回 不需要收集
    // 存在 取depsMap中对应key的effect 执行run
    const depsMap = targetMap.get(target)
    if (!depsMap) return
    let effects = depsMap.get(key)
    if (effects) {
        effects = [...effects] // effects 中 set结构删除再添加会导致死循环
        effects.forEach(effect => {
            // 在执行effect时,又要执行自己,需要屏蔽自己的effect
            if (effect !== activeEffect)
                effect.run()
        });
    }
}

image.png

现在,我们可以看出name即使修改了,也不会再次执行effect✌️✌️✌️

这里的清除方法与普通数组不同的是:在deps中存放的是属性对应的相关依赖的set集合。如果直接赋值成空数组deps = [],其内部的set集合中的属性依旧存在。所以我们需要通过遍历删除相关属性依赖。

此外,set集合遇到遍历还有个特点,就是在遍历的时候,删除后再新增,会导致死循环。

大家可以在本地自己跑下下面这段代码:

let sets = new Set(['a'])
sets.forEach(item => {
    sets.delete('a');
    sets.add('a')
    console.log(sets);
})

控制台会无限输出这个set集合。

image.png

所以我们在清空之后,需要重新赋值一下我们的effectseffects = [...effects]相当于我们的effect又是一个新的effects数组,新的数组重新进行遍历。

调度器

我们可以上面的效果,间隔一秒后渲染了响应式数据的其他属性。 那我们是不是也可以实现手动的将effect停止,修改属性后,继续执行effect呢?

答案是肯定可以的!

比如这个例子:

// index.html 文件
let runner = effect(() => {
    document.getElementById('app').innerHTML = '年龄' + state.age
})
// effectScope
runner.effect.stop()
setTimeout(() => {
    state.age = 18
    setTimeout(() => {
        runner()
    }, 2000)
}, 1000);

我们在手动停止effect监听,修改age属性值后,再次手动执行effect。发现页面上的数据间隔三秒后,就可以重新渲染了。

可以看到下面的动画效果:

1.gif

这边主要的核心功能涉及到的函数就是effectstop,和 effectrun函数。

effect返回值

我们需要手动执行effect函数,那我们就需要用个变量存储,在合适的时机再次执行,这说明effect是有返回值的。并且这个返回值需要执行的其实就是执行effectrun函数🙇‍♀️🙇‍♀️🙇‍♀️

export function effect(fn) {
    // fn可以根据状态变化 重新执行,effect可以嵌套
    const _effect = new ReactiveEffect(fn) // 创建响应式的effect  数据发生变化会重新执行该函数
    // 默认先执行一次
    _effect.run()
    const runner = _effect.run.bind(_effect)// 绑定this 执行
    runner.effect = _effect // 将effect挂载到runner函数上
    return runner
}

既然需要执行run函数,那么就直接将effect作为返回值。只是这里需要注意的是内部的this指向问题。

image.png

源码中的思路跟我们的其实是一致的(当然一致,参考的源码啊🤔),哈哈~

effect的stop

手动停止effect,我们简单化一点,就是将effect的激活状态改为false,停止监听。

stop() {
    if (this.active) {
        this.active = false
        cleanupEffect(this) // 停止effect收集
    }
}

我们实现的只是简版的,在源码中还会牵扯到自身停止状态、通过effect第二个参数传入的options中的onStop

image.png

调度器

上面的隔一段时间,停止监听后,修改参数,再开启监听,我们也可以写自己的调度函数scheduler

2.gif 既然谈及effect第二个参数,那么我们可以看看这个options

export interface DebuggerOptions {
  onTrack?: (event: DebuggerEvent) => void
  onTrigger?: (event: DebuggerEvent) => void
}
export interface ReactiveEffectOptions extends DebuggerOptions {
  lazy?: boolean // 是否缓存
  scheduler?: EffectScheduler // 调度
  scope?: EffectScope
  allowRecurse?: boolean
  onStop?: () => void
}

通过源码的options接口定义可以看出有很多可传递的参数。我们可以先了解scheduler这个函数,onStop函数与此类似。

我们可以在创建_effect实例的时候,类似用户自身回调传入方法,将我们的调度函数传入,在ReactiveEffect构造函数中接收。

// ReactiveEffect 类
constructor(public fn, public scheduler) { } // fn 用户自定义回调函数

image.png

参数接收有了,那么就剩使用了🤪🤪🤪

自己的调度函数肯定在触发的时候执行的呀!

毋庸置疑了,trigger!
effect遍历执行的时候,我们需要判断scheduler属性是否存在:

export function trigger(target, type, key, value, oldValue) {
    // 判断targetMap是否存在target
    // 不存在 直接返回 不需要收集
    // 存在 取depsMap中对应key的effect 执行run
    const depsMap = targetMap.get(target)
    if (!depsMap) return
    let effects = depsMap.get(key)
    if (effects) {
        effects = [...effects] // effects 中 set结构删除再添加会导致死循环
        effects.forEach(effect => {
            // 在执行effect时,又要执行自己,需要屏蔽自己的effect
            if (effect !== activeEffect) {
                if (effect.scheduler)
                    effect.scheduler() // 如果存在自己的调度函数就执行自己的scheduler
                else effect.run() // 否则就执行run
            }
        });
    }
}

依旧是参照源码,在triggerEffect执行的时候判断schedulerimage.png

综上,我们的第二个参数的调度函数就可以拥有和vue3一样的调度功能了。其最大的用途,应该就是类似批处理。在我们多次修改属性的时候,我们只需最后的值时,我们就可以通过这个scheduler去实现。

就像年龄,不管长了几岁,永远18岁~

3.gif

注意

如果options上一定还存在其他属性,我们可以通过继承的方法将其合并🧐🧐🧐

// options
{
scope: true,
scheduler() { // 调度器 如何更新自己决定
    if (!isWait) {
        isWait = true
        setTimeout(() => {
            runner()
        }, 3000);
    }
    console.log('执行自己的 scheduler');
}

这里的参数合并使用的是export const extend = Object.assign。在我们额外传入的scope参数,与之前原有的参数进行了合并,并返回到内部effect上。

image.png

补充

// 更新
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()
    });
}

其实,上篇文章中就已经提到了一个effect不能快速执行n次,其实代码就包含在上文中。我们在遍历执行effect时,需要将自身的effect忽略不执行。

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