二十、手写watch


回顾watch的用法

const x = reactive({
  a: 1,
  b: 2
})

// 单个 ref
watch(x, (newX) => {
  console.log(`x is ${newX}`)
})

// getter 函数
watch(
  () => x.a + x.b,
  (sum) => {
    console.log(`sum is: ${sum}`)
  }
)

简单总结起来,就是前面的响应式数据发生变化,重新执行后面的回调函数。回调函数的参数列表中,会传入新的值和旧的值。

另外 watch 还接收第三个参数,是一个选项对象,可以的配置的值有:

  • immediate:立即执行一次回调函数
  • once:只执行一次
  • flush
    • post:在侦听器回调中能访问被 Vue 更新之后的所属组件的 DOM
    • sync:在 Vue 进行任何更新之前触发

watch 方法会返回一个函数,该函数用于停止侦听

const unwatch = watch(() => {})

// ...当该侦听器不再需要时
unwatch()

实现watch方法

首先写一个工具方法 traverse:

function traverse(value, seen = new Set()) {
  // 检查 value 是否是对象类型,如果不是对象类型,或者是 null,或者已经访问过,则直接返回 value。
  if (typeof value !== "object" || value === null || seen.has(value)) {
    return value;
  }

  // 将当前的 value 添加到 seen 集合中,标记为已经访问过,防止循环引用导致的无限递归。
  seen.add(value);

  // 使用 for...in 循环遍历对象的所有属性。
  for (const key in value) {
    // 递归调用 traverse,传入当前属性的值和 seen 集合。
    traverse(value[key], seen);
  }

  // 返回原始值
  return value;
}

该方法的主要作用是递归遍历一个对象及其所有嵌套的属性,从而触发这些属性的依赖收集。

这个方法在 watch 函数中很重要,因为它确保了所有嵌套属性的依赖关系都能被追踪到,当它们变化时能够触发回调函数。

假设有一个深层嵌套的对象:

const obj = {
  a: 1,
  b: {
    c: 2,
    d: {
      e: 3
    }
  }
};

那么整个遍历过程如下:

  • 由于 obj 是对象,并且没有访问过,会将 obj 添加到 seen 集合里面
  • 遍历 obj 的属性:
    • 访问 obj.a 是数字,会直接返回,不做进一步的处理
    • 访问 obj.b,会进入 traverse(obj.b, seen)
      • 由于 obj.b 是对象,并且未被访问过,将 obj.b 添加到 seen 集合中。
      • 遍历 obj.b 的属性:
        • 访问 obj.b.c 是数字,会直接返回,不做进一步的处理
        • 访问 obj.b.d,会进入 traverse(obj.b.d, seen)
          • 由于 obj.b.d 是对象,并且未被访问过,将 obj.b.d 添加到 seen 集合中。
          • 遍历 obj.b.d 的属性:
            • 访问 obj.b.c.e 是数字,会直接返回,不做进一步的处理

在这个过程中,每次访问一个属性(例如 obj.b 或 obj.b.d),都会触发依赖收集。这意味着当前活动的 effect 函数会被记录为这些属性的依赖。

接下来咱们仍然是进行参数归一化:

/**
 * @param {*} source 
 * @param {*} cb 要执行的回调函数
 * @param {*} options 选项对象
 * @returns
 */
export function watch(source, cb, options = {}) {
  let getter;
  if (typeof source === "function") {
    getter = source;
  } else {
    getter = () => traverse(source);
  }
}

在上面的代码中,无论用户的 source 是传递什么类型的值,都转换为函数(这里没有考虑数组的情况)

  • source 本来就是函数:直接将 source 赋值给 getter
  • source 是一个响应式对象:转换为一个函数,该函数会调用 traverse 方法

接下来定义两个变量,用于存储新旧两个值:

let oldValue, newValue;

好了,接下来轮到 effect 登场了:

const effectFn = effect(() => getter(), {
  lazy: true,
  scheduler: () => {
    newValue = effectFn();
    cb(newValue, oldValue);
    oldValue = newValue;
  },
});

这段代码,首先会运行 getter 函数(前面做了参数归一化,已经将 getter 转换为函数了),getter 函数里面的响应式数据就会被依赖收集,当这些响应式数据发生变化的时候,就需要派发更新。

因为这里传递了 scheduler,因此在派发更新的时候,实际上执行的就是 scheduler 对应的函数,实际上也就是这三行代码:

newValue = effectFn();
cb(newValue, oldValue);
oldValue = newValue;

这三行代码的意思也非常明确:

  • newValue = effectFn( ):重新执行一次 getter,获取到新的值,然后把新的值给 newValue
  • cb(newValue, oldValue):调用用户传入的换掉函数,将新旧值传递过去
  • oldValue = newValue:更新 oldValue

再往后走,代码就非常简单了,在此之前之前,我们先把 scheduler 对应的函数先提取出来:

const job = () => {
  newValue = effectFn();
  cb(newValue, oldValue);
  oldValue = newValue;
};

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

然后实现 immediate,如下:

if (options.immediate) {
  job();
} else {
  oldValue = effectFn();
}

immediate 的实现无外乎就是立马派发一次更新。而如果没有配置 immediate,实际上也会执行一次依赖函数,只不过算出来的值算作旧值,而非新值。

接下来执行取消侦听,其实也非常简单:

return () => {
  cleanup(effectFn);
};

就是返回一个函数,函数里面调用 cleanup 将依赖清除掉即可。

你会发现只要前面响应式系统写好了,接下来的这些实现都非常简单。

最后我们再优化一下,添加 flush 配置项的 post 值的支持。flush 的本质就是指定调度函数的执行时机,当 flush 的值为 post 的时候,代表调用函数需要将最终执行的更新函数放到一个微任务队列中,等待 DOM 更新结束后再执行。

代码如下所示:

const effectFn = effect(() => getter(), {
  lazy: true,
  scheduler: () => {
    if (options.flush === "post") {
      Promise.resolve().then(job);
    } else {
      job();
    }
  },
});

完整代码如下:

import { effect, cleanup } from "./effect/effect.js";

/**
 * @param {*} source 
 * @param {*} cb 要执行的回调函数
 * @param {*} options 选项对象
 * @returns
 */
export function watch(source, cb, options = {}) {
  let getter;
  if (typeof source === "function") {
    getter = source;
  } else {
    getter = () => traverse(source);
  }

  // 用于保存上一次的值和当前新的值
  let oldValue, newValue;

  // 这里的 job 就是要执行的函数
  const job = () => {
    newValue = effectFn();
    cb(newValue, oldValue);
    oldValue = newValue;
  };

  const effectFn = effect(() => getter(), {
    lazy: true,
    scheduler: () => {
      if (options.flush === "post") {
        Promise.resolve().then(job);
      } else {
        job();
      }
    },
  });

  if (options.immediate) {
    job();
  } else {
    oldValue = effectFn();
  }

  return () => {
    cleanup(effectFn);
  };
}

function traverse(value, seen = new Set()) {
  // 检查 value 是否是对象类型,如果不是对象类型,或者是 null,或者已经访问过,则直接返回 value。
  if (typeof value !== "object" || value === null || seen.has(value)) {
    return value;
  }

  // 将当前的 value 添加到 seen 集合中,标记为已经访问过,防止循环引用导致的无限递归。
  seen.add(value);

  // 使用 for...in 循环遍历对象的所有属性。
  for (const key in value) {
    // 递归调用 traverse,传入当前属性的值和 seen 集合。
    traverse(value[key], seen);
  }

  // 返回原始值
  return value;
}

文章作者: 吴俊杰
版权声明: 本博客所有文章除特別声明外,均采用 CC BY 4.0 许可协议。转载请注明来源 吴俊杰 !
  目录