vue3 源码学习,实现一个 mini-vue(四):computed 的响应性

简介: vue3 源码学习,实现一个 mini-vue(四):computed 的响应性

前言

对于响应性系统而言,除了前两章接触的 refreactive 之外,还有另外两个也是我们经常使用到的,那就是:

  1. 计算属性:computed
  2. 侦听器:watch

本章我们先来实现一下 computed 这个 API

1. computed 计算属性

计算属性 computed基于其响应式依赖被缓存,并且在依赖的响应式数据发生变化时 重新计算

我们来看下面这段代码:

<div id="app"></div>
<script>
  const { reactive, computed, effect } = Vue

  const obj = reactive({
    name: '张三'
  })

  const computedObj = computed(() => {
    return '姓名:' + obj.name
  })

  effect(() => {
    document.querySelector('#app').innerHTML = computedObj.value
  })

  setTimeout(() => {
    obj.name = '李四'
  }, 2000)
</script>

上面的代码,程序主要执行了 5 个步骤:

  1. 使用 reactive 创建响应性数据
  2. 通过 computed 创建计算属性 computedObj,并且触发了 objgetter
  3. 通过 effect 方法创建 fn 函数
  4. fn 函数中,触发了 computedgetter
  5. 延迟触发了 objsetter

接下来我们将从源码中研究 computed 的实现:

2. computed 源码阅读

  1. 因为研究过了 reactive 的实现,所以我们直接来到 packages/reactivity/src/computed.ts 中的第 84 行,在 computed 函数出打上断点:

image.png

  1. 可以看到 computed 方法其实很简单,主要就是创建并返回了一个 ComputedRefImpl 对象,我们将代码跳转进 ComputedRefImpl 类。

image.png

  1. ComputedRefImpl 的构造函数中 创建了 ReactiveEffect 实例,并且传入了两个参数:

    1. getter:触发 computed 函数时,传入的第一个参数
    2. 匿名函数:当 this._dirtyfalse 时,会触发 triggerRefValue,我们知道 triggerRefValue依次触发依赖 (_dirty 在这里以为 的意思,可以理解为 《依赖的数据发生变化,计算属性就需要重新计算了》)
  2. 对于 ReactiveEffect 而言,我们之前是有了解过的,生成的实例,我们一般把它叫做 effect,他主要提供两个方法:

    1. run 方法:触发 fn,即传入的第一个参数
    2. stop 方法:语义上为停止的意思,我这里目前还没有实现

至此,我们已经执行完了 computed 函数,我们来总结一下做了什么:

  • 定义变量 getter 为我们传入的回调函数
  • 生成了 ComputedRefImpl 实例,作为 computed 函数的返回值
  • ComputedRefImpl 内部,利用了 ReactiveEffect 函数,并且传入了 第二个参数
  1. computed 代码执行完成之后,我们在 effect 中触发了 computedgetter
computedObj.value

根据我们之前在学习 ref 的时候可知,.value 属性的调用本质上是一个 get value 的函数调用,而 computedObj 作为 computed 的返回值,本质上是 ComputedRefImpl 的实例, 所以此时会触发 ComputedRefImpl 下的 get value 函数。

image.png

  1. get value 中,做了两件事:

    1. 做了trackRefVale 依赖收集。
    2. 执行了之前存在 computed 中的函数 () => return '姓名' + obj.name,并返回了结果
  2. 这里可以提一下第 59 行中的判断条件,_dirty 初始化是 ture(_cacheable 初始化 false),所以会执行这个 if, 在 if 中将 _dirty 改为了 false,也就是说只要不改这个 _dirty,下次再去获取 computedObj.value 值时,不会重新执行 fn
  3. effect 函数执行完成,页面显示 姓名:张三,延迟两秒之后,会触发 obj.namereactivesetter 行为,所以我们可以在 packages/reactivity/src/baseHandlers.ts 中为 set 增加一个断点:

image.png

  1. 可以发现因为之前 oldValue 是张三 ,现在 value 是李四,hasChange 方法为 true,进入到 trigger 方法

image.png

  1. 同样跳过之前相同逻辑,可知,最后会触发:triggerEffects(deps[0], eventInfo) 方法。进入 triggerEffects 方法:

image.png

  1. 这里要注意:因为我们在 ComputedRefImpl 的构造函数中,执行了 this.effect.computed = this,所以此时的 if (effect.computed) 判断将会为 true。此时我们注意看 effects,此时 effect 的值为 ReactiveEffect 的实例,同时 scheduler 存在值;
  2. 接下来进入 triggerEffect

image.png

  1. 不知道大家还有没有印象,在 ComputedRefImpl 的构造函数创建 ReactiveEffect 实例时传进去的第二个参数,那个参数就是这里 scheduler

image.png

  1. 我们进入 scheduler 回调:

image.png

  1. 此时的 _dirtyfalse,所以会执行 triggerRefValue 函数,我们进入 triggerRefValue

image.png

  1. triggerRefValue 会再次触发 triggerEffects 依赖触发函数,把当前的 this.dep 作为参数传入。注意此时的 effect 是没有 computedscheduler 属性的。

image.png

  1. fn 函数的触发,标记着 computedObj.value 触发,而我们知道 computedObj.value 本质上是 get value 函数的触发,所以代码接下来会触发 ComputedRefImplget value

image.png

  1. 获取到 computedObj.value 后 通过 ocument.querySelector('#app').innerHTML = computedObj.value 修改视图。
  2. 至此,整个过程结束。

梳理一下修改 obj.name 到修改视图的过程:

  1. 整个事件有 obj.name 开始
  2. 触发 proxy 实例的 setter
  3. 执行 trigger,第一次触发依赖
  4. 注意,此时 effect 包含 scheduler 调度器属性,所以会触发调度器
  5. 调度器指向 ComputedRefImpl 的构造函数中传入的匿名函数
  6. 在匿名函数中会:再次触发依赖
  7. 即:两次触发依赖
  8. 最后执行 :
() => {
  return '姓名:' + obj.name
}

得到值作为 computedObj 的值

总结:

到这里我们基本上了解了 computed 的执行逻辑,里面涉及到了一些我们之前没有了解过的概念,比如 调度器 scheduler ,并且整体的 computed 的流程也相当复杂。

对于 computed 而言,整体比较复杂,所以我们将分步进行实现

3. 构建 ComputedRefImpl ,读取计算属性的值

我们的首先的目标是:构建 ComputedRefImpl 类,创建出 computed 方法,并且能够读取值

  1. 创建 packages/reactivity/src/computed.ts
import { isFunction } from '@vue/shared'
import { Dep } from './dep'
import { ReactiveEffect } from './effect'
import { trackRefValue } from './ref'

/**
 * 计算属性类
 */
export class ComputedRefImpl<T> {
  public dep?: Dep = undefined
  private _value!: T

  public readonly effect: ReactiveEffect<T>

  public readonly __v_isRef = true

  constructor(getter) {
    this.effect = new ReactiveEffect(getter)
    this.effect.computed = this
  }

  get value() {
    // 触发依赖
    trackRefValue(this)
    // 执行 run 函数
    this._value = this.effect.run()!
    // 返回计算之后的真实值
    return this._value
  }
}

/**
 * 计算属性
 */
export function computed(getterOrOptions) {
  let getter

  // 判断传入的参数是否为一个函数
  const onlyGetter = isFunction(getterOrOptions)
  if (onlyGetter) {
    // 如果是函数,则赋值给 getter
    getter = getterOrOptions
  }

  const cRef = new ComputedRefImpl(getter)

  return cRef as any
}
  1. packages/shared/src/index.ts 中,创建工具方法:
/**
 * 是否为一个 function
 */
export const isFunction = (val: unknown): val is Function =>
  typeof val === 'function'
  1. packages/reactivity/src/effect.ts 中,为 ReactiveEffect 增加 computed 属性:
  /**
   * 存在该属性,则表示当前的 effect 为计算属性的 effect
   */
  computed?: ComputedRefImpl<T>
  1. packages/reactivity/src/index.tspackages/vue/src/index.ts 导出
  2. 创建测试实例:packages/vue/examples/reactivity/computed.html
  <body>
    <div id="app"></div>
  </body>
  <script>
    const { reactive, computed, effect } = Vue

    const obj = reactive({
      name: '张三'
    })

    const computedObj = computed(() => {
      return '姓名:' + obj.name
    })

    effect(() => {
      document.querySelector('#app').innerHTML = computedObj.value
    })

    setTimeout(() => {
      obj.name = '李四'
    }, 2000)
  </script>

此时,我们可以发现,计算属性,可以正常展示。

但是: 当 obj.name 发生变化时,我们可以发现浏览器 并不会 跟随变化,即:计算属性并非是响应性的。那么想要完成这一点,我们还需要进行更多的工作才可以。

4. 初见调度器,处理脏的状态

如果我们想要实现 响应性,那么必须具备两个条件:

  1. 收集依赖:该操作我们目前已经在 get value 中进行。
  2. 触发依赖:该操作我们目前尚未完成,而这个也是我们本小节主要需要做的事情。

代码实现:

  1. packages/reactivity/src/computed.ts 中,处理脏状态和 scheduler:
export class ComputedRefImpl<T> {
  ...

  /**
   * 脏:为 false 时,表示需要触发依赖。为 true 时表示需要重新执行 run 方法,获取数据。即:数据脏了
   */
  public _dirty = true

  constructor(getter) {
    this.effect = new ReactiveEffect(getter, () => {
      // 判断当前脏的状态,如果为 false,表示需要《触发依赖》
      if (!this._dirty) {
        // 将脏置为 true,表示
        this._dirty = true
        triggerRefValue(this)
      }
    })
    this.effect.computed = this
  }

  get value() {
    // 触发依赖
    trackRefValue(this)
    // 判断当前脏的状态,如果为 true ,则表示需要重新执行 run,获取最新数据
    if (this._dirty) {
      this._dirty = false
      // 执行 run 函数
      this._value = this.effect.run()!
    }

    // 返回计算之后的真实值
    return this._value
  }
}
  1. packages/reactivity/src/effect.ts 中,添加 scheduler 的处理:
export type EffectScheduler = (...args: any[]) => any
   
   
/**
 * 响应性触发依赖时的执行类
 */
export class ReactiveEffect<T = any> {
  /**
   * 存在该属性,则表示当前的 effect 为计算属性的 effect
   */
  computed?: ComputedRefImpl<T>

  constructor(
    public fn: () => T,
    public scheduler: EffectScheduler | null = null
  ) {}
  ...
}
  1. 最后不要忘记,触发调度器函数
/**
 * 触发指定的依赖
 */
export function triggerEffect(effect: ReactiveEffect) {
  // 存在调度器就执行调度函数
  if (effect.scheduler) {
    effect.scheduler()
  }
  // 否则直接执行 run 函数即可
  else {
    effect.run()
  }
}

此时,重新执行测试实例,则发现 computed 已经具备响应性。

5. computed 的 缓存问题 和 死循环问题

到目前为止,我们的 computed 其实已经具备了响应性,但是还存在一点问题。我们来看下下面的代码

5.1 存在的问题

我们来看下面的代码:

<body>
  <div id="app"></div>
</body>
<script>
  const { reactive, computed, effect } = Vue

  const obj = reactive({
    name: '张三'
  })

  const computedObj = computed(() => {
    console.log('计算属性执行计算')
    return '姓名:' + obj.name
  })

  effect(() => {
    document.querySelector('#app').innerHTML = computedObj.value
    document.querySelector('#app').innerHTML = computedObj.value
  })
  setTimeout(() => {
    computedObj.value = '李四'
  }, 2000)
</script>

结果报错了:

image.png
调用了两次 computedObj.value 按理说 computed 只会执行一次才对,但是却提示 超出最大调用堆栈大小

5.2 为什么会出现死循环

我们继续从源码中找问题,我们接着从两秒之后的 obj.name = '李四' 开始调试。

  1. 修改 obj.name = '李四',此时会进行 obj 的依赖处理 trigger 函数中

image.png

  1. 代码继续向下进行,进入 triggerEffects(dep) 方法
  2. triggerEffects(dep) 方法中,继续进入 triggerEffect(effect)
  3. triggerEffect 中接收到的 effect,即为刚才查看的 计算属性effect
  4. 此时因为 effect 中存在 scheduler,所以会执行该计算属性的 scheduler 函数

image.png

  1. scheduler 函数中,会触发 triggerRefValue(this)

image.png

  1. triggerRefValue 则会再次触发 triggerEffects

image.png

  1. 特别注意: 此时 effects 的值为 计算属性实例的 dep

image.png

  1. 循环 effects,从而再次进入 triggerEffect 中。
  2. 再次进入 triggerEffect,此时 effect 为 非计算属性的 effect,即 fn 函数(修改 DOM 的函数)
  3. 因为他 不是 计算属性的 effect ,所以会直接执行 run 方法。
  4. 而我们知道 run 方法中,其实就是触发了 fn 函数,所以最终会执行:
document.querySelector('#app').innerHTML = computedObj.value
document.querySelector('#app').innerHTML = computedObj.value
  1. 但是在这个 fn 函数中,是有触发 computedObj.value 的,而 computedObj.value 其实是触发了 computedget value 方法。
  2. 那么这次 run 的执行会触发 两次 computedget value
  • 第一次进入:

    • 进入 computedget value
    • 首先收集依赖
    • 接下来检查 dirty 脏的状态,执行 this.effect.run()!
    • 获取最新值,返回
  • 第二次进入:

    • 进入 computedget value
    • 首先收集依赖
    • 接下来检查 dirty 脏的状态,因为在上一次中 dirty 已经为 false,所以本次 不会在触发 this.effect.run()!
    • 直接返回结束
  1. 按说代码应该到这里就结束了,但是不要忘记,在刚才我们进入到 triggerEffects 时,effets 是一个数组,内部还存在一个 computedeffect,所以代码会 继续 执行,再次来到 triggerEffect 中:
  • 此时 effectcomputedeffect

image.png

这会导致,再次触发 schedulerscheduler 中还会再次触发 triggerRefValuetriggerRefValue 又触发 triggerEffects ,再次生成一个新的 effects 包含两个 effect,就像 第五、第六、第七步 一样
从而导致 死循环

5.3 解决方法

想要解决这个死循环的问题,其实比较简单,我们只需要 packages/reactivity/src/effect.ts 中的 triggerEffects 中修改如下代码:

export function triggerEffects(dep: Dep) {
  // 把 dep 构建为一个数组
  const effects = isArray(dep) ? dep : [...dep]
  // 依次触发
  // for (const effect of effects) {
  //     triggerEffect(effect)
  // }

  // 不在依次触发,而是先触发所有的计算属性依赖,再触发所有的非计算属性依赖
  for (const effect of effects) {
    if (effect.computed) {
      triggerEffect(effect)
    }
  }
  for (const effect of effects) {
    if (!effect.computed) {
      triggerEffect(effect)
    }
  }
}

查看测试实例的打印,此时 computed 只计算了一次。

5.4 解决方法的原理

原理就是将具有 computed 属性的 effect 放在前面,先执行有 computed 属性的 effect,再执行没有 computed 属性的 effect

第一个执行的 computed 属性的 effect
image.png

第二个执行的没有 computed 属性的 effect

image.png

6. 总结

计算属性实现的重点:

  1. 计算属性的实例,本质上是一个 ComputedRefImpl 的实例
  2. ComputedRefImpl 中通过 dirty 变量来控制 run 的执行和 triggerRefValue 的触发
  3. 想要访问计算属性的值,必须通过 .value ,因为它内部和 ref 一样是通过 get value 来进行实现的
  4. 每次 .value 时都会触发 trackRefValue 即:收集依赖
  5. 在依赖触发时,需要谨记,先触发 computedeffect,再触发非 computedeffect
相关文章
|
6天前
|
开发工具 iOS开发 MacOS
基于Vite7.1+Vue3+Pinia3+ArcoDesign网页版webos后台模板
最新版研发vite7+vue3.5+pinia3+arco-design仿macos/windows风格网页版OS系统Vite-Vue3-WebOS。
106 10
|
4月前
|
缓存 JavaScript PHP
斩获开发者口碑!SnowAdmin:基于 Vue3 的高颜值后台管理系统,3 步极速上手!
SnowAdmin 是一款基于 Vue3/TypeScript/Arco Design 的开源后台管理框架,以“清新优雅、开箱即用”为核心设计理念。提供角色权限精细化管理、多主题与暗黑模式切换、动态路由与页面缓存等功能,支持代码规范自动化校验及丰富组件库。通过模块化设计与前沿技术栈(Vite5/Pinia),显著提升开发效率,适合团队协作与长期维护。项目地址:[GitHub](https://github.com/WANG-Fan0912/SnowAdmin)。
735 5
|
1月前
|
缓存 前端开发 大数据
虚拟列表在Vue3中的具体应用场景有哪些?
虚拟列表在 Vue3 中通过仅渲染可视区域内容,显著提升大数据列表性能,适用于 ERP 表格、聊天界面、社交媒体、阅读器、日历及树形结构等场景,结合 `vue-virtual-scroller` 等工具可实现高效滚动与交互体验。
249 1
|
1月前
|
缓存 JavaScript UED
除了循环引用,Vue3还有哪些常见的性能优化技巧?
除了循环引用,Vue3还有哪些常见的性能优化技巧?
145 0
|
2月前
|
JavaScript
vue3循环引用自已实现
当渲染大量数据列表时,使用虚拟列表只渲染可视区域的内容,显著减少 DOM 节点数量。
95 0
|
4月前
|
JavaScript API 容器
Vue 3 中的 nextTick 使用详解与实战案例
Vue 3 中的 nextTick 使用详解与实战案例 在 Vue 3 的日常开发中,我们经常需要在数据变化后等待 DOM 更新完成再执行某些操作。此时,nextTick 就成了一个不可或缺的工具。本文将介绍 nextTick 的基本用法,并通过三个实战案例,展示它在表单验证、弹窗动画、自动聚焦等场景中的实际应用。
410 17
|
4月前
|
JavaScript 前端开发 API
Vue 2 与 Vue 3 的区别:深度对比与迁移指南
Vue.js 是一个用于构建用户界面的渐进式 JavaScript 框架,在过去的几年里,Vue 2 一直是前端开发中的重要工具。而 Vue 3 作为其升级版本,带来了许多显著的改进和新特性。在本文中,我们将深入比较 Vue 2 和 Vue 3 的主要区别,帮助开发者更好地理解这两个版本之间的变化,并提供迁移建议。 1. Vue 3 的新特性概述 Vue 3 引入了许多新特性,使得开发体验更加流畅、灵活。以下是 Vue 3 的一些关键改进: 1.1 Composition API Composition API 是 Vue 3 的核心新特性之一。它改变了 Vue 组件的代码结构,使得逻辑组
1500 0
|
9天前
|
JavaScript
Vue中如何实现兄弟组件之间的通信
在Vue中,兄弟组件可通过父组件中转、事件总线、Vuex/Pinia或provide/inject实现通信。小型项目推荐父组件中转或事件总线,大型项目建议使用Pinia等状态管理工具,确保数据流清晰可控,避免内存泄漏。
108 2
|
3月前
|
人工智能 JavaScript 算法
Vue 中 key 属性的深入解析:改变 key 导致组件销毁与重建
Vue 中 key 属性的深入解析:改变 key 导致组件销毁与重建
538 0
|
3月前
|
JavaScript UED
用组件懒加载优化Vue应用性能
用组件懒加载优化Vue应用性能