Vue3源码学习(3):reactive + effect + track + trigger 实现响应式系统

1,606 阅读6分钟

回顾

上篇文章,我们实现了 reactive 方法,它内部采用了 Proxy 来实现对象属性操作的拦截。这是实现响应式系统的前提,我们必须先拦截到用户对属性的访问,之后才能做依赖收集;再拦截到用户对属性的修改,才能做派发更新。

effect 方法

基本用法

如果之前了解过 Vue2 的响应式原理,那么对于 Watcher 你一定不会陌生。它是 Vue2 响应式系统中的核心之一,无论是响应式数据,还是 computed 计算属性,watch 监听器,内部都是用了 Watcher。简单来说,它就是把需要用户手动执行的逻辑进行了封装,控制权从用户手中转移到了框架层面,从而实现了数据变化,页面自动更新的响应式系统。

Vue3 中的 effect 方法的作用和 Watcher 一样。

先来看一个简单示例:

<body>
    <div id="app"></div>
    
    <script src="https://cdn.bootcdn.net/ajax/libs/vue/3.2.37/vue.global.js"></script>

    <script>
        const { reactive, effect } = Vue
        const person = { name: 'kw', age: 18 }
        const state = reactive(person)

        // effect 方法接收一个函数作为参数。
        effect(() => {
          app.innerHTML = 'Hello! ' + state.name
        })

        setTimeout(() => {
          state.name = 'zk'
        }, 1000)
    </script>
</body>

打开浏览器,可以发现 effect 方法执行,它接收的回调函数也执行了,于是页面上有了内容:

2.png

当 1s 过后,我们修改了 state 的属性,发现页面会自动更新:

1.png

这就是响应式系统带给我们的能力。

副作用函数

关于 effect 方法的理解,一直以来都十分模糊,直到看了 《Vue.js设计与实现》 这本书中的相关介绍。

书中将 Vue3 提供的 effect 定义为用来注册副作用函数的一个方法。所谓的副作用函数,可以理解为一个函数执行,会影响到其他函数的执行。比如:

var num = 10function fn1(){
    num = 20
}
​
function fn2(){
    // fn2 的本职工作:
    console.log('fn2')
    // fn2 产生的副作用
    num = 30
}

fn1 函数的作用是修改 num 变量的值。当 fn2 函数执行时,也修改了 num 的值,于是产生了对 fn1 的影响,也就是产生了副作用。

上面示例中 effect 方法所接收的函数参数,就是一个副作用函数:

4.png

为了方便清楚描述 effect 方法和它接收的副作用函数,我们将前者依然叫 effect 方法,后者叫作 副作用函数 fn。示例中的 fn 其实就是本来要用户自己手动执行的逻辑:当页面渲染时,需要用户手动渲染数据到页面上;当数据更新了,需要用户再手动调用渲染一次。

effect 方法要做的事情,就是将这个原本属于用户的逻辑封装起来,交给框架来管理,在合适的时机去调用

所谓合适的时机,无非就两个,一是页面首次渲染时,二是它依赖的数据更新时

在此基础上,结合前面所实现的 reactive 方法,已经初步具备响应式系统的雏形了:页面首次渲染时,执行 effect 方法,将 副作用函数 fn 收集起来并执行,此时会用到某些响应式数据,需要记住 fn 所依赖的属性;当其依赖的属性发生变化后,再想办法通知 fn 再次执行。

实现 effect

有了上面的思路,我们先来实现 effect 方法。

// reactivity/src/effect.tsexport function effect(fn) {
  // effect 方法接收一个函数参数,需要将其保存,并执行一次;以后还会扩展出更多的功能,所以将其封装为一个 ReactiveEffect 类进行维护
  const _effect = new ReactiveEffect(fn)
  _effect.run()
}
​
class ReactiveEffect {
    constructor(fn) {
        this.fn = fn
    }
    
    run() {
        this.fn()
    }
}

上面我们实现了 effect 方法和一个新的类 ReactiveEffect

effect 方法执行,会创建一个 ReactiveEffect 类的实例对象,命名为 _effect。这个类会将副作用函数 fn 保存起来,并立即执行一次。

后面要实现的依赖收集功能,收集的就是这个 _effect 实例。其实这个 ReactiveEffect 会更像 Vue2 中的 WatcherVue2 中的依赖收集,收集的就是一个 Watcher 类的实例。

注意,要区分 effect方法和它创建的 _effect 实例。前者用来注册副作用函数,生成 _effect实例,这才是依赖收集的真正要收集的东西。

effect 方法暴露出去:

// reactivity/src/index.tsexport { reactive } from './reactive' 
export { effect } from './effect' 

到这里,我们实现的 effect 方法也能像原版那样,在初始化时执行一次 fn,并将 fn 保存下来。

track 依赖收集

前面示例中的副作用函数 fn 执行时,用到了一个 name 属性,也就是访问到了响应式对象的属性,所以逻辑会走到 reactive 方法中实现代理那里,对属性 get 操作的监听。此时就可以做依赖收集了。

那么我们先去定义一个全局变量 activeEffect ,表示当前正在执行的 effect 方法生成的 ReactiveEffect 类的实例 _effect

这样,只要 effect 方法执行,我们就能拿到此时的 _effect

// reactivity/src/effect.tsexport let activeEffect;
​
export class ReactiveEffect {
  constructor(fn) {
    this.fn = fn
  }

  run() {
    // 将 _effect 赋给全局的变量 activeEffect
    activeEffect = this
    // fn执行时,内部用到的响应式数据的属性会被访问到,就能触发 proxy 对象的 get 取值操作
    this.fn() 
  }
}

回到 reactive 方法中,我们要使用一个 track 方法,用于“追踪”并保存 targetkey 和此时的 _effect 的关系:

const handler = {
    // 监听属性访问操作
    get(target, key, receiver) {
      if(key === ReactiveFlags.IS_REACTIVE) {
        return true
      }
      console.log(`${key}属性被访问,依赖收集`)
      // 依赖收集,让 target, key 和 当前的 _effect 关联起来
      track(target, key)

      const res = Reflect.get(target, key)
      if(isObject(res)) {
        return reactive(res)
      }
      return res
    }
}

实现 track 方法

该方法定义在 effect.ts 中。

所谓收集,就是需要有一个存储空间来存放所有的依赖信息

我们使用一个 WeakMap 结构来存储所有的依赖信息,key 是_effect 中用到的响应式对象的原始对象,也就是 target;value 则又是一个 Map结构,它的 key 就是 targetkey 了,它的 value 又是一个 Set结构 ,用来存储所有的 _effect。如下图:

effect依赖缓存的结构.png

// 存储所有的依赖信息,包含 target、key 和 _effect
const targetMap = new WeakMap

/**
 * 依赖收集。关联对象、属性和 _effect。
 */
export function track(target, key) {
  if(!activeEffect) return

  // 从缓存中找到 target 对象所有的依赖信息
  let depsMap = targetMap.get(target)
  if(!depsMap) {
    targetMap.set(target, depsMap = new Map)
  }
  // 再找到属性 key 所对应的 _effect集合
  let deps = depsMap.get(key)
  if(!deps) {
    depsMap.set(key, deps = new Set)
  }
    
  // 如果 _effect 已经被收集过了,则不再收集
  let shouldTrack = !deps.has(activeEffect)
  if(shouldTrack) {
    deps.add(activeEffect)
  }
}

到这里,就实现了一个可用的依赖收集功能。

trigger 派发更新

接下来,当属性发生变化了,还应该有一个机制去做派发更新。

我们使用一个 trigger 方法,用于派发更新:

// reactivity/src/index.ts

const handler = {
    //...
      
    // 监听设置属性操作
    set(target, key, value, receiver) {
      console.log(`${key}属性变化了,派发更新`)
     
      if(target[key] !== value) {
        const result = Reflect.set(target, key, value, receiver);
        // 派发更新,通知 target 的属性,让依赖它的 _effect 再次执行
        trigger(target, key);
        return result
      }
    }
}

实现 trigger 方法

回到 effect.ts 中。trigger 方法的实现思路也很简单,就是从前面的依赖缓存 targetMap 中,找到此时 target 的某个 key 对应的 _effect 依赖集合,让其中的所有 _effect 依次执行即可:

// reactivity/src/effect.ts

export function trigger(target, key) {
  // 找到 target 的所有依赖
  let depsMap = targetMap.get(target)
  if(!depsMap) {
    return 
  }

  // 属性依赖的 _effect 列表
  let effects = depsMap.get(key)
  if(effects) {
    // 属性的值发生变化,找到它依赖的 _effect 列表,让所有的 _effect 依次执行
    effects.forEach(effect => {
      effect.run()
    })
 }
}

测试

先执行打包命令:

pnpm dev

编写测试文件:

// reactivity/test/2.effect-track-trigger.html

<body>
    <div id="app"></div>

    <script src="../dist/reactivity.global.js"></script>
    <script>
        const { reactive, effect } = VueReactivity
        const obj = { name: 'kw', age: 18, grade: { math: 60 } }
        const state = reactive(obj)
        effect(() => {
            app.innerHTML = `${state.name}数学考了${state.grade.math}分`
        })
        setTimeout(() => {
            state.grade.math = 80
        }, 1000)
    </script>
</body>

访问浏览器,结果如图:

5.gif

到这里,我们基本上实现了一个响应式系统:数据变化,页面自动更新。

小结

我们先实现了一个 effect 方法,用于管理一些需要重复执行的逻辑,原本这些都是由用户控制的,比如设置页面的显示内容。

之后,结合上篇文章实现的 reactive 方法,在属性被访问到时,进行依赖收集,主要依靠 track 方法 ;当属性发生变化后,再利用 trigger 方法,通知收集来的 _effect 重新执行。

经过这样的整合,基本上实现了一个可用的响应式系统:

响应式系统演示.png

当然,现在的 effect 方法是不严谨的,还存在一些问题,下一篇文章我们会再进行完善。

从本篇开始,每篇文章对应的代码都放到一个单独分支上,方便大家对照查看。本篇对应的分为为 1.effect-track-trigger,点此访问

我正在参与掘金技术社区创作者签约计划招募活动,点击链接报名投稿