梳理 effect

2024-05-14 19:30:41

effect 的作用可以先简单理解成:建立响应式数据和副作用函数之间的关系。

当函数执行时读取了某个响应式属性,就把这个函数记录到该属性的依赖集合里。等这个属性后续发生变化时,再把对应的函数重新执行。

建立依赖关系

先从一个最简单的结构开始:

const data = {
  a: 1,
  b: 2,
  c: 3
}

function track(target, key) {
}

function trigger(target, key) {
}

const state = new Proxy(data, {
  get(target, key) {
    track(target, key)
    return Reflect.get(target, key)
  },
  set(target, key, value) {
    trigger(target, key)
    return Reflect.set(target, key, value)
  }
})

function effect(fn) {
  fn()
}

effect(() => {
  console.log(state.a)
})

这段代码里,get 拦截可以拿到 targetkey,也就是知道读取的是哪个对象上的哪个属性。

但依赖关系还差另一半:当前这个属性是被哪个函数读取的?

比如 state.a 是在 effect 传入的函数里读取的,所以在执行 track 时,需要知道当前正在执行的副作用函数是谁。

解决方式也很直接:定义一个全局变量 activeEffect,用它记录当前正在执行的副作用函数。

let activeEffect = null
const depsMap = new Map()

function track(target, key) {
  if (activeEffect) {
    let deps = depsMap.get(target)
    if (!deps) {
      deps = new Set() // 可能会依赖多个函数
      depsMap.set(key, deps)
    }
    // 将依赖的函数添加到集合中
    deps.add(activeEffect)
  }
}

function trigger(target, key) {
  const deps = depsMap.get(key)
  if (deps) {
    deps.forEach(effect => effect())
  }
}

function effect(fn) {
  activeEffect = fn
  fn()
  activeEffect = null
}

effect(() => {
  console.log('执行了')
  console.log(state.a)
})

state.a = 2

这里用 depsMap 保存依赖关系。

每个属性对应一个 Set 集合,集合里保存所有依赖这个属性的函数。修改某个属性时,只需要从 depsMap 中找到对应的集合,再依次执行里面的函数。

像上面的示例,读取的是 state.a,所以收集到的依赖是这样:

// depsMap:
// Map(1) { 'a' => Set(1) { [Function (anonymous)] } }
// Map(1) { 'a' => Set(1) { [Function (anonymous)] } }

分支切换的问题

effect(() => {
  if (state.a === 1) {
    state.b
  }
  else {
    state.c
  }
})

第一次执行时,会读取 state.astate.b,所以依赖关系里会记录 a -> effectb -> effect

问题出现在后续重新执行时。

如果直接把原始的回调函数放进 depsMap,那么 trigger 执行这个函数时,并不会重新设置 activeEffect 。函数虽然重新执行了,但这次执行过程中读取到的新属性无法被重新收集。

所以不能只把传入的 fn 当作依赖函数。这里需要再包一层执行环境,让这个环境函数负责在每次执行前设置 activeEffect ,执行完成后再清空。

function effect(fn) {
  const environment = () => {
    activeEffect = environment
    fn()
    activeEffect = null
  }
  environment()
}

包一层之后,重新执行时确实可以继续收集依赖。

但新的问题也随之出现:旧依赖没有被清理。

比如第一次走 if 分支时收集了 b,后面切到 else 分支后又收集了 c。如果不清理旧依赖,b 仍然会留在 depsMap 里,后续修改 b 时,这个副作用函数还是会被触发。

打印出来的结果里,就会同时出现 bc

// effect 函数执行了
// Map(1) { 'a' => Set(1) { [Function: environment] } }
// Map(2) {
//  'a' => Set(1) { [Function: environment] },
//  'b' => Set(1) { [Function: environment] }
// }
// effect 函数执行了
// Map(2) {
//   'a' => Set(1) { [Function: environment] },
//   'b' => Set(1) { [Function: environment] }
// }
// Map(3) {
//   'a' => Set(1) { [Function: environment] },
//   'b' => Set(1) { [Function: environment] },
//   'c' => Set(1) { [Function: environment] }
// }

//  进程已结束,退出代码为 0

因此,每次执行副作用函数之前,都需要先把它上一次收集到的依赖清理掉,然后再根据本次执行结果重新收集。

要做到这一点,需要让环境函数知道自己曾经被收集到哪些集合中。

先在环境函数上挂一个 deps 数组,用来记录它关联过的所有依赖集合:

function effect(fn) {
  const environment = () => {
    activeEffect = environment
    cleanup(environment)
    fn()
    activeEffect = null
  }
  environment.deps = [] // 记录该环境函数在哪些集合中
  environment()
}

然后在 track 中,每次把 activeEffect 收集到某个 deps 集合时,也反向把这个 deps 记录到 activeEffect.deps 中。

function track(target, key) {
  // 在进行依赖收集的时候,建立数据与函数的一个关系
  if (activeEffect) {
    let deps = depsMap.get(key)
    if (!deps) {
      depsMap.set(key, (deps = new Set()))
    }
    deps.add(activeEffect)
    activeEffect.deps.push(deps)
  }
  console.log(depsMap)
}

有了这层反向记录,清理时就可以直接遍历 environment.deps,把当前环境函数从每个集合中删除。

如果某个集合删完后已经没有任何函数了,也可以把这个属性从 depsMap 中移除。

function cleanup(environment) {
  const deps = environment.deps
  if (deps.length) {
    deps.forEach((dep) => {
      dep.delete(environment)
      if (dep.size === 0) {
        for (const [key, value] of depsMap) {
          if (value === dep) {
            depsMap.delete(key)
          }
        }
      }
    })
    deps.length = 0
  }
}

清理之后,再执行同样的分支切换逻辑,依赖关系就会变成当前分支真正需要的样子:

// effect 函数执行了
// Map(1) { 'a' => Set(1) { [Function: environment] { deps: [Array] } } }
// Map(2) {
//   'a' => Set(1) { [Function: environment] { deps: [Array] } },
//   'b' => Set(1) { [Function: environment] { deps: [Array] } }
// }
// effect 函数执行了
// Map(1) { 'a' => Set(1) { [Function: environment] { deps: [Array] } } }
// Map(2) {
//   'a' => Set(1) { [Function: environment] { deps: [Array] } },
//   'c' => Set(1) { [Function: environment] { deps: [Array] } }
// }

无限循环的问题

继续看下面这个例子:

effect(() => {
  console.log('effect1 函数执行了')
  if (state.a === 1) {
    return state.b
  }
  else {
    return state.c
  }
})

effect(() => {
  state.a
  state.c
})

state.a = 2

这里修改 state.a 时,会触发依赖 a 的副作用函数重新执行。

问题在于,副作用函数执行前会先清理旧依赖,执行过程中又会重新收集依赖。也就是说,在遍历原来的依赖集合时,这个集合本身可能又被删除和重新添加。

如果直接遍历原始的 deps,就可能在同一次触发过程中反复执行同一个副作用函数,最终造成无限循环。

解决方式是:触发更新前,先复制一份当前要执行的副作用函数集合。后面真正遍历执行的是这份拷贝,而不是原始的 deps

function trigger(target, key, value) {
  // 设置的时候将对应关联的函数重新执行
  const deps = depsMap.get(key)
  if (deps) {
    const effectToRun = new Set(deps)
    effectToRun.forEach(effect => effect())
  }
}

这样即使副作用函数执行过程中重新修改了原始依赖集合,也不会影响当前这轮更新的遍历。

effect 嵌套

最后再看一个嵌套 effect 的场景:

effect(() => {
  effect(() => {
    state.a
    console.log('effect1')
    effect(() => {
      state.c
    })
  })
  state.b
  console.log('effect2')
})

这里会出现依赖收集错乱的问题。

原因是 activeEffect 只有一个。执行到内部 effect 时,内部函数会覆盖外层的 activeEffect;内部执行完成后,又会把 activeEffect 清空。这样回到外层继续执行时,外层后续读取到的属性就收集不到正确的依赖了。

这个问题和函数调用栈很像:进入一个函数时入栈,函数执行完成后出栈,然后恢复到上一个正在执行的函数。

所以这里也可以维护一个 effectStack,用它记录当前正在执行的 effect 栈。

const effectStack = []

function effect(fn) {
  const environment = () => {
    activeEffect = environment
    effectStack.push(environment)
    cleanup(environment)
    fn()
    effectStack.pop()
    activeEffect = effectStack[effectStack.length - 1]
  }
  environment.deps = []
  environment()
}

这样内部 effect 执行完成后,可以从栈中恢复外层的 effect,后续依赖收集也就能回到正确的上下文。