2020년 1월 Vue.js 3.0.0 alpha가 발표된 이후, 2020년 9월 vue-next
에 코드네임 "One Piece"(그 만화 맞다.)로 3.0이 공식 릴리즈되었다. Vue.js 3에는 많은 변화가 있지만, 이 글에서는 내가 그동안 기다려왔던 새로운 반응형에 대해 살펴보고자 한다.
참고로 본문에 작성된 모든 코드들은 이해를 돕기 위해 vue@2.6.12
와 vue-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
코드의 흐름은 다음과 같다.
walk()
를 통해 data
의 각 속성을 순회하며 get/set
트랩을 추가한다.new Watcher()
의 실행을 통해 매개변수로 전달받은 getter
가 실행된다. 이때 data.a
와 data.b
에 접근하게 되며 각 속성의 get
트랩이 실행된다.
dep.depend()
가 호출되며, 이는 나중에 data.a
가 변경될 때 data.a
를 사용하고 있는 Dep.target
을 한 번 더 실행하여 반응형 처리가 되도록 한다.data.b
의 get
트랩은 2.1. 과 동일하게 동작한다.sum
이 3
으로 평가된다.data.a
를 7
로 변경한다.data.a
의 set
트랩이 호출되며 dep.notify()
를 호출한다.dep
이 가지고 있는 subs
배열엔 getter
가 들어있으므로 이를 실행하여 sum
을 9로 다시 평가한다.Vue.js 2의 반응형은 Object.defineProperty
를 사용하므로 객체 내의 속성이 추가되거나 배열 내의 원소가 변경될 경우 감지가 되지 않아 별도로 vm.$set()
을 사용하여 강제로 notify()
를 유도한다. 배열의 경우 observer/array.js
를 보면 push
, pop
, shift
같은 내장 메서드들에 한하여 반응형 처리를 지원하고 있다.
Vue.js 3에서는 const obj = reactive({a: 0})
과 같은 코드로 반응형 참조를 만들 수 있다. Vue.js 3의 반응형 또한 Vue.js 2의 흐름과 비슷하게 동작한다.
track
)하고trigger
)한다.단계별로 살펴보도록 하겠다.
track
: 반응형으로 실행할 코드를 저장Vue.js 3에서는 반응형을 위한 데이터 설계를 Map
, Set
, WeakMap
로 구현하였다. (obj
와 effect
는 예제용 코드이다.)
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');
위 코드의 컬렉션 구조를 도식화하면 다음과 같다.
targetMap<WeakMap>
은 반응형 객체가 될 target
을 저장한다.depsMap<Map>
은 각 반응형 객체의 값이 되며, 여기엔 target
의 key
가 저장된다.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
: 반응형 객체 생성이제 track
과 trigger
를 호출할 reactive
를 살펴보도록 한다. Vue.js 2와 달리 Vue.js 3에서는 Proxy
를 사용하여 구현하였다. Proxy
는 Object.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
이 호출되어 effect
가 deps
에 계속 추가가 된다는 점이다. 또한 반응형 객체에 따라 자동으로 실행되어야 할 표현식을 수동으로 등록해야 한다는 문제도 있다.
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}`);
코드의 흐름은 다음과 같다.
reactive({a: 1, b: 2})
로 반응형 참조를 생성한다.effect
함수에 sumFn
을 전달하여 실행한다.
sumFn
이 activeEffect
에 할당된다.sumFn
을 실행한다.
numbers
의 a
와 b
에 접근하고 activeEffect
가 존재하므로 각 속성의 deps<Set>
에 추후 실행할 코드로 sumFn
을 저장한다.sum
에 3
이 할당된다.effect
함수에 multiplyFn
을 전달하여 실행하고, 하위 과정은 동일하게 동작한다.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 진영 생태계가 더욱 확장되길 기대해본다.