Ins and Outs of Vue 3 Reactivity


Vue.js 3.0.0 alpha was released on January 2020, and nine months later, on September 2020, vue-next 3.0 was released officially with the code name "One Piece" (yes, the anime). There was a lot of changes with the Vue 3, but I will discuss mainly on the new reactivity system for which I've been waiting.

In order to facilitate your understanding, this article contains simple codes that highlight the flow of the reactivity systems of vue@2.6.12 and vue-next@3.0.4.

Preface

Before we dive into how reactivity system in Vue 3 works, let's take a look at how it is implemented in Vue 2 and some of its caveats. Vue 2 implements reactivity system by going through all attributes of the data option that is passed to the Vue instance and using the Object.defineProperty.

class Dep {
  constructor () {
    this.subs = [];
  }
  depend() {
    if (Dep.target) {
      this.subs.push(Dep.target);
    }
  }
  notify() {
    for (let i = 0; i < this.subs.length; i++) {
      const sub = this.subs[i];
      sub();
    }
  }
}

class Watcher {
  constructor(getter) {
    this.getter = getter;
    this.get();
  }

  get() {
    Dep.target = this.getter;
    this.getter();
    Dep.target = null;
  }
}

function walk(data) {
  const keys = Object.keys(data);
  for (let i = 0; i < keys.length; i++) {
    defineReactive(data, keys[i]);
  }
}

function defineReactive(obj, key) {
  const dep = new Dep();
  let val = obj[key];

  Object.defineProperty(obj, key, {
    get() {
      dep.depend();
      return val;
    },
    set(newVal) {
      if (val === newVal) {
        return;
      }
      val = newVal;
      dep.notify();
    }
  })
}

const data = {a: 1, b: 2};
walk(data);

let sum = 0;
const watcher = new Watcher(() => {
  sum = data.a + data.b;
});

console.log(sum); // 3
data.a = 7;
console.log(sum); // 9

Here's how the code works, step-by-step.

  1. Vue walks through each and every property of data object with the walk() and adds get/set traps.
  2. The getter that was passed as parameter is executed with the new Watcher(). Then, Vue accesses data.a and data.b and activates the get trap.

    1. dep.depend is called, and when data.a changes, dep.depend() executes Dep.target that uses data.a and makes it reactive.
    2. The steps for the data.b's get trap is the same as 2.1.
  3. sum is evaluated to be 3.
  4. data.a is changed to 7.
  5. data.a's set trap is called and calls the dep.notify().
  6. The subs array found in dep has a getter, so the getter is executed to reevaluate the value of sum to be 9.

Vue 2 reactivity system uses the Object.defineProperty, and therefore, cannot detect if a new property is added to the object or if an element of an array changes. Vue 2 had to use vm.$set() method to induce notify() by force. For arrays, if you take a look at observer/array.js, internal methods like push, pop, and shift support reactivity.

Vue 3 Reactivity

In Vue 3, you can make a reactive proxy like const obj = reactive({a: 0}). The Vue 3 reactivity system itself functions similarly to the Vue 2 reactivity system.

  1. track the code when you access a value
  2. trigger the tracked code when the value changes.

Let's take it step by step.

track: the code that will be executed reactively

Vue 3 uses the Map, Set, and WeakMap to implement the reactive data system. (obj and effect were used strictly as examples.)

const targetMap = new WeakMap();
function track(target, key) {
  let depsMap = targetMap.get(target);
  if (!depsMap) {
    targetMap.set(target, (depsMap = new Map()));
  }
  let dep = depsMap.get(key);
  if (!dep) {
    depsMap.set(key, (dep = new Set()));
  }
  dep.add(effect);
}

const numbers = {a: 1, b: 2};
const effect = () => {
  const sum = numbers.a + numbers.b;
}
track(numbers, 'a');

If you map out the collection structure above, you get the following.

image.png

  1. targetMap<WeakMap> tracks the target that will be a reactive object.
  2. depsMap<Map> becomes the values for each reactive object and stores the key for each target.
  3. dep<Set> collection tracks the codes that are executed every time the key is changed.

trigger: executes the tracked code

Now, here's the basics of the trigger. The trigger finds the depsMap from the targetMap and then finds and executes the code to execute from the dep through the trap's key.

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

  const dep = depsMap.get(key);
  if (dep) {
    dep.forEach(eff => eff());
  }
}

reactive: creating the reactive object

Let's look at the reactive function that calls track and trigger. Unlike Vue 2, Vue 3 uses a Proxy to implement the reactivity. Proxy does not modify the original object unlike Object.defineProperty and can detect new properties or changes of an object.

function reactive(target) {
  const proxy = new Proxy(
    target,
    {
      get(target, key, receiver) {
        const res = Reflect.get(target, key, receiver);
        track(target, key)

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

        if (oldValue !== res) {
          trigger(target, key, value, oldValue);
        }
        return res;
      }
    }
  )

  return proxy;
}

const numbers = reactive({a: 1, b: 2});

activeEffect

So far, there is a small problem with the code. Whenever we access all properties of a reactive object, the get trap is called, calling track and continuously adding effect to deps. Furthermore, some reactive objects may require automatic response expressions to be registered manually.

Vue addresses this issue by the activeEffect variable. The code that will be executed reactively, the response expression, is only added to the deps when the activeEffect exists in the get trap.

Putting it all together

Let's put everything together and look at the flow of the Vue 3 Reactivity as a whole.

const targetMap = new WeakMap();

function track(target, key) {
  if (!activeEffect) {
    return;
  }
  let depsMap = targetMap.get(target);
  if (!depsMap) {
    targetMap.set(target, (depsMap = new Map()));
  }
  let dep = depsMap.get(key);
  if (!dep) {
    depsMap.set(key, (dep = new Set()));
  }
  if (!dep.has(activeEffect)) {
    dep.add(activeEffect);
  }
}

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

  const dep = depsMap.get(key);
  if (dep) {
    dep.forEach(effect => effect());
  }
}

function reactive(target) {
  const proxy = new Proxy(
    target,
    {
      get(target, key, receiver) {
        const res = Reflect.get(target, key, receiver);
        track(target, key);

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

        if (oldValue !== res) {
          trigger(target, key, value, oldValue);
        }
        return res;
      }
    }
  )

  return proxy;
}

let activeEffect = null;

function effect(fn) {
  activeEffect = fn;
  fn();
  activeEffect = null;
}

const numbers = reactive({a: 1, b: 2});
const sumFn = () => { sum = numbers.a + numbers.b; };
const multiplyFn = () => { multiply = numbers.a * numbers.b };

let sum = 0;
let multiply = 0;

effect(sumFn);
effect(multiplyFn);

console.log(`sum: ${sum}, multiply: ${multiply}`);
numbers.a = 10;
console.log(`sum: ${sum}, multiply: ${multiply}`);

Here's how the code works, step-by-step.

  1. Create a reactive proxy with reactive({a: 1, b: 2}).
  2. Pass and execute the sumFn to the effect function.

    1. sumFn is assigned to the activeEffect.
    2. sumFn is executed.

      1. Access numbers a and b, and since activeEffect exists, set the sumFn to be executed later in deps<Set>.
      2. 3 is assigned to the sum as the function executes.
  3. Pass and execute the multiplyFn to the effect function, and the steps 2.1 and 2.2 are repeated.
  4. When the numbers.a changes, Vue finds all functions tracked in a's deps<Set> and executes every function.

Conclusion

The official Vue 3 Reactivity guide goes over a lot more of reactive handling, so taking the time to read through the document should help your understanding and development. In 2020, Vue released a web development kit Vite along with the Vue 3, and Ionic, framework used for hybrid web development, developed @ionic/vue, which is compatible with Vue 3.

In 2021, I look forward to more tools and companion libraries to gain compatibility with Vue.js 3, thereby extending the realm of Vue.js.

Jinwoo Lee2021.01.12
Back to list