面试官问vue3响应式原理怎么办,看这个!

366 阅读6分钟

响应式系统的原理

导读

大家好,我是西瓜

我们可以通过这篇文章了解一下vue3用的reactive响应式的原理

通过各个知识点来吃透vue3的响应式原理

响应式和副作用函数的关系(副作用函数用effect表示)

我们先了解一下副作用函数

顾名思义副作用函数就是会产生副作用的函数,如果一个函数执行会影响到其他内容,这就是一种副作用,拿下面的例子表示:当执行effect函数的时候会执行里面的代码改变body的值,这就是effect产生的副作用。

function effect () {
  document.body.innerText = 'hello effect'
}
现在我们来了解一下响应式数据

举例:我们创建了一个obj对象和一个effect函数,我们现在想要实现的功能是当text修改了内容副作用函数可以重新执行,这样就做到了数据响应的效果了,目前我们的副作用函数还是无法做到这一点的。

const obj = {text: 'hello effect'}
function effect () {
  document.body.innerText = obj.text
}

obj.text = 'update obj'
问题:怎么样可以通过obj.text的修改触发副作用函数执行呢?

通过上面的代码我们可以看出当执行effect的时候会获取触发obj.text的读取操作,当修改obj.text时会触发obj.text的设置操作。

QQ截图20220802102920.png

vue2和vue3中实现响应式的区别

vue2是通过Object.defineProperty方法来实现的响应式。

vue3是通过proxy方法来实现的响应式。

两者的区别:

  • Proxy是对整个对象做的代理。而Object.defineProperty只能代理某个属性,所以我们通过Object.defineProperty做响应式的时候需要遍历为每个属性添加监听。
  • vue的defineProperty没有实现对数组的监听,只提供了push,pop, sort, reserve, splice, unshift, shift等七种方法能触发数组监听。proxy是对整个对象进行监听,新增元素或者删除元素都能响应。
  • proxy有个不好的问题,就是兼容性不好,因为它是es6新出的api。
vue3通过proxy实现响应式监听

这里我们需要对effect函数做一个重构,因为之前的effect是固定的内容,我们需要将effect改为公用的副作用函数,这样我们只需要通过绑定一个effect就可以实现监听重新执行副作用函数了。

我们想到在执行副作用函数时需要将副作用函数保存到桶中,这时我们要创建一个临时存储副作用函数的变量(activeEffect)。

let activeEffect = null

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

这样我们就实现了一个公用的副作用函数effect。

现在我们要做的的是通过proxy监听数据。

// 对象
const obj = {text: 'hello proxy'}
// 桶
const bucket = new Set()
// 将对象变为响应式
const proxy = new Proxy(obj, {
  get (target, key) {
  	if (activeEffect) {
  		// 将当前执行effect存入桶中
  		bucket.add(activeEffect)
  	}
    return target[key]
  },
  set (target, key, newValue) {
  	target[key] = newValue
  	// 取出桶中的数据执行
    bucket.forEach((effect) => {
    	effect()
    })
  }
})

effect(() => {
  console.log(proxy.text)
})

proxy.text = 'update proxy'

// console输出
// hello proxy
// update proxy

这样我们就实现了一个简单的响应式数据并触发副作用函数

但是现在还有一个问题就是监听修改数据时会将桶中的数据全部执行,这样会出现一些不必要的执行。

造成这个原因其实是我们在读取的时候无差别的将activeEffect放入了桶中,并在修改触发执行时全部遍历执行,现在我们可以根据固定值进行存储,可以通过target和key进行存储。

现在进行修改代码,将桶改为WeakMap,他们现在的关系时target->key->effects,通过这种关系我们就可以在读取和触发时执行对应的副作用函数了。

const bucket = new WeakMap()
// 将对象变为响应式
const proxy = new Proxy(obj, {
  get (target, key) {
    // 因为没有acticveEffect时这个响应式数据并没有创建副作用函数
  	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)
  	
    return target[key]
  },
  set (target, key, newValue) {
  	target[key] = newValue
  	
  	// 取出桶中的数据执行
    const depsMap = bucket.get(target)
    if (!depsMap) return
    const effects = depsMap.get(key)
    
    effects && effects.forEach(effectFn => {
      effectFn()
    })
  }
})
过期的副作用函数

我们经常会用到这种情况:

const obj = {
  vis: true,
  text: 'hahaha'
}
effect(() => {
  document.body.innerText = obj.vis ? obj.text : 'no'
})

如果当vis为true的时候会执行到obj.text,但是如果obj.vis为false时obj.text应该无效才对,但是现在当我们执行vis为false后在进行text的修改还是会触发副作用的函数调用。

解决办法:我们可以在执行effect函数之前清空一下收集的依赖,这样会在执行effect函数时重新收集依赖,因为当vis为false的时候并没有执行到obj.text所以在收集依赖时并不会对obj.text进行收集操作,这样也就是解除了过期的副作用函数。

现在我们实现一下代码:

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

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

// 还需要修改get的代码
get (target, key) {
    // 因为没有acticveEffect时这个响应式数据并没有创建副作用函数
    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)
    // 新增代码
	activeEffect.deps.push(deps)
    
    return target[key]
}

当执行副作用函数收集时我们将存储副作用函数的容器放入deps中,在执行副作用函数时遍历收集的deps并进行删除操作,从而实现了执行副作用函数清空对应收集的依赖。

可能实操了上面代码的同学就会发现代码这时在进行无线循环,这是因为当执行effect的时候会进行清除依赖,但是依赖还没有清除完毕执行函数时会重新收集依赖,从而导致无限循环。举例:

const set = new Set([1])
// 当set删除后又重新添加了set,从而导致set.forEach一直在进行遍历
set.forEach((item) => {
  set.delete(1)
  set.add(1)
})

其实解决问题的办法很简单,我们可以通过另一个变量存储并遍历,这样新增的属性不会影响到我们新增的变量了,

例如:

const set = new Set([1])

// 当set进行删除和新增时并不会影响到cloneSet
const cloneSet = new Set(set)
cloneSet.forEach((item) => {
  set.delete(1)
  set.add(1)
})

现在我们需要修改set执行函数,这样我们解决了清除之前依赖导致的无限循环了。

set (target, key, newValue) {
 target[key] = newValue
  	
  const depsMap = bucket.get(target)
  if (!depsMap) return
  const effects = depsMap.get(key)
    
  const cloneEffects = new Set(effects)
  cloneEffects && cloneEffects.forEach(effectFn => {
    effectFn()
  })
}
嵌套的effect

首先我们讲一下哪里会用到嵌套的effect,比如父子级组件的时候就会用到嵌套的effect,例如:

// Bar组件
const Bar = {
  render () {
      return /*...*/
  }
}

// Foo组件渲染了Bar组件
const Foo = {
  render () {
      return <Bar /> // jsx语法
  }
}

此时就发生了effect嵌套,相当于:

effect(() => {
  effect(() => {
      Bar.render()
  })
})

现在我们来操作一下嵌套的effect

const obj = {
  a: 1,
  b: 2,
  c: 3
}

const proxyObj = /*...*/

effect(() => {
  console.log(proxyObj.a)
  effect(() => {
    console.log(proxyObj.b)
  })
  console.log(proxyObj.c)
})

// console
// 1
// 2
// 3

目前来看是没问题的,但是如果我们修改proxyObj.c时会发现并没有执行外层的effect而是执行了内侧的effect。

出现这样的原因我们进行一个解刨

effect(() => {
  console.log(proxyObj.a) // activeEffect为外侧
  effect(() => {
    console.log(proxyObj.b) // activeEffect为内侧
  })
  console.log(proxyObj.c) // activeEffect为内侧
})

出现这种情况的原因是当我们执行effect的时候会将activeEffect赋值,当执行a时没有问题,但是执行到b时因为也是effect所以将activeeffect重新赋值了,当执行的c时因为activeEffect在b被重新赋值了所以c绑定的activeEffect会触发到内侧的effect。

解决方法:我们可以在执行子级effect之后将activeEffect恢复成上级的effect这样就会使指针永远保持在对应的位置。

const effectStack = []

effect(() => {
  console.log(proxyObj.a) // effectStack[外侧effect]
  effect(() => {
    console.log(proxyObj.b) // effectStack[外侧effect, 内测effect] 离开时执行effectStack.pop()并将active的指针改回上级(effectStack[effectStack.length - 1])
  })
  console.log(proxyObj.c) // 这是这里的activeEffect就是外侧的effect了
})

通过上面的解析我们来修改一下effect的代码

const effectStack = []

function effect (fn) {
  const effectFn = function () {
    cleanup(effectFn)
    activeEffect = effectFn
    effectStack.push(effectFn)
    fn()
    effectStack.pop()
    activeEffect = effectStack[effectStack.length - 1]
  }
  effectFn.deps = []
  effectFn()
}
现在我们对代码进行一下重构
  • 将set封装为track
  • 将get封装为trigger
  • 将响应式数据封装为reactive

完整代码

// 临时存储注册的effect
let activeEffect;
// effect桶
const effectStack = [];

// 每次清楚绑定依赖
function cleanup (effectFn) {
  for (let i = 0; i < effectFn.deps.length; i++) {
    const deps = effectFn.deps[i];
    deps.delete(effectFn);
  }
  effectFn.deps.length = 0;
}

// effect注册函数
function effect (fn) {
  const effectFn = function () {
    cleanup(effectFn)
    activeEffect = effectFn
    effectStack.push(effectFn)
    fn()
    effectStack.pop()
    activeEffect = effectStack[effectStack.length - 1]
  }
  effectFn.deps = []
  effectFn()
}

// 桶
const bucket = new WeakMap()

// 收集依赖
const track = function (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);
  activeEffect.deps.push(deps);
}

// 执行依赖
const trigger = function (target, key) {
  const depsMap = bucket.get(target);
  if (!depsMap) return;
  const effects = depsMap.get(key);

  const cloneEffects = new Set(effects);
  cloneEffects && cloneEffects.forEach(effectFn => {
    effectFn()
  })
}

// 将对象变为响应式
const reactive = function (data) {
  return new Proxy(data, {
    get (target, key) {

      track(target, key)

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

谢谢同学们的观看!