2023年9月30日 · developer · 3分钟阅读

Vue3 响应式(4)-Wacth 的实现原理

前几篇文章已经构建了较为完善的响应式系统,并且实现了计算属性 computed 以及调度器、懒执行等特性。这一篇文章就来实现另外一个重要的功能—— watch 监听。

watch 的基础原理

简单实现

watch 无非是实现这样一个功能:对于指定的响应式变量,当该变量发生变化时能够执行指定的回调函数。这一个需求完全可以使用调度器 scheduler 来实现。例如下面的代码,通过给 effect 传入一个读取 data.cnt 的函数,来使 data.cnt 与副作用函数进行绑定。然后在 scheduler 上指定回调函数,每当 data.cnt 发生变化时就执行指定的回调函数。

const data = reactive({ cnt: 0 })

effect(() => {
  data.cnt  // 触发 track 函数
}, {
  scheduler: () => {
    // to do something when data.cnt changes
    console.log('data.cnt changed')
  }
})

递归监听所有属性

上一节中实现监听的方式有一个明显的不足,那就是必须指定要监听响应式数据的哪个属性,否则其他属性的变化不会触发绑定的回调函数。但实际上我们希望所监听变量的任何属性发生变化都能触发回调。这就自然而然想到使用递归的方法来遍历变量的所有属性。我们如此封装 watch 函数,其中 traverse(obj) 用来递归的读取 obj 的所有属性:

function watch(obj, handler) {
  effect(
    () => traverse(obj),
    {
      scheduler: hanlder
    }
  )
}

function traverse(obj, seen = new Set()) {
  if (typeof obj !== 'object' || value === null || seen.has(obj)) return
  seen.add(obj)

  for (const k in obj) {
    traverse(obj[k])
  }

  return obj  // 这里之所以返回 obj 是为了后面保存 watch 的值用
}

有了以上的封装我们就可以直接调用 watch 来自动追踪响应式数据的所有变化了:

watch(obj, () => {
  // to do something when data changes
  console.log('obj changed')
})

getter

Vue 中对于 watch 方法还提供了 getter 的使用方式,它的调用方式如下。通过 getter 返回一个响应式数据,watch 仅仅监听 getter 返回的值,并且默认不会进行深层监听。

watch(
  () => obj.cnt,
  () => {
    // to do something when data changes
  }
)

watch 添加 getter 的实现非常简单,即通过判断 watch 的第一个参数是不是函数 (getter),如果是函数则直接将 getter 作为副作用函数传给 effect,否则使用上面的递归处理。代码如下:

function watch(source, handler) {
  let getter
  if (typeof source === 'function') {
    getter = source
  } else {
    getter = () => traverse(source)
  }
  effect(
    getter,
    {
      scheduler: hanlder
    }
  )
}

watch 的功能优化

获得“新旧值”

这里实现 Vue.js 中回到函数中获取新旧值参数的功能。这里主要利用到 lazy 选项。

function watch(source, handler) {
  let getter
  if (typeof source === 'function') {
    getter = source
  } else {
    getter = () => traverse(source)
  }

  let oldVal, val

  const effectFn = effect(
    getter,
    {
      lazy: true,
      scheduler() {
        val = effectFn()
        handler(oldVal, val)
        oldVal = val
      } 
    }
  )

  oldVal = effectFn()
}

immediate 立即执行

通过以上的封装,watch 所监听的数据只有发生变化时才会执行回调函数。我们同样可以像 Vue.js 中为其设置 immediate 参数来使其立即执行。

function watch(source, handler, options) {
  let getter
  if (typeof source === 'function') {
    getter = source
  } else {
    getter = () => traverse(source)
  }

  let oldVal, val

  const job = () => {
    val = effectFn()
    handler(oldVal, val)
    oldVal = val
  }

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

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

小结

本文实现了 Vue.js 数据侦听方法 watch 的基础原理,全部的代码如下。点击这里 在线运行和调试。

let activeEffect;
const effectStack = [];
const bucket = new WeakMap();

const data = reactive({ foo: 1, bar: 2 });

watch(
  () => data.bar,
  (old, val) => {
    console.log(`data change from ${old} to ${val}`);
  },
  { immediate: true }
);

setTimeout(() => {
  data.bar = 100;
}, 2000);

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

  let oldVal, val;
  const job = () => {
    val = effectFn();
    cb(oldVal, val);
    oldVal = val;
  };

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

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

function traverse(obj, seen = new Set()) {
  if (typeof obj !== "object" || obj === null || seen.has(obj)) return;
  for (const k in obj) {
    traverse(obj[k]);
  }
  return obj;
}

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

function reactive(obj) {
  return new Proxy(obj, {
    get(target, key) {
      track(target, key);
      return Reflect.get(target, key);
    },
    set(target, key, value) {
      const res = Reflect.set(target, key, value);
      trigger(target, key);
      return res;
    }
  });
}

function track(target, key) {
  if (!activeEffect) return;
  let depsMap = bucket.get(target);
  if (!depsMap) {
    bucket.set(target, (depsMap = new Map()));
  }
  let deps = depsMap.get(key);
  if (!deps) {
    depsMap.set(key, (deps = new Set()));
  }
  deps.add(activeEffect);
  activeEffect.depSets.push(deps);
}

function trigger(target, key) {
  let depsMap = bucket.get(target);
  if (!depsMap) return;
  let deps = depsMap.get(key);
  if (!deps) return;
  const depsToRun = new Set(deps);
  //  depsToRun.forEach(fn => fn())
  depsToRun.forEach((fn) => {
    if (fn === activeEffect) return;
    if (fn.options.scheduler) {
      // 如果设置了调度器,则将副作用函数的执行交给调度器
      fn.options.scheduler(fn);
    } else {
      // 否则默认直接执行副作用函数
      fn();
    }
  });
}

function effect(fn, options = {}) {
  const effectFn = () => {
    cleanup(effectFn);
    activeEffect = effectFn;
    effectStack.push(effectFn);

    const res = fn(); // 获取副作用函数的结果

    effectStack.pop();
    activeEffect = effectStack[effectStack.length - 1];

    return res; // 返回副作用函数结果
  };
  effectFn.depSets = [];
  effectFn.options = options;
  if (!options.lazy) {
    effectFn(); // 立即执行一次 effectFn 建立绑定
  }
  return effectFn; // 返回 effectFn
}

function cleanup(effectFn) {
  effectFn.depSets.forEach((deps) => {
    deps.delete(effectFn);
  });
  effectFn.depSets.length = 0;
}

参考资料

[1] vuejs/core: 🖖 Vue.js is a progressive, incrementally-adoptable JavaScript framework for building UI on the web.[EB/OL]. [2023-09-22]. https://github.com/vuejs/core.

[2] Vue.js - 渐进式 JavaScript 框架 | Vue.js[EB/OL]. [2023-09-22]. https://cn.vuejs.org/.

[3] 霍春阳. Vue.js设计与实现[M/OL]. 人民邮电出版社, 2022[2023-09-19]. https://book.douban.com/subject/35768338/.

[4] JavaScript | MDN[EB/OL]. 2023-04-10. https://developer.mozilla.org/zh-CN/docs/Web/JavaScript.

评论

登录 — 登录后参与讨论。