Vue.js 3 반응형 동작 원리 살펴보기


2020년 1월 Vue.js 3.0.0 alpha가 발표된 이후, 2020년 9월 vue-next에 코드네임 "One Piece"(그 만화 맞다.)로 3.0이 공식 릴리즈되었다. Vue.js 3에는 많은 변화가 있지만, 이 글에서는 내가 그동안 기다려왔던 새로운 반응형에 대해 살펴보고자 한다.

참고로 본문에 작성된 모든 코드들은 이해를 돕기 위해 vue@2.6.12vue-next@3.0.4에서 반응형 코드의 흐름만 살려 간단하게 작성한 코드이다.

들어가기 전에

Vue.js 3의 반응형을 살펴보기에 앞서 Vue.js 2의 반응형은 어떻게 구현되어 있고, 어떤 주의사항이 있었는지 짚고 넘어가도록 한다. Vue.js 2는 Vue 인스턴스에 data 옵션으로 전달되는 객체의 모든 속성을 순회하며 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

코드의 흐름은 다음과 같다.

  1. walk()를 통해 data의 각 속성을 순회하며 get/set 트랩을 추가한다.
  2. new Watcher()의 실행을 통해 매개변수로 전달받은 getter가 실행된다. 이때 data.adata.b에 접근하게 되며 각 속성의 get 트랩이 실행된다.

    1. dep.depend()가 호출되며, 이는 나중에 data.a가 변경될 때 data.a를 사용하고 있는 Dep.target 을 한 번 더 실행하여 반응형 처리가 되도록 한다.
    2. data.bget 트랩은 2.1. 과 동일하게 동작한다.
  3. sum3으로 평가된다.
  4. data.a7로 변경한다.
  5. data.aset트랩이 호출되며 dep.notify()를 호출한다.
  6. dep이 가지고 있는 subs 배열엔 getter가 들어있으므로 이를 실행하여 sum을 9로 다시 평가한다.

Vue.js 2의 반응형은 Object.defineProperty를 사용하므로 객체 내의 속성이 추가되거나 배열 내의 원소가 변경될 경우 감지가 되지 않아 별도로 vm.$set()을 사용하여 강제로 notify()를 유도한다. 배열의 경우 observer/array.js를 보면 push, pop, shift 같은 내장 메서드들에 한하여 반응형 처리를 지원하고 있다.

Vue.js 3의 반응형

Vue.js 3에서는 const obj = reactive({a: 0})과 같은 코드로 반응형 참조를 만들 수 있다. Vue.js 3의 반응형 또한 Vue.js 2의 흐름과 비슷하게 동작한다.

  1. 값에 접근할 때 실행할 코드를 저장(track)하고
  2. 값이 변경될 때마다 저장한 코드를 실행(trigger)한다.

단계별로 살펴보도록 하겠다.

track: 반응형으로 실행할 코드를 저장

Vue.js 3에서는 반응형을 위한 데이터 설계를 Map, Set, WeakMap로 구현하였다. (objeffect는 예제용 코드이다.)

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');

위 코드의 컬렉션 구조를 도식화하면 다음과 같다.

image.png

  1. targetMap<WeakMap>은 반응형 객체가 될 target을 저장한다.
  2. depsMap<Map>은 각 반응형 객체의 값이 되며, 여기엔 targetkey가 저장된다.
  3. dep<Set>은 각 key가 변경될 때 실행될 코드를 저장하는 컬렉션이다.

trigger: 저장된 코드를 실행

다음은 trigger의 기본 흐름이다. targetMap에서 depsMap을 찾고, 트랩의 key를 통해 dep에서 실행할 코드를 찾고 실행한다.

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

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

reactive: 반응형 객체 생성

이제 tracktrigger를 호출할 reactive를 살펴보도록 한다. Vue.js 2와 달리 Vue.js 3에서는 Proxy를 사용하여 구현하였다. ProxyObject.defineProperty와 달리 원본 객체를 수정하지 않고, 객체 내의 새로운 속성이 추가되는 등의 변화 또한 감지할 수 있다는 장점이 있다.

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

지금까지의 코드에는 약간의 문제가 존재한다. 반응형으로 처리된 객체의 모든 속성에 접근할 때마다 get 트랩이 호출되고 track이 호출되어 effectdeps에 계속 추가가 된다는 점이다. 또한 반응형 객체에 따라 자동으로 실행되어야 할 표현식을 수동으로 등록해야 한다는 문제도 있다.

Vue.js는 이를 activeEffect라는 변수로 해소한다. get 트랩에서는 activeEffect가 존재할 때만 deps에 추후 실행할 코드(activeEffect)를 추가하는 것이다.

모든 코드 조각 합치기

이제 위에서 살펴봤던 각 요소들을 하나로 묶어 Vue.js 3 Reacitivty의 흐름을 살펴보겠다.

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}`);

코드의 흐름은 다음과 같다.

  1. reactive({a: 1, b: 2})로 반응형 참조를 생성한다.
  2. effect 함수에 sumFn을 전달하여 실행한다.

    1. sumFnactiveEffect에 할당된다.
    2. sumFn을 실행한다.

      1. numbersab에 접근하고 activeEffect가 존재하므로 각 속성의 deps<Set>에 추후 실행할 코드로 sumFn을 저장한다.
      2. 함수의 실행에 따라 sum3이 할당된다.
  3. effect 함수에 multiplyFn을 전달하여 실행하고, 하위 과정은 동일하게 동작한다.
  4. numbers.a가 변경되면 a에 할당된 deps<Set>를 찾아 들어있는 모든 함수를 실행한다.

정리하며

Vue.js 3 Reactivity 문서를 보면 글에서 다루지 못한 다양한 반응형의 처리가 있으니 읽어보면 개발에 도움이 될 것이다. 2020년 Vue.js 3의 발표와 더불어 웹 개발 빌드 도구인 Vite가 출시되었고, 하이브리드 앱 개발에 사용되는 Ionic 프레임워크에는 Vue 3가 호환되는 @ionic/vue 개발되었다.

2021년에는 기존에 사용되던 도구들과 컴패니언 라이브러리들이 더 많이 Vue.js 3에 호환성을 갖추고 이를 통해 Vue.js 진영 생태계가 더욱 확장되길 기대해본다.

이진우2021.01.12
Back to list