【话说Vue3】响应式系统

145 阅读6分钟

我报名参加金石计划1期挑战——瓜分10万奖池,这是我的第3篇文章,点击查看活动详情

前言

终于将准备工作做完了,这一章正式进入正题。在此之前我们有必要先搞懂一些基础知识,Vue3 与 Vue2 在响应式系统设计方面最大的不同莫过于:基于 es5+ 的 Proxy 特性来进行了重构。但为什么要这么做呢?这里来说几点原因:

  • 初始化时vue2.x是利用Object.defineProperty来拦截对象的getter/setter方法,需要遍历对象所有 key,如果对象层次较深,性能不好;
  • 通知更新过程需要维护大量 dep 实例和 watcher 实例,额外占用内存较多;
  • 无法监听到数组元素的变化,只能通过劫持重写了几个数组方法;
  • 动态新增,删除对象属性无法拦截,只能用特定 set/delete API 代替;
  • 不支持 Map、Set 等数据结构;

带着上述的原因,我们一步一步在源码实现过程中寻找答案吧!

基于new Proxy重新定义响应方法

在 packages 目录下新建 reactivity 目录

// packages/reactivity/src/index.ts
import { reactive } from "./reactive";
export { reactive };
// packages/reactivity/src/reactive.ts
import { mutaleBaseHandler } from "./baseHandler";
import { isObject } from "@mini-vue/shared";

export const enum ReactiveFlags {
  IS_REACTIVE = '__v_isReactive',
}

// 注意点1
const proxyMap = new WeakMap();

export const isReactive = (value) => {
  return !!(value && value[REACTIVE_FLAGS.IS_REACTIVE]);
};

export const reactive = (obj) => {
  if (!isObject(obj)) {
    return;
  }
  /*
    注意点2
    同一个object
    const obj = { a: "1" };
    const a = vueReactivity.reactive(obj);
    const b = vueReactivity.reactive(obj);
    console.log(a === b);
  */
  const existProxy = proxyMap.get(obj);
  if (existProxy) {
    return existProxy;
  }
  /*
    注意点3
    如果一个对象已经被proxy代理过了,则直接返回
    这个地方稍后在定义 getter 的时候还会有体现
    const obj = { a: "1" };
    const a = vueReactivity.reactive(obj);
    const b = vueReactivity.reactive(a);
    console.log(a === b);
  */
  if (obj[REACTIVE_FLAGS.IS_REACTIVE]) {
    return obj;
  }
  const proxy = new Proxy(obj, mutaleBaseHandler);
  proxyMap.set(obj, proxy);
  return proxy;
};

这里有一个注意点:

上一个章节我们在 packages 下新建了一个 shared 子包作为共享模块,这里就派上用场了,那么如何安装共享模块呢?

在子包目录下,比如在 reactivity 目录下执行:

pnpm install @mini-vue/shared*
// @mini-vue/shared 必须与 shared 子包的 package.json 中定义的 name 属性保持一致
// * 表示最新版本,这样就不用每次手动安装

接下来我们就可以在 packages/reactivity/package.json 中看到:

  "dependencies": {
    "@mini-vue/shared": "workspace:*"
  }

抽离 proxy 中 get、set 方法

// packages/reactivity/src/baseHandler.ts
import { REACTIVE_FLAGS, reactive } from "./reactive";
import { track, trigger } from "./effect";
import { isObject } from "@mini-vue/shared";
export const mutaleBaseHandler = {
  get(target, key, receiver) {
    /*
    当一个对象已经被proxy代理过了,那么再次读取 REACTIVE_FLAGS.IS_REACTIVE 属性时,
    会触发get方法,由此返回true,在这里也可以解释上面在 reactive.js 文件中提到的 注意点3
    */ 
    if (key === REACTIVE_FLAGS.IS_REACTIVE) {
      return true;
    }
    // 依赖收集
    track(target, "get", key);

    const result = Reflect.get(target, key, receiver);

    if (isObject(result)) {
      return reactive(result); // 深度代理,取值的时候才代理,性能好
    }

    return result;
  },
  set(target, key, newValue, receiver) {
    const oldValue = target[key];
    const result = Reflect.set(target, key, newValue, receiver);

    if (oldValue !== newValue) {
    // 触发更新
      trigger(target, "set", key);
    }

    return result;
  },
};

到这里我们需要注意的是 Reflect 这个api。为什么要用这种方式?我们在获取值的时候直接使用:target[key],在设置值的时候使用 target[key] = newValue 这种方式不是更能表达出日常的编码习惯吗?其实这里个人觉得有如下原因:

  1. 在写法上以一种函数式的方式更显优雅(这好像是废话😁);
  2. 使用 Reflect.set() 来设置属性会返回布尔值,而正好 proxy 在拦截 setter 时,需要一个返回一个布尔值;
  3. Reflect.get() 的第三个参数 receiver,可以修正 this 的指向,比如:
const obj = {
    a: 1,
    get b() {
      return this.a;
    },
};
const p = new Proxy(obj, {
    get: function (target, prop, receiver) {
      console.log(prop);
      return target[prop];
      // return Reflect.get(target, prop, receiver);
    },
});
p.b;

这段代码 console.log() 期待要执行两次,第一次正常读取 b 属性,第二次 this.a 也是一次读取操作,但实际只会执行一次,打印”b“;原因是 target[prop] 这种方式读取属性,this 指的原对象,也就是 obj,它并不是代理对象,因为无法触发 getter 方法。

依赖收集与触发更新,抽离到 effect 文件

新建 packages/reactivity/src/effect.ts 文件,要搞清楚这个文件的作用,得先建立起这么几个认知:

  1. 被操作的代理对象 obj,被操作的字段名,比如 text,副作用函数;三者之间的关系:
    target
    └── key            
        └── effect    
    
    类似于 WeakMap{key: target, value: Map{key: key,value: Set}},这是为了解决副作用函数嵌套的问题
  2. 每一个副作用函数在初始化执行时都会有一个全局变量 activeEffect ,当触发 getter 时,activeEffect 会被收集起来,执行 setter 时,依次执行 activeEffect。
// packages/reactivity/src/effect.ts
export let activeEffect = undefined;
const cleanupEffect = (context) => {
  for (let index = 0; index < context.deps.length; index++) {
    const element = context.deps[index];
    element.delete(context);
  }
  context.deps.length = 0;
};

export class ReactiveEffect {
  // 保存当前的 activeEffect,当有effect嵌套情况时,方便复位
  public parent = null;

  // 储存effect对应的依赖 方便effect卸载时 删除对应的依赖
  public deps = [];

  // 标记当前effect是否被激活 只有在激活状态 才会有依赖收集
  public active = true;

  constructor(public fn, public scheduler) {}

  run() {
    if (!this.active) {
      // 未激活 只需要执行fn即可
      return this.fn();
    }
    try {
      /* 
        effect嵌套时 activeEffect 与属性对应关系会错乱
        比如:
        effect(()=>{ effect1
            state.name name -> effect1
            effect(()=>{ effect2
                state.age  age -> effect2
            })
            state.address address -> undefined ( finally 执行的结果 )
        })
       */
      this.parent = activeEffect;
      activeEffect = this;
      cleanupEffect(this);
      return this.fn();
    } finally {
      // 让当前的 activeEffect 复位
      activeEffect = this.parent;
      this.parent = null;
    }
  }

  stop() {
    this.active = false;
    cleanupEffect(this);
  }
}

export const effect = (fn, options: any = {}) => {
  const _effect = new ReactiveEffect(fn, options.scheduler);

  const runner = _effect.run.bind(_effect);

  runner.effect = _effect;

  _effect.run();

  return runner;
};

const targetMap = new WeakMap();

export const trackEffects = (dep) => {
  const shouldTrack = dep.has(activeEffect);
  if (!shouldTrack) {
    dep.add(activeEffect);
    activeEffect.deps.push(dep);
  }
};

/**
 * 此处需要注意数据结构
 * weakMap{key:target,value:Map{key:key,value:Set}}
 * @param target
 * @param type
 * @param key
 * @returns
 */
export const track = (target, type, key) => {
  if (!activeEffect) return;
  let depMap = targetMap.get(target);
  if (!depMap) {
    targetMap.set(target, (depMap = new Map()));
  }
  let dep = depMap.get(key);
  if (!dep) {
    depMap.set(key, (dep = new Set()));
  }

  trackEffects(dep);
};

export const triggerEffects = (effects) => {
  if (effects) {
    /*
    逻辑分支切换时清除effect对应的依赖,对于Set数据的删除、新增、循环操作时 造成的死循环
       比如:effect(()=>flag?state.name:state.age)
    */
    effects = new Set(effects);
    effects.forEach((effect) => {
      /*
         解决循环调用trigger
         * 比如:
         * effect(() => {
              state.a = Math.random();
              document.getElementById("app").innerHTML = state.a;
            });
        */
      if (activeEffect !== effect) {
        // 如果用户自定义了更新函数 则执行
        if (effect.scheduler) {
          effect.scheduler();
        } else {
          effect.run();
        }
      }
    });
  }
};

export const trigger = (target, type, key) => {
  const depMap = targetMap.get(target);
  if (!depMap) return;
  let effects = depMap.get(key);
  triggerEffects(effects);
};

上述代码看起来很简单,但依然有至少三个点值得我们注意:

  1. effect 嵌套的问题;比如:

    effect(()=>{ // effect1
     effect(()=>{ // effect2
       state.age  
     })
     state.address
    })
    

    当我们执行 effect1 时,会导致 effect2 执行,此时的 activeEffect 会指向 effect2,当触发 getter 依赖收集时,实际上搜集的是 efffect2,那么我们的 effect1 即使在 setter 触发后依然得不到执行。因此 ReactiveEffect 类中的 parent 属性是用来解决这个问题的;

  2. 循环调用 trigger;比如:

    effect(()=>{
     obj.foo++
    })
    

    首先读取obj.foo,会触发 getter 进行依赖收集,紧接着又将 obj.foo 加1,又触发了 setter 操作,有需要调用 effect 了, 但是此时副作用函数 effect 并没有执行完,因此就会导致无限调用自己。解决办法就是在执行 trigger 函数时,如果当前的 activeEffect 与 调用的 effect 相同时则不执行。

  3. 逻辑分支的切换;比如:

    effect(()=> flag ? state.name : state.age)
    setTimeout(()=>{
       state.flag = false;
       setTimeout(()=>{
         state.name = '新值'
       },1000)
     },1000)
    

    期望的是flag 置为 false 后,即使改变了 state.name 后,effect 不执行。要解决这个问题,我们需要在 track 依赖收集之前,将之前已经收集的清空,重新收集。因此才有了上面 cleanupEffect 函数。但在执行 cleanupEffect 函数时,仍需注意 new Set() 陷阱。比如:

    // 伪代码 会造成无限循环
    const set = new Set([1]);
    set.forEach(item=>{
        set.delete(item);
        set.add(1);
    })
    

    要解决其实也很简单,我们只需要在 trigger 执行时重新复制一份 new Set() 即可, effects = new Set(effects) ,使其不要去执行同一个 Set 。

总结

到这里我们已经基本实现了 Vue3 整个的响应式系统,当然在源码中,其实比这个还要复杂得多。比如在 trigger 时,还判断了 type 来区分是哪种类型的更新操作等等。完整的框架实现是一个复杂的过程,在这里向尤大及其团队致敬😁。麻雀虽小五脏俱全,这个 mini-vue,至少使得我们理清了 getter/setter 的实现流程、一些边界条件以及与 Vue2 版本的巨大差异。感谢各位大佬赏脸阅读,如有不足,请指正,请轻喷😄。