Vue3 响应式数据设计(二)从4个方面思考完善响应式式数据设计

176 阅读10分钟

Vue3 响应式设计相关文章:

Vue3响应式数据设计(一)4个步骤写出一个响应式数据系统

接着上一篇:我们已经实现了一个基础的响应式数据系统。先来回顾一下之前的代码:



let bucket = new WeakMap()
// 用一个全局变量存储被注册的副作用函数
let activeEffect

function effect (fn) {
  activeEffect = fn
  fn()
}

const obj = {
  content: 'hello 响应式数据',
  title: 'hello title'
}

const proxy = new Proxy(obj, {
  get (target, key) {
    track(target, key)
    return target[key]

  },
  set (target, key, newVal) {
    target[key] = newVal
    trigger(target, key, newVal)
    return true
  }
})

function track (target, key) {
  // acctiveEffect 直接返回
  if (!activeEffect) return target[key]

  // 根据target 从bucket中取出depsMap, 它也是一个Map 类型: key: effects
  let depsMap = bucket.get(target)
  // 如果不存在depsMap,那么新建一个Map 与target 关联
  if (!depsMap) {
    bucket.set(target, depsMap = new Map())
  }
  // 如果deps不存在,新建一个Set 与key关联。
  let deps = depsMap.get(key)
  if (!deps) {
    depsMap.set(key, deps = new Set())
  }
  deps.add(activeEffect)
  activeEffect = null // 收集完成之后将存储的变量变为空,因为多个副作用函数的时候会篡位
}

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

function test () {
  console.log('test')
  document.body.innerHTML = proxy.content
}
function test2 () {
  console.log('test2')
  document.title = proxy.title
}
effect(test)
effect(test2)
proxy.content = 'hello 新的响应式数'
proxy.title = 'hello 新title'

上面代码已经能实现响应是数据额基本功能,但是还是有很多不足,需要进一步完善。

本篇将从4个方面思考来完善我们的响应式数据设计。

分支切换:

先来看一段代码:

const obj = {
  content: 'hello 响应式数据',
  isContent: true
}

function test () {
  document.body.innerHTML = proxy.isContent ? proxy.content : 'No content'
}
effect(test)

基于之前的代码。我们将obj的title字段改成了isContent: true。 test 改成了document.body.innerHTML = proxy.isContent ? proxy.content : 'No content' 三元表达式。当proxy.isContent 的值变化时。document.body.innerHTML 会接收到不一样的值。这就是分支切换。

上面的代码执行后,test 函数会被收集两次。proxy.isContent 的时候触发读取操作触发一次收集,proxy.content 读取content 属性触发一次收集。这本身没有问题。但是现在我们将obj.isContent的值改为false 式就有问题了。会触发更新。但是现在由于isContent 的值是false,无论怎么修改proxy.content的值,document.body.innerHTML 都为 No content. 所以理想的结果是当proxy.isContent 更改为false 后无论proxy.content 怎么修改。都不需要再触发test 的执行。但是我们现在的实现并不能达到这样的目的。 来运行下下面的代码:

function test () {
  console.log('test')
  document.body.innerHTML = proxy.isContent ? proxy.content : 'No content'
}
effect(test)
proxy.isContent = false
proxy.content = '新内容'

image.png

可以看到test 被执行了3次。初始化的时候执行了一次,修改proxy.isContent 时触发了一次。修改proxy.content 时又触发了一次。第三次是我们不需要的,因为当proxy.isContent 为false 时,proxy.content 触发更新已经没有意义。这个问题该怎么解决呢?

解决分支切换导致的不必要的更新

  1. 每次执行副作用函数时,先把它从所有与之关联的依赖集合中删除。
  2. 当副作用函数执行完毕之后重新建立联系

但是问题又来了, 要想将副作用函数从所有与之关联的的依赖集合中移除,就需要明确的知道哪些集合包含了它。因此需要重新设计下副作用函数的收集,注册功能。

来看下具体的实现:

注册副作用函数方法修改

function effect(fn) {
  function effectFn() {
    // 调用clearUp 方法清除与之关联的
    clearUp(effectFn)
    acctiveEffect = effectFn
    fn()
  }
  // 用来存储所有与该副作用函数相关联的依赖集合。
  effectFn.deps = []
  effectFn()
}

收集副作用函数方法修改

function track(target, key) {
  if (!acctiveEffect) 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(acctiveEffect)
  // 收集副作用函数关联的所有集合
  acctiveEffect.deps.push(deps)
}

新增清除副作用函数关联的所有集合方法

function clearUp (effectFn) {
  for (var i = 0; i < effectFn.deps.length; i++ ) {
    const deps = effectFn.deps[i]
    deps.delete(effectFn)
  }
  effectFn.deps.length = 0
}

这个时候来运行下代码,会发现出现了无限循环, 问题出在哪呢?,主要出在trigger 函数中

function trigger (target, key) {
  const depsMap = bucket.get(target)
  if (!depsMap) return
  const effects = depsMap.get(key)
  const newEffects = new Set(effects)
  newEffects && newEffects.forEach(fn => { // 问题出现在这行代码
    fn()
  })
}

在trigger 中我们遍历副作用函数集合执行副作用函数,而当副作用函数执行时会调用clearUp 方法,实际上就是从effects结合中将当前执行的副作用函数移除,但是副作用函数的执行又会导致其被重新收集到集合中。而此时effects 还正在进行,所以就导致了无限循环。那怎么解决这个问题呢?

可以声明一个新的集合来进行遍历,来看下具体代码:

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

现在来看看运行效果,

image.png 这个时候就可以看到test 只执行了两次,结果符合预期。

effect 嵌套

我们知道Vue组件有嵌套情况,同样响应式数据一样需要考虑嵌套的情况。但是我们目前实现的响应式数据设计是不支持嵌套的。来试一试

const obj = {
  content: 'hello 响应式数据',
  title: '响应式标题'
}

const proxy = new Proxy(obj, {
  get (target, key) {
    track(target, key)
    return target[key]
  },
  set (target, key, newVal) {
    target[key] = newVal
    trigger(target, key)
    return true
  }
})
let activeEffect = null
const effectStack = []
function effect(fn) {
  function effectFn() {
    clearUp(effectFn)
    activeEffect = effectFn
    fn()
  }
  effectFn.deps = []
  effectFn()
}

let bucket = new WeakMap()
function track(target, key) {
  // activeEffect 直接返回
  if (!activeEffect) return target[key]

  // 根据target 从bucket中取出depsMap, 它也是一个Map 类型: key: effects
  let depsMap = bucket.get(target)
  // 如果不存在depsMap,那么新建一个Map 与target 关联
  if (!depsMap) {
    bucket.set(target, depsMap = new Map())
  }
  // 如果deps不存在,新建一个Set 与key关联。
  let deps = depsMap.get(key)
  if (!deps) {
    depsMap.set(key, deps = new Set())
  }
  // 把当前激活的副作用函数添加到依赖集合deps 中。
  deps.add(activeEffect)
  // deps 就是一个与当前副作用函数存在联系的依赖集合
  activeEffect.deps.push(deps)
}

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

function clearUp (effectFn) {
  for (var i = 0; i < effectFn.deps.length; i++ ) {
    const deps = effectFn.deps[i]
    deps.delete(effectFn)
  }
  effectFn.deps.length = 0
}


effect(() => {
  console.log('effect执行')
  effect(() => {
    console.log('effect2执行')
    document.querySelector('#app').innerHTML = proxy.content
  })
  document.title = proxy.title
})

proxy.title = '新标题'

在上述代码中,我们执行了嵌套的副作用函数。然后在内层副作用函数里面读取了proxy.content的值,在外层副作用函数中读取了proxy.title 的值。然后我们修改了title 的值。理想的情况是内层和外层都会执行两次,因为我们修改的是最外层的,最外层的执行会执行会导致内层的执行。但实际情况并不是这样的。来看看效果

image.png

那么是什么原因导致的这种情况的呢?思考一分钟...
问题主要出现在activeEffect上。来回顾下effect函数:

let activeEffect = null
const effectStack = []
function effect(fn) {
  function effectFn() {
    clearUp(effectFn)
    activeEffect = effectFn
    fn()
  }
  effectFn.deps = []
  effectFn()
}

我们用了一个全局变量activeEffect 来存储注册的副作用函数,这意味着同一时刻activeEffect所存储的副作用函数只能有一个。当副作用函数发生嵌套时,内层副作用函数的执行会覆盖activeEffect 的值。这时候如果再有响应式数据进行依赖收集,即使这个响应式数据是在最外层副作用函数。它收集到的副作用函数也会是内层副作用函数。

为了解决这个问题,我们需要声明一个数组来模拟栈。来看看具体代码。

let activeEffect = null
const effectStack = []
function effect(fn) {
  function effectFn() {
    clearUp(effectFn)
    activeEffect = effectFn
    effectStack.push(effectFn)
    fn()
    effectStack.pop()
    activeEffect = effectStack[effectStack.length - 1]
  }
  effectFn.deps = []
  effectFn()
}

在上述代码中,我们定义了effectStack 数组用来模拟栈,当前副作用函数会被压入栈顶,这样副作用函数发生嵌套时,栈底存储的就是外层副作用函数,而栈顶存储的就是内层副作用函数。 来看看执行效果。

image.png

可以看到效果符合预期。

解决无限递归循环问题。

现在我们将 obj 数据包改为如下数据

const obj = {
  count: 1
}

effect 函数执行

effect(() => {
  proxy.count++
})

来运行代码 会看到Uncaught RangeError: Maximum call stack size exceeded 错误,为什么会出现这个错误呢?因为proxy.count++ 这句代码既包含了读取操作又包含了设置操作。该怎么解决呢?我们可以在trigger函数中增加守卫条件。来看看具体代码的实现

function trigger (target, key) {
  const depsMap = bucket.get(target)
  if (!depsMap) return
  const effects = depsMap.get(key)
  console.log(effects)
  const newEffects = new Set()
  effects && effects.forEach(effectFn => {
    // 如果trigger 触发执行的副作用函数与当前正在执行的副作用函数相同,则不触发执行。
    if (effectFn !== activeEffect) {
      newEffects.add(effectFn)
    }
  })
  newEffects && newEffects.forEach(fn => {
    fn()
  })
}

调度执行

所谓的调度,指的是当trigger 动作触发副作用函数重新执行时。有能力决定副作用函数执行的时机、次数,以及方式。我们目前实现的响应式系统是没法实现的,他会在trigger 触发时立即执行。那么怎样才能让我们的响应式系统具备可调度性呢?我们可以为effect 设计一个选项参数options,允许用户指定调度器。来看看具体的代码实现。

effect 函数

let acctiveEffect = null
const effectStack = []
function effect(fn, options = {}) {
  function effectFn() {
    clearUp(effectFn)
    acctiveEffect = effectFn
    effectStack.push(effectFn)
    fn()
    effectStack.pop()
    acctiveEffect = effectStack[effectStack.length - 1]
  }
  effectFn.options = options
  effectFn.deps = []
  effectFn()
}

trigger 函数

function trigger (target, key) {
  const depsMap = bucket.get(target)
  if (!depsMap) return
  const effects = depsMap.get(key)
  console.log(effects)
  const newEffects = new Set()
  effects && effects.forEach(effectFn => {
    if (effectFn !== acctiveEffect) {
      newEffects.add(effectFn)
    }
  })
  newEffects && newEffects.forEach(fn => {
    if (fn.options && fn.options.scheduler) {
      fn.options.scheduler(fn)
    } else {
      fn()
    }
  })
}

现在我们就可以控制trigger 触发时副作用函数的执行时机了。

effect(() => {
  console.log('effect执行', proxy.count)
}, {
  scheduler (effectFn) {
    setTimeout(() => {
      effectFn()
    }, 0);
  }
})
proxy.count++

来看看结果

image.png

可以看到我们通过options 传入scheduler 改变了副作用函数的执行时机。

调度器除了可以控制执行顺序外还可以控制控制执行次数。来看个例子:

effect(() => {
  console.log('effect执行', proxy.count)
})
proxy.count++
proxy.count++

看下执行结果:

image.png 控制台会打印三次。如果我们只关心结果,中间的次数就是多余的。那么我们有没有办法省掉中间的步骤呢?其实也是可以的,基于调度器就可以实现。来看下具体代码实现。

// 定义一个任务队列。
const jobQueue = new Set()
const p = Promise.resolve()
// 代表是否正在刷新的标志。
let isFlushing = false
function flushJop () {
  // 如果正在刷新则什么都不做。
  if (isFlushing) {
    return
  }
  
  isFlushing = true
  p.then(() => {
    jobQueue.forEach(job => job())
  }).finally(() => {
    isFlushing = false
  })
}

effect(() => {
  console.log('effect执行', proxy.count)
}, {
  scheduler (effectFn) {
    jobQueue.add(effectFn)
    flushJop()
  }
})

来看下效果:

image.png

看到结果符合我们的预期。

现在来看看我们响应式设计的完整代码


const obj = {
  count: 1
}

const proxy = new Proxy(obj, {
  get (target, key) {
    track(target, key)
    return target[key]
  },
  set (target, key, newVal) {
    target[key] = newVal
    trigger(target, key)
    return true
  }
})
// 当前激活的副作用函数
let acctiveEffect = null

const effectStack = []
function effect(fn, options) {
  function effectFn() {
    clearUp(effectFn)
    acctiveEffect = effectFn
    // 在调用副作用函数之前将当前副作用函数压入栈中。
    effectStack.push(effectFn)
    fn()
    // 在当前副作用函数执行完毕之后,将当前副作用函数弹出栈,并把activeEffect还原为之前的值。
    effectStack.pop()
    acctiveEffect = effectStack[effectStack.length - 1]
  }
  effectFn.options = options
  // 
  effectFn.deps = []
  effectFn()
}

let bucket = new WeakMap()
function track(target, key) {
  if (!acctiveEffect) 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(acctiveEffect)
  acctiveEffect.deps.push(deps)
}

function trigger (target, key) {
  const depsMap = bucket.get(target)
  if (!depsMap) return
  const effects = depsMap.get(key)
  const newEffects = new Set() // 声明新变量时为了避免无限循环
  effects && effects.forEach(effectFn => {
    if (effectFn !== acctiveEffect) {
      newEffects.add(effectFn)
    }
  })
  newEffects && newEffects.forEach(fn => {
    if (fn.options && fn.options.scheduler) {
      fn.options.scheduler(fn)
    } else {
      fn()
    }
  })
}

function clearUp (effectFn) {
  for (var i = 0; i < effectFn.deps.length; i++ ) {
    const deps = effectFn.deps[i]
    deps.delete(effectFn)
  }
  effectFn.deps.length = 0
}

// 定义一个任务队列。
const jobQueue = new Set()
const p = Promise.resolve()
// 代表是否正在刷新的标志。
let isFlushing = false
function flushJop () {
  // 如果正在刷新则什么都不做。
  if (isFlushing) {
    return
  }
  
  isFlushing = true
  p.then(() => {
    jobQueue.forEach(job => job())
  }).finally(() => {
    isFlushing = false
  })
}

effect(() => {
  console.log('effect执行', proxy.count)
}, {
  scheduler (effectFn) {
    jobQueue.add(effectFn)
    flushJop()
  }
})
proxy.count++
proxy.count++

Vue3 响应式数据设计(二)就分享到这里了,感谢收看,一起学习一起进步。