vue3学习与my-vue3实现05: effect栈与无限递归循环

248 阅读6分钟

嵌套的effect

出现嵌套effect的场景

在vuejs中渲染函数就是在一个effect中执行的:

// Foo组件
const Foo = {
    render() {
        return ...
    }
}

在一个effect中执行Foo组件的渲染函数:

effect(() => {
    Foo.render()
})

当组件发生嵌套时,例如Foo组件渲染了Bar组件:

const Bar = {
    render() {
        return ...
    }
}
const Foo = {
    render() {
        return <Bar />
    }
}

此时effect就发生了嵌套:

effect(() => {
    Foo.render()
    // 嵌套
    effect(() => {
        Bar.render()
    })
})
不支持嵌套带来的问题

我们现在实现的响应系统并不支持effect嵌套,我们可以用以下代码进行测试:

const obj = reactive({
  foo: true,
  bar: true,
})
let tmp1, tmp2
// outside嵌套了inside
effect(() => {
  console.log('outside effect run')
  tmp1 = obj.foo
  effect(() => {
    console.log('inside effect run')
    tmp2 = obj.bar
  })
})
obj.foo = false

在上面的代码中,outside嵌套了inside,outside的执行会导致inside的执行。这里我们需要注意的是,我们inside中读取了obj.bar,在outside中读取了obj.foo,并且inside的执行优先于对字段obj.foo的读取操作。在这种情况下,我们希望当修改obj.foo时会触发outside执行,而且由于inside嵌套在outside中,所以也会被触发执行,而当修改obj.bar时,只会触发inside的执行。

//修改obj.foo
01 outside effect run
02 inside effect run
03 outside effect run
04 inside effect run

//修改obj.bar
01 outside effect run
02 inside effect run
03 inside effect run

但是我们发现修改obj.foo时,结果并不如我们所预想的那样:

image.png 而问题就出在我们实现的ReactiveEffect类中的run方法与activeEffect上。

class ReactiveEffect<T = any> {
  // 用于存储所有与该副作用函数相关联的依赖集合
  deps: Dep[] = []

  constructor(public fn: () => T) {}

  run() {
      // 当调用effect注册副作用ReactiveEffect对象时,将副作用对象赋值给activeEffect
      activeEffect = this!

      cleanupEffect(this)

      return this.fn()
  }
}

我们用全局变量activeEffect来存储通过effect函数注册的副作用对象,这意味着同一时刻activeEffect所存储的副作用对象只能有一个,当副作用函数发生嵌套时,内层副作用函数的执行会覆盖activeEffect的值,并且永远不会恢复到原来的值了。这时如果再有响应式数据进行依赖收集,即使这个响应式数据是在外层副作用函数中读取,它们收集到的副作用函数也都是内层副作用函数,这就是问题所在。

要解决这个问题,我们需要一个副作用函数栈,在副作用函数执行时,将当前副作用函数压入栈中,待副作用函数执行完毕后从栈中弹出,并始终让activeEffect指向栈顶的副作用函数。这样就能做到一个响应式数据只会收集直接读取其值的副作用函数,而不会出现互相影响的情况。参照vue3源码,我们实现了如下代码的链表栈:

class ReactiveEffect<T = any> {
  // 用于存储所有与该副作用函数相关联的依赖集合
  deps: Dep[] = []

  // 将外层的副作用函数存储,如果是第一层,则为undefined
  parent: ReactiveEffect | undefined = undefined

  constructor(public fn: () => T) {}

  run() {
    try {
      // 将外层副作用函数存储到parent属性中,相当于压栈操作
      this.parent = activeEffect
      // 将副作用函数赋值给activeEffect, 相当于将当前副作用函数放到栈顶
      activeEffect = this!

      cleanupEffect(this)

      return this.fn()
    }
    finally {
      // 副作用函数执行完毕后,将外层副作用函数赋值给activeEffect,相当于将当前副作用函数弹出栈并修改栈顶
      activeEffect = this.parent
      // 弹出后断绝与外层副作用函数的关联
      this.parent = undefined
    }
  }
}

我们在ReactiveEffect类中添加了parent属性,通过链表来模拟栈,activeEffect指向了栈顶,而当副作用函数发生嵌套时,我们可以将外层的副作用函数赋值给parent从而实现压栈操作。如下图:

image.png

当内层副作用函数out执行完毕后, 它会被弹出栈,并将副作用函数outsideEffect设置为activeEffect。如下图:

image.png

这样响应式数据就能收集到读取其值的副作用函数作为依赖,从而避免发生错乱。

无限递归循环

完成了effect栈的实现后,运行以下代码,我们会发现obj.num = obj.num + 1引起了栈溢出:

const obj = reactive({
  num: 1,
})
effect(() => {
obj.num = obj.num + 1
})

在这个语句中,既会读取obj.num的值,又会设置obj.num的值,这是导致栈溢出的根本原因。分析执行流程我们可以看到:首先读取obj.num的值,这会触发track操作,将副作用函数收集到桶中,,接着将其+1在赋值给obj.num,此时就会触发trigger操作,即把桶中的副作用函数取出并执行。这就导致该副作用函数还没有执行完,就要开始下一次执行了。这样就导致了无限递归地调用自己,导致栈溢出。

通过分析我们会发现,读取和设置操作都在同一个副作用函数内进行,此时无论是track时收集到的副作用函数,还是trigger时要触发的副作用函数,都是activeEffect。基于这个逻辑,我们在ReactiveEffectrun方法中,只要增加一个守卫条件:被触发的副作用函数与当前正在执行的副作用函数相同,则不执行。如以下代码所示:

export class ReactiveEffect<T = any> {
  run() {
    // 用于判断当前正在执行的副作用函数是否与被触发的副作用函数相同,相同不执行
    if (activeEffect === this)
      return
    try {
      this.parent = activeEffect
      activeEffect = this!

      cleanupEffect(this)

      return this.fn()
    }
    finally {
      activeEffect = this.parent
      this.parent = undefined
    }
  }
}

这样我们就解决了无限递归循环的问题。

嵌套无线递归循环

但是如果我们将嵌套effect与无限递归循环结合后,如下代码,我们会看到栈又溢出了。

const obj = reactive({
  num: 1,
})
effect(() => {
  obj.num = obj.num + 1
  effect(() => {
    obj.num = obj.num + 1
  })
})
console.log(obj.num)

而这次发生栈溢出的很简单,想必大家也都能看出来。首先运行外层副作用函数,很明显,外层副作用函数的执行,会导致内层副作用函数的运行,而内层副作用函数的执行,obj.num的设置操作,会触发被收集到的外层副作用函数,导致无限递归循环。

而要解决这个问题,就需要我们在内层副作用函数执行的obj.num的设置操作时,判断外层副作用函数的执行,是否是内层副作用函数执行导致的。代码如下:

export class ReactiveEffect<T = any> {
  run() {
    // 默认activeEffect,用于判断是否引发了无限递归循环
    let parent: ReactiveEffect | undefined = activeEffect
    while (parent) {
      // 如果当前执行的副作用函数更底层的副作用函数与当前执行的函数相同,则不执行,从而打断无限递归循环
      if (parent === this)
        return
      // 结合effect栈,追溯当前执行的副作用函数更底层的副作用函数
      parent = parent.parent
    }
    try {
      this.parent = activeEffect
      activeEffect = this!

      cleanupEffect(this)

      return this.fn()
    }
    finally {
      activeEffect = this.parent
      this.parent = undefined
    }
  }
}

再运行刚才的测试代码,我们可以看到可以正常运行了。

代码仓库

github.com/KoiraCMT/my…