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

  • 【源码系列#02】Vue3响应式原理(Effect)已关闭评论
  • 93 次浏览
  • A+
所属分类:Web前端
摘要

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


专栏分享: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

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

编写effect函数

// 当前正在执行的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 默认会先执行一次

依赖收集

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, 目的是在稍后清理的时候会用到

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

触发更新

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】

举个栗子

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

// 每次执行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,原则上不应更新页面

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

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

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

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

看一下 triggerEffects 方法,遍历了 effects

export function triggerEffects(effects) {   effects.forEach(effect => { effect.run() }) } 

effect.run 方法中

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

解决方法:

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嵌套

// 当前正在执行的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初始版本
// 运行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后续版本
// 这个执行流程 就类似于一个树形结构 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和调度执行

 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
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

完整代码如下

/**  * @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 - 博客园