Vue3 – 实现响应式核心 effect

Vue3 – 实现 effect

实现响应式的核心 API – effect

使用

const obj = { name: "xm", age: 20 };
const state = reactive(obj);
effect(() => {
  app.innerHTML = state.name + state.age;
  console.log("yes");
});

特性

  • 所有的渲染都是基于 effect 来实现的

  • 默认叫响应式 effect,数据变化后会重新执行此函数

  • 属性会收集 effect(数据的依赖收集

    数据会记录自己在哪个 effect 中使用了,稍后数据变化可以找到对应的 effect

ReactiveEffect

  • effect 方法会创建一个响应式 effect,并且让 effect 执行
  • 其中使用到了 ReactiveEffect 类来处理我们的入参函数

这么说有点抽象,我们通过代码来理解

function effect(fn) {
  // 创建一个响应式 effect,并且让 effect 执行
  const _effect = new ReactiveEffect(fn)
  _effect.run()
}
let activeEffect = undefined
class ReactiveEffect {
  // 默认会将 fn 挂载到类的实例上
  constructor(private fn) { }
  run() {
    try {
      activeEffect = this
      return this.fn()
    } finally {
      activeEffect = null
    }
  }
}

可以看到,在 ReactiveEffect 之外定义了一个 activeEffect 全局变量,这个变量有什么作用呢?

  1. 执行 fn 之前会把当前的 effect 放到 activeEffect 上
  2. 这样 fn 中取属性的时候就可以访问到当前的 effect

比如在开篇的使用中取值 state.name时,我们可以在 getter 中访问到 activeEffect(也就是当前属性对应的 effect

除此之外,最后会将 activeEffect 置空,大家思考一下,如果不这么操作会产生什么后果呢?我们来看一个例子

effect(() => {
  const name = state.name;
});
const myName = state.name

我们执行完 effect 后,activeEffect 会对应当前的 effect,当我们再进行属性读取时,发现 activeEffect 有值

此时问题就浮现了,我们会将 effect 外的属性对应到 effect 中,其实这是不对的

effect 嵌套问题

effect(() => {
  app.innerHTML = state.age;
  effect(() => {
    app.innerHTML = state.name;
  });
  app.innerHTML = state.address;
});

我们分析一下上面这个嵌套 effect

  1. 第一个 effect,state 对应 e1
  2. 第二个 effect,state 对应 e2;此时要注意,第二个执行完毕后 activeEffect 是被置空了的
  3. 最后进行的state.address是找不到对应 effect 的

问题显而易见,那么 vue 是如何解决这个问题的呢?

vue2 和早期的 vue3 是使用栈来解决的,大致思想就是先入栈再执行,当前 effect 执行完毕后出栈

我们看到嵌套其实应该想到树结构,现在的 vue3 就是使用了树结构进行父子关系的维护(增加了一个 parent 标记)

class ReactiveEffect {
  // 默认会将 fn 挂载到类的实例上
  constructor(private fn) { }
  parent = undefined
  deps = [] // 依赖了哪些列表[effect]
  run() {
    try {
      this.parent = activeEffect
      activeEffect = this
      return this.fn()
    } finally {
      activeEffect = this.parent
      this.parent = undefined // 标记用完进行回收
    }
  }
}

我们来分析一下

  1. 第一个 effect,activeEffect => e1
  2. 第二个 effect,activeEffect => e2,e2.parent = e1
  3. 当第二个 effect 执行完毕后将 activeEffect 还原,activeEffect = e1

依赖收集 – track

所谓的依赖收集,其实就是记录属性和 effect 两者之间的关系:

  1. 多对多 – 一个属性对应多个 effect,一个 effect 对应多个属性
  2. 一个 effect 中相同属性执行多次 getter,只需要记录一次对应的 effect
  3. 结构:{ name: 'xm' }: -> 'name': -> [effect, effect, ...]

这样,我们可以得到一个存储的结构:weakMap: => Map: => Set

根据结构梳理一下整个依赖收集的流程

  1. 判断用户是否是在 effect 中使用的这个数据
  2. 判断映射表(Map)是否存在当前属性
  3. 判断集合(Set)中有没有这个 effect

了解基本结构和流程后就可以进行代码编写了

const targetMap = new WeakMap()
export function track(target, key) {
  if (activeEffect) {
    // 1.说明用户是在 effect 中使用的这个数据
    let depsMap = targetMap.get(target)
    if (!depsMap) {
      targetMap.set(target, (depsMap = new Map()))
    }
​
    // 2.在映射表中查找一下有没有这个属性
    let dep = depsMap.get(key)
    // 如果没有 Set 集合创建集合
    if (!dep) {
      depsMap.set(key, (dep = new Set()))
    }
​
    // 3.看一下 Set 中有没有这个 effect
    let shouldTrack = !dep.has(activeEffect)
    if (shouldTrack) {
      dep.add(activeEffect)
      activeEffect.deps.push(dep)
    }
  }
}

track 的使用

我们使用 reactive 创建响应式对象时,在 Proxy 中进行我们的依赖收集,如果不了解 reactive 原理,建议看上一篇文章[juejin.cn/post/735209…]

const mutableHandlers = {
  get(target, key, receiver) {
    if (key === ReactiveFlags.IS_REACTIVE) {
      return true
    }
    const res = Reflect.get(target, key, receiver)
    console.log(activeEffect, key);
    // 做依赖收集,记录属性和当前 effect 的关系
    track(target, key)
    return res
  }
}
const obj = { name: "xm", age: 20 };
effect(() => {
  app.innerHTML = state.name + state.age;
});

我们最终输出的结构如下图所示:

Vue3 - 实现响应式核心 effect

依赖更新 – trigger

当我们属性更改时,要触发视图更新,也就是让当前属性对应的 effect 重新执行

const mutableHandlers = {
  set(target, key, value, receiver) { // 更新
    // 找到这个属性对应的 effect 让他执行
    let oldValue = target[key]
    const r = Reflect.set(target, key, value, receiver)
​
    if (oldValue !== target[key]) {
      trigger(target, key, value, oldValue)
    }
    return r
  }
}
function trigger(target, key, value, oldValue) {
  // 通过对象找到对应的属性 让这个属性对应的 effect 重新执行
  const depsMap = targetMap.get(target)
  if (!depsMap) {
    return
  }
  
  let deps = depsMap.get(key)
  if (deps) {
    deps = [...deps]
    deps.forEach((effect) => {
      // 正在执行的 effect,不要多次执行
      if (effect !== activeEffect) effect.run()
    })
  }
}

可能有同学注意到,我将获取到的 deps 进行了浅拷贝deps = [...deps],这是为了后面执行依赖清理的时候使用,大家可以先忽略

到这一步,我们已经实现了视图更新,但是我们还要考虑两种场景:

effect(() => {
  state.age = Math.random();
  app.innerHTML = state.name + state.age;
});
effect(() => {
  app.innerHTML = state.age;
  effect(() => {
    state.age = Math.random();
    app.innerHTML = state.name;
  });
});
  1. 当我们在 effect 中进行属性更新时,会再次触发 effect,
  2. effect 中嵌套 effect,内层修改外层用到的属性,会触发外层,外层触发又会触发内层

这就造成了死循环,我们需要优化一下这种场景,其实就是做一层判断,正在执行的 effect 或者当前 activeEffect 的 parent 等于当前的 effect,不执行

dep && dep.forEach((effect) => {
  let run = true
  let parent = activeEffect && activeEffect.parent
  while (parent && run) {
    run = (effect !== parent)
    parent = parent.parent ? parent.parent : null
  }
  if (effect !== activeEffect && run) effect.run()
})

依赖清理 cleanupEffect

为什么要进行依赖清理,我们先来看一种场景

const obj = { name: "xm", age: 20, flag: true };
const state = reactive(obj);

effect(() => {
  console.log("触发");
  app.innerHTML = state.flag ? state.name : state.age;
});

setTimeout(() => {
  state.flag = false;
  setTimeout(() => {
    console.log("修改name不应该触发effect");
    state.name = "gx";
    state.age = 30;
  }, 1000);
}, 1000);

以上例子中:

  1. 使用 flag 来决定展示 name 还是 age

  2. 当我们修改 flag 后再进行 name 的修改,原则上不应该触发 effect

    因为此时 name 属性并没有使用到,所以更改 name 属性不应该触发 effect

针对这种情况,我们就需要进行依赖的清理,也就是在我们执行 effect 之前进行 clean

function cleanupEffect(effect) {
  // 找到 deps 中的 set,清理掉里面的 effect
  let deps = effect.deps
  for(let i = 0; i < deps.length; i++) {
    deps[i].delete(effect)
  }
}
class ReactiveEffect {
  constructor(private fn) { }
  parent = undefined
  deps = [] // 我依赖了哪些列表[effect]
  run() {
    try {
      this.parent = activeEffect
      activeEffect = this
      cleanupEffect(this) // 依赖清理
      return this.fn()
    } finally {
      activeEffect = this.parent
      this.parent = undefined
    }
  }
}

看到这里,是不是感觉有点懵,清空之后我再修改 state 还会更新视图吗?我们逐步进行分析一下:

  1. flag = true => track 收集 flag 和 name,此时activeEffect 中的deps: [Set(effect -> flag), Set(effect -> name)]
  2. 修改 flag = false => flag 触发 trigger 执行 effect.run() 清空依赖:循环 deps 删除每个 Set 中对应的 effect,此时 name 和 flag 对应的 Set 中就不存在对应的 effect 了
  3. effect.run() 后执行 effet 碰到 state.flag 后触发 track 收集 flag 和 age
  4. 修改 name 触发 trigger,此时 Set 中没有对应的 effect,所以不执行
  5. 修改 age 触发 trigger,Set 中有对应的 effect,执行 effect.run()

实践是检验真理的唯一标准,还是不太明白的同学可以动手打个断点测试一下(想偷懒的图贴在下面了),输出过程如下:

Vue3 - 实现响应式核心 effect

冷知识

大家还记不记得,依赖更新中获取到 deps 执行前,我进行了一层浅拷贝,思考一下,为什么要做这个操作呢?

let deps = depsMap.get(key)
if (deps) {
  deps = [...deps]
  deps.forEach((effect) => {
    if (effect !== activeEffect) effect.run()
  })
}

我们来看一段代码:

let a = 1;
let s = new Set([a]);

s.forEach((item) => {
  s.delete(item);
  s.add(item);
  console.log("kill");
});

可以看到,我们先进行了删除再添加到操作,最终导致的结果就是无限循环,因为我们添加后 s 是一直在增加的所以会一直循环

我们再回过头来看,

  1. 先执行 effect.run(),执行 effect 之前会进行依赖清理 cleanupEffect => deps[i].delete(effect)
  2. 执行 effect,触发 state 的 getter 进入到依赖收集 track
  3. 在 track 中会进行 effect 的收集activeEffect.deps.push(dep)

这个过程中会先删除再添加,就出现无限循环的问题了,这也是我们为什么要进行浅拷贝的原因

总结

涉及响应式核心,所以本期内容比较多也比较杂,大家只要记住几个核心点就好

  • 创建响应式 effect 的核心类 ReactiveEffect
  • 获取属性触发 getter 进行依赖收集,记录属性和 effect 对应关系,形成多对多关系
  • 属性修改触发 setter 进行视图更新,重新执行对应的 effect
  • 依赖清理,视图未使用的属性不执行 effect

除此之外,我们还进行了一些优化:

比如 effect 嵌套问题、deps 浅拷贝无限循环问题、effect 嵌套引用触发外层 effect导致的无限循环问题

本节内容到这就结束了,如有遗漏或错误欢迎大家指正批评(我可以不接受hh🤓)

原文链接:https://juejin.cn/post/7353804180802945035 作者:海下钢琴师

(0)
上一篇 2024年4月5日 下午5:12
下一篇 2024年4月6日 下午4:05

相关推荐

发表回复

登录后才能评论