Vue3 的响应式的系统设计原理

#前端框架/vue #2023/03/25 #前端 #vue3

目录

1. 总结

1.1. 摘要

本文详细介绍了Vue3响应式系统的设计原理和实现过程。文章从副作用函数的概念出发,逐步讲解了

  • 响应式数据的实现
  • 数据结构的设计
  • 分支切换问题的解决
  • effect函数嵌套的处理
  • 调度系统的实现
  • 计算属性的原理
  • watch函数的实现

文章通过循序渐进的方式,展示了Vue3响应式系统从简单到复杂的演进过程,并提供了大量代码示例来解释各个概念和实现细节。

1.2. 要点

  • 响应式数据的基本实现依赖于Proxy和副作用函数
  • 使用WeakMap、Map和Set组合的数据结构来存储依赖关系
  • 通过cleanup函数解决分支切换问题
  • 使用effectStack来处理effect函数的嵌套问题
  • 实现调度系统来控制副作用函数的执行时机和次数
  • 计算属性的实现基于lazy执行和缓存机制
  • watch函数的实现涉及递归遍历对象属性和处理竞态问题

2. 何为副作用函数?

如修改了全局变量等

更多参考 11. 前端/2. 前端架构篇/1. 前端框架原理篇/1. 前端框架相关的前置知识点#7. 什么是副作用|什么是副作用

3. 为何响应式数据?

如下代码,希望 data.text 值改变时,会自己执行 effect函数 , 那么data 数据就是响应式的

// 原始数据
const data = { text: 'hello world' }
function effect() {
  document.body.innerText = obj.text
}
effect()

4. 响应式数据最简单的实现

借助 Proxy , 每次读取时,将 effect 函数存储到桶:bucket 中,每次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())
  }
})

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

但上面的方式缺点是硬编码effect

5. 解决 硬编码 effect 函数的问题

思路是,effect(fn) 传入一个函数,标识注册副作用函数 fn ,

  • 并使用全局变量 activeEffect 来存储 当前激活的 effect 函数
// 存储副作用函数的桶
const bucket = new Set()

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


// 用一个全局变量存储当前激活的 effect 函数
let activeEffect
function effect(fn) {
  // 当调用 effect 注册副作用函数时,将副作用函数复制给 activeEffect
  activeEffect = fn
  // 执行副作用函数
  fn()
}

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

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

但是,上面代码如果我们设置不存在的属性时,如 obj.noExist = 'hello ' , 传入的effect 中的 fn执行两次

所以,副作用函数需要与目标字段 建立映射,所以我们需要重新设计数据结构

6. 重新设计数据结构

解决上面设置不存在的属性时也执行问题,可以重新设计数据结构

如下代码:

// 用一个全局变量存储当前激活的 effect 函数
let activeEffect
function effect(fn) {
    // 当调用 effect 注册副作用函数时,将副作用函数复制给 activeEffect
    activeEffect = fn
    // 执行副作用函数
    fn()
}

const obj = { text1: 'text1', text2: 'text2' };
const obj2 = { text1: 'text1', text2: 'text2' };

effect(function fn1() {
    console.log(obj.text1);
})

effect(function fn2() {
    console.log(obj.text2);
    console.log(obj.text1);
})

/*************************************************
 * :::: 以上代码的映射关系如下
 * obj
 *  text1: [fn1,fn2]
 *  text2: [fn2]
 ************************************************/

effect(function fn3() {
    console.log(obj.text1);
    console.log(obj.text2);
})
/*************************************************
 * :::: 以上代码的映射关系如下
 * obj
 *  text1: [fn3]
 *  text2: [fn3]
 ************************************************/

effect(function fn1() {
    console.log(obj.text1);
})

effect(function fn2() {
    console.log(obj2.text2);
    console.log(obj.text1);
})

/*************************************************
 * :::: 以上代码的映射关系如下
 * obj
 *   text1: [fn1,fn2] 
 * obj2
 *   text2: [fn2]
 ************************************************/

结构如下:

  • WeakMap
    • keyobj 或者 obj1 对象
    • valueMap
      • key 为 字段名,如 text1 ,text2
      • valueSet ,比如上面的 fn1 fn2

如下图:

6.1. 为什么要使用 WeakMap?

const weakMap = new WeakMap();
const map = new Map();

(function () {
    let w = {w: "w"};
    let m = {m: "m"};
    weakMap.set(w, 1);
    map.set(m, 2);
    console.log(weakMap);
    console.log(map);
    w = null;
    m = null;
})()

// 这 5s的时间,weakMap 会被回收,而 map 不会,因为 map 的 key 是强引用,不会被回收
// chrome performance 手动触发垃圾回收
setTimeout(() => {
    console.log(weakMap);
    console.log(map);
}, 5000);

手动触发垃圾回收机制:

最终打印打印结果如下:

所以,结论就是:使用 WeakMap 能够保证 GC,不会像 Map 那个强引用导致内存溢出

6.2. 最终代码

最终代码如下:封装 track(targe,key)trigger(targe,key)

// 存储副作用函数的桶
const bucket = new WeakMap()
// 原始数据
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())
}
// 用一个全局变量存储当前激活的 effect 函数
let activeEffect
function effect(fn) {
  // 当调用 effect 注册副作用函数时,将副作用函数复制给 activeEffect
  activeEffect = fn
  // 执行副作用函数
  fn()
}

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

setTimeout(() => {
  trigger(data, 'text')
}, 1000)

[!tip]
其实如何能够梳理清楚这个数据结构,那么就容易写出代码,所以也不用抠代码细节,真正需要自己使用即可!

7. 分支切换问题

即使用三元符号时,如何避免不必要的执行?

如下代码:

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

根据上面,我们知道关系如下:

  • obj
    • ok
    • fn
  • text
    • fn

所以,当 text 值改变时,必然会导致 fn 重新执行 ,但其实当okfalse 时,无论 text 如何变化,我们不希望 fn 重新执行。如何解决呢?

解决方案是 每次副作用函数执行之前,清除上一次建立的关系

重新设计结构,effect.deps 用来存储 所有与该副作用函数相关的依赖集合 ,如下代码:

// 用一个全局变量存储当前激活的 effect 函数
let activeEffect
function effect(fn) {
  const effectFn = () => {
    cleanup(effectFn)
    // 当调用 effect 注册副作用函数时,将副作用函数复制给 activeEffect
    activeEffect = effectFn
    fn()
  }
  // activeEffect.deps 用来存储所有与该副作用函数相关的依赖集合
  effectFn.deps = []
  // 执行副作用函数
  effectFn()
}

// 每次都会清除
function cleanup(effectFn) {
  for (let 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);
  // here ,这里出现无限循环
  effects && effects.forEach(effectFn => effectFn())
}

但是,上面的代码会出现无限循环,原因类似于下面的代码:

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

解决方案:新的变量 newSet

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

所以最终 trigger 代码:创建新的变量 effectsToRun

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 => effectsToRun.add(effectFn))
  effectsToRun.forEach(effectFn => effectFn())
  // effects && effects.forEach(effectFn => effectFn())
}

8. Effect 函数嵌套的场景

8.1. 先说说为什么要支持嵌套

因为 jsx 天然需要支持嵌套,如下代码:

const Foo = {
    render() {
        return h('div', 'foo')
    }
}

const Bar = {
    render() {
        return h(Foo, 'bar')
    }
}

8.2. 现有 effect 函数不支持嵌套

如下代码:

let temp1, temp2

effect(function effectFn1() {
  console.log('1')
  effect(function effectFn2() {
    console.log('2')
    temp2 = obj.bar
  })
  temp1 = obj.foo
})

// obj.foo之前的值为 true
setTimeout(() => {
    obj.foo = false
}, 5000);

我们修改 obj.foo , 预期是:

  • 输出 1 2 1 , 因为 obj.foo 修改导致 fn1 的执行,打印 1fn2 嵌套在 fn1 里,所以还会执行fn2 打印出 2,5s 后,值obj.foo改变了,导致 fn1 执行,打印出 2

但实际上是 1 2 2

原因是:发生嵌套时,内层激活的 activeEffect 会覆盖外层的副作用函数,所以最终执行的都是内层副作用函数

8.3. 解决思路:副作用函数栈 efectStack

如下代码:

// 用一个全局变量存储当前激活的 effect 函数
let activeEffect
// effect 栈
const effectStack = []
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()
}

9. 新的问题:无限递归循环

如下代码,会引起 栈溢出

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

因为 obj.foo 同时读取和设置 ,从而导致无限递归循环。

因为读取和操作是在同一个副作用函数中,进行的。所以可以增加守卫条件trigger 触发的副作用函数和当前执行的副作用函数,是一个函数,则不执行。

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())
}

10. 响应式系统的调度

所谓调度,即 trigger 重新触发副作用函数时,能够决定执行的时机,次数等自定义行为

10.1. 例子 1

以下执行顺序是:1 ,2 ,end...

const data = {foo: 1};
const obj = new Proxy(data, {});

effect(() => {
    console.log(obj.foo);
})

obj.foo++;
console.log('end...');
// ::::顺序是:1 ,2 ,end... 

如果我们希望顺序变成了:1 ,end... ,2 呢???

解法方法是:给 effect 函数添加一个 options 参数

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 => {
      // ::::新增代码::::
    if (effectFn.options.scheduler) {
      effectFn.options.scheduler(effectFn)
    } else {
      effectFn()
    }
  })
}

修改上面例子代码如下:

const data = {foo: 1};
const obj = new Proxy(data, {});

effect(
    () => {
        console.log(obj.foo);
    },
    {
        scheduler: (fn) => {
            setTimeout(fn, 0);
        }
    }
);

obj.foo++;
console.log('end...');

// ::::顺序就可以变成 1 ,end... ,2

10.2. 示例 2

如下代码

const data = {foo: 1};
const obj = new Proxy(data, {});

effect(
    () => {
        console.log(obj.foo);
    }
);

obj.foo++;
obj.foo++;
obj.foo++;
obj.foo++;

// ::::顺序: 1 2 3 4 5 

如果我只打印 初始值最终值呢?其实就有点类似于 React 中的 setState 多次或者 Vue 中的连续改变响应式数据。

10.2.1. 微任务队列

所以,关键是 需要实现一个微任务队列,并去重,并且如何保证一个微任务队列里,只执行一次,如下代码:

const jobQueue = new Set()
const p = Promise.resolve()
// 标识是否正在刷新微任务队列,
// 如果正在刷新,则不再执行 flushJob,所以一个事件循环中只会执行一次 flushJob
let isFlushing = false
// 利用 promise ,保证 flushJob 在一个事件循环中只执行一次
function flushJob() {
  if (isFlushing) return
  isFlushing = true;
  // 将一个函数添加到微任务队列中
  p.then(() => {
    jobQueue.forEach(job => job())
  }).finally(() => {
    isFlushing = false
  })
}

effect(() => {
  console.log(obj.foo)
}, {
  scheduler(fn) {
    // 使用 Set 来去重
    jobQueue.add(fn);
    // 刷新微任务队列
    flushJob()
  }
})

11. 计算属性 computedlazy

11.1. 懒执行

以下代码是立即执行的,如何实现代码 lazy 执行 呢?如下代码:

const data = {foo: 1};
const obj = new Proxy(data, {});
effect(
    () => {
        console.log(obj.foo); // ::::立即执行
    }
);

添加 options.lazy = true, 使得 effect 不会立即执行 ? 如下代码:

effect(
    () => {
        console.log(obj.foo); // ::::不立即执行
    },
    // ::::options.lazy = true 时不立即执行
    {lazy: true}
);

改造 effect函数 代码如下:

|536

但是,上面的代码,仅仅能够手动执行 ,如下代码:

|496

如果 fngetter函数呢? 如下:

上面代码能否再抽象成 computed ? 这就引出了 computed

11.2. computed 计算属性

  • 计算属性的 懒计算,即 只有读取时,才会计算
  • 另外,多次读取一个属性时,还需要做到 缓存,避免多次计算,通过脏变量 dirty来标识,类似于 Angular 的概念。
  • 类似于 Vue , 如果计算属性发生变化会重新触发渲染 , 但是如果一个计算属性依赖另外一个计算属性时,会发生 effect 嵌套,所以,每次读取计算属性时,需要手动触发 trigger 追踪

所以,上面的代码会建立响应联动关系如下:

  • computed(obj)
  • value
  • effectFn

即,你就把 对于computed嵌套,当做是两个副作用函数的嵌套来理解,就对了!

最终代码见#参考 地址

[!info]
真正还是需要看代码去,说真的,自己手写实现完整的功能,得实现多久啊!!,这就是业务代码和真正技术代码的区别吧!

[!tip]
所以,这里基本思路掌握即可,不用装牛角尖,也不可能让你段时间内徒手写出来,需要的话就去自己看代码。

12. watch函数 的实现原理

12.1. 简单实现

通过前面我们知道,副作用函数重新执行时,可调度 ,所以 我们把调度里,加个回调,就能实现,如下代码:

上面代码硬编码了 source.foo 的读取操作,更通用的解法:递归读取对象的所有属性

12.2. 相对完善的实现

就想想如何使用 watch 函数的? 所以,需要支持一下功能:

  • watch 入参支持三个,想想是 哪三个?
    • 第一个参数 支持 getter 函数对象
    • 第二个参数,回调函数里支持 newValoldVal
    • 第三个自定义参数 options

下面是 watch 函数的代码

[!tip]
注意,上面代码高亮的部分 ,另外 flush 的值 prepost 代表组件更新前和更新后,后面会涉及到具体原理,这里不深究。

12.3. 回调的第三个参数?

先看一个例子,如果 watch 一个 obj 对象,改变了就发请求,如下:

所以,会存在过期的副作用函数第三个参数就是解决这种竞态问题的 ,以下是代码实现:

注意上面代码的 高亮部分

下面是 使用实例

所以,最终的效果如下:

13. 最后

[!warning]
强调下,这一章很难啃的,但层层递进的思想比代码本身更重要,具体如何实现,代码最终如何编写实现,远远没有理解来龙去脉重要。最终代码都有,多些思考与理解,装牛角尖意义不大

14. 参考