Vue3 设计响应式系统(二),相对完善的系统!

114 阅读5分钟

前情提要

在上篇文章中,我们引入了副作用函数来解释为什么我们需要响应式,并且完成了一个简单的响应式系统。传送门

读这篇文章会有什么收获?

我们将设计一个更完善的响应式系统

开始

使用匿名副作用函数

上篇中,我们采用硬编码的方式定义了一个副作用函数,它需要一点改变,让我们可以更随心所欲地使用副作用函数。

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

再来看看我们对Proxy 进行的操作

// ‘桶’ 用于储存副作用函数
const bucket = new Set()
// 原始数据
const data = { text: 'Hello Vue3' }
// 原始数据的代理
const obj = new Proxy(data, {
  // 拦截读取函数
  get(target, key){
    // 把activeEffect 中的副作用函数收集到桶中
    if(activeEffect){ // 新增
      bucket.add(activeEffect) // 新增
    } // 新增
    return target[key]
  },
  // 拦截设置函数
  set(target, key, newVal){
    bucket.forEach((fn) => fn())
    target[key] = newVal    
    return true
  }
})

如上代码所示,我们将注册的activeEffect 副作用函数收集到桶中,这样响应式系统就不依赖于副作用函数的名字了。
我们可以这样调用effect 来注册副作用函数

effect(
  // 匿名副作用函数
  () => {
    domment.body.innerText = obj.text
  }
)

但我们如果对这个系统稍加测试,例如在响应式数据obj 上设置一个不存在的值

effect(() => {
  console.log('effect run') // 用于观察副作用函数运行次数
  // 替代 domment.body.innerText = obj.text ,以便在node 环境中运行
  let innerText = obj.text
})
setTimeout(() => {
 // 副作用函数中并没有读取obj.desc 的值
  obj.desc = `Hello, let's go`
}, 1000)

运行上面代码,我们发现effect run打印了两次

image.png
我们明明没有读取obj.desc 的值,却在obj.desc 发生改变的时候运行了副作用函数,这并不是我们希望看到的结果。

将副作用函数与目标字段建立联系

导致这个结果发生的根本原因是我们并没有将副作用函数与目标字段之间建立起正确的对应关系,我们需要重新设计一个‘桶’,来让他们产生正确的关系。请观察下面代码

effect(effectFn() {
  document.body.innerText = obj.text
})

这段代码中存在三个角色

  1. 被操作(读取)的代理对象obj
  2. 被操作(读取)的字段名text
  3. 使用effect 注册的副作用函数effectFn
    如果用target 来代表代理对象obj,用key 来代表字段名text,用effectFn 来代表被注册的副作用函数,那么他们之间有着这样的对应关系
target 
   └── key 
        └── effectFn

如果有两个副作用函数对应相同的target 和key,例如

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

那么他们的对应关系是

target 
   └── key 
        └── effectFn1
        └── effectFn2

同理,当其他相同key 不同时

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

对应关系

target 
   └── key1 
        └── effectFn
   └── key2
        └── effectFn

如果在不同的副作用函数中读取了两个不同对象的不同属性:

effect(effectFn1 () => {
  obj1.text1
})
effect(effectFn2 () => {
  obj2.text2
})

对应关系

target1 
   └── key1 
        └── effectFn1
target2
   └── key2
        └── effectFn2

所以,我们设计的‘桶’是个三层结构的树,我们可以这样来设计

// 使用WeekMap 代替Set,用于内存回收
const bucket = new WeakMap()

接着看拦截器

// 补充
let activeEffect
function effect(fn){
 acticeEffect = fn
 fn()
}

const data = { text: 'Hello Vue3' }
const obj = new Proxy(data, {
  get(target, key){
    // 没有activeEffect 的时候直接return
    if(!activeEffect) return
    // 根据target 从‘桶’里取出一个depsMap, 它是一个Map 类型: key --> effects)
    let depsMap = bucket.get(target)
    // 如果depsMap 不存在,则新建一个depsMap 加入桶中
    if(!depsMap) {
      bucket.set(target, (depsMap = new Map()))
    }
    // 根据key,从depsMap 中取出一个deps,它是一个Set 类型:effects
    let deps = depsMap.get(key)
    // 如果deps 不存在,则新建一个deps 加入depsMap 中
    if(!deps) { 
      depsMap.set(key, (deps = new Set()))
    }  
    // 最后,将activeEffect 副作用函数加入‘桶’中
    deps.add(activeEffect)
    return target[key]
  },
  set(target, key, newVal){
    // 设置新值
    target[key] = newVal
    // 根据target 取出depsMap
    let depsMap = bucket.get(target)
    // 不存在直接return
    if(!depsMap) return
    // 根据key 从depsMap 中取出所有副作用函数 
    let deps = depsMap.get(key)
    // 执行
    deps && deps.forEach((effect) => effect())
    return true
  }
})

从这段代码中我们不难看出:

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

如下图所示(偷了一张书里的图,方便大家理解)

image.png

使用WeakMap 可以让其中的数据在合适的时候被回收,防止内存溢出。

提升代码灵活性

我们发现,不管是拦截读取函数还是拦截设置函数,只有中间一部分进行了改动,我们完全可以将其封装起来

const obj = (data, {
  get(target, key) {
    track(target, key)
    return target[key]
  },
  set(target, key, newVal){
     target[key] = newVal
     trigger(target, key)
     return true
  }
})

function track(target, key) {
  if(!activeEffect) return
  // 根据target 从‘桶’里取出一个depsMap, 它是一个Map 类型: key --> effects)
  let depsMap = bucket.get(target)
  // 如果depsMap 不存在,则新建一个depsMap 加入桶中
  if(!depsMap) {
    bucket.set(target, (depsMap = new Map()))
  }
  // 根据key,从depsMap 中取出一个deps,它是一个Set 类型:effects
  let deps = depsMap.get(key)
  // 如果deps 不存在,则新建一个deps 加入depsMap 中
  if(!deps) { 
    depsMap.set(key, (deps = new Set()))
  }  
  // 最后,将activeEffect 副作用函数加入‘桶’中
  deps.add(activeEffect)
}

function trigger(target, key) {
  // 根据target 取出depsMap
  let depsMap = bucket.get(target)
  // 不存在直接return
  if(!depsMap) return
  // 根据key 从depsMap 中取出所有副作用函数 
  let deps = depsMap.get(key)
  // 执行
  deps && deps.forEach((effect) => effect())
}

如上,把逻辑代码封装到track 和tigger 中。

最后

const data = { ok: true, text: 'Hello Vue3' }
const obj = new Proxy(data, {/*...*/})
effect(() => {
  document.body.innerText = obj.ok ? obj.text : 'No'
})

上面代码,使用effect 函数注册的副作用函数中出现了一个三元运算符,大家可以思考一下,当obj.ok = false 时,修改obj.text 的值,我们的响应式系统会有什么问题。
理论上:当obj.ok = false 时,三元运算符运算结果为document.body.innerText = 'No',此时并不会读取obj.text 的值,当我们修改obj.text 时,理应不触发任何操作(即不会触发副作用函数)。

但我们的响应式系统会这样吗?大家可以试试看,下篇文章将为为大家揭秘。

// 可以用下列代码测试
effect(() => {
  console.log('effect run')
  let res = obj.ok ? obj.text : 'No'
})

setTimeout(() => {
  obj.ok = false
}, 1000)

setTimeout(() => {
  obj.text = 'Hello Aganivi'
}, 2000)