从零到一打造 Vue3 响应式系统 Day 19 - Reactive:reactive 的基础实现

上一次我们提到:

  • 每个对象的每个属性都需要自己的 Dep
  • 如何建立 target.aDep 的对应关系?
  • 如何在不污染原始对象的情况下存储这个关系?

我们可以先来做一个简单的比较。

Ref 与 Reactive 比较

Ref Reactive
数据结构 单一值 对象(多个属性)
依赖存储 直接在实例上 (this) 需要一个外部的存储机制
依赖数量 一个 ref 对应一个 Dep 一个对象对应多个 Dep(每个属性一个)

匯出到試算表

Ref 可以用 this 来存储依赖,是因为它本身就是一个封装了 subssubsTail 属性的实例。

Reactive 的每个属性都需要自己的 Dep,那要存在哪里呢?这时候我们就可以使用 WeakMap 对象。

什么是 WeakMap?

  • WeakMap 是一种键值对集合 (key-value pairs)。
  • key 只能是对象(不能是字符串、数字、布尔值),value 可以是任意类型。
  • 弱引用 (weak reference) :如果一个对象只被 WeakMap 作为 key 使用,而程序中没有其他变量引用它,这个对象就会被垃圾回收 (GC) 自动清除。

看来它正好适合我们用来建立依赖的关联关系。

核心概念

我们使用 WeakMap 创建一个全局的 targetMap,它的三层嵌套结构如下:

  1. 第一层 targetMap (WeakMap)key 是原始的目标对象 targetvalue 是第二层的 depsMap{ target => depsMap }
  2. 第二层 depsMap (Map)keytarget 对象中的属性名 keyvalue 是第三层的 dep{ key => dep }
  3. 第三层 dep (Dep 实例) :依赖的容器,存储了所有订阅该属性变更的 effect{ subs, subsTail }

为何不直接用 Map?

因为如果使用普通的 MapMap 会一直保持对 target 对象的强引用。只要 target 还存在于 Map 中,GC 就无法回收它,即使程序其他地方已经不再使用这个 target,从而导致内存泄漏。

JavaScript 复制代码
const targetMap = new WeakMap()

它的结构会长这样:

JavaScript 复制代码
target = {
  a: 0,
  b: 1
}

targetMap = {
  [target]: { // WeakMap 的 key 是 target 对象本身
    'a': Dep,  // Map 的 key 是属性名 'a'
    'b': Dep   // Map 的 key 是属性名 'b'
  }
}

这样 Deptarget 的属性就有关系了。我们可以通过 target 找到对应的 Map,再通过属性 a 找到对应的 Dep 实例,这样就可以建立关联关系。

等到需要触发更新时,通过 target 找到对应的 Map,再通过 key 找到对应的 Dep 来通知更新。

收集依赖

收集依赖分为首次收集和后续收集,所以我们可以这样写。

TypeScript 复制代码
function track(target, key){
  if (!activeSub) return
  // 通过 targetMap 获取 target 的依赖合集 (depsMap)
  let depsMap = targetMap.get(target)

  // 首次收集依赖,如果之前没有收集过,就新建一个
  // key: target (obj) / value: depsMap (new Map())
  if (!depsMap) {
    depsMap = new Map()
    targetMap.set(target, depsMap)
  }

  // 获取属性对应的 Dep,如果不存在则新建一个
  let dep = depsMap.get(key)
  // key: key (property name) / value: new Dep()
  if (!dep) {
    dep = new Dep()
    depsMap.set(key, dep)
  }

  console.log(targetMap, dep)

  link(dep, activeSub)
}

可以 console.log 看一下 targetMapdep

看起来确实是我们想的那样,接下来实现触发更新。

触发更新

触发更新时,我们理应去 targetMap 中寻找之前存储的 dep

TypeScript 复制代码
function trigger(target, key){
  const depsMap = targetMap.get(target)
  // 如果 depsMap 不存在,表示没有任何依赖被收集过,直接返回
  if (!depsMap) return

  const dep = depsMap.get(key)
  // 如果 dep 不存在,表示这个 key 没有在 effect 中被使用过,直接返回
  if (!dep) return

  // 找到依赖,触发更新
  if (dep.subs) {
    propagate(dep.subs)
  }
}

接下来回头看我们的示例,初始化成功输出 0,一秒之后输出 1。

看起来成功了。接下来我们来测试 reactivegetter 的响应式追踪:

JavaScript 复制代码
//import { reactive, effect } from '../../../node_modules/vue/dist/vue.esm-browser.js'
import { reactive, effect } from '../dist/reactivity.esm.js'

const state = reactive({
  a: 0,
  get count(){
    return this.a
  }
})
effect(() => {
  console.log(state.count)
})

setTimeout(() => {
  state.a = 1
}, 1000)

预期结果是先输出 0,一秒后输出 1。但实际执行后,我们发现只有初始的 0 被输出,state.a = 1 的更新并未触发 effect

通过在 track 函数中输出 (target, key),发现只有 count 属性被追踪了,a 属性并没有。

原因在于 return this.a。在 getter 内部,this 默认指向的是原始的 target 对象 ,而不是我们的 proxy 对象。

因此 this.a 的取值过程绕过了 Proxyget 处理器,a 属性的依赖自然也无法被收集。

JavaScript

csharp 复制代码
const state = reactive({
  a: 0,
  get count() {
    return this.a  // 这里的 this 应该指向谁?
  }
})

它应该指向我们的 Proxy 对象而不是原始对象,这样它在触发 getter 的时候,才会再次进入 Proxyget 处理器去执行 track(target, key)

那我们要怎么做?

Proxyhandler 提供了第三个参数 receiver,它指向的就是 proxy 对象本身。因此我们只需要将它传递给 Reflect.get 就可以修正 this 的指向。

TypeScript 复制代码
function createReactiveObject(target) {
  // reactive 只处理对象
  if (!isObject(target)) return target

  // 创建 target 的代理对象
  const proxy = new Proxy(target, {
    get(target, key, receiver) { // 接收第三个参数 receiver
      // 收集依赖:绑定 target 的属性与 effect 的关系
      track(target, key)
      // 将 receiver 传给 Reflect.get
      return Reflect.get(target, key, receiver)
    },
    set(target, key, newValue, receiver) { // 接收第四个参数 receiver
      const res = Reflect.set(target, key, newValue, receiver)
      // 触发更新:通知之前收集的依赖,重新执行 effect
      trigger(target, key)
      return res
    }
  })

  return proxy
}

这样我们就完成了,也可以看到初始化时 console.log 输出 0,一秒之后输出 1。

执行步骤

回顾我们今天:

  • 我们引入了以 WeakMap 为核心的 targetMap 数据结构,解决了在不污染原始对象的前提下,为多属性对象管理各自依赖的问题。
  • 我们实现了与 targetMap 配套的 tracktrigger 函数。
  • 我们利用 Proxyreceiver 参数,修正了 getterthis 指向的问题。

想了解更多 Vue 的相关知识,抖音、B站搜索我师父「远方os」,一起跟日安当同学。

相关推荐
TZOF2 小时前
TypeScript的新类型(二):unknown
前端·后端·typescript
caicai_lf_niuniu2 小时前
VUE3+element plus 实现表格行合并
前端
李宏伟~2 小时前
uniapp生成二维码组件全能组件复制即用
前端·uni-app
TZOF2 小时前
TypeScript的新类型(三):never
前端·后端·typescript
余防2 小时前
文件上传漏洞(二)iis6.0 CGI漏洞
前端·安全·web安全·网络安全
毕业设计制作和分享2 小时前
springboot523基于Spring Boot的大学校园生活信息平台的设计与实现
前端·vue.js·spring boot·后端·生活
我是ed.2 小时前
vite + vue3 实现打包后 dist 文件夹可以直接打开 html 文件预览
前端·html
正义的大古3 小时前
OpenLayers地图交互 -- 章节十四:拖拽缩放交互详解
javascript·vue.js·openlayers