如何实现一个响应式系统(二)

35 阅读15分钟

该系列文章为《Vue.js设计与实现》这本书的读书笔记,若想了解更详细的内容可以阅读原书。

示例代码:Github

一、调度执行

什么是可调度性?

可调度,指的是当 trigger 动作触发副作用函数重新执行时,有能力决定副作用函数执行的时机、次数以及方式。

指定调度器

为 effect 函数添加一个选项参数 options,允许自定义调度器 scheduler

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

然后将 options 挂载到 effectFn 上:

function effect(fn, options = {}) {
  const effectFn = () => {/* 省略 */};
  // 将 options 挂载到 effectFn 上
  effectFn.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) => {
      // 如果 trigger 触发执行的副作用函数和当前正在执行的副作用函数相同,则不触发执行
      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);
    },
  }
);
obj.foo++;
console.log("结束了");

在不添加 scheduler 配置的时候,输出结果是:

1
2
结束了

而添加了 scheduler 配置后,输出顺序被改变了,结果是:

1
结束了
2

修改执行次数

Vue.js 中连续多次修改响应式数据但只会触发一次更新,我们来简单模拟一下这个流程:

// 定义一个任务队列
const jobQueue = new Set();
// 创建一个 promise 实力,我们用它将一个任务添加到微任务队列
const p = Promise.resolve();

// 标志,代表是否正在刷新列表
let isFlushing = false;
function flushJob() {
  // 如果正在刷新,则什么都不做
  if (isFlushing) return;
  // 设置 true, 表示正在刷新
  isFlushing = true;
  // 在微任务队列中刷新 jobQueue 队列
  p.then(() => {
    jobQueue.forEach((job) => job());
  }).finally(() => {
    // 结束后重置 isFlushing
    isFlushing = false;
  });
}

// 控制副作用函数的执行次数
effect(
  () => {
    console.log(obj.foo);
  },
  {
    scheduler(fn) {
      jobQueue.add(fn);
      flushJob();
    },
  }
);

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

最终输出结果是:

1
3

代码执行流程:

  1. 初始时,jobQueue 为空,isFlushingfalseobj.foo1
  2. 执行 effect ,输出 1
  3. 第一次同步执行 obj.foo++obj.foo2,触发 trigger 并同步执行 scheduler 调度函数,将当前副作用函数添加到 jobQueue 中;
  4. 执行 flushJob ,此时 isFlushingfalse,所以继续向下执行, isFlushing 置为 true
  5. p.then 的回调中会依次执行 jobQueue 中保存的副作用函数。不过,因为 p.then 中的是异步的,并且是微任务,所以只是将其添加到微任务队列里,并不会立刻执行
  6. 继续同步执行第二次的 obj.foo++obj.foo3,并继续同步执行 scheduler 调度函数,因为 Set去重,所以执行 jobQueue.add 后,jobQueue 中的元素还是一个;
  7. 继续执行 flushJob,此时 isFlushingtrue,所以不会继续向下执行,到此处同步代码执行完毕;
  8. 此时便会继续执行微任务队列,也就是执行依次执行 jobQueue 中保存的副作用函数,队列中只有一个元素,所以最终只执行了一次,输出 3

二、计算属性 computed 和 lazy

懒执行和副作用函数返回值

现在我们所实现的 effect 函数,会立即执行传递给它的副作用函数:

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

但在一些场景中,我们不希望它立刻执行,而是希望它在需要的时候才执行。那副作用函数应该什么时候执行呢?我们可以直接手动执行,但仅仅支持手动执行,那意义也不大。如果我们把传递给 effect 的函数看做一个 gettergetter 可以返回值,那么我们手动执行的时候,就能拿到它的返回值。

effect 进行改造,支持懒执行和副作用函数支持返回值:

function effect(fn, options = {}) {
  const effectFn = () => {
    cleanup(effectFn);
    activeEffect = effectFn;
    effectStack.push(activeEffect);
    const res = fn(); // 新增
    effectStack.pop();
    activeEffect = effectStack[effectStack.length - 1];
    return res;  // 新增
  };
  effectFn.options = options;
  effectFn.deps = [];
  
  if (!options.lazy) { // 新增
    // 执行副作用函数
    effectFn();
  }
  return effectFn; // 新增
}

这样我们变可以拿到副作用的返回值

const effectFn1 = effect(
  () =>  obj.foo + obj.bar, 
  { lazy: true}
)
console.log(effectFn1())

computed 的基础实现

我们已经实现了懒执行的副作用函数,并能拿到副作用函数的执行结果,那么我们便可以简单实现计算属性了:

const data = { foo: 1, bar: 2 };
const obj = new Proxy(data, {/* 省略 */});

function computed(getter) {
  const effectFn = effect(getter, {
    lazy: true
  });

  const obj = {
    get value() {
      return effectFn();
    },
  };
  return obj;
}

const sumRes = computed(() => {
  console.log("computed run");
  return obj.foo + obj.bar;
});
console.log("value", sumRes.value);
console.log("value", sumRes.value);

定义一个 computed 函数,以参数 getter 为副作用函数创建一个懒执行的 effect。返回一个对象,在读取该对象的 value 属性时,才会执行 effectFn,并将结果作为返回值返回。

缓存计算结果以及缓存更新

我们发现多次访问 sumRes.value 的值的时候,会多次运行 effectFn 函数,即使 obj.fooobj.bar 的值没有发生任何变化。所以我们需要为 computed 添加缓存功能:

const data = { foo: 1, bar: 2 };
const obj = new Proxy(data, {/* 省略 */});

function computed(getter) {
  // 对值进行缓存
  let value;
  // 标识是否需要重新计算
  let dirty = true;

  const effectFn = effect(getter, {
    lazy: true,
  });

  const obj = {
    get value() {
      if (dirty) {
        // 需要重新计算,将值进行缓存
        value = effectFn();
        dirty = false;
      }
      return value;
    },
  };
  return obj;
}
const sumRes = computed(() => {
  console.log("computed run");
  return obj.foo + obj.bar;
});
console.log("value", sumRes.value); // 3
console.log("value", sumRes.value); // 3
obj.foo++;
console.log("value", sumRes.value); // 3

我们新增了 valuedirtyvalue 用来缓存上次计算的结果,dirty 用来标识是否需要重新计算。

但是我们发现,我们改变了 obj.foo 的值以后,sumRes.value 的值没有发生变化。这是因为 dirty 的值置为 false 以后就没有再改变过了。

解决办法就是,当 obj.fooobj.bar 发生改变时,我们将 dirty 的值重置为 true 就行了,这就要用到前面的 scheduler 函数了:

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;
}

effect 添加 scheduler 函数,它会在 getter 函数中所依赖的响应式数据发生改变时执行,这样我们就能将 dirty 重置为 true 了。下次访问 sumRes.value 的值就是最新的了。

在副作用函数中读取 computed 属性的值

我们发现在另一个 effect 的副作用函数中读取 sumRes.value 的值,在修改 obj.foo 的值后,副作用函数并没有执行,这和我们的预期不符,我们希望这时也能触发副作用函数的执行:

const sumRes = computed(function fn1 () {
  return obj.foo + obj.bar;
});
effect(function fn2 () {
  console.log("effect run", sumRes.value);
});
obj.foo++;

我们来分析一下原因:

  1. sumRes 是一个计算属性,每个计算属性内部都有自己的 effect,在运行后, computedgetter 参数 fn1,只会被 computed 内部的 effect 收集为依赖,而不会被外部的 effect 收集。
  2. sumRes 并不是被代理的响应式数据,所以调用 sumRes.value 时,并不会触发 track

解决方法:当读取计算属性时,我们可以手动调用 track 函数进行追踪;当计算属性依赖的响应式数据发生变化时,我们手动调用 trigger 函数触发响应:

function computed(getter) {
  // 对值进行缓存
  let value;
  // 标识是否需要重新计算
  let dirty = true;

  const effectFn = effect(getter, {
    lazy: true,
    scheduler() {
      if (!dirty) {
        dirty = true;
        // 当计算属性依赖的响应式数据发生改变时,手动调用 trigger 函数触发响应
        trigger(obj, "value");
      }
    },
  });

  const obj = {
    get value() {
      if (dirty) {
        value = effectFn();
        dirty = false;
      }
      // 当读取 value 时,手动调用 track 函数进行追踪
      track(obj, "value");
      return value;
    },
  };
  return obj;
}
  1. 当在 effect 中读取计算属性 sumResvalue 时,我们手动调用 track 函数。注意,此时的 activeEffect 中的 fnfn2,所以我们便建立了这样的关系:
obj
└── value
    └── fn2
  1. obj.foo 变化时,会执行 computed 内部 effect 的调度函数 scheduler,触发 trigger 操作,这样我们便可以根据上面的关系,取出 fn2 对应的 effectFn 执行。这样就符合我们的预期了。

三、实现 watch

watch 本质上就是观测一个响应式数据,当数据发生变化时,通知并执行相应的回调函数:

watch(obj, () => {
  console.log("数据变了");
})
obj.foo++;

最简单的 watch 实现

前面我们知道 effect 在指定了 scheduler 选项的情况下,当响应式数据发生改变,就会触发 scheduler 函数的执行。所以,我们就可以利用 scheduler 调度函数的这个特点,来实现一个简单的 watch 函数:

function watch(source, cb) {
  effect(() => source.foo, {
    scheduler(fn) {
      cb();
    },
  });
}

完善 source 的通用性

上面的代码,我们硬编码了对 source.foo 的读取操作,这不够通用,所有我们需要封装一个通用的读取操作:

function traverse(value, seen = new Set()) {
  // 如果读取原始值 或 已经读取过,什么都不做
  if (typeof value !== "object" || value === null || seen.has(value)) {
    return;
  }
  // 记录,避免死循环
  seen.add(value);
  // 暂时只支持 对象。递归遍历
  for (const key in value) {
    traverse(value[key], seen);
  }
  return value;
}

function watch(source, cb) {
  effect(() => traverse(source), {
    scheduler(fn) {
      // 发生变化时,调用 callback
      cb();
    },
  });
}

我们在 watch 内部的 effect 中递归读取 source 的属性。这样,当任意属性发生变化时都能触发回调函数执行。

watch 函数除了可以观察响应式数据,还可以接收一个 getter 函数:

function watch(source, cb) {
  let getter
  // 兼容 source 是 function 的情况
  if(typeof source === 'function') {
    getter = source
  } else {
    getter = () => traverse(source)
  }
  effect(() => getter(), {
    scheduler(fn) {
      // 发生变化时,调用 callback
      cb();
    },
  });
}

watch(() => obj.foo, () => {
  console.log("数据变了2");
});
obj.foo++;

这个只需要判断 source 的类型就可以了。如果是 function,直接使用传入的 getter 函数;否则的话,还是使用 traverse 递归读取 source 的属性。

回调函数中拿到 oldVal 和 newVal

现在还有一个重要的能力:我们的回调函数还拿不到旧值和新值。我们来分析下,调用 watch 的时候,会自动执行 getter,并且数据发生变化的时候,scheduler 也没有再去获取新值。所以,只要我们能手动控制执行的时机就可以了。这正是 effect 函数的 lazy 选项可以办到的:

function watch(source, cb) {
  let getter;
  // 兼容 source 是 function 的情况
  if (typeof source === "function") {
    getter = source;
  } else {
    getter = () => traverse(source);
  }
  // 新值和旧值
  let oldVal, newVal;
  // 递归读取
  let effectFn = effect(() => getter(), {
    lazy: true, // 懒执行
    scheduler(fn) {
      // 重新执行副作用函数,获取新值
      newVal = effectFn();
      // 发生变化时,调用 callback
      cb(newVal, oldVal);
      // 更新旧值
      oldVal = newVal;
    },
  });
  // 手动调用副作用函数,拿到的就是旧值
  oldVal = effectFn();
}

watch(
  () => obj.foo,
  (newVal, oldVal) => {
    console.log("数据变了2", newVal, oldVal);
  }
);
obj.foo++;

整体流程更改为:

  1. lazy 创建一个懒执行的 effect
  2. 手动执行一次。第一次调用 effectFn 得到的就是旧值;
  3. 当数据发生变化时触发 scheduler,调用 effectFn 得到新值;
  4. 回调中传递新值和旧值;
  5. 更新旧值。

立即执行的 watch

watch 还支持立即执行的回调函数。默认情况下一个 watch 的回调只会在响应式数据发生变化时才执行。所以,我们需要加一个 immediate 选项,表示回调函数在 watch 创建时立即执行一次。然后对代码优化后,逻辑为:

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

  let oldVal, newVal;
  // 封装为一个独立的 job 函数
  const job = () => {
    newVal = effectFn();
    cb(newVal, oldVal);
    oldVal = newVal;
  };
  const effectFn = effect(() => getter(), {
    lazy: true,
    scheduler: () => {
      job();
    },
  });

  if (options.immediate) {
    // 当 immediate 为 true 时立即执行 job,从而触发回调执行
    job();
  } else {
    oldVal = effectFn();
  }
}

immediatetrue 时立即执行回调函数,此时 oldValundefined,这也是符合预期的。

回调函数的执行时机

除了指定回调函数立即执行外,还可以通过 flush 选项指定回调函数的执行时机。

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

  let oldVal, newVal;
  const job = () => {
    newVal = effectFn();
    cb(newVal, oldVal);
    oldVal = newVal;
  };
  const effectFn = effect(() => getter(), {
    lazy: true,
    scheduler: () => {
      if (options.flush === "post") {
        // 将其放到微任务队列中
        const p = Promise.resolve();
        p.then(job);
      } else {
        job();
      }
    },
  });
  if (options.immediate) {
    job();
  } else {
    oldVal = effectFn();
  }
}

我们可以规定当 flush'post' 时,代表调度函数需要将副作用函数放到一个微任务队列中,等 DOM 更新完后再执行。

watch( obj, () => {
  console.log("数据变了1");
},{
  flush: "post",
});
watch( obj, () => {
  console.log("数据变了2");
});
obj.foo++;

这样输出结果为:

数据变了2
数据变了1

四、过期的副作用

我们来看一个例子:

const data = { foo: 1 };
const obj = new Proxy(data, {/* 省略 */});

// 模拟网络请求,会根据传的值计算延迟返回的时间
function mockFetch(data) {
  return new Promise((resolve) => {
    setTimeout(() => {
      resolve({ data: data });
    }, data * 100);
  });
}

let finalData;
watch(obj, async () => {
  const res = await mockFetch(obj.foo);
  finalData = res.data;
  console.log('finalData', finalData);
});

obj.foo = 2; // 触发请求 A
obj.foo = 1; // 触发请求 B

我们使用 watch 观测 obj 的变化,每次变化都会发送网络请求,并将结果赋值给 finalData

但是这段代码其实是有问题的,它发生了竞态问题。当我们先执行 obj.foo = 2,触发请求A,再执行 obj.foo = 1触发请求 B ,根据 mockFetch 的逻辑,我们知道肯定是请求 B 的请求结果先返回,请求 A 的请求结果再返回。也就是模拟了请求 A 比请求 B 先发出去,但是请求 B 的结果比请求 A 的结果先返回的情况,这时会发现 finalData 最后的值是 2,也就是请求 A 的返回结果。这是不符合预期的,我们希望 finalData 的值是最新的,也就是 1 。归根结底是,请求 A 过期了,但是它返回的结果依然被使用了。

所以,我们需要一个让副作用过期的手段。在 Vue.js 中,watch 函数的回调函数接收第三个参数 onInvalidate,它是一个函数,类似于事件监听器,我们可以使用 onInvalidate 注册一个回调函数,这个回调函数会在当前副作用函数过期时执行:

let finalData;
watch(obj, async (newVal, oldVal, onInvalidate) => {
  // 定义一个标识,代表当前副作用函数是否过期
  let expired = false;
  // 注册过期回调
  onInvalidate(() => {
    expired = true;
  });
  const res = await mockFetch(obj.foo);
  // 只有当该副作用函数的执行没有过期时,才会执行后续操作
  if (!expired) {
    finalData = res.data;
    console.log("finalData", finalData);
  }
});

我们再来看下 onInvalidate 的实现原理:在 watch 内部每次检测到变更后,在副作用函数重新执行前,会先调用我们通过 onInvalidate 注册过的回调函数:

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

  let oldVal, newVal;
  // 存储注册的过期回调
  let cleanup;
  function onInvalidate(fn) {
    // 将过期回调存储到 cleanup
    cleanup = fn;
  }
  const job = () => {
    newVal = effectFn();
    // 在调用回调函数前,先调用过期回调
    if (cleanup) {
      cleanup();
    }
    // 将 onInvalidate 作为回调函数的第三个参数
    cb(newVal, oldVal, onInvalidate);
    oldVal = newVal;
  };
  const effectFn = effect(() => getter(), {
    lazy: true,
    scheduler: () => {
      if (options.flush === "post") {
        const p = Promise.resolve();
        p.then(job);
      } else {
        job();
      }
    },
  });
  if (options.immediate) {
    job();
  } else {
    oldVal = effectFn();
  }
}

这里,我们先定义了 cleanup 变量,用来存储通过 onInvalidate 注册的过期回调。onInvalidate 的实现很简单,只是把过期回调赋值给了 cleanup。然后在 job 函数内,每次执行 cb 之前,先检查是否存在过期回调,若存在则先执行。最后我们把 onInvalidate 作为回调函数的第三个参数传递给 cb,方便外部使用。

我们再分析一下加上过期回调后的例子:

  1. 连续两次修改 obj.foo 的值,都是立即执行,这会导致 watch 的回调函数连续执行 2 次。同时我们在回调函数中注册过期回调。
  2. 第一次执行,我们在回调函数中注册过期回调,然后触发请求 A,我们知道在 200ms 后会返回请求结果
  3. 第二次执行,我们发现存在过期回调,会执行过期回调,这是请求 A 对应的 expired 置为 true,接着触发请求 B,我们知道在 100ms 后会返回请求结果
  4. 100ms后,请求B的结果返回,这时请求 B 所在的闭包中的 expiredfalse,所以对 finalData 进行赋值
  5. 200ms后,请求A的结果返回,这时请求 A 所在的闭包中的 expiredtrue,所以不做赋值操作

五、总结

所谓可调度,指的是当 trigger 动作触发副作用函数重新执行时,有能力决定副作用函数执行的时机、次数以及方式。为了实现调度能力,我们为 effect 函数 增加了第二个选项参数,可以通过 scheduler 选项指定调用器,这样用户可以通过调度器自行完成任务的调度。

计算属性,即 computed,实际上是一个懒执行的副作用函数,我们通过 lazy 选项使得副作用函数可以懒执行。被标记为懒执行的副作用函数可以通过手动方式让其执行。 利用这个特点,我们设计了计算属性,当读取计算属性的值时,只需要手动执行副作用函数即可。当计算属性依赖的响应式数据发生变化时,会通过 schedulerdirty 标记设置为 true。这样,下次读取计算属性的值时,我们会重新计算真正的值。

watch 本质上利用了副作用函数重新执行时的可调度性。一个 watch 本身会创建一个 effect,当这个 effect 依赖的响应式数据发生变化时,会执行该 effect 的调度器函数,即 scheduler。这里的 scheduler 可以理解为“回调”,所以我们只需要在 scheduler 中执行用户通过 watch 函数注册的回调函数即可。

过期的副作用函数,它会导致竞态问题。为了解决这个问题,Vue.js 为 watch 的回调函数设计了第三个参数,即 onInvalidate。它是一个函数,用来注册过期回调。每当 watch 的回调函数执行之前,会优先执行用户通过 onInvalidate 注册的过期回调。这样,用户就有机会在过期回调中将上一次的副作用标记为“过期”,从而解决竞态问题。


系列文章:

如何实现一个响应式系统(一)

如何实现一个响应式系统(二)

如何实现一个响应式系统(三)