Vue3 源码分析 04 – 响应系统的作用与实现-更新中!!

大家也可以去我的博客看相关技术文章,欢迎大家,一同进步!!!!

vue3 源码分析,第四章 响应系统的作用与实现

目录

副作用函数的定义

副作用函数是会产生副作用的函数(废话),其实是指这个函数会对函数外产生作用,比如修改全局变量这种

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

这个就是一个明显的副作用函数,他会作用到全局的document对象,而其他的函数你无法控制,是可能会使用到的,所以称effect产生了副作用

响应式数据的定义

假设一个副作用函数中读取了某个对象的值

const obj = {
  text: "hello world",
};
function effect() {
  // effect 会读取obj.text
  document.body.innerText = obj.text;
}

副作用函数会进行设置,我们希望obj.text变化的时候,再次执行该副作用函数,如果实现这样功能,就称之为响应式对象

  • 响应式变量指的是,响应式变量变化后,依赖它的副作用函数会重新执行

响应式数据的基本实现

  • 如何把一个变量变为响应式变量
  • 1 当副作用函数执行的时候,去收集该副作用函数,如果副作用函数使用到了我们的变量
  • 2 当前我们去修改响应式变量的时候,重新去执行副作用函数

简化一下:

  • effect执行的时候,触发对象读取操作
  • 修改对象,触发对象的设置操作
  • 同时我们要收集好这些函数
// 存储副作用函数的桶
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());
    return true;
  },
});

测试代码

// 副作用函数
function effect() {
  document.body.innerText = obj.text;
}

// 执行副作用函数,触发读取
effect();

// 1s后修改响应式数据
setTimeout(() => {
  obj.text = "hello vue3";
}, 1000);
  1. 我们直接通过名字来获取副作用函数,硬编码不合理,副作用函数可以是任何名字,甚至是匿名函数

更完善的响应式系统

解决函数命名硬编码写死问题 – 采用副作用函数注册机制

解决上一节的副作用函数硬编码命名的问题,我们用一个注册的机制,即我们只收集全局变量activeEffect, 每当你有对应的副作用函数,都会注册,注册后,就是activeEffect就是副作用函数,你只需要副作用函数执行时候,添加该依赖即可

// 用一个全局变量存储被注册的副作用函数
let activeEffect;
// effect 现在这个effect指的是注册副作用函数的函数
// 而它的参数fn才是我们的副作用函数
function effect(fn) {
  // 注册副作用函数
  activeEffect = fn;
  // 执行副作用函数
  fn(); // 这里帮你调用副作用函数,就不用手动掉了
}

ok,这样的化,无论你的fn是什么名字,匿名函数也行,都可以实现注册,收集到的机制了

当然,我们的响应式机制也需要改一下

const obj = new Proxy(data, {
  get(target, key) {
    // 现在我们统一使用activeEffect这个名字即可,收集到桶里
    if (activeEffect) {
      bucket.add(activeEffect);
    }
    return target[key];
  },
  //...
});

缺陷:修改响应式的时候,如果是修改不存在的属性,已久会触发副作用函数的执行

什么意思

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

setTimeout(() => {
  obj.notExist = "hello vue3";
}, 1000);

这里的effect run会打印两遍

  • 第一遍是注册副作用函数机制中,会执行一次副作用函数,打印一次
  • 第一遍是,1s后,修改响应式对象,又从新去执行副作用函数了,又打印了一次

但是这个是不合理的,我们的副作用函数没有依赖到这个属性即,notExist,但是还是重新执行了,根本原因是

  • 我们在注册副作用函数的时候,是直接放进一个桶里面的
  • 这个桶往里面扔的逻辑是:obj.text,即获取响应式对象数据的值时候,如果当前有注册的副作用函数就往里面扔
  • 而取的时候呢?obj.xxx = xxx, 即设置响应式对象数据的值时候,它是全部拿出来,全部执行一遍

所以,不存在的属性修改的时候,依旧会从桶里全部取出来,只要你注册的时候注册了。

  • 核心关键,我们收集的副作用函数没有关联响应式对象的字段,只是关联到了响应式对象本身
  • 需要做到,修改对象属性,与对象属性关联的副作用函数拿出来执行即可

解决副作用函数和响应式对象字段的映射关系 – 新的Map数据结构

其实很简单,就是这样的映射关系

  • target
    • key
      • effectFn
  • bucket target 之间, 是map结构,结果也是map结构 bucket[target] = map
  • target key之间,是map结构,结果是set结构 target[key] = set
  • key effectFn 之间就是set结构,结果就是effectFn key1 = set(fn1, fn2, fn3)
//  存储副作用函数的桶
const bucket = new WeakMap();

const obj = new Proxy(data, {
  // 拦截读取操作
  get(target, key) {
    if (!activeEffect) return target[key];
    // 根据target从桶里获取map
    let depsMap = bucket.get(target);
    // 没有对应target的map,构造一个放入桶中
    if (!depsMap) {
      depsMap = new Map();
      bucket.set(target, depsMap);
    }
    // 根据key从桶里获取set
    let depsSet = depsMap.get(key);
    // 没有对应key的set,构造放入map中
    if (!depsSet) {
      depsSet = new Set();
      depsMap.set(key, depsSet);
    }
    // 最后将activeEffect 放到set结构中
    depsSet.add(activeEffect);
    return target[key];
  },
  // 拦截设置操作
  set(target, key, newVal) {
    target[key] = newVal;
    // 获取 target key 对应的副作用函数
    // 先获取 bucket[target]
    const depsMap = bucket.get(target);
    if (!depsMap) return;
    // 再获取 bucket[target][key]
    const effects = depsMap.get(key);
    effects && effects.forEach(fn => fn());
    return true;
  },
});

最后的依赖其实就是下图:

Vue3 源码分析 04 - 响应系统的作用与实现-更新中!!

单独抽离tracktrigger

最后,现在的getset函数都太长了,我们抽象成为独立的函数

const obj = new Proxy(data, {
  // 拦截读取操作
  get(target, key) {
    // 收集依赖
    track(target, key);
    return target[key];
  },
  // 拦截设置操作
  set(target, key, newVal) {
    target[key] = newVal;
    // 触发依赖
    trigger(target, key);
    return true;
  },
});

// 封装track函数
function track(target, key) {
  // 没有activeEffect,直接return
  if (!activeEffect) return;
  // 根据target从桶中获取depsMap,它是一个map类型:target -> key -> effects
  let depsMap = bucket.get(target);
  if (!depsMap) {
    depsMap = new Map();
    bucket.set(target, depsMap);
  }
  // 根据key从桶中获取depsSet,它是一个set类型:key -> effects
  let deps = depsMap.get(key);
  if (!deps) {
    deps = new Set();
    depsMap.set(key, deps);
  }
  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());
}

分支切换与cleanup

我们完成了数据结构的重新定义,实现了响应式对象字段修改和对应副作用函数的映射关系存储

但是现在依旧存在一下缺陷:

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

这里副作用函数内部存在一个三元表达式,根据obj.ok来执行不同的分支,这就是分支切换,我们当前的实现分支切换会有问题:

  • obj.ok = true, 读取 obj.text, 所以effectFn 执行,收集 obj.ok 和 obj.text 的依赖
  • data
    • ok
      • effectFn
    • text
      • effectFn

这有啥问题?
obj.ok=false时候,并触发依赖的副作用函数重新执行,obj.text对应收集的依赖没有被清理

  • 理想状况下:effectFn 应该只执行一次,(被ok依赖收集的执行,而text依赖收集的不应该执行-应该被清理掉)
  • 真实情况:effectFn 执行了两次

说人话:我们当前没有做到分支切换的cleanup,即清理依赖,依赖依旧保留了

解决方案:我们要做删除依赖的动作,其实很简单,就是每次副作用函数执行的时候,清空之前的副作用函数的依赖即可,再利用这一次执行建立依赖,而关键就是我们需要有反向的映射关系,即

  • 我们当前的实现:响应式对象 -> 副作用函数
  • cleanup 需要 :副作用函数 -> 响应式对象
  • 这里给 effectFn 添加一个属性,存储它对应依赖集合
let activeEffect;
function effect(fn) {
  const effectFn = () => {
    activeEffect = effectFn;
    fn();
  };
  // activeEffect = effectFn;
  // activeEffect.deps 用来存储所有与该副作用函数相关联的依赖集合
  effectFn.deps = [];
  // 老规矩,执行一次副作用函数
  effectFn();
}

好了,我们现在依旧给所有副作用函数放了一个属性deps,让其收集依赖,那么收集的时候我们需要

function track(target, key) {
  // 没有activeEffect,直接return
  if (!activeEffect) return;
  let depsMap = bucket.get(target);
  if (!depsMap) {
    depsMap = new Map();
    bucket.set(target, depsMap);
  }
  let deps = depsMap.get(key);
  if (!deps) {
    deps = new Set();
    depsMap.set(key, deps);
  }
  // 把当前注册激活的副作用函数添加到依赖集合 deps 中
  deps.add(activeEffect);
  // deps 就是一个与当前副作用函数存在联系的依赖集合
  // 将其添加到 activeEffect.deps 数组中
  activeEffect.deps.push(deps);
}

ok,现在我们关系就建立好了

  • ok -> set <- effectFn
  • text -> set <- effectFn

Vue3 源码分析 04 - 响应系统的作用与实现-更新中!!

下一步:就是cleanup, 即每次调用副作用函数,先cleanup之前的依赖,再建立新的依赖

let activeEffect;
function effect(fn) {
  const effectFn = () => {
    // 调用cleanup清理
    cleanup(effectFn);
    activeEffect = effectFn;
    fn();
  };
  // 依赖
  effectFn.deps = [];
  // 执行副作用函数
  effectFn();
}

function cleanup(effectFn) {
  for (let i = 0; i < effectFn.deps.length; i++) {
    const deps = effectFn.deps[i];
    // 删除 effectFn即可
    deps.delete(effectFn);
  }
  // 重置effectFn.deps数组
  effectFn.deps.length = 0;
}

解决无限循环问题

我们在trigger内部,遍历effects
我们写完cleanup 后,每次副作用函数执行的时候就会清理,实际上本质就是从 effects集合中删除当前执行的副作用函数。但是副作用函数的执行又导致重新被收集
关键:此时对于effects集合的遍历仍在继续,基于forEach的规范,(人话:你就是从里面遍历拿出来执行副作用函数,对于同一个set,不断的add、delete)

解决:构造一个新的set即可

function trigger(target, key) {
  const depsMap = bucket.get(target);
  if (!depsMap) return;
  const effects = depsMap.get(key);
  const effectsToRun = new Set(effects);
  effectsToRun.forEach(effectFn => effectFn());
}

我们新构造了effectsToRun集合并遍历它, 代替直接遍历effects集合,从而避免了无限执行

解决嵌套的effect问题

之前我们考虑的副作用函数都是单层的,没有考虑嵌套,让我们看下如果是嵌套会有什么问题

// 原始对象
const data = {foo: true, bar: true};
// 代理对象(响应式对象)
const obj = new Proxy(data, {/* ... */});

// 全局变量
let temp1, temp2;

effect(function effectFn1() {
  console.log('effectFn1 run');
  effect(function effectFn2() {
    console.log('effectFn2 run');
    temp2 = obj.bar
  }
  temp1 = obj.foo
})
  • 我们在effectFn1中读取了obj.bar

  • 然后effectFn2中读取了obj.foo

  • 理想状况下:我们希望的副作用用函数和对象属性的关系

data
  |- foo
    |- effectFn1
  |- bar
    |- effectFn2

我们想要的情况是什么:

  1. 修改obj.foo, 触发effectFn1执行, 由于effectFn2嵌套在effectFn1中,我们希望effectFn2也执行
  2. 修改obj.bar,只触effectFn2执行

问题现状是什么?

  • 修改obj.foo, 会触发三次
    • effectFn1执行,effectFn2执行 (注册的时候,即执行的时候打印的)
    • effectFn2执行 ? 为什么是 fn2 执行了,不应该先fn1嘛再fn2嘛

问题的根源在我们注册的逻辑上

let activeEffect;
function effect(fn) {
  const effectFn = () => {
    cleanup(effectFn);
    activeEffect = effectFn;
    fn();
  };

  effectFn.deps = [];
  effectFn();
}

我们注册的逻辑上啥,是去更新全局变量activeEffect,所以嵌套的副作用函数,往下执行,这个activeEffect会更新到最里层,真实收集到的是最里层的副作用函数,即fn2,所以你只会收到fn2的执行

为了解决这个问题,引入一个新的副作用函数的栈,辅助我们更新activeEffect

// 用一个全局变量存储当前激活的副作用函数
let activeEffect;
// effect 栈
const effectStack = [];

function effect(fn) {
  const effectFn = () => {
    // 清理依赖
    cleanup(effectFn);
    activeEffect = effectFn;
    // 将调用副作用函数之前将当前的副作用函数压栈
    effectStack.push(effectFn);
    fn();
    effectStack.pop(); // 出栈
    // 你每次出栈的时候, 要自动更新activeEffect
    activeEffect = effectStack[effectStack.length - 1];
  };

  effectFn.deps = [];
  effectFn();
}

有了这个栈结构,我们每次遇到嵌套的副作用函数,就能够帮我们准确的更新当前的activeEffect,完美

避免无限递归

来看这样的副作用函数

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

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

这里会导致栈溢出,问题出在哪里?

  • 问题是 obj.foo++ => obj.foo = obj.foo + 1
  • 读取obj.foo => track => 收集依赖到桶中
  • +1 赋值给 obj.foo, 触发 trigger, 即把桶中的副作用函数拿出来执行
  • 但是:此时,该副作用函数还没有执行完毕,就要开始下一次的执行了 => 循环了,g了

怎么解决呢?问题出在我们是在同一个副作用函数里面,读取和设置都有
所以,我们把他们区分开来就好,添加一个动作发生的条件,如果当前副作用函数,和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 => {
      // 如果trigger 触发执行的副作用函数与当前的副作用函数相同,就不触发执行了
      if (effectFn !== activeEffect) {
        effectsToRun.add(effectFn);
      }
    });

  effectsToRun.forEach(effectFn => {
    effectFn();
  }
}

这样我们就能够避免无限递归调用,从而避免栈溢出

原文链接:https://juejin.cn/post/7334755783752613924 作者:知无涯者

(0)
上一篇 2024年2月17日 上午10:53
下一篇 2024年2月17日 上午11:04

相关推荐

发表评论

登录后才能评论