学习《玩转 Vue3 全家桶》之手写响应式

238 阅读5分钟

Offer 驾到,掘友接招!我正在参与2022春招打卡活动,点击查看活动详情

本文源代码参考极客时间的《玩转 Vue3 全家桶》

1. 前置知识

1.1 Proxy

Vue3 使用了 proxy 做响应式拦截,所以有必要先熟悉该对象是怎么使用的。MDN 上说,proxy 用于创建对象代理,从而实现基本操作的拦截和自定义。这里使用了两种常用的2种拦截方法,其中 set 用于拦截设置属性值的操作,get 用于拦截获取属性值的操作。在实际运用当中,注意比较它们的不同点,比如说 set 需返回 Boolean 值,get 会返回任意值。通过 Proxy 对象就可以在设置对象属性值或者获取对象属性值的时候进行一些操作。

let obj = {
  name: "lee",
};

let p = new Proxy(obj, {
  set(target, prop, value, receiver) {
    console.log("setValue");          // setValue
    target[prop] = value;
    return true;
  },
  get(target, prop, receiver) {
    console.log("getValue");          // getValue
    return target[prop];
  },
});

p.name = "leon";
console.log(p.name);     // leon

1.2 发布订阅模式

发布订阅模式是一种消费范式,发布者不会将消息直接发送给订阅者,而是将消息广播出去,供订阅该消息的订阅者消费。以下提供了最简单的实现例子,其中 list 变量保存发布者发布的内容,on 函数用于将发布者发布的内容收集到 list 中,emit 函数用于订阅者订阅需要的消息。

let list = {};

function on(key, fn) {
  if (!list[key]) {
    list[key] = [];
  }
  list[key].push(fn);
}

function emit() {
  const [key, ...args] = [...arguments];
  const fns = list[key];
  if (!fns || fns.length === 0) {
    return false;
  }
  fns.forEach((fn) => {
    fn(args);
  });
}

// 例子
on("join", (position, salary) => {
  console.log("你的职位是:" + position);
  console.log("期望薪水:" + salary);
});
on("other", (skill, hobby) => {
  console.log("你的技能有: " + skill);
  console.log("爱好: " + hobby);
});
emit("join", "前端", 10000);
emit("join", "后端", 10000);
emit("other", "端茶和倒水", "打游戏");
/* 输出的内容:
    你的职位是:前端
    期望薪水:10000
    你的职位是:后端
    期望薪水:10000
    你的技能有: 端茶和倒水
    爱好: 足球
*/

2. 源码分析

2.1 实现 reactive

源码使用了模块导入导出并做了很多拆分,这里将所有模块的代码合并到了一起,方便进行分析理解。

  1. 首先需要说明的是 reactive 方法,它会对目标对象的 set 和 get 操作进行拦截。在进行 get 操作的时候会调用 track 方法,该方法将目标对象,属性以及对应的 activeEffect 方法存放到 targetMap 对象中;在进行 set 操作的时候会调用 trigger 方法,该方法获取到保存在 targetMap 对象的目标对象中属性的 activeEffect (也就是 effectFn)方法。
  2. effect 方法包含了 effectFn 方法,该方法将自身放到 activeEffect 中,然后返回 fn 方法,最后还会清空 activeEffect。
let activeEffect = null; // 用于存放包含 fn 函数的 effectFn 函数

function effect(fn, options = {}) {
  const effectFn = () => {
    try {
      activeEffect = effectFn;
      return fn();
    } finally {
      activeEffect = null;  // 执行完后清空 activeEffect
    }
  };
  effectFn();
  return effectFn;
}

const targetMap = new WeakMap(); // 收集 target 对象中 key 属性对应的 effect 函数

function track(target, type, key) { // get 操作后进行追踪
  let depsMap = targetMap.get(target);
  if (!depsMap) {
    targetMap.set(target, (depsMap = new Map()));
  }
  let deps = depsMap.get(key);
  if (!deps) {
    deps = new Set();
  }
  if (!deps.has(activeEffect) && activeEffect) {
    deps.add(activeEffect);
  }
  depsMap.set(key, deps);
}

function trigger(target, type, key) { // set 操作后触发 targetMap 保存的 effectFn 函数
  const depsMap = targetMap.get(target);
  if (!depsMap) {
    return;
  }
  const deps = depsMap.get(key);
  if (!deps) {
    return;
  }
  deps.forEach((effectFn) => {
    effectFn();
  });
}

function reactive(target) {     // 代理对象的 set 和 get 操作
  return new Proxy(target, {
    get: function get(target, key) {
      const res = Reflect.get(target, key);
      track(target, "get", key);
      return res;
    },
    set: function set(target, key, value) {
      const result = Reflect.set(target, key, value);
      trigger(target, "set", key);
      return result;
    },
  });
}

const ret = reactive({ num: 0 });
let val;
effect(() => {
  val = ret.num;
});
console.log(val); // 0
ret.num++;
console.log(val);  // 1
ret.num = 10;
console.log(val);  // 10

如下图,通过绘制流程图,可以更清晰的看到代码执行的运行流程。在执行代码 effect(() => { val = ret.num}) 的时候可以将 ret 对象的 num 属性的函数操作存放到 targetMap 中,在执行代码 ret.num = 10 的时候可以去 targetMap 中找到 ret 对象的 num 属性的函数操作并执行。

image.png

2.2 实现 ref

由于前面已经实现了 reactive 方法,所以这部分实现的 ref 方法看起来比较简单。整体结构比较清晰,在使用 ref 方法的时候,首先通过 __isRef 标示来判断 val 值是否为 ref,如果是的话直接返回,如果不是创建 RefImpl 实例对象。在 RefImpl 对象中,可以对 value 属性进行 set 或者 get 操作,并且在 get 的时候通过 track 方法进行追踪,在 set 的时候通过 trigger 方法进行触发。特别的是如果设置的 value 值是对象的话,会直接去调用 reactive 方法,所以说 ref 方法既支持基础类型值,也通过调用 reactive 方法支持对象类型的值。

function ref(val) {
  if (isRef(val)) {
    return val;
  }
  return new RefImpl(val);
}
function isRef(val) {
  return !!(val && val.__isRef);
}

// ref 就是利用面向对象的 getter 和 setter 进行 track 和 trigget
class RefImpl {
  constructor(val) {
    this.__isRef = true;
    this._val = convert(val);
  }
  get value() {
    track(this, "get", "value");
    return this._val;
  }

  set value(val) {
    if (val !== this._val) {
      this._val = convert(val);
      trigger(this, "set", "value");
    }
  }
}

function isObject(val) {
  return typeof val === "object" && val !== null;
}

// ref 也可以支持复杂数据结构
function convert(val) {
  return isObject(val) ? reactive(val) : val;
}

// 测试用例
let a = ref(4);
let b;
effect(() => {
  b = a.value;
});
a.value++;
console.log(b); // 5

2.3 实现 computed(待完善)

computed 函数里面的 effect 方法比较特别,比普通的 effect 多了 lazy 和 scheduler,而且 effect 是否执行受到 _dirty 属性的影响

/*
 * 需要修改的内容
 */
function effect(fn, options = {}) {
  const effectFn = () => {
   ......
  };

  if (!options.lazy) {  // 如果配置了 lazy 属性不执行
    effectFn();
  }
  effectFn.scheduler = options.scheduler; // effecFn 函数增加调度器
  return effectFn;
}

function trigger(target, type, key) {
  ......
  
  deps.forEach((effectFn) => {
    if (effectFn.scheduler) {  // 如果包含调度器,则直接运行调度器的方法
      effectFn.scheduler();
    } else {
      effectFn();
    }
  });
}

/*
 * 新增的内容
 */
function computed(getterOrOptions) { // getterOrOptions 可以是一个 get 函数,也可以是一个包含 get 函数和 set 函数的对象
  let getter, setter;
  if (typeof getterOrOptions === "function") {
    getter = getterOrOptions;
    setter = () => {
      console.warn("计算属性不能修改");
    };
  } else {
    getter = getterOrOptions.get;
    setter = getterOrOptions.set;
  }
  return new ComputedRefImpl(getter, setter);
}
class ComputedRefImpl {
  constructor(getter, setter) {
    this._setter = setter;
    this._val = undefined;
    this._dirty = true;
    // computed 就是一个特殊的 effect,它设置了 lazy 和 scheduler
    this.effect = effect(getter, {
      lazy: true,
      scheduler: () => {
        if (!this._dirty) {
          this._dirty = true;
          trigger(this, "get", "value");
        }
      },
    });
  }
  get value() {
    track(this, "set", "value");
    if (this._dirty) {
      this._dirty = false;
      this._val = this.effect();
    }
    return this._val;
  }
  set value(val) {
    this._setter(val);
  }
}

// 例子
const calculate = reactive({ count: 1 });
const num = ref(2);
const sum = computed(() => num.value + calculate.count);
console.log(sum.value); // 3
calculate.count++;
console.log(sum.value); // 4
num.value = 10;
console.log(sum.value); // 12

3. 总结

reactive 方法的实现结合了 Proxy 和发布订阅模式,从而实现了响应式,简单来说就是 effect 方法收集变量的操作,当变量的值变化后触发 effect 方法重新执行。在 reactive 方法的基础上,ref 方法就好理解一点,它生成了 new RefImpl 实例对象,从而实现了响应式。computed 方法还是比较难于理解,以后有时间再补充。