본문 바로가기

FrontEnd

Vue3의 반응성 완벽 이해 1편 : 종속성 추적

반응형

vue mastery의 리액트 코어팀이 설명해주는 vue3의 반응성의 원리를 알아봅니다.

공식 문서nhn의 게시물을 읽어봤지만, 약간 납득이 안되는 부분들이 있었습니다.

해당 강의를 통해 어느정도 해소된 느낌입니다.

해당 아티클에서 다룰 내용

  • Vue3 Reactive 모듈의 디자인 패턴 및 디자인 디시전
  • Vue3 반응성의 원리

뷰의 반응성 이해하기

아래의 간단한 뷰 앱을 봅시다.

해당 앱의 뷰모델은 세 가지 역할을 수행해야 합니다.

  • 페이지의 product.price 값 업데이트
  • 페이지의 product.price * product.quantity 값 업데이트
  • totalPriceWithTax 함수를 다시 호출하고 페이지 업데이트
<div id="app">
  <div>Price: ${{ product.price }}</div>
  <div>Total: ${{ product.price * product.quantity }}</div>
  <div>Taxes: ${{ totalPriceWithTax }}</div>
</div>
<script src="https://cdn.jsdelivr.net/npm/vue"></script>
<script>
  var vm = new Vue({
    el: '#app',
    data: {
      product: {
        price: 5.00,
        quantity: 2
      }
    },
    computed: {
      totalPriceWithTax() {
        return this.product.price * this.product.quantity * 1.03
      }
    }
  })
</script>

 

주 : 엄밀하게 말해서 모던 프레임워크를 기존에 MVC, MVVM에 끼워맞추는 것은 틀린 것입니다.
각 모던 프레임워크는 Model, View, ViewModel 혹은 기타 컴포넌트 대한 각자의 정의와 솔루션이 있습니다.
리액트가 참고한 아키텍처 https://guide.elm-lang.org/architecture/는 model , view, update개념을 다루고 있으며,
Vue.js의 초기 문서는 https://012.vuejs.org/guide/ Vue 프레임워크 전체가 뷰 모델이며, view는 DOM, model은 plane js object로 정의합니다.

뷰의 반응성 시스템은 값의 변화를 어떻게 추적할까요?

 

이는 일반적으로 JS 프로그래밍이 동작하는 방식이 아닙니다.

즉, 위에서 아래로 한번 실행하면 끝입니다.

let product = { price: 5, quantity: 2 }
let total = product.price * product.quantity  // 10 right?
product.price = 20
console.log(`total is ${total}`)

우리는 중간에 price를 업데이트 했으니 total이 40이 되길 원할 수 있습니다.

나중에 실행될 코드 저장

문제

위의 코드에서 본 것처럼 반응성 구축을 시작하려면

price이나 quantity가 변경될 때 다시 실행할 수 있도록 total를 계산하는 방법을 저장해야 합니다.

다시 실행할 수 있도록 함수(효과)을 기록하여 이 작업을 수행할 수 있습니다.

let product = { price: 5, quantity: 2 }
let total = 0

let effect = function () { 
  total = product.price * product.quantity
})

track() // 나중에 실행되도록 기억
effect() // 해당 함수를 실행

effect 변수 안에 익명 함수를 저장한 다음 track 함수를 호출합니다.

track을 정의하려면 effect를 저장할 장소가 필요합니다.

또한 많은 effect가 존재할 수 있기 떄문에, 여러 개를 저장해야 합니다.

dep라는 종속성을 의미하는 변수를 만듭니다.

 

옵저버(Observer) 디자인 패턴을 사용하면

  • 일반적으로 종속성(Dependency)은 객체가 상태를 변경할 때 알림을 받는 구독자(Subscriber;우리의 경우 effect)를 소유하고 있습니다.
    • 종속성은 옵저버의 변경을 구독하는 구독자(효과)를 갖고 있다.

Vue 2 버전에서처럼 종속성을 구독자 배열이 있는 클래스로 만들 수 있습니다.

그러나 저장해야 하는 것은 효과 집합 뿐이므로 Set을 사용 가능합니다.

let dep = new Set() // Our object tracking a list of effects

그런 다음 track 함수는 이 컬렉션에 효과를 추가하기만 하면 됩니다.

function track () {
  dep.add(effect) // Store the current effect
}

 

우리는 나중에 실행할 수 있도록 효과를 저장하고 있습니다(이 경우 { total = price * quantity }). 다음은 이 dep Set의 시각화입니다.

나중에 실행할 effect를 deps 집합에 저장

우리가 기록한 모든 효과를 실행하는 trigger 함수를 작성해 봅시다.

이것은 우리가 dep Set 내부에 저장한 모든 익명 함수 각각을 실행합니다.

let product = { price: 5, quantity: 2 }
let total = 0
let dep = new Set()

function track() {
  dep.add(effect)
}

function trigger() {
  dep.forEach(effect => effect())
}

let effect = () => {
  total = product.price * product.quantity
}

track()
effect()

product.price = 20
console.log(total) // => 10

trigger()
console.log(total) // => 40

속성 별로 효과 추적하기

Vue3은 reactive혹은 ref 함수를 통해 반응형 객체를 생성합니다.

이를 자주 반응성 참조 객체라고 부르는데요,

그 이유는 원본 객체의 프록시이기 때문입니다. (자세한 설명은 추후에)

 

반응성 참조 객체는 서로 다른 속성을 갖게 되며 이러한 속성은 각각 고유한 dep(효과 세트)가 필요합니다.

여기에서 우리의 객체를 살펴볼까요?

let product = { price: 5, quantity: 2 }

즉, price를 사용하는 효과, quantity를 사용하는 효과를 따로 추적해야 합니다.

이를 올바르게 기록하기 위한 솔루션을 구축해 봅시다.

솔루션: depsMap

이제 track 또는 trigger를 호출할 때  우리가 목표로 삼고 있는 객체의 속성(price 또는 quantity)을 알아야 합니다.

이를 위해 Map 타입(키와 값을 생각해 보세요)인 depsMap을 생성합니다.

 

하나의 객체 > depsMap에 해당

하나의 객체의 key의 의존성 > depsmap[key]

아키텍처 시각화

depsMap에 새 효과를 추가(또는 추적)하려는 속성 이름이 되는 키가 어떻게 있는지 확인하십시오.

따라서 이 키를 track 함수에 보내야 합니다.

아래 코드를 자세히 보면, 의존성 추적은 lazy하게 발생함을 알 수 있습니다.

(모든 key를 depsMap에 initialize하지 않음)

const depsMap = new Map()
function track(key) {
  // Make sure this effect is being tracked.
  let dep = depsMap.get(key) // Get the current dep (effects) that need to be run when this key (property) is set
  if (!dep) {
    // There is no dep (effects) on this key yet
    depsMap.set(key, (dep = new Set())) // Create a new Set
  }
  dep.add(effect) // Add effect to dep
}
  }
function trigger(key) {
  let dep = depsMap.get(key) // Get the dep (effects) associated with this key
  if (dep) { // If they exist
    dep.forEach(effect => {
      // run them all
      effect()
    })
  }
}

let product = { price: 5, quantity: 2 }
let total = 0

let effect = () => {
  total = product.price * product.quantity
}

track('quantity')
effect()
console.log(total) // --> 10

product.quantity = 3
trigger('quantity')
console.log(total) // --> 40

문제 : 여러 개의 반응성 객체

위 솔루션은 하나의 객체에 대한 해답입니다.

이제 각 객체(예: product)에 대한 depsMap을 저장하는 방법이 필요합니다.

우리는 각 객체에 대해 하나씩 또 다른 맵이 필요하지만 키는 뭘로 해야 하나요?

WeakMap을 사용하면 실제로 객체 자체를 키로 사용할 수 있습니다.

WeakMap은 객체만 키로 사용하는 JavaScript Map입니다.

아래는 예시입니다.

let product = { price: 5, quantity: 2 }
const targetMap = new WeakMap()
targetMap.set(product, "example code to test")
console.log(targetMap.get(product)) // ---> "example code to test"

대상(target) 객체를 저장하는 weakMap이므로 targetMap이라고 부릅니다.

이것이 target이라고 불리는 또 다른 이유가 있습니다만, 이후에 설명하겠습니다.

목표 객체를 키로, 값을 이전의 depsMap으로

track 또는 trigger를 호출할 때 이제 tareget(대상) 객체와 대상 속성(key)을 알아야 합니다.

따라서 target과 key를 모두 파라미터로 전달합니다.

const targetMap = new WeakMap() // targetMap stores the effects that each object should re-run when it's updated

function track(target, key) {
  // We need to make sure this effect is being tracked.
  let depsMap = targetMap.get(target) // Get the current depsMap for this target

  if (!depsMap) {
    // There is no map.
    targetMap.set(target, (depsMap = new Map())) // Create one
  }

  let dep = depsMap.get(key) // Get the current dependencies (effects) that need to be run when this is set
  if (!dep) {
    // There is no dependencies (effects)
    depsMap.set(key, (dep = new Set())) // Create a new Set
  }

  dep.add(effect) // Add effect to dependency map
}

function trigger(target, key) {
  const depsMap = targetMap.get(target) // Does this object have any properties that have dependencies (effects)
  if (!depsMap) {
    return
  }

  let dep = depsMap.get(key) // If there are dependencies (effects) associated with this
  if (dep) {
    dep.forEach(effect => {
      // run them all
      effect()
    })
  }
}

let product = { price: 5, quantity: 2 }
let total = 0
let effect = () => {
  total = product.price * product.quantity
}

// 객체를 키로, 객체의 quantitiy 구독
track(product, 'quantity')
effect()
console.log(total) // --> 10

product.quantity = 3
trigger(product, 'quantity')
console.log(total) // --> 15

지금까지 우리는 직접 track및 trigger를 호출하고 있었습니다.

ES6 프록시를 사용하여 자동으로 track 및 trigger를 호출하는 방법을 알아봅니다.

Proxy and Reflect

반응성 객체에서 get 및 set 메서드를 hooking(또는 리스닝)하는 방법이 필요합니다.

  • GET 속성 => 현재 효과를 추적해야 합니다.
  • SET 속성 => 이 속성에 대해 추적된 종속성(effect)을 트리거해야 합니다.

이를 수행하는 방법을 이해하는 첫 번째 단계는

Vue 3에서 ES6 Reflect 및 Proxy를 사용하여 GET 및 SET 호출을 가로채는 방법을 이해하는 것입니다.

Vue 2에서는 ES5 Object.defineProperty로 이 작업을 수행했습니다.

이는 기존 객체를 직접 조작하며, 프록시와 다르게 반응형을 레이지하게 구현하기 어렵다는 단점이 있었습니다.

Understanding ES6 Reflect

객체 속성을 다음과 같이 print 할 수 있습니다.

let product = { price: 5, quantity: 2 }
console.log('quantity is ' + product.quantity)
// or 
console.log('quantity is ' + product['quantity'])

그러나 Reflect를 사용하여 객체의 값을 GET할 수도 있습니다.

Reflect를 사용하면 객체의 속성을 가져올 수 있습니다.

위에서 쓴 것을 수행하는 또 다른 방법입니다.

console.log('quantity is ' + Reflect.get(product, 'quantity'))

Reflect를 사용하는 이유는 뭘까요?

Understanding ES6 Proxy

프록시는 기본적으로 메서드의 실행을 대상 객체에 위임하는 Placeholder입니다.

따라서 다음 코드를 실행하면 quantity의 get을 다른 객체에 위임합니다.

let product = { price: 5, quantity: 2 }
let proxiedProduct = new Proxy(product, {})
console.log(proxiedProduct.quantity)

프록시 객체의 {}를 사용하는 두 번째 인수에 주목하세요

이것을 핸들러라고 하며 get 및 set 호출을 가로채는 것과 같이

프록시 객체의 동작을 사용자 정의할 수 있습니다.

이러한 인터셉터 메서드를 트랩(trap)이라고 합니다.

프록시 생성자의 두번째 인자 핸들러 객체의 메서드 : 트랩

핸들러에서 get 트랩을 설정하는 방법은 다음과 같습니다.

let product = { price: 5, quantity: 2 }

let proxiedProduct = new Proxy(product, {
  get() {
    console.log('Get was called')
    return 'Not the value'
  }
})

console.log(proxiedProduct.quantity)

콘솔은 다음과 같이 출력됩니다.

Get was called
Not the value

즉 우리는 대상 객체의 get이 리턴하는 값을 재정의 했습니다.

하지만 우리는 실제 객체의 값을 리턴해야 합니다.

let product = { price: 5, quantity: 2 }

let proxiedProduct = new Proxy(product, {
  get(target, key) {  // <--- The target (our object) and key (the property name)
    console.log('Get was called with key = ' + key)
    return target[key]
  }
})

console.log(proxiedProduct.quantity)

get에는 객체(예:product) 대상(target)과 객체에서 조회하고자 하는 속성(key) 두 가지 인자가 있습니다.

로그 출력은 다음과 같습니다.

 

Get was called with key = quantity

2

 

여기에서 Reflect를 사용하여 추가 인수(receiver)를 전달할 수 있습니다.

let product = { price: 5, quantity: 2 }
let proxiedProduct = new Proxy(product, {
  get(target, key, receiver) {  // <--- notice the receiver
    console.log('Get was called with key = ' + key)
    return Reflect.get(target, key, receiver) // <----
  }
})

get에는 Reflect.get에 인수로 보내는 receiver라는 추가 매개변수가 있습니다.

이렇게 하면 target 객체가 다른 객체에서 값/함수를 상속받았을 때에도 적절한 this 값이 사용됩니다.

이것이 우리가 항상 Proxy 내부에서 Reflect를 사용하는 이유입니다.

그래서 우리가 커스터마이징하는 원래 동작(get)을 유지할 수 있습니다.

 

이제 setter 메서드를 추가해 보겠습니다. 별 차이는 없습니다.

let product = { price: 5, quantity: 2 }

let proxiedProduct = new Proxy(product, {
  get(target, key, receiver) {  
    console.log('Get was called with key = ' + key)
    return Reflect.get(target, key, receiver) 
  }
  set(target, key, value, receiver) {
    console.log('Set was called with key = ' + key + ' and value = ' + value)
    return Reflect.set(target, key, value, receiver)
  }
})

proxiedProduct.quantity = 4
console.log(proxiedProduct.quantity)

set은 대상(product)을 설정하기 위해 값을 받는 Reflect.set을 사용한다는 점을 제외하면 get과 매우 유사합니다.

예상대로 출력은 다음과 같습니다.

 

Set was called with key = quantity and value = 4

Get was called with key = quantity

4

Vue3 reactive function

Vue 3 소스 코드는 위의 함수를 캡슐화합니다.

즉, 프록시 코드를 프록시를 반환하는 reactive 함수로 래핑합니다.

이 함수는 Vue 3 Composition API를 사용해 본 적이 있다면 친숙해 보일 것입니다.

그런 다음 트랩과 함께 핸들러를 별도로 선언하고 프록시로 보냅니다.

function reactive(target) {
  const handler = {
    get(target, key, receiver) {
      console.log('Get was called with key = ' + key)
      return Reflect.get(target, key, receiver)
    },
    set(target, key, value, receiver) {
      console.log('Set was called with key = ' + key + ' and value = ' + value)
      return Reflect.set(target, key, value, receiver)
    }
  }
  return new Proxy(target, handler)
}

let product = reactive({ price: 5, quantity: 2 }) // <-- Returns a proxy object
product.quantity = 4
console.log(product.quantity)

이제 여러 reactive 객체를 쉽게 생성할 수 있습니다.

프록시와 Effect 저장소 결합

지금까지 반응형 객체를 생성하기 위한 코드를 학습했습니다.

다음을 기억하세요

  • GET property => 우리는 해당 속성에 대한 effect를 track해야 합니다.
    • deps 그래프에 적절하게 effect 저장
  • SET property => 우리는 해당 속성에 대한 effects들을 trigger해야 합니다.
    • deps 그래프에서 적절하게 effects 실행

track 및 trigger를 호출해야 하는 위치를 상상할 수 있습니다.

function reactive(target) {
  const handler = {
    get(target, key, receiver) {
      let result = Reflect.get(target, key, receiver)
        // Track
      return result
    },
    set(target, key, value, receiver) {
      let oldValue = target[key]
      let result = Reflect.set(target, key, value, receiver)
      if (result && oldValue != value) { // Only if the value changes 
        // Trigger
      } 
      return result
    }
  }
  return new Proxy(target, handler)
}

이제 track과 trigger 함수를 해당 위치에 집어넣습니다.

목표 객체를 키로, 값을 이전의 depsMap으로

const targetMap = new WeakMap() // targetMap stores the effects that each object should re-run when it's updated
function track(target, key) {
  // We need to make sure this effect is being tracked.
  let depsMap = targetMap.get(target) // Get the current depsMap for this target
  if (!depsMap) {
    // There is no map.
    targetMap.set(target, (depsMap = new Map())) // Create one
  }
  let dep = depsMap.get(key) // Get the current dependencies (effects) that need to be run when this is set
  if (!dep) {
    // There is no dependencies (effects)
    depsMap.set(key, (dep = new Set())) // Create a new Set
  }
  dep.add(effect) // Add effect to dependency map
}
function trigger(target, key) {
  const depsMap = targetMap.get(target) // Does this object have any properties that have dependencies (effects)
  if (!depsMap) {
    return
  }
  let dep = depsMap.get(key) // If there are dependencies (effects) associated with this
  if (dep) {
    dep.forEach(effect => {
      // run them all
      effect()
    })
  }
}

function reactive(target) {
  const handler = {
    get(target, key, receiver) {
      let result = Reflect.get(target, key, receiver)
      track(target, key) // If this reactive property (target) is GET inside then track the effect to rerun on SET
      return result
    },
    set(target, key, value, receiver) {
      let oldValue = target[key]
      let result = Reflect.set(target, key, value, receiver)
      if (result && oldValue != value) {
        trigger(target, key) // If this reactive property (target) has effects to rerun on SET, trigger them.
      }
      return result
    }
  }
  return new Proxy(target, handler)
}

let product = reactive({ price: 5, quantity: 2 })
let total = 0

let effect = () => {
  total = product.price * product.quantity
}
effect()

console.log('before updated quantity total = ' + total)
product.quantity = 3
console.log('after updated quantity total = ' + total)

원래 한 편으로 정리하려 했으나 생각보다 내용이 많네요.

다음 편에서는 지금까지 배운 내용을 기반으로 ref와 computed를 구현해 봅니다.

반응형