Vue.js设计与实现(第一章)— 初探响应式系统

545 阅读7分钟

大家好我是小瑜,本章是通过阅读Vue.js设计与实现,将比较精炼的部分写成笔记。 里面很多设计非常巧妙,以为是很难的源码,现在通过作者的需分拆解,发现其实原理并不是特别难。可能这是本书的开篇,所以难度并没有深入。

片头总结,设计桶数据结构中略有难度,得画图总结,方便理解。收获很大,又变强了。

响应式的基本实现

把存储副作用的容器想象成一个桶,当触发读取操作的时候,就往桶里面添加,当触发set的时候,就执行桶里面所有的依赖

111.png

<!DOCTYPE html>
<html lang="zh-CN">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <meta http-equiv="X-UA-Compatible" content="ie=edge" />
    <title>Document</title>
    <style></style>
  </head>
  <body>
    <script type="module">
      // 存储副作用函数的桶
      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());
          // 返回 true 达标设置操作成功
          return true;
        },
      });

      // 副作用函数
      function effect() {
        document.body.innerHTML = obj.text;
      }
      // 执行副作用函数
      effect();
      // 1秒后修改响应式数据
      setTimeout(() => {
        console.log(bucket);
        obj.text = "hello Vue3";
      }, 1000);

      // 以上函数存在的缺陷
      // 例如外面直接通过名字(effect)来获取副作用函数,这种硬编码的方式很不灵活
      // 副作用函数的名字可以任意取, 甚至是一个匿名函数
      // 因此需要想办法去掉这种硬编码的机制
    </script>
  </body>
</html>

这里虽然实现了一个基础的响应式,但是还是存在一些缺陷

例如

  • 外面直接通过名字(effect)来获取副作用函数,这种硬编码的方式很不灵活
  • 副作用函数的名字可以任意取, 甚至是一个匿名函数,因此需要想办法去掉这种硬编码的机制

2. 取消硬编码

我们硬编码了副作用函数的名字(effect),导致一旦副作用

函数的名字不叫 effect,那么这段代码就不能正确地工作了。而我们

希望的是,哪怕副作用函数是一个匿名函数,也能够被正确地收集到

“桶”中。为了实现这一点,我们需要提供一个用来注册副作用函数的

机制

可以用一个全局变量存储被注册的副作用函数

当effect被触发的时候,就将函数赋值给activeEffect

这样就可以结果,不论什么名字的函数都可以正常执行

<!DOCTYPE html>
<html lang="zh-CN">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <meta http-equiv="X-UA-Compatible" content="ie=edge" />
    <title>Document</title>
    <style></style>
  </head>
  <body>
    <script type="module">
      // 上一节硬编码了一个副作用函数effect,如果不叫这个名称则无法正常执行
      // 而希望的是,不论是什么名字的函数都可以正常执行
      // 所以需要提供一个用来注册副作用函数的机制

      // 用一个全局变量存储被注册的副作用函数
      let activeEffect;

      // effect函数用来注册副作用函数
      function effect(fn) {
        // 当调用effect 注册副作用那函数xiq时,将副作用函数fn赋值给全局变量activeEffect
        activeEffect = fn;
        // 立即执行副作用函数
        fn();
      }

      // 响应式
      const data = { text: "hello world" };
      const bucket = new Set();
      const obj = new Proxy(data, {
        get(target, key) {
          if (activeEffect) {
            console.log(target, "@@target");
            bucket.add(activeEffect);
          }
          return target[key];
        },
        set(target, key, newVal) {
          target[key] = newVal;
          bucket.forEach((fn) => fn());
          return true;
        },
      });

      // 测试 , 例如在响应式数据obj上设置一个不存在的属性时
      // 可以按照如下的方式使用effect函数
      effect(() => {
        console.log("effect run"); // 打印两次
        document.body.innerHTML = obj.text;
      });

      setTimeout(() => {
        // 副作用函数中并没有读取 notExist 属性的值
        obj.noExist = "hello Vue3";
      }, 1000);

      // 下一节需要解决的问题
      // 在匿名副作用函数中兵没有读取obj.notExist 并没有与副作用建立建立响应联系
      // 因此,定时器内的语句的执行不应该触发匿名副作用函数重新执行,但是这里却执行了,所以这是不正确的,
      // 为了解决这个问题, 我们需要重新设计桶的数据结构
    </script>
  </body>
</html>

3. 重新设计桶的数据结构

在第二节中,在延时器中设置了一个 obj.noExist = "hello Vue3" 的操作

即使 data 中没有定义 obj.noExist 属性,但是依然触发了effect的运行,这显然是不正确的,所以需要重新设计桶的数据结构

2024-05-26-14-32-39.gif

1. 创建WeakMap容器

例如 响应式对象为

const target = { text: 'hello' }

这里依然需要创建一个桶来存储effect,这里使用WeakMap, 使用原因是

  • 创建一个WeakMap对象 key只可以是对象,value是可以任意类型

  • 这时候将target 作为 bucket 的key ,value 则是 一个新的Map对象

  • WeakMap 是对key的弱引用,使用完成,垃圾回收机制会回收内存,不会导致内存泄漏

  • 如果用的是Map,则对key是强引用,不会销毁内存,容易内容泄漏

    const bucket = new WeakMap()
    

2. 将对象作为Key 存储到WeakMap容器

bucket.set(target, new Map())

时候的数据就是

WeakMap(1) = {
 { text: 'hello' }: {}(Map = 这个Map 取个名字就叫 depsMap)
}

3. depsMap.set( key,new Set() )

  • 给 depsMap 添加一个set 也就是一个不重复的数组
  • 这里的key就是对象中的key 也就是这里的text
Map(1) = {
 { text: 'hello' }: { 
   [ {key:'text', value: [] } ], 
  }
}

4. new Set().add( effect )

给set数组添加副作用函数

此时的数据结构就是

Map(1) = {
 { text: 'hello' }: { 
   [ {key:'text', value: [ ()=> effect ] } ], 
  }
}

5. 数据关系

22.png

6. 完整代码

let activeEffect

// effect函数用来注册副作用函数
export function effect(fn) {
  // 当调用effect 注册副作用那函数时,将副作用函数fn赋值给全局变量activeEffect
  activeEffect = fn
  // 立即执行副作用函数
  fn()
}

// 响应式
const data = { text: "hello world" }
const bucket = new WeakMap()

/**
 * 1. 如果没有activeEffect 直接return
 * 2. 根据target, 在桶中获取这个Map 这个Map的类型是 key(target)=>effect => {text:'hello world'} => effect
 * 3. 如果在桶里面没有找到对应的target,那么就需要新建一个(depsMap),使得target与bucket进行关联,关联关系还是key(target)=>effect => {text:'hello world'} => effect
 * 4. 再根据key从depsMap中获取deps,他是一个set类型,里面存储的就是对应的副作用函数
 */

export let obj = new Proxy(data, {
  get(target, key) {
    // 1. 没有activeEffect,直接return
    if (!activeEffect) return target[key]
    /// 2. 根据target 从 桶中取得depsMap,它也是一个Map类型:key=>effects
    let depsMap = bucket.get(target)

    // 3. 如果不存在 depsMap,那么新建一个Map,并与target关联
    if (!depsMap) {
      bucket.set(target, (depsMap = new Map()))
      console.log(bucket, "@bucket")
    }

    // 4. 再根据key从depsMap中取得deps,他是一个set类型
    // 里面存储着所有与当前key相关联的副作用函数: effects
    let deps = depsMap.get(key)

    if (!deps) {
      depsMap.set(key, (deps = new Set()))
    }
    // 最后将当前激活的副作用函数添加到桶里
    deps.add(activeEffect)

    // console.log(bucket, "@@bucket")
    return target[key]
  },
  set(target, key, newVal) {
    target[key] = newVal
    // 根据target从桶中取得 depsMap 他是 Key => effects
    const depsMap = bucket.get(target)
    if (!depsMap) return
    // 根据key取得所有副作用函数effects
    const effects = depsMap.get(key)
    effects && effects.forEach((fn) => fn())
    return true
  },
})

7. 测试查看效果

这里定义两个延时器,看没有定义的属性,是否还会被触发依赖

通过运行查看, obj.noExist = "hello Vue3" obj.noExist = "hello Vue3 = ss" 都不会触发 effect run

<!DOCTYPE html>
<html lang="zh-CN">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <meta http-equiv="X-UA-Compatible" content="ie=edge" />
    <title>Document</title>
    <style></style>
  </head>
  <body>
    <script type="module">
      import { effect, obj } from "./02-重新设计桶数据结构.js"

      effect(() => {
        console.log("effect run")
        document.body.innerHTML = obj.text
      })

      setTimeout(() => {
        // 副作用函数中并没有读取 notExist 属性的值
        obj.noExist = "hello Vue3"
      }, 1000)

      setTimeout(() => {
        // 副作用函数中并没有读取 notExist 属性的值
        obj.noExist = "hello Vue3 = ss"
      }, 2000)
    </script>
  </body>
</html>

4. 抽离 收集和触发依赖函数

就是将代理中的get 和set函数进行抽离封装

let activeEffect

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

const bucket = new WeakMap()

const data = { text: "hello Vue3", ok: true }

export const obj = new Proxy(data, {
  get(target, key) {
    console.log("????")
    track(target, key)
    return target[key]
  },
  set(target, key, newVal) {
    target[key] = newVal
    trigger(target, key)
    return true
  },
})

/**
 * 收集依赖
 */
function track(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)
  console.log(bucket, "开始读")
}

/**
 * 触发依赖
 */
function trigger(target, key) {
  const depsMap = bucket.get(target)
  if (!depsMap) return
  const effects = depsMap.get(key)
  effects && effects.forEach((fn) => fn())
}

5. 条件分支减少不必要effect

1. 什么是条件分支?

假设定义了一段响应式数据,并触发依赖执行

在逻辑中添加了三元表达式,就是条件分支

const data = { text:'hello', ok:true }

effect(() => {
  document.body.innerHTML = obj.ok ? obj.text : "not"
})

2. 要解决什么问题?

按照下面的例子运行函数

<!DOCTYPE html>
<html lang="zh-CN">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <meta http-equiv="X-UA-Compatible" content="ie=edge" />
    <title>Document</title>
    <style></style>
  </head>
  <body>
    <script type="module">
      import { effect, obj } from "./index.js"
      let ss = "not"
      effect(() => {
        // 当判断条件不成立的时候,应该不会触发副作用函数
        document.body.innerHTML = obj.ok ? obj.text : "not"
      })

      setTimeout(() => {
        obj.ok = false
      }, 1000)

      setTimeout(() => {
        obj.text = "两秒后再次修改"
      }, 2000)

      setTimeout(() => {
        obj.text = "三秒后再次修改"
      }, 3000)
    </script>
  </body>
</html>

......... 省略

/**
 * 收集依赖
 */
function track(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)
  console.log(bucket, "开始读") // 打上日志
}

2024-05-25-23-27-11.gif

通过日志,看到即使三元运行的代码条件分支修改为了false,所以是页面始终是渲染not,但是每次都需要去触发收集依赖并执行

*所以要解决的就是,当条件不成立,或者条件并非需要触发响应式才能渲染页面的逻辑。这些操作都不需要进行依赖收集

分支切换可能会产生遗留的副作用函数。拿上面这段代码来说,字段 obj.ok 的初始值为 true,这时会读取字段 obj.text 的值,

所以当 effectFn 函数执行时会触发字段 obj.ok 和字段 obj.text。这两个属性的读取操作,此时副作用函数 effectFn 与响应式数据之

间建立的联系如下:

333.png

3. 解决因为条件分支的条件改变且不应该触发的effect重复触发

要解决这个问题其实很简单,在触发依赖前,将所有的依赖设置为空,然后在去触发依赖,这样每次触发依赖都会删除上一次遗留的依赖

  • 创建一个空数组用来存储所有的相关依赖的集合
 function effect(fn) {
    // 解决条件分支变化导致的非必要副作用函数执行
    const effectFn = () => {
      // 调用cleanup函数,完成清除工作
      cleanup(effectFn)
      // 当effectFn执行时,将其设置为当前激活的副作用函数
      activeEffect = effectFn
      fn()
    }
    // activeEffect.deps 用来存储所有与该副作用函数相关联的依赖集合
    effectFn.deps = []
    effectFn()
  }
  • 在收集依赖时,将关联的依赖进行存储到 effectFn.deps
/**
 * 收集依赖
 */
function track(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)
  // deps 就是一个与当前副作用函数存在联系的依赖集合
  // 将其添加到 activeEffect.deps 数组中
  activeEffect.deps.push(deps)
  console.log(deps, "开始读")
}
  • 删除依赖
function cleanup(fn) {
  // fn,就是当前要执行的副作用函数
  fn &&
    fn.deps.forEach((item) => {
      // 此时的 item 是 new Set();
      item.delete(fn) // 将他从集合中删除掉
    })
  fn.deps.length = 0 // 然后清空这个数组
}

解决死循环问题

以下代码会有死循环问题

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

产生的原因是因为删除了上一次的依赖后,又重新set了一遍,重新触发依赖,并且由于Set 原本的问题,导致死循环

语言规范中对此有明确的说明:在调用 forEach 遍历 Set 集合时,如果一个值已经被访问过了,但该值被删除并重新添加到集合,

如果此时 forEach 遍历没有结束,那么该值会重新被访问。因此,上面的代码会无限执行。解决办法很简单,我们可以构造另外一个 Set

集合并遍历它。

/**
 * 触发依赖
 */
function trigger(target, key) {
  const depsMap = bucket.get(target)
  if (!depsMap) return
  const effects = depsMap.get(key)
  // const
  // effects && effects.forEach((fn) => fn())
  // 解决cleanup死循环问题
  const effectsToRun = new Set(effects) // 新增
  effectsToRun.forEach((effectFn) => effectFn()) // 新增
}

现在再去将ok修改为false,就不再会去触发依赖的收集了

2024-05-25-23-46-57.gif