Vue3响应式对象是如何实现的(2)

目录
  • 前言
  • 分支切换的优化
  • 副作用函数嵌套产生的BUG
  • 自增/自减操作产生的BUG

前言

在Vue3响应式对象是如何实现的(1)中,我们已经从功能上实现了一个响应式对象。如果仅仅满足于功能实现,我们就可以止步于此了。但在上篇中,我们仅考虑了最简单的情况,想要完成一个完整可用的响应式,需要我们继续对细节深入思考。在特定场景下,是否存在BUG?是否还能继续优化?

分支切换的优化

在上篇中,收集副作用函数是利用get自动收集。那么被get自动收集的副作用函数,是否有可能会产生多余的触发呢?或者说,我们其实进行了多余的收集呢?同样,还是从一个例子入手。

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

const objsMap = new WeakMap()
const data = { text: 'hello vue', ok: true } // (1)
const obj = new Proxy(data, {
  get(target, key) {
    track(target, key)
    return target[key]
  },
  set(target, key, newValue) {
    target[key] = newValue
    trigger(target, key)
    return true
  }
})
function track(target, key) {
  if(!activeEffect) return
  let propsMap = objsMap.get(target)
  if(!propsMap) {
    objsMap.set(target, (propsMap = new Map()))
  }
  let fns = propsMap.get(key)
  if(!fns) {
    propsMap.set(key, (fns = new Set()))
  }
  fns.add(activeEffect)
}

function trigger(target, key) {
  const propsMap = objsMap.get(target)
  if(!propsMap) return
  const fns = propsMap.get(key)
  fns && fns.forEach(fn => fn())
}

function fn() {
  document.body.innerText = obj.ok ? obj.text : 'ops...' // (2)
  console.log('Done!')
}
effect(fn)

这段代码中,我们做了(1)(2)两处更改。我们在(1)处给响应式对象新增加了一个boolean类型的属性ok,在(2)处我们利用ok的真值,来选择将谁赋值给document.body.innerText。现在,我们将obj.ok的值置为false,这就意味着,document.body.innerText的值不再依赖于obj.text,而直接取字符串'ops...'

此时,我们要能够注意到一件事,虽然document.body.innerText的值不再依赖于obj.text了,但由于ok的初值是true,也就意味着在ok的值没有改变时,document.body.innerText的值依赖于obj.text,更进一步说,这个函数已经被obj.text当作自己的副作用函数收集了。这会导致什么呢?

我们更改了obj.text的值,这会触发副作用函数。但此时由于ok的值为false,界面上显示的内容没有发生任何改变。也就是说,此时修改obj.text触发的副作用函数的更新是不必要的。

这部分有些绕,让我们通过画图来尝试说明。当oktrue时,数据结构的状态如图所示:

从图中可以看到,obj.textobj.ok都收集了同一个副作用函数fn。这也解释了为什么即使我们将obj.ok的值为false,更改obj.text仍然会触发副作用函数fn

我们希望的理想状况是,当okfalse时,副作用函数fn被从obj.text的副作用函数收集器中删除,数据结构的状态能改变为如下状态。

这就要求我们能够在每次执行副作用函数前,将该副作用函数从相关的副作用函数收集器中删除,再重新建立联系。为了实现这一点,就要求我们记录哪些副作用函数收集器收集了该副作用函数。

let activeEffect
function cleanup(effectFn) { // (3)
  for(let i = 0; i < effectFn.deps.length; i++) {
    const fns = effectFn.deps[i]
    fns.delete(effectFn)
  }
  effectFn.deps.length = 0
}
function effect(fn) {
  const effectFn = () => {
    cleanup(effectFn)
    activeEffect = effectFn
    fn()
  }
  effectFn.deps = [] // (1)
  effectFn()
}

const objsMap = new WeakMap()
const data = { text: 'hello vue', ok: true }
const obj = new Proxy(data, {
  get(target, key) {
    track(target, key)
    return target[key]
  },
  set(target, key, newValue) {
    target[key] = newValue
    trigger(target, key)
    return true
  }
})

function track(target, key) {
  if(!activeEffect) return
  let propsMap = objsMap.get(target)
  if(!propsMap) {
    objsMap.set(target, (propsMap = new Map()))
  }
  let fns = propsMap.get(key)
  if(!fns) {
    propsMap.set(key, (fns = new Set()))
  }
  fns.add(activeEffect)
  activeEffect.deps.push(fns) // (2)
}

function trigger(target, key) {
  const propsMap = objsMap.get(target)
  if(!propsMap) return
  const fns = propsMap.get(key)
  fns && fns.forEach(fn => fn())
}

function fn() {
  document.body.innerText = obj.ok ? obj.text : 'ops...'
  console.log('Done!')
}
effect(fn)

在这段代码中,我们增加了3处改动。为了记录副作用函数被哪些副作用函数收集器收集,我们在(1)处给每个副作用函数挂载了一个deps,用于记录该副作用函数被谁收集。在(2)处,副作用函数被收集时,我们记录副作用函数收集器。在(3)处,我们新增了cleanup函数,从含有该副作用函数的副作用函数收集器中,删除该副作用函数。

看上去好像没啥问题了,但是运行代码会发现产生了死循环。问题出在哪呢?

以下面这段代码为例:

const set = new Set([1])
set.forEach(item => {
    set.delete(1)
    set.add(1)
    console.log('Done!')
})

是的,这段代码会产生死循环。原因是ECMAScript对Set.prototype.forEach的规范中明确,使用forEach遍历Set时,如果有值被直接添加到该Set上,则forEach会再次访问该值。

  const effectFn = () => {
    cleanup(effectFn) // (1)
    activeEffect = effectFn
    fn() // (2)
  }

同理,我们的代码中,当effectFn被执行时,(1)处的cleanup清除副作用函数,就相当于set.delete;而(2)处执行副作用函数fn时,会触发依赖收集,将副作用函数又加入到了副作用函数收集器中,相当于set.add,从而造成死循环。

解决的方法也很简单,我们只需要避免在原Set上直接进行遍历即可。

const set = new Set([1])
const otherSet = new Set(set)
otherSet.forEach(item => {
    set.delete(1)
    set.add(1)
    console.log('Done!')
})

在上例中,我们复制了setotherset中,otherset仅会执行set.length次。按照这个思路,修改我们的代码。

let activeEffect

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

function effect(fn) {
  const effectFn = () => {
    cleanup(effectFn)
    activeEffect = effectFn
    fn()
  }
  effectFn.deps = []
  effectFn()
}

const objsMap = new WeakMap()
const data = { text: 'hello vue', ok: true }
const obj = new Proxy(data, {
  get(target, key) {
    track(target, key)
    return target[key]
  },
  set(target, key, newValue) {
    target[key] = newValue
    trigger(target, key)
    return true
  }
})

function track(target, key) {
  if(!activeEffect) return
  let propsMap = objsMap.get(target)
  if(!propsMap) {
    objsMap.set(target, (propsMap = new Map()))
  }
  let fns = propsMap.get(key)
  if(!fns) {
    propsMap.set(key, (fns = new Set()))
  }
  fns.add(activeEffect)
  activeEffect.deps.push(fns)
}

function trigger(target, key) {
  const propsMap = objsMap.get(target)
  if(!propsMap) return
  const fns = propsMap.get(key)
  const otherFns = new Set(fns) // (1)
  otherFns.forEach(fn => fn())
}

function fn() {
  document.body.innerText = obj.ok ? obj.text : 'ops...'
  console.log('Done!')
}
effect(fn)

在(1)处我们新增了一个otherFns,复制了fns用来遍历。让我们再来看看结果。

①处,更改obj.ok的值为false,改变了页面的显示,没有导致死循环。②处,当obj.okfalse时,副作用函数没有执行。至此,我们完成了针对分支切换场景下的优化。

副作用函数嵌套产生的BUG

我们继续从功能角度考虑,前面我们的副作用函数还是不够复杂,实际应用中(如组件嵌套渲染),副作用函数是可以发生嵌套的。

我们举个简单的嵌套示例:

let t1, t2
effect(function effectFn1() {
  console.log('effectFn1')
  effect(function effectFn2() {
    console.log('effectFn2')
    t2 = obj.bar
  })
  t1 = obj.foo
})

这段代码中,我们将effectFn2嵌入了effectFn1中,将obj.foo赋值给t1,obj.bar赋值给t2。从响应式的功能上看,如果我们修改obj.foo的值,应该会触发effectFn1的执行,且间接触发effectFn2执行。

修改obj.foo的值仅触发了effectFn2的更新,这与我们的预期不符。既然是effect这里出了问题,让我们再来过一遍effect部分的代码,看看能不能发现点什么。

let activeEffect // (1)

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

function effect(fn) {
  const effectFn = () => {
    cleanup(effectFn)
    activeEffect = effectFn
    fn() // (2)
  }
  effectFn.deps = []
  effectFn()
}

仔细思考后,不难发现问题所在。我们在(1)处定义了一个全局变量activeEffect用于副作用函数注册,这意味着同一时刻,我们仅能注册一个副作用函数。在(2)处执行了fn,此时注意,在我们给出的副作用函数嵌套示例中,effectFn1是先执行effectFn2,再执行t1 = obj.foo。也就是说,此时activeEffect注册的副作用函数已经由effectFn1变为了effectFn2。因此,当执行到t1 = obj.foo时,track收集的activeEffect已经是被effectFn2覆盖过的。所以,修改obj.footrigger触发的就是effectFn2了。

要解决这个问题也很简单,既然后出现的要先被收集,后进先出,用栈解决就好了。

let activeEffect
const effectStack = [] // (1)

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

function effect(fn) {
  const effectFn = () => {
    cleanup(effectFn)
    activeEffect = effectFn
    effectStack.push(effectFn)
    fn() // (2)
    effectStack.pop()
    activeEffect = effectStack[effectStack.length - 1]
  }
  effectFn.deps = []
  effectFn()
}

这段代码中,我们在(1)处定义了一个栈effectStack。不管(2)处如何更改activeEffect的内容,都会被effectStack[effectStack.length - 1]回滚到原先正确的副作用函数上。

运行的结果和我们的预期一致,到此为止,我们已经完成了对嵌套副作用函数的处理。

自增/自减操作产生的BUG

这里还存在一个隐蔽的BUG,还和之前一样,我们修改effect

effect(() => obj.foo++)

很简单的副作用函数,这会有什么问题呢?执行一下看看。

很不幸,栈溢出了。这个副作用函数仅包含一个obj.foo++,所以可以确定,栈溢出就是由这个自增运算引起的。接下来的问题就是,这么简单的自增操作,怎么会引起栈溢出呢?为了更好的说明问题,让我们先来拆解问题。

effect(() => obj.foo = obj.foo + 1)

这段代码中obj.foo = obj.foo + 1就等价于obj.foo++。这样拆开之后问题一下就清楚了。这里同时进行了obj.foogetset操作。先读取obj.foo,收集了副作用函数,再设置obj.foo,触发了副作用函数,而这个副作用函数中obj.foo又要被读取,如此往复,产生了死循环。为了验证这一点,我们打印执行的副作用函数。

上面的打印结果印证了我们的想法。造成这个BUG的主要原因是,当getset操作同时存在时,我们收集和触发的都是同一个副作用函数。这里我们只需要添加一个守卫条件:当触发的副作用函数正在被执行时,该副作用函数则不必再被执行。

function trigger(target, key) {
  const propsMap = objsMap.get(target)
  if(!propsMap) return
  const fns = propsMap.get(key)
  const otherFns = new Set()
  fns && fns.forEach(fn => {
    if(fn !== activeEffect) { // (1)
      otherFns.add(fn)
    }
  })
  otherFns.forEach(fn => fn())
}

如此一来,相同的副作用函数仅会被触发一次,避免了产生死循环。最后,我们验证一下即可。

到此这篇关于Vue3响应式对象是如何实现的的文章就介绍到这了,更多相关Vue3响应式对象内容请搜索我们以前的文章或继续浏览下面的相关文章希望大家以后多多支持我们!

(0)

相关推荐

  • vue3响应式Object代理对象的读取示例详解

    目录 正文 读取属性 xx in obj for ... in 正文 从这一章开始,作者将更新深入的讲解响应式,尤其是vue3响应式的具体的实现.其实在前面一章,如果你仔细阅读,你是可以实现一个简单的响应式函数的,类似于@vue/reactive,当然那只是个demo,是个玩具,我能不能在生产环境上去使用的,它差了太多功能和边界条件. 现在,我们才是真正的深入@vue/reactive. 在vue中,obj.a是一个读取操作,但是仔细想来,读取这个操作很宽泛. obj.a // 访问一个属性 '

  • Vue3响应式对象是如何实现的(1)

    目录 简单的响应式实现 Proxy与响应式 为什么需要Proxy? Proxy创建的代理对象与原始对象有何不同? 多副作用函数的响应式实现 简单的响应式实现 为了方便说明,先来看一个简单的例子. const obj = { text: 'hello vue' } function effect() { document.body.innerText = obj.text } 这段代码中,如果obj是一个响应式数据,会产生什么效果呢?当obj.text中的内容改变时,document.body.i

  • vue响应式Object代理对象的修改和删除属性

    目录 正文 set delete 正文 上一篇文章我们学习了如何代理对象的读取,下面我们学习如何代理对象的修改和删除属性. set set就是修改代理的属性,按照我们之前写的reactive,它大概是这样的 const ITERATE_KEY=symbol() const p = new Proxy(obj,{ set(target,key,newVal,receiver){ const res = Reflect.set(target,key,newVal,receiver) trigger(

  • Vue响应式添加、修改数组和对象的值

    有些时候,不得不想添加.修改数组和对象的值,但是直接添加.修改后又失去了getter.setter. 由于 JavaScript 的限制, Vue 不能检测以下变动的数组: 1. 利用索引直接设置一个项时,例如: vm.items[indexOfItem] = newValue 2. 修改数组的长度时,例如: vm.items.length = newLength 为了避免第一种情况,以下两种方式将达到像 vm.items[indexOfItem] = newValue 的效果, 同时也将触发状

  • 一步一步实现Vue的响应式(对象观测)

    平时开发中,Vue的响应式系统让我们不再去操作DOM,只需关心数据逻辑的处理,极大地降低了代码的复杂度.而响应式系统也是Vue的核心,作为开发者有必要了解其实现原理! 简易版 以watch为切入点 watch是平时开发中使用率非常高的功能,其目的是观测一个数据,当数据变化时执行我们预先定义的回调.使用方式如下: { watch: { obj(val, oldVal) { console.log(val, oldVal); } } } 上面观测了Vue实例的obj属性,当其值发生变化时,打印出新值

  • vue3 响应式对象如何实现方法的不同点

    目录 vue3响应式对象实现方法的不同点 Vue2和Vue3响应式原理对比 响应式原理实现逻辑 Vue2响应式原理简化 Vue2响应式原理弊端 Vue3响应式原理简化 vue3响应式对象实现方法的不同点 vue 响应式对象是利用 defindeProperty 实现,vue3 则是使用 Proxy 来实现响应式的. 二者,虽然都实现了 响应式的功能,但实现方式不一样,解决问题也有些不同. vue2 中,如果想要实现,数组元素的更新,则是需要 用到检测更新的方法 set 之类的,但 vue3的响应

  • Vue3响应式对象是如何实现的(2)

    目录 前言 分支切换的优化 副作用函数嵌套产生的BUG 自增/自减操作产生的BUG 前言 在Vue3响应式对象是如何实现的(1)中,我们已经从功能上实现了一个响应式对象.如果仅仅满足于功能实现,我们就可以止步于此了.但在上篇中,我们仅考虑了最简单的情况,想要完成一个完整可用的响应式,需要我们继续对细节深入思考.在特定场景下,是否存在BUG?是否还能继续优化? 分支切换的优化 在上篇中,收集副作用函数是利用get自动收集.那么被get自动收集的副作用函数,是否有可能会产生多余的触发呢?或者说,我们

  • 源码分析Vue3响应式核心之effect

    目录 一.effect用法 1.基本用法 2.lazy属性为true 3.options中包含onTrack 二.源码分析 1.effect方法的实现 2.ReactiveEffect函数源码 三.依赖收集相关 1.如何触发依赖收集 2.track源码 3.trackEffects(dep, eventInfo)源码解读 四.触发依赖 1.trigger依赖更新 2.triggerEffects(deps[0], eventInfo) 3.triggerEffect(effect, debugg

  • Vue3 响应式侦听与计算的实现

    响应式侦听和计算 有时我们需要依赖于其他状态的状态--在 Vue 中,这是用组件 计算属性 处理的,以直接创建计算值,我们可以使用 computed 方法:它接受 getter 函数并为 getter 返回的值返回一个不可变的响应式 ref 对象. 我们先来看看一个简单的例子,关于计算值的方式,同样我们在 src/TemplateM.vue 写下如下代码: <template> <div class="template-m-wrap"> count --->

  • setup+ref+reactive实现vue3响应式功能

    setup 是用来写组合式 api ,内部的数据和方法需要通过 return 之后,模板才能使用.在之前 vue2 中,data 返回的数据,可以直接进行双向绑定使用,如果我们把 setup 中数据类型直接双向绑定,发现变量并不能实时响应.接下来就看看setup如何实现data的响应式功能? 一.ref setup 内的自定义属性不具备响应式能力,所以引入了 ref ,ref 底层通过代理,把属性包装值包装成一个 proxy ,proxy 内部是一个对象,使得基础类型的数据具备响应式能力,使用之

  • 一文详解Vue3响应式原理

    目录 回顾 vue2.x 的响应式 vue3的响应式 Reflect 回顾 vue2.x 的响应式 实现原理: 对象类型:通过object.defineProperty()对属性的读取.修改进行拦截(数据劫持) 数组类型:通过重写更新数组的一系列方法来实现拦截(对数组的变更方法进行了包裹) Object.defineProperty(data,'count ",{ get(){}, set(){} }) 存在问题: 新增属性.删除属性,界面不会更新 直接通过下标修改数组,界面不会自动更新 但是

  • 浅析vue3响应式数据与watch属性

    是Vue3的 composition API中2个最重要的响应式API ref用来处理基本类型数据, reactive用来处理对象(递归深度响应式) 如果用ref对象/数组, 内部会自动将对象/数组转换为reactive的代理对象 ref内部: 通过给value属性添加getter/setter来实现对数据的劫持 reactive内部: 通过使用Proxy来实现对对象内部所有数据的劫持, 并通过Reflect操作对象内部数据 ref的数据操作: 在js中要.value, 在模板中不需要(内部解析

  • 手写 Vue3 响应式系统(核心就一个数据结构)

    目录 前言 响应式 总结 前言 响应式是 Vue 的特色,如果你简历里写了 Vue 项目,那基本都会问响应式实现原理.而且不只是 Vue,状态管理库 Mobx 也是基于响应式实现的.那响应式是具体怎么实现的呢?与其空谈原理,不如让我们来手写一个简易版吧. 响应式 首先,什么是响应式呢? 响应式就是被观察的数据变化的时候做一系列联动处理.就像一个社会热点事件,当它有消息更新的时候,各方媒体都会跟进做相关报道.这里社会热点事件就是被观察的目标.那在前端框架里,这个被观察的目标是什么呢?很明显,是状态

  • Vue3 响应式系统实现 computed

    目录 前言 实现 computed 总结 前言 上篇文章我们实现了基本的响应式系统,这篇文章继续实现 computed. 首先,我们简单回顾一下: 响应式系统的核心就是一个 WeakMap --- Map --- Set 的数据结构. WeakMap 的 key 是原对象,value 是响应式的 Map.这样当对象销毁的时候,对应的 Map 也会销毁. Map 的 key 就是对象的每个属性,value 是依赖这个对象属性的 effect 函数的集合 Set.然后用 Proxy 代理对象的 ge

随机推荐