【源码系列#02】Vue3响应式原理(Effect)

专栏分享:vue2源码专栏vue3源码专栏vue router源码专栏玩具项目专栏,硬核💪推荐🙌 欢迎各位ITer关注点赞收藏🌸🌸🌸

Vue3中响应数据核心是 reactive , reactive 的实现是由 proxy 加 effect 组合 ,上一章节我们利用 proxy 实现了一个简易版的 reactive,# 【源码系列#01】Vue3响应式原理(Reactive)。接下来让我们一起手写下 effect 的源码

effect

effect 作为 reactive 的核心,主要负责收集依赖,更新依赖 在学习 effect之前,我们再来看下这张图

  • targetMap: 存储了每个 "响应性对象属性" 关联的依赖;类型是 WeakMap
  • depsMap: 存储了每个属性的依赖;类型是 Map
  • dep: 存储了我们的 effects ,一个 effects 集,这些 effect 在值发生变化时重新运行;类型是 Set

编写effect函数

javascript 复制代码
// 当前正在执行的effect
export let activeEffect = undefined

export class ReactiveEffect {
  // @issue2
  // 这里表示在实例上新增了parent属性,记录父级effect
  public parent = null
  // 记录effect依赖的属性
  public deps = []
  // 这个effect默认是激活状态
  public active = true

  // 用户传递的参数也会传递到this上 this.fn = fn
  constructor(public fn, public scheduler) {}

  // run就是执行effect
  run() {
    // 这里表示如果是非激活的情况,只需要执行函数,不需要进行依赖收集
    if (!this.active) {
      return this.fn()
    }
    // 这里就要依赖收集了 核心就是将当前的effect 和 稍后渲染的属性关联在一起
    try {
      // 记录父级effect
      this.parent = activeEffect
      activeEffect = this
      // 当稍后调用取值操作的时候 就可以获取到这个全局的activeEffect了
      return this.fn()
    } finally {
      // 还原父级effect
      activeEffect = this.parent
    }
  }
}

export function effect(fn, options: any = {}) {
  // 这里fn可以根据状态变化 重新执行, effect可以嵌套着写
  const _effect = new ReactiveEffect(fn) // 创建响应式的effect
  // issue1
  _effect.run() // 默认先执行一次
}

@issue1 effect 默认会先执行一次

依赖收集

javascript 复制代码
const targetMap = new WeakMap()
export function track(target, type, key) {
  // @issue3
  // 我们只想在我们有activeEffect时运行这段代码
  if (!activeEffect) return 
  
  let depsMap = targetMap.get(target) // 第一次没有
  if (!depsMap) {
    targetMap.set(target, (depsMap = new Map()))
  }
  let dep = depsMap.get(key) // key -> name / age
  if (!dep) {
    depsMap.set(key, (dep = new Set()))
  }
  // 单向指的是 属性记录了effect, 反向记录,应该让effect也记录他被哪些属性收集过,这样做的好处是为了可以清理
  trackEffects(dep)
}

export function trackEffects(dep) {
  if (activeEffect) {
    let shouldTrack = !dep.has(activeEffect) // 去重了
    if (shouldTrack) {
      dep.add(activeEffect)
      // @issue4
      // 存放的是属性对应的set
      activeEffect.deps.push(dep) // 让effect记录住对应的dep, 稍后清理的时候会用到
    }
  }
}

@issue3 当activeEffect有值时,即只在effect运行时执行track依赖收集

@issue4 双向记录 ,一个属性对应多个effect,一个effect对应多个属性

一个属性对应多个 effect: 在之前的 depsMap 图中,我们得知,一个属性映射一个 dep(即 effect 集合,类型为 Set)

一个effect对应多个属性: 在 effect 中,有一个 deps 属性,她记录了此 effect 依赖的每一个属性所对应的 dep。让 effect 记录对应的 dep, 目的是在稍后清理的时候会用到

触发更新

javascript 复制代码
export function trigger(target, type, key) {
  const depsMap = targetMap.get(target)
  if (!depsMap) return // 触发的值不在模板中使用

  let effects = depsMap.get(key) // 找到了属性对应的effect

  // 永远在执行之前 先拷贝一份来执行, 不要关联引用
  if (effects) {
    triggerEffects(effects)
  }
}
export function triggerEffects(effects) {
  effects.forEach(effect => {
    // 我们在执行effect的时候 又要执行自己,那我们需要屏蔽掉,不要无限调用,【避免由activeEffect触发trigger,再次触发当前effect。 activeEffect -> fn -> set -> trigger -> 当前effect】
    // @issue5
    if (effect !== activeEffect) {
      effect.run() // 否则默认刷新视图
    }
  })
}

@issue5 避免由run触发trigger,无限递归循环

我们在执行 effect 的时候,又要执行自己,那我们需要屏蔽掉,不要无限调用【避免由 activeEffect 触发 trigger,再次触发当前 effect。 activeEffect -> fn -> set -> trigger -> 当前effect】

举个栗子

javascript 复制代码
const { effect, reactive } = VueReactivity
const data = { name: '柏成', age: 13, address: { num: 517 } }
const state = reactive(data)
// vue3中的代理都是用proxy来解决的

// 此effect函数默认会先执行一次, 对响应式数据取值(取值的过程中数据会依赖于当前的effect)
effect(() => {
  state.age = Math.random()
  document.getElementById('app').innerHTML = state.name + '今年' + state.age + '岁了'
})

// 稍后name和age变化会重新执行effect函数
setTimeout(() => {
  state.age = 18
}, 1000)

分支切换与cleanup

javascript 复制代码
// 每次执行effect的时候清理一遍依赖,再重新收集,双向清理
function cleanupEffect(effect) {
  // deps 里面装的是name对应的effect, age对应的effect
  const { deps } = effect
  for (let i = 0; i < deps.length; i++) {
    // 解除effect,重新依赖收集
    deps[i].delete(effect)
  }
  effect.deps.length = 0
}

export class ReactiveEffect {
  // @issue3
  // 这里表示在实例上新增了parent属性,记录父级effect
  public parent = null
  // 记录effect依赖的属性
  public deps = []
  // 这个effect默认是激活状态
  public active = true

  // 用户传递的参数也会传递到this上 this.fn = fn
  constructor(public fn, public scheduler) {} // @issue8 - scheduler

  // run就是执行effect
  run() {
    // 这里表示如果是非激活的情况,只需要执行函数,不需要进行依赖收集
    if (!this.active) {
      return this.fn()
    }
    // 这里就要依赖收集了 核心就是将当前的effect 和 稍后渲染的属性关联在一起
    try {
      // 记录父级effect
      this.parent = activeEffect
      activeEffect = this
      // 这里我们需要在执行用户函数之前将之前收集的内容清空
      cleanupEffect(this) // @issue6
      // 当稍后调用取值操作的时候 就可以获取到这个全局的activeEffect了
      return this.fn() // @issue1
    } finally {
      // 还原父级effect
      activeEffect = this.parent
    }
  }
}

export function triggerEffects(effects) {
  // 先拷贝,防止死循环,new Set 后产生一个新的Set
  effects = new Set(effects) // @issue7
  effects.forEach(effect => {
    // 我们在执行effect的时候 又要执行自己,那我们需要屏蔽掉,不要无限调用,【避免由activeEffect触发trigger,再次触发当前effect。 activeEffect -> fn -> set -> trigger -> 当前effect】
    if (effect !== activeEffect) {
      effect.run() // 否则默认刷新视图
    }
  })
}

@issue6 分支切换 - cleanupEffect。我们需要在执行用户函数之前将之前收集的内容清空,双向清理, 在渲染时我们要避免副作用函数产生的遗留,举个栗子,我们再次修改name,原则上不应更新页面

每次副作用函数执行时,可以先把它从所有与之关联的依赖集合中删除。当副作用函数执行完毕后,响应式数据会与副作用函数之间建立新的依赖关系,而分支切换后,与副作用函数没有依赖关系的响应式数据则不会再建立依赖,这样副作用函数遗留的问题就解决了;

javascript 复制代码
const { effect, reactive } = VueReactivity
const state = reactive({ flag: true, name: '柏成', age: 24 })

effect(() => {
  // 我们期望的是每次执行effect的时候都可以清理一遍依赖,重新收集
  // 副作用函数 (effect执行渲染了页面)
  console.log('render')
  document.body.innerHTML = state.flag ? state.name : state.age
})

setTimeout(() => {
  state.flag = false
  setTimeout(() => {
    // 修改name,原则上不更新页面
    state.name = '李'
  }, 1000)
}, 1000)

@issue7 分支切换 - 死循环。遍历 set 对象时,先 delete 再 add,会出现死循环

在调用循环遍历 Set 集合时,如果一个值已经被访问过了,但该值被删除,并重新添加到集合,如果此时循环遍历没有结束,那该值会被重新访问

参考资料:ECMAScript Language Specification 提示:语言规范说的是forEach时是这样的,实测 for of 遍历Set会有同样的问题。

看一下 triggerEffects 方法,遍历了 effects

javascript 复制代码
export function triggerEffects(effects) {
  effects.forEach(effect => { effect.run() })
}

effect.run 方法中

  • 执行 cleanupEffect(effect),清理一遍依赖
javascript 复制代码
deps[i].delete(effect) 
  • 执行 this.fn(),重新执行函数,重新收集依赖
javascript 复制代码
// track() 方法中
dep.add(activeEffect) // 将副作用函数activeEffect添加到响应式依赖中

解决方法:

javascript 复制代码
let effect = () => {};
let deps = new Set([effect])
deps.forEach(item=>{
  console.log('>>>')
  deps.delete(effect); 
  deps.add(effect)
}); // 这样就导致死循环了

// 解决方案如下,先拷贝一份,遍历的Set对象 和 操作(delete、add)的Set对象不是同一个即可
let effect = () => {};
let deps = new Set([effect])
const newDeps = new Set(deps) 
newDeps.forEach(item=>{
  console.log('>>>')
  deps.delete(effect); 
  deps.add(effect)
}); 

effect嵌套

javascript 复制代码
// 当前正在执行的effect
export let activeEffect = undefined

export class ReactiveEffect {
  // @issue2
  // 这里表示在实例上新增了parent属性,记录父级effect
  public parent = null
  // 记录effect依赖的属性
  public deps = []
  // 这个effect默认是激活状态
  public active = true

  // 用户传递的参数也会传递到this上 this.fn = fn
  constructor(public fn, public scheduler) {}

  // run就是执行effect
  run() {
    // 这里表示如果是非激活的情况,只需要执行函数,不需要进行依赖收集
    if (!this.active) {
      return this.fn()
    }
    // 这里就要依赖收集了 核心就是将当前的effect 和 稍后渲染的属性关联在一起
    try {
      // 记录父级effect
      this.parent = activeEffect
      activeEffect = this
      // 当稍后调用取值操作的时候 就可以获取到这个全局的activeEffect了
      return this.fn()
    } finally {
      // 还原父级effect
      activeEffect = this.parent
    }
  }
}

export function effect(fn, options: any = {}) {
  // 这里fn可以根据状态变化 重新执行, effect可以嵌套着写
  const _effect = new ReactiveEffect(fn) // 创建响应式的effect
  // issue1
  _effect.run() // 默认先执行一次
}

@issue2 利用 parent 解决effect嵌套问题, effect 嵌套的场景在 Vue.js 中常常出现,如:Vue中的渲染函数(render)就是在一个effect中执行的,嵌套组件就会伴随着嵌套 effect

  1. 解决effect嵌套问题----栈方式------------------------vue2/vue3.0初始版本
javascript 复制代码
// 运行effect,此effect入栈,运行完毕,最后一个effect出栈,属性关联栈中的最后一个effect
[e1] -> [e1,e2] -> [e1]
effect(() => {   // activeEffect = e1
  state.name     // name -> e1
  effect(() => { // activeEffect = e2
    state.age    // age -> e2
  })
                 // activeEffect = e1
  state.address  // address = e1
})
  1. 解决effect嵌套问题----树形结构方式----------------vue3后续版本
javascript 复制代码
// 这个执行流程 就类似于一个树形结构
effect(()=>{       // parent = null  activeEffect = e1
  state.name       // name -> e1
  effect(()=>{     // parent = e1  activeEffect = e2
     state.age     // age -> e2
     effect(()=> { // parent = e2  activeEffect = e3
        state.sex  // sex -> e3
     })            // activeEffect = e2
  })               // activeEffect = e1

  state.address    // address -> e1

  effect(()=>{     // parent = e1   activeEffect = e4
    state.age      // age -> e4
  })
})

停止effect和调度执行

javascript 复制代码
export class ReactiveEffect {
  // @issue8 - stop
  stop() {
    if (this.active) {
      this.active = false
      cleanupEffect(this) // 停止effect的收集
    }
  }
}

export function effect(fn, options: any = {}) {
  // 这里fn可以根据状态变化 重新执行, effect可以嵌套着写
  const _effect = new ReactiveEffect(fn, options.scheduler) // 创建响应式的effect @issue8 - scheduler
  _effect.run() // 默认先执行一次

  // @issue8 - stop
  // 绑定this,run方法内的this指向_effect,若不绑定,这样调用run方法时,runner(),则指向undefined
  const runner = _effect.run.bind(_effect)
  // 将effect挂载到runner函数上,调用stop方式时可以这样调用 runner.effect.stop()
  runner.effect = _effect
  return runner
}


export function triggerEffects(effects) {
  // 先拷贝,防止死循环,new Set 后产生一个新的Set
  effects = new Set(effects) // @issue7
  effects.forEach(effect => {
    // 我们在执行effect的时候 又要执行自己,那我们需要屏蔽掉,不要无限调用,【避免由activeEffect触发trigger,再次触发当前effect。 activeEffect -> fn -> set -> trigger -> 当前effect】
    if (effect !== activeEffect) {
      // @issue8 - scheduler
      if (effect.scheduler) {
        effect.scheduler() // 如果用户传入了调度函数,则执行调度函数
      } else {
        effect.run() // 否则默认刷新视图
      }
    }
  })
}

如何使用 stop 和 scheduler ?举个小栗子

  • 当我们调用 runner.effect.stop() 时,就双向清理了 effect 的所有依赖,后续 state.age 发生变化后,将不再重新更新页面
  • 基于 scheduler 调度器,我们可以控制页面更新的周期,下面例子中,会在1秒后,页面由 30 变为 5000
javascript 复制代码
let waiting = false
const { effect, reactive } = VueReactivity
const state = reactive({ flag: true, name: 'jw', age: 30, address: { num: 10 } })
let runner = effect(
  () => {
    // 副作用函数 (effect执行渲染了页面)
    document.body.innerHTML = state.age
  },
  {
    scheduler() {
      // 调度 如何更新自己决定
      console.log('run')
      if (!waiting) {
        waiting = true
        setTimeout(() => {
          runner()
          waiting = false
        }, 1000)
      }
    },
  },
)

// 清理 effect 所有依赖,state.age 发生变化后,将不再重新更新页面
// runner.effect.stop()

state.age = 1000
state.age = 2000
state.age = 3000
state.age = 4000
state.age = 5000

effect.ts

完整代码如下

javascript 复制代码
/**
 * @issue1 effect默认会先执行一次
 * @issue2 activeEffect 只在effect运行时执行track保存
 * @issue3 parent 解决effect嵌套问题
 * @issue4 双向记录  一个属性对应多个effect,一个effect对应多个属性 √
 * @issue5 避免由run触发trigger,递归循环
 * @issue6 分支切换 cleanupEffect
 * @issue7 分支切换 死循环,set循环中,先delete再add,会出现死循环
 * @issue8 自定义调度器 类似Vue3中的effectScope stop 和 scheduler
 */

// 当前正在执行的effect
export let activeEffect = undefined

// @issue6
// 每次执行effect的时候清理一遍依赖,再重新收集,双向清理
function cleanupEffect(effect) {
  // deps 里面装的是name对应的effect, age对应的effect
  const { deps } = effect
  for (let i = 0; i < deps.length; i++) {
    // 解除effect,重新依赖收集
    deps[i].delete(effect)
  }
  effect.deps.length = 0
}

export class ReactiveEffect {
  // @issue3
  // 这里表示在实例上新增了parent属性,记录父级effect
  public parent = null
  // 记录effect依赖的属性
  public deps = []
  // 这个effect默认是激活状态
  public active = true

  // 用户传递的参数也会传递到this上 this.fn = fn
  constructor(public fn, public scheduler) {} // @issue8 - scheduler

  // run就是执行effect
  run() {
    // 这里表示如果是非激活的情况,只需要执行函数,不需要进行依赖收集
    if (!this.active) {
      return this.fn()
    }
    // 这里就要依赖收集了 核心就是将当前的effect 和 稍后渲染的属性关联在一起
    try {
      // 记录父级effect
      this.parent = activeEffect
      activeEffect = this
      // 这里我们需要在执行用户函数之前将之前收集的内容清空
      cleanupEffect(this) // @issue6
      // 当稍后调用取值操作的时候 就可以获取到这个全局的activeEffect了
      return this.fn() // @issue1
    } finally {
      // 还原父级effect
      activeEffect = this.parent
    }
  }
  // @issue8 - stop
  stop() {
    if (this.active) {
      this.active = false
      cleanupEffect(this) // 停止effect的收集
    }
  }
}

export function effect(fn, options: any = {}) {
  // 这里fn可以根据状态变化 重新执行, effect可以嵌套着写
  const _effect = new ReactiveEffect(fn, options.scheduler) // 创建响应式的effect @issue8 - scheduler
  _effect.run() // 默认先执行一次

  // @issue8 - stop
  // 绑定this,run方法内的this指向_effect,若不绑定,这样调用run方法时,runner(),则指向undefined
  const runner = _effect.run.bind(_effect)
  // 将effect挂载到runner函数上,调用stop方式时可以这样调用 runner.effect.stop()
  runner.effect = _effect
  return runner
}

// 对象 某个属性 -》 多个effect
// WeakMap = {对象:Map{name:Set-》effect}}
// {对象:{name:[]}}
// 多对多  一个effect对应多个属性, 一个属性对应多个effect
const targetMap = new WeakMap()
export function track(target, type, key) {
  // 我们只想在我们有activeEffect时运行这段代码
  if (!activeEffect) return // @issue2
  let depsMap = targetMap.get(target) // 第一次没有
  if (!depsMap) {
    targetMap.set(target, (depsMap = new Map()))
  }
  let dep = depsMap.get(key) // key -> name / age
  if (!dep) {
    depsMap.set(key, (dep = new Set()))
  }
  // 单向指的是 属性记录了effect, 反向记录,应该让effect也记录他被哪些属性收集过,这样做的好处是为了可以清理
  trackEffects(dep)
}

export function trackEffects(dep) {
  if (activeEffect) {
    let shouldTrack = !dep.has(activeEffect) // 去重了
    if (shouldTrack) {
      dep.add(activeEffect)
      // @issue4
      // 存放的是属性对应的set
      activeEffect.deps.push(dep) // 让effect记录住对应的dep, 稍后清理的时候会用到
    }
  }
}

export function trigger(target, type, key) {
  const depsMap = targetMap.get(target)
  if (!depsMap) return // 触发的值不在模板中使用

  let effects = depsMap.get(key) // 找到了属性对应的effect

  // 永远在执行之前 先拷贝一份来执行, 不要关联引用
  if (effects) {
    triggerEffects(effects)
  }
}
export function triggerEffects(effects) {
  // 先拷贝,防止死循环,new Set 后产生一个新的Set
  effects = new Set(effects) // @issue7
  effects.forEach(effect => {
    // 我们在执行effect的时候,有时候会改变属性,那我们需要屏蔽掉,不要无限调用,【避免由activeEffect触发trigger,再次触发当前effect。 activeEffect -> fn -> set -> trigger -> 当前effect】
    // @issue5
    if (effect !== activeEffect) {
      // @issue8 - scheduler
      if (effect.scheduler) {
        effect.scheduler() // 如果用户传入了调度函数,则执行调度函数
      } else {
        effect.run() // 否则默认刷新视图
      }
    }
  })
}

参考资料

Vue3响应式系统实现原理(二) - CherishTheYouth - 博客园

相关推荐
设计师小聂!3 分钟前
vue3 - 自定义hook
开发语言·javascript·ecmascript
伍哥的传说4 分钟前
daisyUI 扩展之 pin input 组件开发,极致pin码输入框
前端·javascript·react.js·交互
云小遥32 分钟前
Cornerstone3D 2.x升级调研
前端·数据可视化
李明卫杭州38 分钟前
浅谈JavaScript中Blob对象
前端·javascript
springfe010138 分钟前
Cesium 3D地图 图元 圆柱 图片实现
前端·cesium
meng半颗糖41 分钟前
vue3 双容器自动扩展布局 根据 内容的多少 动态定义宽度
前端·javascript·css·vue.js·elementui·vue3
yt9483242 分钟前
jquery和CSS3圆形倒计时特效
前端·css3·jquery
teeeeeeemo44 分钟前
CSS3 动画基础与技巧
前端·css·笔记·css3
年纪轻轻就扛不住1 小时前
CSS3 渐变效果
前端·css·css3
Aisanyi1 小时前
【鸿蒙开发】使用HMRouter路由的使用
前端·harmonyos