Vue3 源码分析 04 - 补充响应式系统中的一些其他特性 - 更新中

230 阅读7分钟

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

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

tips:本章依旧是第四章,只是为了方便阅读,将其拆分了一下,本人博客依旧是连续的,在一章呢

调度执行

什么是调度执行:指的是当trigger动作触发副作用函数重新执行的时候,我们能够自定义

  • 比如:副作用函数执行的时机、次数、方式等

举个例子

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

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

obj.foo++
console.log('结束了')

它的打印是如下的

1  // 副作用函数首次执行
2  // 自增触发trigger
结束了 // 最后打印

如果我们有变动呢?想要

1
结束了
2

我们给effect添加第二个参数,options,运行用户指定调度器

effect(
  () => {
    console.log(obj.foo);
  }, // options
  {
    // 调度器是一个函数 scheduler
    scheduler(fn) {},
  }
);

先不管具体实现,我们effect注册的时候也需要给他加上

function effect(fn, options = {}) {
  const effectFn = () => {
    cleanup(effectFn);
    activeEffect = effectFn;
    effectStack.push(effectFn);
    fn();
    effectStack.pop();
    activeEffect = effectStack[effectStack.length - 1];
  };
  effectFn.options = options; // 把options 挂载到副作用函数上
  effectFn.deps = [];
  effectFn();
}

好,有了调度函数,我们在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 => {
    // 新增,如果有调度器
    if (effectFn.options.scheduler) {
      effectFn.options.scheduler(effectFn);
    } else {
      effectFn();
    }
  });
}

在 trigger 动作触发副作用函数执行时,我们优先判断该副作用函数是否存在调度器,如果存在,则直接调用调度器函数,并把当前副作用函数作为参数传递过去,由用户自己控制 如何执行;否则保留之前的行为,即直接执行副作用函数

这样我们就实现了副作用函数可调度

调度器的实际例子

1 上文中的那个打印输出怎么构造调度器能实现呢?

sheduler(fn) {
  // 放到宏任务即可
  setTimeout(fn)
}

2 控制次数 假设我们不关心trigger多少次,只关心最后一次的结果呢?,又该构造一个怎么样的调度器呢? 参考原文的队列实现

计算属性

计算属性 和 lazy

来实现vue.js 中的计算属性吧

lazy 的含义

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

我们希望lazy执行该如何呢?

effect(
  () => {
    console.log(obj.foo);
  },
  {
    lazy: true,
  }
);

我们需要修改effect的注册机制

function effect(fn, options = {}) {
  const effectFn = () => {
    cleanup(effectFn);
    activeEffect = effectFn;
    effectStack.push(effectFn);
    fn();
    effectStack.pop();
    activeEffect = effectStack[effectStack.length - 1];
  };
  effectFn.options = options;
  effectFn.deps = [];
  // 只有非lazy的时候,才执行
  if (!options.lazy) {
    effectFn();
  }
  // 将副作用函数作为返回值执行
  return effectFn;
}

什么时候执行,交给用户了

const effectFn = effect(
  () => {
    console.log(obj.foo);
  },
  {
    lazy: true,
  }
);

effectFn(); // 手动执行

可以把传递给effect的函数看作一个getter,我们每次调用副作用函数都可以获取getter值, 要改造成这样,就需要继续改造

function effect(fn, options = {}) {
  const effectFn = () => {
    cleanup(effectFn);
    activeEffect = effectFn;
    effectStack.push(effectFn);
    const res = fn(); // 将fn执行的结果存储到res中
    effectStack.pop();
    activeEffect = effectStack[effectStack.length - 1];
    return res; // 将res作为effectFn的返回值
  };
  effectFn.options = options;
  effectFn.deps = [];
  if (!options.lazy) {
    effectFn();
  }
  return effectFn;
}

新的代码可以看到,传递给effect函数的参数fn是真正的副作用函数,而effectFn 是我们包裹后的副作用函数

让我们来试试实现计算属性吧

function computed(getter) {
  // 把getter作为一个副作用函数,创建一个lazy的effect
  const effectFn = effect(getter, { lazy: true });
  const obj = {
    // 当读取value时才执行effectFn
    get value() {
      return effectFn();
    },
  };
  return obj;
}

首先我们定义一个 computed 函数,它接收一个 getter 函数作 为参数,我们把 getter 函数作为副作用函数,用它创建一个 lazy 的 effect。computed 函数的执行会返回一个对象,该对象的 value 属性是一个访问器属性,只有当读取 value 的值时,才会执行 effectFn 并将其结果作为返回值返回

来试一下我们写的computed

const data = { foo: 1, bar: 2 };
const obj = new Proxy(data, {});
const sumRes = computed(() => {
  return obj.foo + obj.bar;
});

console.log(sumRes.value); // 3

ok,但是问题时啥,我们只做到了懒计算,但是没有缓存呀

computed 添加上缓存

function computed(getter) {
  let value;
  let dirty = true; // true 意味着脏,需要重新计算
  const effectFn = effect(getter, { lazy: true });
  const obj = {
    get value() {
      if (dirty) {
        value = effectFn();
        dirty = false;
      }
      return value;
    },
  };
  return obj;
}

有问题呀,obj.foo 或者 obj.bar 修改了,但是还是走的缓存呀 让我们修正这个问题

function computed(getter) {
  let value;
  let dirty = true;
  const effectFn = effect(getter, {
    lazy: true,
    scheduler() {
      dirty = true;
    },
  });
  const obj = {
    get value() {
      if (dirty) {
        value = effectFn();
        dirty = false;
      }
      return value;
    },
  };
  return obj;
}

还记得我们之前写的调度代码吗?trigger的时候会调用调度器,这里就是trigger,即响应式变量更新时候触发,dirty 设置为 true,这样下次读取 value 的时候就会重新计算

  • 这里的getter变化的时候就会按照调度器执行
  • 而getter就是副作用函数,里面就是响应式变化的变化会导致从新执行

但是还有问题呀:

看下面

const sumRes = computed(() => {
  return obj.foo + obj.bar;
}

effect(() => {
  console.log(sumRes.value);
})

obj.foo++

我们注册的这个副作用函数不会重新执行

解决computed嵌套在effect中的问题

解决方案?其实很简单,你执行的时候手动进行跟踪一下

function computed(getter) {
  let value
  let dirty = true
  const effectFn = effect(getter, {
    lazy: true,
    scheduler() {
      if(!dirty) {
        // 当计算属性依赖的响应值变化的时候,手动的去trigger触发响应
        dirty = true
        trigger(obj, 'value')
      }
    }
  }

  const obj = {
    get value() {
      if(dirty) {
        value = effectFn()
        dirty = false
      }
      // 当读取 value 时候,手动调用track函数去追逐
      track(obj, 'value')
      return value
    }
  }
  return obj
}

好啦,这样就可以了

  • 当你去读取一个计算属性的value值的时候,我们手动去track下当前的副作用函数就可以了
    • 手动调用track,注册当前的副作用函数(嵌套外层的那个副作用函数也注册下)
  • 当计算属性依赖的响应式变量变化的时候,触发调度器,去重新执行
    • 把dirty设置为true,这样下次读取value的时候就会重新计算
    • 重新调用一次trigger, 触发下外层的副作用函数
effect(function effectFn() {
  console.log(sumRes.value);
});
  • computed(obj)
    • value
      • effectFn

image.png

watch 的实现原理

//  watch接受两个参数,source响应式数据,cb回掉函数
function watch(source, cb) {
  effect(
    // 触发读取操作,从而建立联系
    () => source.foo,
    {
      scheduler() {
        // 当数据变化的时,调用回掉函数cb
        cb();
      },
    }
  );
}

使用

const data = { foo: 1 };
const obj = new Proxy(data, {});
watch(obj, () => console.log("数据变化了"));
obj.foo++;

封装解决硬编码问题

前面的代码,硬编码了source.xxx,不太合理

function watch(source, cb) {
  effect(
    // 调用 traverse 递归地读取
    () => traverse(source)
  ),
    {
      scheduler() {
        cb();
      },
    };
}

function traverse(value, seen = new Set()) {
  // 如果读取的值是原始值,或者已经被读取过了,那就什么都不做
  if (typeof value !== "object" || value === null || seen.has(value)) return;
  // 将当前值加入 seen,表示已经读取过了,避免循环引用
  seen.add(value);
  // 暂时不考虑数组等其他类型,只考虑对象
  for (const k in value) {
    traverse(value[k], seen);
  }
  return value;
}

在 watch 内部的 effect 中调用 traverse 函数进行递归的读取操作,代替硬编码的方式,这样就能读取一个对象上的任意属性,从而当任意属性发生变化时都能够触发回调函数执行。

指定getter

function watch(source, cb) {
  let getter;
  if (typeof source === "function") {
    getter = source;
  } else {
    getter = () => traverse(source);
  }

  effect(
    // 执行getter就好
    () => getter(),
    {
      scheduler() {
        cb();
      }
    }
}

新旧值

function watch(source, cb) {
  let getter;
  if (typeof source === "function") {
    getter = source;
  } else {
    getter = () => traverse(source);
  }
  // 定义旧值和新值
  let oldValue, newValue;
  // 使用effect注册,开启lazy,
  const effectFn = effect(() => getter(), {
    lazy: true,
    scheduler() {
      // 在scheduler中重新执行,获取新值
      newValue = effectFn();
      // 传递
      cb(newValue, oldValue);
      // 更新旧值
      oldValue = newValue;
    },
  });
  // 手动调用一次,拿到的值就是旧值
  oldValue = effectFn();
}

我们手动调用 effectFn 函数得到的返回值就是旧值,即第一次执行得到的值。当变化发生并触发 scheduler 调度函数执行时,会重新调用 effectFn 函数并得到新值,这样我们就拿到了旧值与新值