Vue3响应式基本原理

231 阅读10分钟

一、基本概念:响应式数据和副作用函数

前言:

本篇文章是《Vue.js设计与实现》第 4 章 响应系统的作用与实现笔记,其中的代码和图片部分来源于本书,用于记录学习收获并且分享。

1.副作用函数

副作用函数:在执行过程中会影响到其他函数的执行(产生副作用)。 比如设置body文本的内容:

function effect() {  
    document.body.innerText = 'hello vue3'
}

当body文本内容被改变时,可能会有其它使用到body文本的函数会受到影响。

2.响应式数据

响应式数据:在副作用函数中使用的的值,当其变化时,可以触发副作用函数重新执行。若此时改变obj.text的值副作用函数effect()能够自动重新执行,则其为响应式数据。

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

二、响应式数据的基本实现

1.思路:

通过对副作用函数和响应式数据的了解可知,为了实现响应式,我们需要:
1.在数据被读取时,保存相对应的副作用函数。
2.在数据变化时,将保存的副作用函数取出并执行。

2.如何对数据的读取和设置操作进行拦截呢?

Vue2中,使用的是Object.defineProperty函数实现的。Vue3则采用了ES6中新增的代理对象Proxy实现。

3.Proxy:

proxy的基础用法如下:

let p = new Proxy(target, handler);
  • target:被代理的目标对象。
  • handler:一个对象,定义了代理对象的行为。

为了拦截数据的读取和变化操作,我们需要在handler对象中进行相应的定义:

  • get(target, property, receiver):拦截对象属性的读取操作。
  • set(target, property, value, receiver):拦截对象属性的设置操作。

其中:
target为被代理的对象
property为被代理对象的属性名
receiver为代理后的proxy实例,这里为p

具体使用为:

let p = new Proxy(data, {
  // 拦截读取操作
  get(target, key) {
      ...
    return target[key]
  },
  // 拦截设置操作
  set(target, key, newVal) {
    ...
  }
})

4.简单实现:

将数据使用Proxy代理,并在拦截函数中进行副作用函数的存取操作,为了避免重复的收集副作用函数,使用Set数据类型存储。


// 定义存储副作用函数的容器
const bucket = new Set()

// 原始数据
const data = { text: 'hello world' }

// 对原始数据的代理
const obj = new Proxy(data, {
  // 拦截读取操作
  get(target, key) {
    // 将副作用函数 effect 添加到存储副作用函数的容器中
    bucket.add(effect)
    // 返回属性值
    return target[key]
  },
  // 拦截设置操作
  set(target, key, newVal) {
    // 设置属性值
    target[key] = newVal
    // 把副作用函数从容器里取出并执行
    bucket.forEach(fn => fn())
  }
})

三、完善

以上实现的响应式在实际情况下还会存在一些问题:
1.副作用函数是通过硬编码来实现的,但实际情况下副作用函数名不一定是effect;
2.在以上情况下实现的响应式会使得整个对象数据和副作用函数建立联系,而我们通常只需要数据中的某些值与其建立联系;
3.分支切换过程中情况没有被考虑;
4.副作用函数如果发生嵌套要如何处理;
5.副作用函数中可能会导致无限递归循环的操作;

1.使用一个函数去注册副作用函数

为了避免硬编码的问题,需要设置一个用于注册副作用函数的方法:

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

首先定义了一个全局变量activeEffect,用于存储被激活的副作用函数。副作用函数fn则作为effect的参数传入,并被赋值给activeEffect,在响应式数据的读取操作发生时收集activeEffect即可。

const obj = new Proxy(data, {
  // 拦截读取操作
  get(target, key) {
    ...
    bucket.add(activeEffect)
    return target[key]
  },
  // 拦截设置操作
  set(target, key, newVal) {
      ...
  }
})

2.副作用函数与响应式数据的字段而不是整个响应式数据对象建立联系

先前我们已经使用Proxy拦截对应操作让原始数据data与副作用函数建立了联系,使其变成了响应式数据obj。 但是在执行如下操作时会发现存在问题:

effect(() => {
  console.log('effect run')
  document.body.innerText = obj.text
})

setTimeout(() => {
  obj.text2 = 'hello vue3'
}, 1000)

理想情况下,effect应该只会在注册副作用函数时运行一次,即打印一个'effect run'但实际上给obj设置一个新属性值text2仍会触发副作用函数执行。这是我们所不希望的,在响应式数据obj中只有text和副作用函数有关,那么联系也应该仅存在于两者之间。 为了解决这个问题,我们需要修改存储副作用函数的数据结构,让响应式数据的目标字段和副作用函数建立联系
通过观察副作用函数可以发现,可以建立如下联系:

target
    └── key
        └── effectFn

对于这种树形结构的数据关系,我们使用如下数据结构进行存储

image.png

targetkey使用WeakMap存储,键为target值为一个Map实例。使用WeakMap存储的原因是因为WeakMap的键所引用的对象不存在的情况下会通过垃圾回收从内存中移除。这样当target不再被使用时可以被回收,可以防止内存溢出。
keyeffectFn的关系则使用Map存储。 接着我们需要调整拦截数据读取的过程。为了逻辑拆分将拦截的方法单独封装到track函数中,触发的方法放在trigger中。

/ 原始数据
const data = { text: 'hello world' }
// 对原始数据的代理
const obj = new Proxy(data, {
  // 拦截读取操作
  get(target, key) {
    // 将副作用函数 activeEffect 添加到存储副作用函数的桶中
    track(target, key)
    // 返回属性值
    return target[key]
  },
  // 拦截设置操作
  set(target, key, newVal) {
    // 设置属性值
    target[key] = newVal
    // 把副作用函数从桶里取出并执行
    trigger(target, key)
  }
})

function track(target, key) {
  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)
}

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

这样只有在和副作用函数建立了联系的那个属性值obj[key]变化时才会触发副作用函数执行。

3.分支切换和cleanp函数

副作用函数中存在条件语句时,当数据的值不同时会执行不同分支的代码,可能会导致不需要的副作用函数遗留

const data = { ok: true, text: 'hello world' } 
const obj = new Proxy(data, { /* ... */ })
effect(function effectFn() {  
    document.body.innerText = obj.ok ? obj.text : 'not'  
})

如上所示,当obj.oktrueobj.text会被读取,此时会建立如下联系:

 data 
    └── ok  
        └── effectFn  
    └── text 
        └── effectFn

image.pngobj.okfalseobj.text和副作用函数之间已经没有联系,此时理想情况下联系应该如下:

 data 
    └── ok  
        └── effectFn  

为了解决这个问题,我们需要在每次副作用函数执行之前先清除所有和副作用函数有关的依赖集合,从而起到一种类似刷新的效果。

为了实现这个效果,前提是需要在副作用函数中能够拿到所有和他有关的依赖集合。
1.在effectFn上定义一个deps数组用于存储依赖集合

let activeEffect
function effect(fn) {
  const effectFn = () => {
    cleanup(effectFn)
    activeEffect = effectFn
    fn()
  }
  // activeEffect.deps 用来存储所有与该副作用函数相关的依赖集合
  effectFn.deps = []
  // 执行副作用函数
  effectFn()
}

2.在trace函数中将从key到effect的Map实例存入deps中

function track(target, key) {
  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)
}

3.实现一个cleanup函数,并在每次副作用函数执行前使用cleanup清除与其相关依赖

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

let activeEffect
function effect(fn) {
  const effectFn = () => {
      //调用cleanup清除依赖关系
    cleanup(effectFn)
    activeEffect = effectFn
    fn()
  }
  effectFn.deps = []
  effectFn()
}

4.会导致无限循环的问题

function trigger(target, key) { 
const depsMap = bucket.get(target) 
if (!depsMap) return 04 const effects = depsMap.get(key) 
//问题出在这里
effects && effects.forEach(fn => fn()) 
}

当响应式数据被设置时,trigger函数被调用,副作用函数执行,cleanup会被调用以清除和其有关的依赖集合。然后副作用函数被继续执行,其重新被收集到了依赖集合之中,从而产生了如下的效果,导致无限循环。

const set = new Set([1])

const newSet = new Set(set)
newSet.forEach(item => {
  set.delete(1)
  set.add(1)
})

解决办法:构造另一个Set集合遍历 trigger函数被修改如下

function trigger(target, key) {
  const depsMap = bucket.get(target)
  if (!depsMap) return
  const effects = depsMap.get(key)

  const effectsToRun = new Set()
  effectsToRun.forEach(effectFn => effectFn())
}

通过重新构造了effectsToRun而不是直接遍历effects,避免了无限循环。

4.嵌套的effect

在Vue中effect是会出现嵌套的情况的,Vue的渲染函数就是在effect中执行的,当组件之间发生嵌套时,effect也就发生了嵌套

// Bar 组件 
const Bar = { 
render() { /* ... */ }, 
} 
// Foo 组件渲染了 Bar 组件 
    const Foo = { 
    render() { 
    return // jsx 语法 
    }
}

effect(() => { 
    Foo.render() 
    // 嵌套 
    effect(() => { 
        Bar.render() 
    }) 
})

目前实现的响应式是并不支持effect嵌套的:

// 原始数据 
const data = { foo: true, bar: true } 
// 代理对象 
const obj = new Proxy(data, { /* ... */ }) 
// 全局变量 
let temp1, temp2 
// effectFn1 嵌套了 effectFn2 
effect(function effectFn1() { 
    console.log('effectFn1 执行') 
    effect(function effectFn2() { 
        console.log('effectFn2 执行') 
        // 在 effectFn2 中读取 obj.bar 属性 
        temp2 = obj.bar 
    }) 
    // 在 effectFn1 中读取 obj.foo 属性 
    temp1 = obj.foo 
})

理想情况下当修改obj.fooeffectFn1会执行,effectFn2由于嵌套在effectFn1里面也会执行,而修改obj.bar时只有 effectFn2会被触发. 实际情况 当修改obj.foo时,输出为

'effectFn1 执行' 
'effectFn2 执行' 
'effectFn2 执行'

第一次和第二次是初始情况下effetc执行的正常值,但是第三次的执行却有问题:修改obj.foo的值effectFn2 执行了effectFn1并没有执行.
原因: 通过activeEffect来存储副作用函数,同一时间只能有一个副作用函数被存储,当发生嵌套时内层的副作用函数会覆盖activeEffect的值,这时不管内外层副作用函数的数据都只能收集到内层副作用函数
解决方法 使用一个栈去存储副作用函数,副作用函数执行时入栈,执行完后出栈,并使得activeEffect一直指向栈顶的副作用函数。

function effect(fn) {
  const effectFn = () => {
    cleanup(effectFn)
    // 当调用 effect 注册副作用函数时,将副作用函数复制给 activeEffect
    activeEffect = effectFn
    // 在调用副作用函数之前将当前副作用函数压栈
    effectStack.push(effectFn)
    fn()
    // 在当前副作用函数执行完毕后,将当前副作用函数弹出栈,并还原 activeEffect 为之前的值
    effectStack.pop()
    activeEffect = effectStack[effectStack.length - 1]
  }
  // activeEffect.deps 用来存储所有与该副作用函数相关的依赖集合
  effectFn.deps = []
  // 执行副作用函数
  effectFn()
}

image.png

如上所示effectFn2执行完毕后其出栈,effectFn1执行时activeEffect变成了effectFn1

5.自增引起的无限递归循环

当副作用函数中有如下语句时,会产生栈溢出

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

原因在于:obj.foo被读取触发track,此时其被加1,又触发了trigger操作,副作用函数再次执行,但是此时本次操作尚未结束,下一次副作用函数的执行开始了,由此产生了栈溢出。 解决办法: trigger触发时做一个判断,若activeEffect等于当前正在执行的副作用函数,则不继续执行。

function trigger(target, key) {
  const depsMap = bucket.get(target)
  if (!depsMap) return
  const effects = depsMap.get(key)

  const effectsToRun = new Set()
  effects && effects.forEach(effectFn => {
    if (effectFn !== activeEffect) {
      effectsToRun.add(effectFn)
    }
  })
  effectsToRun.forEach(effectFn => effectFn())
}