架构的思考(4)

准备

目标的实现里涉及的两个问题:

  • 监听数据的读和写。
  • 关联函数和数据

前面部分已经实现了监听数据的读和写,涉及了依赖收集和派发更新,每次读写都会触发这两个,之前是仅仅通过打印来看效果,现在就要真正地去实现了。

如何把这两个函数搞定呢?肯定要建立一个对应关系,数据和函数的对应关系。这个对应关系呢肯定要有一个数据结构。那怎么样的数据结构比较合适呢?

这个数据结构与vue有点不同,比vue复杂一点点。

  • targetMap

    map结构,键值对,键就是我们的对象,每个对象的属性又对应一个map。

  • propMap

    这个是属性的map,属性的映射关系,每个属性就是他的键,属性值又对应一个map。为啥会有个问号?那就是当进行迭代,操作类型是INTERATE的时候,压根就没有传属性。

  • typeMap

    这里的键呢就是操作行为,每个操作里边是一个集合,这里的集合称之为 dep,表示依赖。

也就是说,哪个函数依赖哪个对象的哪个属性的读取行为,那dep是一个集合,就会保留很多个函数。

接下来就是在effect.js里建立数据结构。

js 复制代码
//effect.js

const targetMap = new WeakMap(); 
const INTERATE_KEY = Symbol('iterate') //迭代时的属性

好了,明确一下目标,依赖收集就是按照这些结构去建立这些对应关系。派发更新就是要找到对应关系里的函数重新运行一遍。

回到我们的effect.js,我们的参数,有对象,属性,操作类型,但缺少了函数,怎么搞,怎么找到哪个使用了属性的函数?

js 复制代码
function fn() {
  state.a;
}
fn();

这个很明确,就是fn在使用。那如果是这样呢?

js 复制代码
function fn() {
  function fn1() {
    state.a;
  }
  fn1();
}
fn();

这种情况到底是哪个将哪个函数存入集合?如果再有多层嵌套呢?那就说不清了到底是哪个函数了?说不清这三个字熟悉吗?之前在讲函数与数据的时候,也是说不清是哪个数据。所以,把决定权交给用户,给需要进行依赖收集的函数打上个标记。比如有这么个函数effect,这个函数帮你运行函数。

js 复制代码
  function fn1() {
    state.a;
  }
  fn1();
}
effecty(fn) //运行函数

在运行函数的期间,用到的所有响应式数据,比如state.a,不管他在哪个地方用的,只要是运行fn的期间,用到了某个响应式数据,那这个响应式数据要关联的函数就是fn

js 复制代码
/**
 * @description: 副作用函数。运行fn函数期间,将用到的所有响应式数据与fn进行关联
 * @param {* function} fn 要执行的函数
 * @return {*}
 */
export function effect(fn) {}

接下来就是要在track依赖收集里使用这个fn函数。

js 复制代码
export function effect(fn) {
  activeEffect = fn;
  fn();
  activeEffect = null;
}

刚开始把fn赋值给activeEffect,然后执行fn(),这就能保证在函数运行期间,这个activeEffect是有值的,函数运行结束之后,设置为null。而fn运行期间有可能用到了响应式数据,用到了那就会触发track函数,也就是说在track运行期间,activeEffect是有值的。

js 复制代码
export function track(target, type, key) {
  //不应该进行依赖收集 或 缺少函数 则不进行依赖收集
  if (!shouldTrack || !activeEffect) {
    return;
  }
...
}

现在是信息齐全了,但我们想一个问题,依赖收集的目的是什么?是派发更新对吧,将来我数据变动的时候能通过这个对应关系,找到对应函数让它重新运行。

那将来重新运行这个函数的时候,还能够建立这个对应关系吗?我们的对应关系是每一次运行函数都要重新建立的。为啥要重新建立而不是一开始就遍历呢?来说个比较抽象的逻辑。

js 复制代码
function fn() {
  if (state.a === 1) {
    state.b;
  } else {
    state.c;
  }
}

第一次我运行这个函数的时候,依赖关系是state.astate.b的读和fn关联起来了。有一天进行派发更新,state.a=2,这个函数是不是得重新运行,依赖关系是不是得变成 state.astate.c的读和fn啊。目前我们的做法是收集了具体的函数fn,那将来重新运行的是这个:

js 复制代码
if (state.a === 1) {
    state.b;
  } else {
    state.c;
  }

那直接重新运行这个函数,不通过effect来运行,fn就没有啦,那 activeEffect = fn;那就没了,有了针对activeEffect的两个操作,才能保证在执行fn期间,能拿到fn,才能进入track进而收集依赖。有点抽象啊,头疼😫😫😫

因此收集依赖的时候,应该把那个执行环境,那3行代码都收集进来。这样子,将来在派发更新的时候,会把这个3行代码重新运行一遍,这样子才能达到重新收集依赖的目的。

js 复制代码
export function effect(fn) {
  const effectFn = () => {
    try {
      activeEffect = fn;
      return fn();
    } finally {
      activeEffect = null;
    }
  };
  effectFn();
}

依赖收集,建立对应关系

都处理差不多了,下面处理tarck函数了。

  • targetMap里的对象
js 复制代码
  let propMap = targetMap.get(target);
  if (!propMap) {
    propMap = new Map();
    targetMap.set(target, propMap);
  }

这下propMap已经是有东西了,现在要把propMap里的属性拿出来。

  • propMap里的属性
js 复制代码
 if (type === TrackOpTypes.INTERATE) {
    key = INTERATE_KEY;
  }
  let typeMap = propMap.get(key);
  if (!typeMap) {
    typeMap = new Map();
    propMap.set(key, typeMap);
  }
  • typeMap里的操作类型
js 复制代码
  let depsMap = typeMap.get(type);
  if (!depsMap) {
    depsMap = new Map();
    typeMap.set(type, depsMap);
  }
  //将函数加入 deps
  if (!depsMap.has(activeEffect)) {
    depsMap.add(activeEffect);
  }

看一下这个结构,一个对象对应一个map,一个属性对应一个map,一个操作类型对应一个set,set里面存了那个函数。

派发更新,找到对应函数一次运行

你改的是哪个对象的哪个属性,改的是哪个行为,然后找到那个函数,将那个函数重新运行。

js 复制代码
/**
 * @description: 处理函数,找到对应的函数
 * @param {* object} target 源对象
 * @param {* stirng} type 操作类型
 * @param {* stirng} key 属性
 * @return {*}
 */
function getEffectFns(target, type, key) {
  const propMap = targetMap.get(target);
  if (!propMap) {
    return;
  }
}

如果有迭代和修改属性一起,那可能有多个属性要拿,所以在这个typeMap里,得处理。

js 复制代码
 const keys = [key];
  if (type === TriggerOpTypes.ADD || type === TriggerOpTypes.DELETE) {
    keys.push(INTERATE_KEY);
  }

  const effectFn = new Set();
  for (const key of keys) {
    const typeMap = propMap.get(key);
    if (!typeMap) {
      continue;
    }
    console.log('typeMap',typeMap);
  }
  return effectFn;

如果操作类型是ADDDELECTE时,还得把这个迭代属性加上,因为这个收集和派发的属性是对应的,等会儿说明。 所以得有个数组装这些属性,然后遍历这个属性,把这个属性从propMap里拿出来。

那现在直接从typeMap里把对应的动作的函数集合拿出来不就行了?不一样,比如之前是get动作,存了一些函数。那现在是add动作,要到哪里去拿这个集合?所以说,派发动作和收集动作有一个关联关系。

js 复制代码
export const TrackOpTypes = {
  GET: "get", //读取属性值
  HAS: "has", //判断属性是否存在
  INTERATE: "interate", //迭代对象
};
export const TriggerOpTypes = {
  SET: "set", //设置属性
  ADD: "add", //添加属性
  DELETE: "delete", //删除属性
};

这就是他们的影响关系,所以我们得建立一个关系。

js 复制代码
  const triggerTypeMap = {
    [TriggerOpTypes.SET]: [TrackOpTypes.GET],
    [TriggerOpTypes.ADD]: [
      TrackOpTypes.GET,
      TrackOpTypes.HAS,
      TrackOpTypes.ITERATE,
    ],
    [TriggerOpTypes.DELETE]: [
      TrackOpTypes.GET,
      TrackOpTypes.HAS,
      TrackOpTypes.ITERATE,
    ],
  };

下面就是处理了,把对应的函数拿出来。

js 复制代码
function getEffectFns(target, type, key) {
  const propMap = targetMap.get(target);
  if (!propMap) {
    return;
  }

  const keys = [key];
  if (type === TriggerOpTypes.ADD || type === TriggerOpTypes.DELETE) {
    keys.push(ITERATE_KEY);
  }

  const effectFns = new Set();//用来存储函数的集合

  const triggerTypeMap = {
    [TriggerOpTypes.SET]: [TrackOpTypes.GET],
    [TriggerOpTypes.ADD]: [
      TrackOpTypes.GET,
      TrackOpTypes.HAS,
      TrackOpTypes.ITERATE,
    ],
    [TriggerOpTypes.DELETE]: [
      TrackOpTypes.GET,
      TrackOpTypes.HAS,
      TrackOpTypes.ITERATE,
    ],
  };
  //循环所有属性
  for (const key of keys) {
    const typeMap = propMap.get(key);
    if (!typeMap) {
      continue; //拿不到这个 操作 就继续
    }
    
    const trackTypes = triggerTypeMap[type]; //派发操作对应的依赖操作集合

    //对操作集合 比如 get has iterate 进行循环
    for (const trckType of trackTypes) {
      const dep = typeMap.get(trckType); //拿出这个操作类型的函数集合
      if (!dep) {
        continue;
      }
      for (const effectFn of dep) {
        effectFns.add(effectFn); //将函数集合存起来
      }
    }
  }
  return effectFns;
}
js 复制代码
/**
 * @description: 派发更新
 * @param {* object} target 代理的源对象
 * @param {* stirng} key 属性
 * @param {* stirng} type 写的操作类型
 * @return {*}
 */
export function trigger(target, type, key) {
  const effectFns = getEffectFns(target, type, key);
  for (const effectFn of effectFns) {
    effectFn(); //依次执行函数
  }
}

现在effect的核心模块的核心已经结束了。

麻了,感觉是就是一直在打补丁,现在发现一个问题。

js 复制代码
function fn() {
  console.log("fn");
  if (state.a == 1) {
    state.b;
  } else {
    state.c;
  }
}
effect(fn); //运行函数
state.a=2;
state.b=4
  • 第一次,条件成立,会运行,打印fn。没问题。
  • 第二次,改变了a的值,重新收集依赖,重新运行。没问题。
  • 第三个,改变这个b的值,因为涉及到了,也会重新运行函数。

但仔细想想,修改了a之后,那收集的依赖就是a和c,和b就没有关系了,这时候再去改变b,是不是就不应该重新运行函数? 那我们说的是运行fn的时候,是重新收集依赖,也就是抛弃之前的不要了,但目前好像还保留之前的。 那就是说在运行fn之前,要把它从集合里删掉,重新运行fn,重新进行依赖收集。

那问题就来了,运行之前查找所有表,把fn从所有表里删除,这是非常浪费效率的。所以可以有这么个方案,我去记录一下这个函数在那个集合里面。

开搞,给这个fn添加个属性,用来记录所在的集合。

js 复制代码
export function effect(fn) {
  const effectFn = () => {
    try {
      activeEffect = effectFn;
      clearFn(effectFn); //清除函数所在集合
      return fn();
    } finally {
      activeEffect = null;
    }
  };
  effectFn.deps = [];// 存放函数集合
  effectFn();
}
js 复制代码
// effect.js

// tract()
if (!depsSet.has(activeEffect)) {
    depsSet.add(activeEffect);
    activeEffect.deps.push(depsSet);//往属性里添加 函数集合
  }
js 复制代码
/**
 * @description: 辅助函数,用来清除 fn 所在集合
 * @param {* function} effectFn fn
 * @return {*}
 */
export function clearFn(effectFn) {
  const { deps } = effectFn;

  if (!deps.length) {
    return;
  }

  for (const dep of deps) {
    dep.delete(effectFn);//把fn 从dep函数集合里删掉
  }
  deps.length = 0;
}

那这个重新收集依赖的需求就搞定了。还有一个问题,函数嵌套。

js 复制代码
function fn() {
  console.log("fn");
  effect(() => {
    console.log("inner");
    state.a;
  });
  state.b;
}
effect(fn);
state.b = 4;

第一次执行,打印fn,打印inner,没毛病。改变之后,应该是会执行fn的,可是并没有打印fn。来分析下逻辑。

activeEffect刚开始是没有值的,运行fn的时候,它被赋值为fn所在的环境,在这个fn运行的期间呢,又运行了inner,然后又把inner所在的环境赋值给activeEffect,然后inner运行结束,activeEffect变为null,这时fn还没运行完,回去运行fn,这时state.b,就收集不到依赖了。

这是一个执行栈的问题。第一个函数调用第二个函数,第二个运行第三个函数。第三个运行结束出栈,到第二个函数出栈,最后第一个函数出栈。

所以上面是的执行逻辑和栈的逻辑是不一样的,那我们就准备一个执行栈。

js 复制代码
//effect.js

...
const effectStack = [];
...

export function effect(fn) {
  const effectFn = () => {
    try {
      activeEffect = effectFn;
      effectStack.push(effectFn); //把函数加入栈
      clearFn(effectFn); //清除函数所在集合
      return fn();
    } finally {
      effectStack.pop();//把函数推出
      activeEffect = effectStack[effectStack.length - 1];//取栈顶
    }
  };
  effectFn.deps = []; // 存放函数集合
  effectFn();
}

好了还有个问题,无限递归造成栈溢出。只需要判断一下触发的函数是不是当前函数。

js 复制代码
export function trigger(target, type, key) {
  const effectFns = getEffectFns(target, type, key);
  for (const effectFn of effectFns) {
    if (effectFn === activeEffect) {
      continue;
    }
    effectFn();
  }
}

ok没问题了,优化一下,现在函数是立即执行,那优化下把交给用户选择。

js 复制代码
//index.js

const effectFn = effect(fn, {
  lazy: true,
});
js 复制代码
//effect.js

export function effect(fn, optitons) {
  const { lazy = false } = optitons; //默认立即执行
  const effectFn = () => {
    try {
      activeEffect = effectFn;
      effectStack.push(effectFn);
      clearFn(effectFn); //清除函数所在集合
      return fn();
    } finally {
      effectStack.pop();
      activeEffect = effectStack[effectStack.length - 1];
    }
  };
  effectFn.deps = []; // 存放函数集合

  if (!lazy) {
    effectFn();
  }
  return effectFn; //返回
}

还有个问题,

js 复制代码
function fn() {
  console.log("fn");
  state.a = state.a + 1;
}
const effectFn = effect(fn, {
  lazy: true,
});

effectFn()

state.a++
state.a++
state.a++
state.a++
state.a++

这个执行了很多次,嗯怎么说呢,vue不是每次都更新,是会等所有数据都变动之后,再统一更新,这个样可以避免无用更新哈。那我们也可以把这个执行权交给用户。

现在默认是把函数拿出来重新执行的,那我们可以配置一个调度器,把要执行的函数交给调度器,然后由调度器决定要不要执行。

js 复制代码
export function effect(fn, optitons) {
  const { lazy = false } = optitons;
  const effectFn = () => {
    try {
      activeEffect = effectFn;
      effectStack.push(effectFn);
      clearFn(effectFn); 
      return fn();
    } finally {
      effectStack.pop();
      activeEffect = effectStack[effectStack.length - 1];
    }
  };
  effectFn.deps = []; 
  effectFn.optitons = optitons; //保存配置项

  if (!lazy) {
    effectFn();
  }
  return effectFn;
}
js 复制代码
export function trigger(target, type, key) {
  const effectFns = getEffectFns(target, type, key);
  for (const effectFn of effectFns) {
    if (effectFn === activeEffect) {
      continue;
    }
    //如果有配置 让用户自行决定
    if (effectFn.optitons.scheduler) {
      effectFn.optitons.scheduler(effectFn);
    } else {
      effectFn();
    }
  }
}
相关推荐
曈欣27 分钟前
vue 中属性值上变量和字符串怎么拼接
前端·javascript·vue.js
计算机学姐36 分钟前
基于SpringBoot+Vue的宠物医院管理系统
java·vue.js·spring boot·后端·mysql·intellij-idea·mybatis
customer081 小时前
【开源免费】基于SpringBoot+Vue.JS教师工作量管理系统(JAVA毕业设计)
java·vue.js·spring boot·后端·开源
缘月叙文1 小时前
vue2项目实现国际化(若依框架示例)
vue.js
QGC二次开发1 小时前
Vue3:v-model实现组件通信
前端·javascript·vue.js·前端框架·vue·html
努力的小雨2 小时前
从设计到代码:探索高效的前端开发工具与实践
前端
小鼠米奇2 小时前
详解Ajax与axios的区别
前端·javascript·ajax
Bunury3 小时前
Vue3新组件transition(动画过渡)
前端·javascript·vue.js
zero.cyx3 小时前
JS函数部分
开发语言·前端·javascript
超级小的大杯柠檬水3 小时前
SpringBoot lombok(注解@Getter @Setter)
java·前端·spring