使用Proxy实现Vue3—watchEffect

1.watchEffect

watchEffect 是Vue3新增的一个api,其功能与watch类似,均可以在侦听到数据发生变化时执行回调。不同之处在于
  • 1 无需直接指定要监听的数据, 回调函数中使用到哪些数据就监视哪些数据(必须是响应式数据)。
  • 2 默认初始时就会执行一次, 收集所需要监听的数据。
  • 3 无需配置,默认深度侦听。 下面是其基本使用
<script setup>
  import { reactive, ref, watchEffect } from "vue";
  let firstName = ref("李");
  let lastName = ref("云龙");
  let fullName = "";
  let obj = reactive({
    wife: {
      name: "秀芹",
    },
  });
  watchEffect(() => {
    fullName = firstName.value + lastName.value;
    console.log(fullName);
  });
  watchEffect(() => {
    console.log(obj.wife.name);
  });
  firstName.value = "赵";
  lastName.value = "刚";
  obj.wife.name = "冯楠";
</script>
从控制台打印结果能够看出,watchEffect默认会执行一次,能够自动收集所需要侦听的数据。数据变化时触发回调,且能实现深度侦听。

2.实现思路

下面让我们梳理一下实现以上功能的大致思路。
  • 首先,watchEffect接收的必须是响应式数据,即我们要确保数据在发生变化时能够被侦听到。这一点我们可以参考Vue数据响应式原理,使用观察者模式,对数据的get和set进行拦截,触发get时进行依赖收集,即将当前属性与其在watchEffect中指定的回调函数进行关联,触发set时执行相应的回调函数。以上操作可以使用ES6的Proxy来实现。
  • 接下来就是watchEffect具体要做的事。由于watchEffect默认会执行一次,因此要做的第一点就是,执行该回调函数。前面讲到watchEffect接收的是响应式数据,而执行该回调函数则一定会访问到所需要的变量,因此就一定会触发依赖收集。之前提到,依赖收集是将当前依赖与其对应的回调函数进行关联。那如何能访问到这个回调函数呢。显然,我们需要一个全局变量,用来存储当前正在被收集的依赖所对应的回调函数。在依赖收集之前将其挂载到全局,以确保触发依赖收集时能够访问到该回调。收集结束则踢出全局,让出位置来给下一个被收集的依赖存储其回调。 总结一下,watchEffect要做的就是三点。
  • 1 全局挂载回调
  • 2 执行回调
  • 3 踢出回调 下面依照上述思路来实现。首先使用Proxy实现数据响应化。

3.数据响应化

3.1Proxy
首先简单介绍一下Proxy。借阮一峰老师的话说,Proxy 可以理解成在目标对象之前架设一层“拦截”,外界对该对象的访问,都必须先通过这层拦截,因此提供了一种机制可以对外界的访问进行过滤和改写。其基本用法如下:
// target是目标对象,handler参数也是一个对象,用来定制拦截行为。
// 返回的是代理对象,后面所有的操作都是操作代理对象而不是目标对象
var proxy = new Proxy(target, handler);
下面演示如何定制基础的拦截行为
const obj = {
  name: "xiaom",
};
const objProxy = new Proxy(obj, {
  get(target, key) {
    console.log("get", key);
    /* Reflect对象的方法与Proxy对象的方法一一对应,只要是Proxy对象的方法,
    就能在Reflect对象上找到对应的方法。这就让Proxy对象可以方便地调用对应的Reflect方法,完成默认行为  */
    return Reflect.get(target, key);
  },
  set(target, key, val) {
    console.log("set", key, val);
    const res = Reflect.set(target, key, val);
    return res;
  },
});

console.log(objProxy.name);
objProxy.name = "xiaom";
console.log(objProxy.name);

// 下面是控制台打印结果
get name
xiaom
set name xiaom
get name
xiaom
可以看到,Proxy使用Reflect对象上对应的方法来完成默认行为,并返回代理对象。关于Proxy的更多用法这里不做详细介绍,后续会继续分享。下面使用Proxy来完成数据的响应化
function observe(obj) {
  if (typeof obj !== "object") {
    return obj;
  }
  return new Proxy(obj, {
    get(target, key, receiver) {
      //console.log(key + '被收集');
      const res = Reflect.get(target, key, receiver); 
      // 依赖收集
      collect(target, key);
      // 深层响应化
      return typeof obj === "object" ? observe(res) : res;
    },
    set(target, key, val, receiver) {
      //console.log("set", key);
      const res = Reflect.set(target, key, val, receiver);
      // 侦听到数据变化,执行相应回调
      update(target, key);
      return res;
    }
  });
}
接下来实现最关键的两步:依赖收集 ,监听执行
3.2 实现依赖收集
我们首先要明确一点,依赖与其对应的回调函数是如何关联起来的? 首先想到的是采用键值对的方式存入字典(Map)中。键名是被收集的依赖。键值是所对应的回调函数。显然这样是有缺陷的,那就是当我们对多个对象进行响应式处理时,这多个对象中可能存在同名属性,将会造成覆盖。因此我们应对每一个响应式对象单独创建一个字典。并将这个对象作为键名,所对应的字典作为键值。存入一个全局字典中。但是,我们知道字典是无法将对象作为键名的。因此我们要用到ES6的WeakMap结构。WeakMap只接受对象作为键名(null除外),不接受其他类型的值作为键名。同时还要注意,一个属性(依赖)对应的回调函数可能不止一个。可能再多个watchEfect中都收集了该属性。因此每个属性对应的键值应当是一个Set结构。 明确了以上所述,开始实现依赖收集。首先创建创建一个全局的WeakMap,以及一个全局变量用于临时存储回调。
// 存储回调的全局变量
let cb = null;
/* 
存储依赖关系的数据结构。它的整体结构是,以需要响应化的对象作为键名,键值是一个map结构。
该Map以对象的每个属性名为键名,键值为set结构。
该Set存储了该属性变化时需要触发的所有回调。
   */
const targetMap = new WeakMap();
接下来实现依赖收集。主要过程是为响应式对象的某个属性所对应的集合添加一个回调函数。
/**
 * @description 实现依赖收集,为属性和其对应的回调函数建立关联
 * @param {Object} target 目标对象
 * @param {any}  key  当前被收集的属性
 */
function collect(target, key) {
  const effect = cb; // 从全局获取当前依赖对应的回调
  if (effect) {
    // 建立target,key和effect之间映射关系
    let depMap = targetMap.get(target);
    // 初始化时,该对象所对应的键值对还不存在,则创建一个map
    if (!depMap) {
      depMap = new Map();
      targetMap.set(target, depMap);
    }
    // 获取该属性对应的的Set
    let deps = depMap.get(key);
    if (!deps) { 
      deps = new Set();
      depMap.set(key, deps);
    }
    deps.add(effect); // 给当前属性添加一个回调
  }
}
3.3 实现侦听执行
接下来实现监听执行。从全局WeakMap中取出当前变更属性所对应的所有回调函数,依次执行 ,这一步比较简单。
function update(target, key) {
  // 根据target和key获取对应的set
  // 循环执行
  const depMap = targetMap.get(target);
  if (!depMap) {
    return;
  }
  const deps = depMap.get(key);
  deps.forEach((dep) => dep());
}
至此,数据相应化的工作已经完成,接下来实现watchEffect。

4.实现watchEffect

前面已经总结过,watchEffect只有三点。全局挂载回调,执行回调,踢出回调。
function watchEffect(fn) {
  cb = fn;
  fn();// 触发依赖收集
  cb = null;
}
至此所有工作都已完成,我们来测试一下。
let firstName = observe({ value: "李" });
let lastName = observe({ value: "云龙" });
let fullName = "";
let obj = observe({
  wife: {
    name: "秀芹",
  },
});
watchEffect(() => {
  fullName = firstName.value + lastName.value;
  console.log(fullName);
});
watchEffect(() => {
  console.log(obj.wife.name);
});
firstName.value = "赵";
lastName.value = "刚";
obj.wife.name = "冯楠";
可以看到其能够实现首次执行,依赖收集,侦听执行,深度侦听。行为与Vue的watchEffect一致。 最后附上完整代码
let cb = null;
const targetMap = new WeakMap();
// 数据响应化
function observe(obj) {
  if (typeof obj !== "object") {
    return obj;
  }
  return new Proxy(obj, {
    get(target, key, receiver) {
      const res = Reflect.get(target, key, receiver);
      collect(target, key);
      return typeof obj === "object" ? observe(res) : res;
    },
    set(target, key, val, receiver) {
      const res = Reflect.set(target, key, val, receiver);
      update(target, key);
      return res;
    },
  });
}
// 依赖收集
function collect(target, key) {
  const effect = cb;
  if (effect) {
    let depMap = targetMap.get(target);
    if (!depMap) {
      depMap = new Map();
      targetMap.set(target, depMap);
    }
    let deps = depMap.get(key);
    if (!deps) {
      deps = new Set();
      depMap.set(key, deps);
    }
    deps.add(effect);
  }
}
// 侦听执行
function update(target, key) {
  const depMap = targetMap.get(target);
  if (!depMap) {
    return;
  }
  const deps = depMap.get(key);
  deps.forEach((dep) => dep());
}

function watchEffect(fn) {
  cb = fn;
  fn();
  cb = null;
}
参考:https://es6.ruanyifeng.com/#docs/proxy

本文章由javascript技术分享原创和收集

发表评论 (审核通过后显示评论):