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의 시각화입니다.
우리가 기록한 모든 효과를 실행하는 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이라고 불리는 또 다른 이유가 있습니다만, 이후에 설명하겠습니다.
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 함수를 해당 위치에 집어넣습니다.
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를 구현해 봅니다.
'FrontEnd' 카테고리의 다른 글
Vue3 반응성 완벽 이해 3편 : vue3 core 코드 직접 읽어보기 (0) | 2022.12.11 |
---|---|
Vue3의 반응성 완벽 이해 2편 : ref와 computed (0) | 2022.12.11 |
zustand와 react query를 같이 사용하는 방법 (1) | 2022.12.09 |
[번역] Vue3 Coding Better Composables : await 없이 비동기를 처리하는 컴포저블 잘만들기 (0) | 2022.12.09 |
[번역] Vue3 Coding Better Composables : 인터페이스를 잘 설계하여 컴포저블 잘 만들기 (0) | 2022.12.09 |