zl程序教程

您现在的位置是:首页 >  前端

当前栏目

基于Proxy从0到1实现响应式数据

响应数据 实现 基于 Proxy
2023-06-13 09:11:14 时间

一、前言

基于Proxy从0到1实现响应式数据,读完本文你会收获:

  1. 什么是响应式数据
  2. 响应式数据的实现原理
  3. 在通过Proxy实现响应式数据时,Proxy中的getset都分别做了什么

二、副作用函数

在本文开始前我们先理解一个概念副作用函数

副作用函数是什么?

副作用的函数不仅仅只是返回了一个值,而且还做了其他的事情:

  •  修改了一个变量
  •  直接修改数据结构
  •  设置一个对象的成员
  •  抛出一个异常或以一个错误终止
  •  打印到终端或读取用户输入
  •  读取或写入一个文件

通俗点理解就是副作用函数就是会产生副作用的函数如下所示:

function effect() {
  document.getElementById('text').innerHTML = obj.text
}

除了effect 函数之外的任何函数都可以读取或设置body的文本内容,也就是说,effect函数的执行会直接或间接影响其他函数的执行,这时就可以说effect函数产生了副作用

三、响应式数据

什么是响应式数据?

假设在一个副作用函数中读取了某个对象的属性:

const obj = { text: 'hello anju' }
function effect() {
  document.getElementById('text').innerHTML = obj.text
}

这时我们希望当obj.text 发生变化时,副作用函数 effect会重新执行

obj.text = 'hello world'

如果能实现这个目标,那对象obj就是响应式数据

实现一个基础版响应式系统

观察如下代码:

const obj = { text: 'hello anju' }
function effect() {
  document.getElementById('text').innerHTML = obj.text
}

1、当副作用函数effect执行时,会触发字段 obj.text读取 操作;

2、当修改obj.text的值时,会触发字段obj.text设置 操作

那么如果我们能拦截一个对象的读取设置操作是不是就可以让事情变得简单? 如何拦截?

这里我们看一下vue的拦截方式:

1、vue2:Object.defineProperty()

2、vue3:Proxy

  • Proxy 对象用于定义基本操作的自定义行为(如属性查找、赋值、枚举、函数调用等);
  • Proxy 用于修改某些操作的默认行为,也可以理解为在目标对象之前架设一层拦截,外部所有的访问都必须先通过这层拦截,因此提供了一种机制,可以对外部的访问进行过滤和修改

因为Object.defineProperty()存在一定的缺陷,所以这里我们采用Proxy来实现


首先我们定义一个存储副作用函数的桶

// 存储副作用函数的桶
const bucket = new Set()
// 原始数据
const data = { text: 'hello anju' }

使用Proxy代理原始数据

// 代理原始数据
const obj = new Proxy(data, {
    // 拦截读取操作
    get(target, key) {
        // 将副作用函数存储至桶中
        bucket.add(effect)
        // 返回属性值
        return target[key]
    },

    // 拦截设置操作
    set(target, key, newVal) {
        // 设置属性值
        target[key] = newVal
        // 把副作用函数从桶里取出并执行
        bucket.forEach(fn => fn())
        // 返回true 代表设置成功
        return true
    }
})

我们执行下实例代码(因为是在vue项目里写的实例代码,所以这里用onMounted):

function effect() {
  document.getElementById('text').innerHTML = obj.text
}

onMounted(() => {
  effect()
  setTimeout(() => {
    obj.text = 'hello vue3'
  }, 2000)
})

看下执行效果:

成功(。◝‿◜。),至此,一个基础版的响应式系统就实现了

设计一个完善的响应式系统

我们目前实现的只是一个基础版的响应式系统,那跟完善的响应式系统相比我们还差哪些东西?

首先,我们可以看到我们刚实现的基础版的响应式系统存在一个硬编码的问题,耦合度高,过度依赖副作用函数的名称(effect)

所以我们要优先解决下硬编码的问题,这里我们再次的观察一下我们刚实现的基础版响应式数据,想一想一个响应式系统的工作流程是什么?

// 存储副作用函数的桶
const bucket = new Set()
// 原始数据
const data = { text: 'hello anju' }
// 代理原始数据
const obj = new Proxy(data, {
    // 拦截读取操作
    get(target, key) {
        // 将副作用函数存储至桶中
        bucket.add(effect)
        // 返回属性值
        return target[key]
    },

    // 拦截设置操作
    set(target, key, newVal) {
        // 设置属性值
        target[key] = newVal
        // 把副作用函数从桶里取出并执行
        bucket.forEach(fn => fn())
        // 返回true 代表设置成功
        return true
    }
})

响应式系统的工作流程:

  • 读取操作发生时,将副作用函数存储在桶中
  • 设置操作发生时,将副作用函数从桶中取出并执行

所以这里我们就要提供一个机制,能去注册副作用函数

// 用一个全局变量存储被注册的副作用函数
let activeEffect
// effect 函数用于注册副作用函数
function effect(fn) {
  // 调用effect 注册副作用函数时,将副作用函数fn 赋值给activeEffect
  activeEffect = fn
  // 执行副作用函数
  fn()
}

加入此机制,重新改善下我们基础版的响应式系统:

// 触发响应式数据obj.text 的读取操作,
// 进而触发代理对象Proxy的 get 拦截函数
effect(
  // 匿名的副作用函数
  () => {
    document.getElementById('text').innerHTML = obj.text
  }
)

const obj = new Proxy(data, {
    get(target, key) {
            // 将activeEffect中存储的副作用函数收集至桶中
            if (activeEffect) {
                 bucket.add(activeEffect)
            }
            return target[key]
    },

    set(target, key, newVal) {
        target[key] = newVal
        bucket.forEach(fn => fn())
        return true
    }
})

(。◝‿◜。),至此,一个解决了硬编码问题的响应式系统就实现了


但是,到这里我们的响应式系统还是不够完善,如果我们给响应式数据obj上设置一个不存在的属性时,会发生什么呢?

effect(
    () => {
      console.log('effect run')
      document.getElementById('text').innerHTML = obj.text
    }
)

setTimeout(() => {
  obj.notExist = 'hello vue3'
})

执行结果:

这里我们会发现匿名副作用函数执行了两遍。在副作用函数里我们并没有读取 obj.notExist 的值,理论上 obj.notExist 并没有与副作用建立响应关系,因此定时器内的语句执行不应该触发匿名副作用函数重新执行,所以造成这样的原因是什么呢?

首先我们看下我们设计存储副作用函数的桶用的是什么数据结构:

const bucket = new Set()

所以问题的根本原因是:我们没有在副作用函数与被操作的目标字段之间建立明确的关系

读取属性时,无论读取的是哪一个属性,其实都一样,都会把副作用函数收集到桶中

设置属性时,无论设置的是哪一个属性,也都会把桶里的副作用函数取出并执行

副作用函数与被操作的字段直接没有明确的联系,所以我们要在副作用函数被操作字段之间建立联系即可,我们需要重新设计我们的桶的数据结构,不能简单的使用一个Set类型的数据作为桶


那我们应该设计一个什么样的数据结构呢?

首先,观察如下代码:

effect(
    effectFn(()=> {
      document.getElementById('text').innerHTML = obj.text
    })
  )

上述代码存在三个角色:

  1. 被操作(读取)的代理对象 obj
  2. 被操作(读取)的字段名 text
  3. 使用effect 函数注册的副作用函数effectFn

那么这三者的关系是什么?

这里我们
用 target 来表示一个代理对象所代理的原始对象
用 key 来表示被操作的字段名
用 effectFn 来表示被注册的副作用函数

关系如下:


如果有两个副作用函数同时读取了同一个对象的属性值

effect(
  effectFn1(()=>{
    obj.text
  })
)

effect(
  effectFn2(()=>{
    obj.text
  })
)

关系如下:


一个副作用函数读取了同一个对象的两个不同属性值

effect(
  effectFn(()=>{
    obj.text1
    obj.text2
  })
)

关系如下:


综上所述,这其实就是一个树形数据结构,建立起这个关系,就可以解决我们的问题

首先我们需要 使用 weakMap代替 Set 作为桶的数据结构(weakMap对key是弱引用,不影响垃圾回收器的工作)

// 存储副作用函数的桶
const bucket = new WeakMap()

然后修改 get/set拦截器的代码

const obj = new Proxy(data, {
  // 拦截读取操作
  get(target, key) {
    //没有 activeEffect 直接return
    if(!activeEffect) return
    // 根据 target 从桶中取得 depsMap, 它也是一个 Map 类型:key --> effects
    let depsMap = bucket.get(target)
    // 如果不存在 depsMap,那么新建一个Map 并与 target 关联
    if (!depsMap) {
      bucket.set(target, (depsMap = new Map()))
    }
    // 再根据key从 depsMap中取得 deps,它是一个set 类型
    // 里面存储着所有与当前 key 相关联的副作用函数 effects
    let deps = depsMap.get(key)
    // 如果不存在 deps,同样新建一个 Set 并与 key 关联
    if (!deps) {
      depsMap.set(key, (deps = new Set()))
    }
    // 最后将当前激活的副作用函数添加到 桶中
    deps.add(activeEffect)

    // 返回值
    return target[key]
  },

  // 拦截设置操作
  set(target, key, newVal) {
    // 设置属性值
    target[key] = newVal
    // 根据target 从桶中取得 depsMap, 它是 key --> effects
    const depsMap = bucket.get(key)
    if(!depsMap) return
    // 根据key 取得所有副作用函数 effects
    const effects = depsMap.get(key)
    // 执行副作用函数
    effect && effect.forEach(fn => fn())
  }
})

在上述代码内,我们分别使用了 WeakMapMapSet

  • WeakMap 由target ---> Map 构成
  • Map 由 key---> Set 构成

其中 WeakMap 的键是原始对象 target, 值是一个Map实例

Map的键是原始对象 target 的key,值是一个由副作用函数组成的Set

三者关系如下:

最后,我们在提取封装下我们的代码:

将把副作用函数收集至桶中的逻辑封装至 track(追踪) 函数

把触发副作用函数重新执行的逻辑封装至 trigger(触发)函数

const obj = new Proxy(data, {
  get(target, key) {
    // 将副作用函数 activeEffect 添加到存储副作用函数的桶中
    tarck(target, key)
    // 返回属性值
    return target[key]
  },

  set(target, key, newVal) {
    // 设置属性值
    target[key] = newVal
    // 取出副作用函数并执行
    trigger(target, key)
  }
})

track

function track(target, key) {
  if(!activeEffect) return
  let depsMap = bucket.get(target)
  if (!depsMap) {
    bucket.set(target, (depsMap = new Map()))
  }
  let deps = depsMap.get(key)
  if (!deps) {
    depsMap.set(key, (deps = new Set()))
  }
  deps.add(activeEffect)
}

trigger

function trigger(target, key) {
  const depsMap = bucket.get(target)
  if(!depsMap) return
  const effects = depsMap.get(key)
  effects && effects.forEach(fn => fn())
}

结尾

(。◝‿◜。),至此我们本篇《基于Proxy从0到1实现响应式数据》就结束了,感谢大家阅读(。◝‿◜。),如果有任何问题欢迎在评论区指出

参考内容:

[1] 霍春阳《Vue.js 设计与实现》