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

简介: 本文介绍了如何实现一个可用的响应式系统,包括使用 reactive 完成数据变化的监听,使用 effect 封装更新视图的逻辑,使用 track 和 trigger 分别完成依赖收集和触发依赖更新。

回顾

上篇文章,我们实现了 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 方法执行,它接收的回调函数也执行了,于是页面上有了内容:

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

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

副作用函数

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

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

var num = 10

function fn1(){
    num = 20
}

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

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

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

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

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

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

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

实现 effect

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

// reactivity/src/effect.ts

export 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.ts

export { reactive } from './reactive' 
export { effect } from './effect' 

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

track 依赖收集

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

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

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

// reactivity/src/effect.ts

export 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。如下图:

// 存储所有的依赖信息,包含 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>

访问浏览器,结果如图:

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

小结

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

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

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

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

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

目录
相关文章
|
2天前
|
JavaScript 前端开发 开发者
Vue的响应式原理:深入探索Vue的响应式系统与依赖追踪
【4月更文挑战第24天】Vue的响应式原理通过JavaScript getter/setter实现,当数据变化时自动更新视图。它创建Watcher对象收集依赖,并通过依赖追踪机制精确通知更新。当属性改变,setter触发更新相关Watcher,重新执行操作以反映数据最新状态。Vue的响应式系统结合依赖追踪,有效提高性能,简化复杂应用的开发,但对某些复杂数据结构需额外处理。
|
4天前
|
设计模式 JavaScript 前端开发
Vue源码学习需要哪些工具和技能
【4月更文挑战第20天】学习Vue源码需具备的工具与技能:VS Code或WebStorm作为代码编辑器,Node.js与npm管理依赖,Git操作仓库。基础包括JavaScript、ES6+语法、前端知识(HTML/CSS/浏览器原理)及Vue基础知识。进阶则需源码阅读理解能力,调试技巧,熟悉设计模式和架构思想。学习方法强调系统学习、实践与持续关注Vue最新动态。
18 8
|
4天前
|
JavaScript 前端开发 编译器
Vue 源码学习路线
【4月更文挑战第20天】探索Vue源码涉及响应式系统、虚拟DOM、模板编译等核心概念。先掌握Vue基础知识、JavaScript(ES6+)和前端工程化。从源码入口文件开始,研究响应式、虚拟DOM、模板编译、实例方法、全局API及生命周期。理解编译器和渲染器工作原理,实践编写Vue插件,参与开源项目,阅读相关文章教程,持续关注Vue最新动态。这是一个循序渐进、需要耐心和实践的过程。
8 1
|
9天前
|
JavaScript 算法 前端开发
vue3和vue2的区别都有哪些
【4月更文挑战第15天】Vue3与Vue2在响应式系统(Proxy vs. Object.defineProperty)、组件模块化(Composition API vs. Options API)、数据变化检测(Reactive API vs. $watch)、虚拟DOM算法(基于迭代 vs. 基于递归)及Tree-Shaking支持上存在显著差异。Vue3的改进带来了更好的性能和灵活性,适合追求新技术的项目。Vue2则因其成熟稳定,适合维护大型项目。选择版本需根据项目需求、团队情况和技术追求来决定。
13 0
|
10天前
|
JavaScript
vue3+vite项目配置ESlint
vue3+vite项目配置ESlint
12 0
|
10天前
乾坤子应用配置(vue3+vite)
乾坤子应用配置(vue3+vite)
16 0
vue3中使用router路由实现跳转传参
vue3中使用router路由实现跳转传参
|
1天前
|
JavaScript 前端开发
【vue】iview如何把input输入框和点击输入框之后的边框去掉
【vue】iview如何把input输入框和点击输入框之后的边框去掉
7 0
|
1天前
|
JavaScript
【vue实战】父子组件互相传值
【vue实战】父子组件互相传值
6 1
|
1天前
|
JavaScript
vue2_引入Ant design vue
vue2_引入Ant design vue
6 0