实现一个简易版@Vue3/reactivity

1,669 阅读16分钟

前言

@Vue3/reactivity 模块作为可以独立于 Vue3 框架使用的一个模块,它可以单独作为一个响应式的工具库,用在任何项目中,甚至是Node项目。其底层原理是基于 Proxy 实现的,IE11 及以下是不能使用的,具体兼容性见: caniuse.com/proxy

值得注意的是, @Vue3/reactivity模块提供的只是一些基本的API,并不完全等同于Vue3的reactivity的API,Vue3的相关API其实是由该模块提供的最基础的API进行了封装

本文将一步一步实现一个简陋版的 reactivity 模块, 实现其比较关键的API和功能。文章中的完整代码在文章最后。

语言约定,@Vue3/reactivity 以下将会简称为「 响应式模块」 ,传入 effect API 中的函数,称为「 副作用函数 ref返回的变量叫「 ref变量 」,reactive返回的变量叫「 响应式变量 」。

用法、思想

最最简单的用法

import {effect, reactive} from @vue/reactivity
let a= {count: 0}
let value = reactive(a);

effect(() => {
  console.log('effect', a.value);
})

setTimeout(() => {
  value.count++
}, 1000)
// 将会打印// effect: 0
// effect: 1

该例子中,声明了一个 响应式变量 value,effect 传入其中的 副作用函数 马上运行了一遍。当 value 的 count 发生变化时, 副作用函数 又重新运行了一遍。

我们直观的可以感受到响应式模块暴露的两个最基本的 API 的用法:reactive 接受一个对象,返回一个响应式的对象。effect 函数接受一个函数,当函数中用到的响应式对象的属性发生变化的时候,副作用函数将会重新运行。

基本思想

了解 Vue2 的同学都知道,vue2 的响应式原理可以概括于下图:

通过图中我们知道,在渲染的时候,通过 getter 收集对应的依赖,当对应的依赖发生变化的时候,触发重新渲染。

vue3 的响应式模块其实也是相似的思想: reactive 函数的作用就是代理变量的 get 和 set 操作,用于收集依赖和通知更新。effect 函数对于传入的副作用函数,就是运行时收集对应的依赖,当对应的依赖发生变化时,重新运行副作用函数。 其本质是发布订阅模式

实现简易版

基于以上的思想,我们尝试实现一个简易版的响应式模块,跑通最最简单的例子,通过解决一些问题,然后逐步扩展讲解一些核心功能。

先实现 reactive

通过上面的例子,我们知道,reactive函数就是代理了传入其中变量的一些操作。基本上就是,get 时收集 target对应的依赖,set 时触发对应的依赖更新。

代码如下:

function reactive(target) {
  if (!isObject(target)) {
    console.error("target must be an object");
    return;
  }
  const proxyValue = new Proxy(target, {
    get: (target, key) => {
      track(target, key); // 收集依赖
      const result = Reflect.get(target, key);
      return result;
    },
    set: (target, key, value) => {
      const oldValue = target[key];
      const result = Reflect.set(target, key, value);
      if (oldValue !== value) {
        trigger(target, key);
      }
      return result;
    },
  });
  return proxyValue;
}

很简单,就是 get 的时候调用 track 函数收集依赖,set 的时候调用 trigger 函数触发更新。

再实现 trigger、track、effect

收集依赖,那么需要建立依赖和对应副作用的关系。源码中采用这样的数据结构: target -> key -> dep ,target 对应需要收集的对象,key 对应对象的 key,dep 对应副作用函数的集合。TS 的类型定义可能更加直观:

type Dep = Set<ReactiveEffect>;
type KeyToDepMap = Map<any, Dep>;
const targetMap = new WeakMap<any, KeyToDepMap>();

其中的 ReactiveEffect 就是副作用函数的类型。

所以 track 的实现思路,就是建立这样一个映射关系:

function track(target, key) {
  if (!activeEffect) {
    return;
  }

  let keyToDepMap = targetMap.get(target);
  if (!keyToDepMap) {
    // 如果该targe还没收集过依赖,就新建。
    keyToDepMap = new Map();
    targetMap.set(target, keyToDepMap);
  }

  let effects = keyToDepMap.get(key);
  if (!effects) {
    // 如果对应的key还没收集过依赖,就新建。
    effects = new Set();
    keyToDepMap.set(key, effects);
  }

  effects.add(activeEffect);
}

可能你注意到了,在 track 函数内部有一个 activeEffect,表示当前的副作用函数, 由于在收集依赖的时候,我们并不知道当前变量在哪个副作用函数中运行,所以我们依赖一个外部变量记录当前的副作用函数。

在实现完 track 完后,实现 trigger 就比较简单了: 找到依赖对应的副作用集合 ,然后全部运行一次就行了。

function trigger(target, key) {
  let keyToDepMap = targetMap.get(target);
  if (!keyToDepMap) {
    return;
  }
  let effects = keyToDepMap.get(key);
  if (!effects) {
    return;
  }
  effects.forEach((effect) => {
    effect();
  });
}

最后我们实现 effect 函数,如果就是为了跑通最简单的例子,其实他的作用就是把 activeEffect指向最新的副作用函数,然后把传入其中的函数自动运行了一遍罢了:

function effect(curEffect) {
  activeEffect = curEffect;
  activeEffect();
  activeEffect = undefined;
}

问题 1:深层次的结构没有变成响应式的。

const value = reactive({ foo: { bar: 1 } });
effect(() => {
  console.log("count:", value.foo.bar);
});

value.foo.bar = 2;// 并不会触发副作用函数。

问题的原因也很明显,我们在代理 get 操作的时候,直接把 result 返回了,而返回的值并不是响应式(并 没有代理 get 和 set 操作 ,也就是没有调用 track 跟踪操作,也就没有调用 trigger 触发运行副作用函数)。修改代码如下:

function reactive(target) {
  if (!isObject(target)) {
    console.error("target must be an object");
    return;
  }
  const proxyValue = new Proxy(target, {
    get: (target, key) => {
      track(target, key); // 收集依赖
      const result = Reflect.get(target, key);
      // 如果get的结果是一个对象,那么把它变成响应式的再返回。
      if (isObject(result)) {
        return reactive(result);
      }
      return result;
    },
    set: (target, key, value) => {
      const result = Reflect.set(target, key, value);
      if (result !== value) {
        trigger(target, key); //         触发更新
      }
      return result;
    },
  });
  return proxyValue;
}

我们都知道在 vue2 中实现响应式的原理是 defineProperty,在初始化的时候,总是递归的定义对象的所有属性描述符,当对象层级较深的时候,这务必会带来一定的性能问题。 在新的响应式模块的实现中,响应式代理总是"惰性“的,只有真正 get 到响应的结构,才会代理这部分结构的操作。

更改后,子结构也是响应式的:

const value = reactive({ foo: { bar: 1 } });
const { foo } = value; // 解构拿到的结构依然是响应式的。
effect(() => {
    console.log("effect:", foo.bar);
});
foo.bar = 2;

另一个问题:如果子结构不是对象,怎么办?

// 如果内层是基本类型
const value = reactive({ foo: 1 });
const { foo } = value; // 解构拿到的结构失去响应式。

如果内层结构是对象,我们拿到 内层解构依然是响应式的,但如果内层解构如果是基本类型,我们并不会代理。 那基本类型是怎样实现响应式的呢?由于js中简单类型的值传递是直接复制值,所以新的值与原来的值没有任何关系。所以只能通过ref引用简单值,来达到不丢失原始值的效果。

实现ref

响应式模块中提供ref API来解决这个问题,简单用法:

const count = ref(0);
effect(() => {
  console.log("effect:", count.value)
})
setTimeout(() => {
  count.value = count.value + 1
}, 1000)

其实就是ref包装的值,总是通过value引用来get/set原始的值。 所以实现上也是代理get、set value的操作

function ref(rawValue) {
  let _value = isObject(rawValue) ? reactive(value) : rawValue;
  const refValue = {
    get value() {
      track(refValue, 'value');
      return _value;
    },
    set value(_newValue) {
      if (_newValue !== _value) { //实际上要比较原始的值是否相同,而不是代理的。
        _value = _newValue;
        trigger(refValue, 'value');
      }
    }
  }
  return refValue;
}

代码中可以看到,如果传入的值是一个对象,就转换成了reactive的对象。在代理get/set value的时候调用track、trigger函数,收集、触发依赖。

解决问题

所以对于刚刚的问题,如果值不是对象,而是基本类型,我们可以拿到之后,自己做ref的转换操作。

const value = reactive({ foo: 1 });
const { foo } = value; // 解构拿到的结构失去响应式。
const fooRef = ref(foo);  // ref包装一下。
effect(() => {
    console.log('effect', fooRef.value)
})
fooRef.value = 2

如果每次要自己写,就很麻烦,所以响应式模块提供了 toRef,toRefs API ,toRef把响应式对象的指定key的value转化为ref对象,toRefs把所有key的value转换为ref对象,内部的实现都很简单,判断当前value是否是Ref对象,如果不是就转换。感兴趣的可以去看源码,这里就不一一实现了。

值类型问题

目前为止,已经有reactive类型、ref类型、正常类型......在正常使用的时候, 特别是ref类型的使用和转化,在响应式变量解构的时候,需要特别注意 。虽然模块提供了toRaw,toRef等 相关类型转换和判断类型的API ,但是还是需要 注意正确使用和理解其原理 。所以是有一定学习成本和使用的心智负担的。

Vu3的理念是渐进式框架,使用和学习的成本是渐进式的。所以新的 API还是被放在" 高级使用指南 "中, 提供更加灵活和可复用的可能性。

问题 2:数组 push 操作会爆栈。

const arr = reactive([]);

effect(() => {
    arr.push(1)
})
console.log(arr);

我们一步一步分析数组push 函数运行的时候的操作步骤: - 首先 get 拿到 arr.length

  - 然后 set 把 arr[length - 1] 设为 value 

  - 最后 set arr.length = length + 1 

通过步骤分析我们可知,第一步 get 拿到 length 的时候,收集了依赖,第三步 set arr.length 的时候又触发了副作用,导致副作用函数重新运行,然后又运行 push,然后又会 set arr.length,然后... BOOM!

既然是因为 length 的依赖收集导致,length 更新后重复触发副作用函数的运行,那是不是 length 属性不收集依赖就行了呢?

假设改动代码如下:

// ...if (Array.isArray(target) && key === "length") {
  // 如果是数组的length属性,直接返回对应的值。不进行依赖收集       
  return Reflect.get(target, key);
}

track(target, key);// ...

虽然看起来没啥问题,但是 在 js 中,数组的 length 是可以直接修改的 ,例如

arr = [1, 2, 3, 4];
arr.length = 2; // 相当于arr.splice(arr.length - 2, arr.length - 2)

所以显然直接忽略 length 的收集是不合理的,不仅如此, length 改变的时候,应该把该数组收集到的依赖的所有副作用函数都运行一遍(其实源码中针对数组、Map、Set等有很多特殊的处理,不具体展开处理,这里只是遇到了,简单提一下)

增加代码如下:

function trigger(target, key) {
  let keyToDepMap = targetMap.get(target);
  if (!keyToDepMap) {
    return;
  }

  if (Array.isArray(target) && key === "length") {
    keyToDepMap.forEach((effects) => {
      effects.forEach((effect) => {
        effect();
      });
    });
    return;
  }
  // ...
}

回到刚刚的问题,其实我们 应该需要一种机制,阻止进行依赖收集 。问题本身其实可以变成,push 操作的时候,不应该去收集依赖。修改代码如下:

// 包装的push函数
function wrappedPush(...args) {
    shouldTrack = false
    const result = Array.prototype.push.apply(this, args)
    shouldTrack = true
    return result
}

// get操作时特殊处理
 get: (target, key) => {
            //  如果判断是数组的push方法直接返回包装的函数。
            // 简化写法,另外的函数还有'push' 'pop', 'shift', 'unshift', 'splice'等。
            if (Array.isArray(target) && key === 'push') {
                return wrappedPush
            }

// 收集依赖的时候判断是否能最终依赖。
function track(target, key) {
    if (!shouldTrack) {
        return
    }

其实不只是 push 操作,类似的还有'push' 'pop', 'shift', 'unshift', 'splice'等函数。 这里简化了相应的代码!

控制依赖收集过程

我们引入了外部变量shouldTrack,标识是否进行依赖收集。我们可以提供相应的API, 这样可以灵活控制哪些代码不需要进行依赖收集

let shouldTrack = true
const trackStack = []
function pauseTracking() {
    trackStack.push(shouldTrack)
    shouldTrack = false
}
function enableTracking() {
    trackStack.push(shouldTrack)
    shouldTrack = true
}
function resetTracking() {
    const last = trackStack.pop()
    shouldTrack = last === undefined ? true : last
}

trackStack是一个储存shouldTrack的栈的数据解构。 为什么提供这种栈的数据解构,而不是直接改变shouldTrack的值呢?

是因为提供了enableTracking的API。 因为对于一个函数来说,如果内部进行了依赖控制,但是他并不知道运行他时,外部的依赖收集状态, 所以需要提供这样一个API。

// 灵活控制依赖收集过程
const aRef = ref('a');
const bRef = ref('b');
function foo() {
    enableTracking()
    console.log(bRef.value)
    resetTracking()
}
function fn() {
    pauseTracking()
    foo()
    console.log(aRef.value)
    resetTracking()
}
effect(() => {
    fn()
})
setTimeout(() => {
    // 会触发更新
    bRef.value = 'changeB'
    // 并不会触发更新
    aRef.value = 'changeA'
}, 1000)

问题 3:依赖的过度收集

let a = reactive({ foo: true, bar: 1 });
let dummy;
effect(() => {
  dummy = a.foo ? a.bar : 999;
  console.log("run!", dummy);
});
a.foo = false;
a.bar = 2; // 并不改变结果但是还是触发了副作用函数

当我们改变 foo 的时候,依赖发生改变,副作用函数会重新运行。但是之后改变 bar 的时候,其实这个时候,对 dummy 的值没有影响,但是副作用函数还是重新运行了。虽然在这里,副作用函数在这里多次运行没有什么关系。但是可以想象到,vue3 在编译模版,然后运行渲染函数的时候,实际上就是在进行依赖收集,当对应的依赖发生变化的时候,重新进行渲染。所以这里的问题是,如果分支条件发生变化,原来收集的依赖发生变化,依然会重新更新视图,这显然不是我们所期望的。

想要解决这个问题,其实也很简单粗暴,就是在运行 副作用函数的之前,重新去收集依赖 ,把之前的依赖都清除。 想要在运行副作用函数的之前,解除副作用函数对应的依赖,我们需要增加一层副作用函数到副作用集合的映射。直接看 TS 类型可能更加直观。

export interface ReactiveEffect<T = any> {
  (): T
  raw: () => T
  deps: Array<Dep>
         ...
}

type Dep = Set<ReactiveEffect>type 
KeyToDepMap = Map<any, Dep>
const targetMap = new WeakMap<any, KeyToDepMap>()

传入 effect 的副作用函数现在被包装成了 ReactiveEffect 函数类型 ,这个函数上收集着,Dep 的集合,也就是依赖映射副作用函数的集合。

在我们真正运行 ReactiveEffect 函数之前,我们拿到 deps,然后对其中的每个集合逐个删除自己。。。可能比较绕,直接看代码:

// 直接对effect函数进行改造
function effect(fn) {
  function reactiveEffect() {
    activeEffect = reactiveEffect;

    // 运行之前,先清除依赖。
    const { deps } = activeEffect;
    if (deps) {
      deps.forEach((dep) => {
        dep.delete(activeEffect);
      });
    }
    const result = fn();
    //         运行后需要重置为空
    activeEffect = null;
    return result;
  }

  // 源码用数组优化空间,这里简单用set。
  reactiveEffect.deps = new Set();

  reactiveEffect();
  return effect;
}

然后在一开始 track 建立依赖的时候,把依赖添加进 deps 里:

function track(target, key) {
     ...

    // 建立双向映射。
    activeEffect.deps.add(effects)
    effects.add(activeEffect);
}

这里注意需要修改 trigger 函数:

function trigger(target, key) {
          ...

    // 拿到触发时的副作用函数,防止后续重复添加导致死循环
    [...effects].forEach((effect) => {
        // 防止副作用函数中更改依赖,自己触发自己,爆栈。
        if (effect === activeEffect) {
            return;
        }
        effect();
    });
}

至此我们解决了依赖过度收集的问题, 解决的办法就是建立副作用函数与副作用函数的双向映射在运行前清除依赖,在运行时重新收集依赖

完整的数据结构

问题 4:在 effect 基础上实现 computed

const fooRef = ref(1);
const computedValue = computed(() => {
    console.log("computed:", fooRef.value);
    return fooRef.value;
});
console.log(computedValue.value) // computed: 1
fooRef.value = 2;
fooRef.value = 3;
console.log(computedValue.value) // 这里才会打印computed:3

computed 函数运行总是惰性的,没有访问它的 value 之前,他是不会主动运行函数去计算的,而且依赖没有发生变化,他会直接从缓存中取值直接返回。

实现思路:我们期望依赖不发生变化的时候,不重新运行函数(因为一般来说都相当耗时), 所以给effect 函数增加可以选择的参数,我们添加一个 lazy,scheduler 两个选项。 首次运行时候,判断是否有 lazy,就不初始化运行收集依赖 。在第一次 get 之后,才收集到相应的依赖。 后续如果依赖没有变化,直接从缓存中去拿值。 如果依赖发生变化, 再次 get 的时候,重新运行副作用函数,更新缓存

function computed(fn) {
    let obj = {};
    let cache;
    let dirty = true; // 可能会有新的变化
    const effectFn = effect(fn, {
        lazy: true,
        scheduler: () => {
            // 对应依赖发生变化,更新标志。
            dirty = true;
            trigger(obj, "value")
        },
    });
    Object.defineProperty(obj, "value", {
        get: () => {
            if (dirty) {
                dirty = false;
                // 依赖发生变化,更新cache
                cache = effectFn();
            }
            track(obj, "value");
            return cache;
        },
    });
    return obj;
}

function effect(fn, options = {}) {
    function reactiveEffect() {
                        ...
    }

    // 把options直接挂载到函数上
    reactiveEffect.options = options;
    if (!options.lazy) {
        // 不自动运行一次收集依赖。
        reactiveEffect();
    }

    return reactiveEffect;
}

function trigger(target, key) {
                ...

    [...effects].forEach(effect => {
              if(effect !== activeEffect){
           if (effect.options.scheduler) {
             // 如果有scheduler,那么运行
            effect.options.scheduler();
          } else {
              effect()
          }
        }
    });
}

scheduler选项

在vue3暴露出的API中, 并非直接暴露effect,而是提供了computed,watch,watchEffect等API。这些API是runtime-core暴露出来的,也就是说这些基本API与run-timecore的实现结合了,才重新暴露出来的。副作用函数与组件的实例绑定,便于在组件销毁时删除。

这主要说一下watch和watchEffect实现(用法见文档: vue3js.cn/docs/zh/api… ),他们在源码中都是通过doWatch同一个函数实现的。值得注意的是, Vue中在依赖发生变化后,可能不会立即运行副作用函数,函数的运行可能是异步的。看看一段源码:

  let scheduler
  if (flush === 'sync') {
    scheduler = job
  } else if (flush === 'post') {
    scheduler = () => queuePostRenderEffect(job, instance && instance.suspense)
  } else {
    // default: 'pre'
    scheduler = () => {
      if (!instance || instance.isMounted) {
        queuePreFlushCb(job)
      } else {
        // with 'pre' option, the first call must happen before
        // the component is mounted so it is called synchronously.
        job()
      }
    }
  }

  const runner = effect(getter, {
    lazy: true,
    onTrack,
    onTrigger,
    scheduler
  })



源码中,把watchEffect传入的副作用函数,和watch传入的观察变量变化的数组都转换为了getter函数。通过代码不难看出, 通过 scheduler 选项,可以灵活的安排函数运行的时机

问题 5:嵌套 computed 与 effect。

const nums = reactive({ num1: 1, num2: 2, num3: 3 })

const count = computed(() => nums.num1 + nums.num2 + nums.num3)
effect(() => {
        console.log('count:'count)
})
nums.num1 = -1;

例子中:展示了副作用函数与计算值之间相互嵌套,互相引用(运行)的例子。实际上副作用函数中的计算属性的值的依赖发生变化就可能重新产生新的值,我们期望副作用函数也重新运行。

我们在上面实现 computed 的时候,在 get value 的时候调用 track 收集依赖,在依赖发生变化不仅更新了 dirty,还调用了 trigger 触发通知。这样我们代理了 computed 的操作,让他也能像正常的响应式变量一样,能收集依赖并触发。但是运行上面的例子运行后,并不能触发 effect 的函数变化。

原因是在运行完 effect 时,由于 computed 时惰性运行的,所以在 get Value 的时候才会运行传入 computed 其中的函数,此时运行 reactiveEffect,先改变了 activeEffect,此时收集了 computed 的依赖,但是收集完成后,activeEffect 被置为了 null,然后再触发 track,由于此时 activeEffect 是 null,依赖收集失败,所以改变 nums.num1 的值不会触发副作用函数重新运行。 简单的说就是,activeEffect被多次赋值,导致没有真正表达当前需要收集依赖的副作用函数。

要想解决这个问题,就需要在内层收集完依赖后,还能还原 ativeEffect。想到之前shouldTrack面对嵌套的解决办法,就是增加了栈的结构,这里也是同样的办法: 用effectStack 来记录副作用函数栈,然后activeEffect 永远指向栈顶。

let effectStack = []
function effect(fn, options = {}) {
    function reactiveEffect() {
        activeEffect = reactiveEffect;
        // 运行前先入栈
        effectStack.push(activeEffect);

       ...

        const result = fn();
        // 运行完后,出栈
        effectStack.pop();
        activeEffect = effectStack[effectStack.length - 1];
        return result
    }
                ...
}

疯狂套娃的结构也能正常运行。

const nums = reactive({ num1: 1, num2: 2, num3: 3 });
let dummy1;
dummy1 = computed(() => 1 + nums.num1); // 2
let dummy2;
dummy2 = computed(() => dummy1.value + nums.num2); // 4
const fn = effect(() => {
    console.log("fn", dummy2.value + nums.num3); // 7
});
effect(() => {
    fn();
});
nums.num1 = 3;

总结

这个模块代码设计的基本思想是订阅发布模式。其内部的数据结构如上图。我们知道了内部reactive、ref、effect的实现,控制依赖收集的机制及解决嵌套的问题都是用了栈的数据结构,通过scheduler选项,可以灵活控制函数执行时机等。

附录

完整代码: codesandbox.io/s/bold-star… 两个Vue 的库: