从0撸出一套Vue3 watch 的 API

544 阅读8分钟

一、开篇

想要更加深入了解 Vue3的响应原理,建议看一下 保姆式地带你从0到1实现Vue3 Effect,对你阅读这里的代码更容易理解

二、响应数据的完成代码

// 存储副作用的桶
const bucket = new WeakMap();
// 原始数据
const data = { text: "Hello World" };

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

// effect 栈
const effectStack = [];

// 对原始数据的代理
const obj = new Proxy(data, {
  // 拦截读取操作
  get(target, key) {
    // 将副作用函数 activeEffect 添加到存储副作用函数的桶中
    track(target, key);
    // 返回属性值
    return target[key];
  },
  // 拦截设置操作
  set(target, key, newVal) {
    // 设置属性值
    target[key] = newVal;
    // 把副作用从桶中取出并执行
    trigger(target, key);
    return true;
  },
});

// 在get 拦截函数内调用 track 函数 追踪变化
function track(target, key) {
  // 没有 activeEffect 直接 return
  if (!activeEffect) return;
  // 根据 target 从 桶中取出 depsMap,它也是一个map 类型,key-->effects
  let depsMap = bucket.get(target);
  // 如果 depsMap 不存在,创建一个新的map,并与 target 关联
  if (!depsMap) {
    bucket.set(target, (depsMap = new Map()));
  }

  // 再根据 key 从depsMap 中取得 deps,它是一个 Set 集合
  // 里面存储着所有与当前 key 相关的副作用函数:effects
  let deps = depsMap.get(key);
  if (!deps) {
    depsMap.set(key, (deps = new Set()));
  }
  // 最后将当前激活的副作用函数添加到桶里
  deps.add(activeEffect);
  // deps 就是一个与当前副作用函数存在联系的依赖集合
  // 将其添加到 effectFn.deps 数组中
  activeEffect.deps.push(deps);
}

// 在 set 拦截函数内调用 trigger 函数 触发变化
function trigger(target, key) {
  // 从桶中取出 depsMap,它也是一个map 类型,key-->effects
  const depsMap = bucket.get(target);
  if (!depsMap) return true;
  // 再根据 key 从depsMap 中取得 副作用函数 effects
  const effects = depsMap.get(key);

  const effects2Run = new Set();
  effects &&
    effects.forEach((effectFn) => {
      // 如果trigger触发执行的副作用函数与当前的副作用函数相同,则不触发执行
      if (effectFn !== activeEffect) {
        effects2Run.add(effectFn);
      }
    });

  effects2Run.forEach((effectFn) => {
    if (effectFn.options.scheduler) {
      effectFn.options.scheduler(effectFn);
    } else {
      // 否则直接执行副作用函数
      effectFn();
    }
  });
}

// 用于注册副作用函数
function effect(fn, options = {}) {
  const effectFn = () => {
    // 删除与 effectFn 相关的关系
    cleanup(effectFn);
    // 调用effect函数的时候,将 effectFn 赋值给 activeEffect
    activeEffect = effectFn;
    // 在调用副作用函数之前,将当前副作用函数添加到 effectStack 栈中
    effectStack.push(effectFn);
    // 执行副作用函数
    fn();
    // 在调用副作用函数执行完毕后,将当前副作用函数弹出栈,并把 activeEffect 还原为之前的值
    effectStack.pop();
    activeEffect = effectStack[effectStack.length - 1];
  };
  // 将 options 挂在到 effectFn 上
  effectFn.options = options;
  // effectFn.deps 用来存储所有与该副作用函数相关联的依赖集合
  effectFn.deps = [];
  // 只有非lazy的时候才会执行
  if (!options.lazy) {
    effectFn();
  }
  // 将副作用函数作为返回值返回
  return effectFn;
}

function cleanup(effectFn) {
  // 遍历 effectFn.deps 数组
  effectFn.deps.forEach((deps) => {
    // 将 effectFn 从依赖集合中删除
    deps.delete(effectFn);
  });
  // 清空 effectFn.deps
  effectFn.deps.length = 0;
}

const effectFn = effect(
  () => {
    document.body.innerText = obj.text;
  },
  {
    lazy: true,
  }
);

三、watch的实现

1. 需要实现的功能

const data = { foo: "foo", bar: "bar" };

const obj = new Proxy(data, {...})

// 第一种
watch(obj,function cb(preValue, value) {
    console.log(preValue, value.foo)
})

// 第二种
watch(()=>obj.foo,function cb(preValue, value) {
    console.log(preValue, value)
})
  1. 当监听 obj 值发生变化的时候,重新执行 cb并将上一个obj值和当前的obj值作为参数给 cb
  2. 当监听 obj.foo 值发生变化的时候,重新执行 cb并将上一个obj.foo值和当前的obj.foo值作为参数给 cb

1). 代码实现

function watch(source, cb) {
  let getter;
  if (typeof source === "function") {
    // 如果是function,直接作为 getter
    getter = source;
  } else {
    // 如果是对象,监听source内所以的属性
    getter = () => traverse(source);
  }

  // 定义旧值和新值
  let oldVal, newVal;
  // 使用 effect 注册将副作用函数时候,开启 lazy 选项,并把返回值存储到 effectFn ,便于后面手动调用
  const effectFn = effect(() => getter(), {
    lazy: true,
    scheduler: () => {
      newVal = effectFn();
      cb(oldVal, newVal);
      oldVal = newVal;
    },
  });

  // 手动调用,将执行的结果存储到 oldVal 中
  oldVal = effectFn();
}

function traverse(source, seen = new Set()) {
  // 如果要读取的数据是原始数据,或者已经被读取过,那么什么都不做
  if (typeof source !== "object" || source === null || seen.has(source)) return;
  // 将数据添加到 seen 中,代表遍历地读取过了,避免循环引用引起死循环
  seen.add(source);

  // 暂时不考虑数组等的情况
  // 假设 source 是一个对象,使用 for in 循环遍历读取每一个值,并递归调用 traverse 进行处理
  for (let key in source) {
    // 如果是对象,递归调用
    traverse(source[key], seen);
  }
  return source;
}

// 第一种
watch(obj, function cb(preValue, value) {
  console.log("第一种", preValue.foo, value.foo);
});

// 第二种
watch(
  () => obj.foo,
  function cb(preValue, value) {
    console.log("第二种", preValue, value);
  }
);

2)效果

Kapture 2022-05-15 at 16.31.36.gif

可能眼尖小伙伴会发现,第一种preVal输出的和newVal是同一个值,是因为 preVal newVal 指向的是同一个引用类型。

2. 立即执行watch

1) 代码实现

image.png

2) 效果

Kapture 2022-05-15 at 16.51.23.gif

3. 回调时机

image.png post代表异步执行,sync 同步执行,就相当于直接执行job,pre 需要结合冬 需要结合组件更新执行

4. 过期的副作用

1)问题描述

watch(
  () => obj.foo,
  async function cb() {
    const res = fetch("/get/user/list");
    finalData = res;
  }
);
obj.foo = "foo1";

setTimeout(() => {
  obj.foo = "foo2";
}, 500);

变更两次 obj.foo 导致 watch 会执行两次,会发送两次 fetch1 fetch2,如果fetch1 响应 晚于 fetch2,那么会导致 finalData 最终获取的数据不是最新的。这个也就类似竞态问题

2) 问题分析

watch需要告诉在开发者,在哪个watch的cb是过期的副作用

3)代码实现

image.png

image.png

四、完成代码

// 存储副作用的桶
const bucket = new WeakMap();
// 原始数据
const data = { foo: "foo", bar: "bar" };

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

// effect 栈
const effectStack = [];

// 对原始数据的代理
const obj = new Proxy(data, {
  // 拦截读取操作
  get(target, key) {
    // 将副作用函数 activeEffect 添加到存储副作用函数的桶中
    track(target, key);
    // 返回属性值
    return target[key];
  },
  // 拦截设置操作
  set(target, key, newVal) {
    // 设置属性值
    target[key] = newVal;
    // 把副作用从桶中取出并执行
    trigger(target, key);
    return true;
  },
});

// 在get 拦截函数内调用 track 函数 追踪变化
function track(target, key) {
  // 没有 activeEffect 直接 return
  if (!activeEffect) return;
  // 根据 target 从 桶中取出 depsMap,它也是一个map 类型,key-->effects
  let depsMap = bucket.get(target);
  // 如果 depsMap 不存在,创建一个新的map,并与 target 关联
  if (!depsMap) {
    bucket.set(target, (depsMap = new Map()));
  }

  // 再根据 key 从depsMap 中取得 deps,它是一个 Set 集合
  // 里面存储着所有与当前 key 相关的副作用函数:effects
  let deps = depsMap.get(key);
  if (!deps) {
    depsMap.set(key, (deps = new Set()));
  }
  // 最后将当前激活的副作用函数添加到桶里
  deps.add(activeEffect);
  // deps 就是一个与当前副作用函数存在联系的依赖集合
  // 将其添加到 effectFn.deps 数组中
  activeEffect.deps.push(deps);
}

// 在 set 拦截函数内调用 trigger 函数 触发变化
function trigger(target, key) {
  // 从桶中取出 depsMap,它也是一个map 类型,key-->effects
  const depsMap = bucket.get(target);
  if (!depsMap) return true;
  // 再根据 key 从depsMap 中取得 副作用函数 effects
  const effects = depsMap.get(key);

  const effects2Run = new Set();
  effects &&
    effects.forEach((effectFn) => {
      // 如果trigger触发执行的副作用函数与当前的副作用函数相同,则不触发执行
      if (effectFn !== activeEffect) {
        effects2Run.add(effectFn);
      }
    });

  effects2Run.forEach((effectFn) => {
    if (effectFn.options.scheduler) {
      effectFn.options.scheduler(effectFn);
    } else {
      // 否则直接执行副作用函数
      effectFn();
    }
  });
}

// 用于注册副作用函数
function effect(fn, options = {}) {
  const effectFn = () => {
    // 删除与 effectFn 相关的关系
    cleanup(effectFn);
    // 调用effect函数的时候,将 effectFn 赋值给 activeEffect
    activeEffect = effectFn;
    // 在调用副作用函数之前,将当前副作用函数添加到 effectStack 栈中
    effectStack.push(effectFn);
    // 执行副作用函数,得到返回值
    const res = fn();
    // 在调用副作用函数执行完毕后,将当前副作用函数弹出栈,并把 activeEffect 还原为之前的值
    effectStack.pop();
    activeEffect = effectStack[effectStack.length - 1];
    // 将返回值 return 出去
    return res;
  };
  // 将 options 挂在到 effectFn 上
  effectFn.options = options;
  // effectFn.deps 用来存储所有与该副作用函数相关联的依赖集合
  effectFn.deps = [];
  // 只有非lazy的时候才会执行
  if (!options.lazy) {
    effectFn();
  }
  // 将副作用函数作为返回值返回
  return effectFn;
}

function cleanup(effectFn) {
  // 遍历 effectFn.deps 数组
  effectFn.deps.forEach((deps) => {
    // 将 effectFn 从依赖集合中删除
    deps.delete(effectFn);
  });
  // 清空 effectFn.deps
  effectFn.deps.length = 0;
}

// 遍历 source 内所有的属性,从而触发 track 函数
function traverse(source, seen = new Set()) {
  // 如果要读取的数据是原始数据,或者已经被读取过,那么什么都不做
  if (typeof source !== "object" || source === null || seen.has(source)) return;
  // 将数据添加到 seen 中,代表遍历地读取过了,避免循环引用引起死循环
  seen.add(source);

  // 暂时不考虑数组等的情况
  // 假设 source 是一个对象,使用 for in 循环遍历读取每一个值,并递归调用 traverse 进行处理
  for (let key in source) {
    // 如果是对象,递归调用
    traverse(source[key], seen);
  }
  return source;
}

function watch(source, cb, options = {}) {
  let getter;
  if (typeof source === "function") {
    // 如果是function,直接作为 getter
    getter = source;
  } else {
    // 如果是对象,监听source内所以的属性
    getter = () => traverse(source);
  }
  // 存储用户注册过的过期回调
  let cleanup;
  // 定义 过期回调
  function onInvalidate(fn) {
    // 将过期回调存储起来
    cleanup = fn;
  }

  const job = () => {
    newVal = effectFn();
    // 需要注意的是,第一次执行 watch cb的时候,不执行 过期回调,再次触发watch cb就会执行 上一个过期回调
    if (cleanup) {
      // 在调用cb之前,执行过期回调
      cleanup();
    }
    cb(oldVal, newVal, onInvalidate);
    oldVal = newVal;
  };

  // 定义旧值和新值
  let oldVal, newVal;
  // 使用 effect 注册将副作用函数时候,开启 lazy 选项,并把返回值存储到 effectFn ,便于后面手动调用
  const effectFn = effect(() => getter(), {
    lazy: true,
    scheduler: () => {
      if (options.flush === "post") {
        // flush 为 post,放到微任务队列中执行
        Promise.resolve().then(job);
      } else {
        job();
      }
    },
  });

  if (options.immediate) {
    job();
  } else {
    // 手动调用,将执行的结果存储到 oldVal 中
    oldVal = effectFn();
  }
}

watch(
  () => obj.foo,
  async function cb(preVal, newVla, onInvalidate) {
    // 记录是否是过期副作用
    let expired = false;
    onInvalidate(() => {
      // 当过期时候,expired 设置为 true
      expired = true;
    });
    const res = fetch("/get/user/list");
    // 只有非过期的时候,才将响应的数据赋值给finalData
    if (!expired) {
      finalData = res;
    }
  }
);
obj.foo = "foo1";

setTimeout(() => {
  obj.foo = "foo2";
}, 500);

五、总结

watch实际是对effect的二次封装,然后添加tract 属性、执行时机、提供给用户过期副作用的处理

六、参考

《Vue.js 设计与实现》