훌륭한 개발자분들이 미래 개발자를 위해 더 좋은 프레임워크, 라이브러리등을 만들어주시면서 과거 직접 구현한 내용들이 지금은 많이 추상화 된 것 같다.
실제로 이는 나에게 코딩을 할 때, 내부 구조에 대해 소홀히 만든 것 같다.
이를 해소하고자 오늘은 Vue3.x를 사용하면서 Vue가 어떻게 반응성을 유지하고 있는지 조사한 내용을 기술한다.
Vue2.x에서 Vue3.x버전으로 올라오면서 Vue에는 많은 변화가 생겼다.(Vue가 가고 있는 방향)
그 중 반응성측면에서 대표적으로 추가된 것은 ref와 reactive 기능이다.
ref와 reactive의 개념은 다음 문서에서 확인할 수 있다.
https://v3.ko.vuejs.org/guide/reactivity-fundamentals.html#ref-%E1%84%91%E1%85%A9%E1%84%8C%E1%85%A1%E1%86%BC-%E1%84%87%E1%85%A5%E1%86%BA%E1%84%80%E1%85%A7%E1%84%82%E1%85%A2%E1%84%80%E1%85%B5-unwrapping
해당 본문과 관련이 없는 내용이긴 하지만,
나는 실제로 ref와 reactive를 사용하면서 사소하면서 치명적인 실수를 몇번 했다.
읽으시는 분들은 다음과 같은 실수는 안했으면 한다.
1. reactive object를 구조분해 하면 reactive한 속성이 사라지는 점
2. ref와 toRef를 혼동해서 사용한 점
const fooRef=ref(object.foo);
foo의 값 자체가 fooRef에 들어가기는 하나 foo의 값이 바뀌면 fooRef는 바뀌지 않는다.
하지만,
const fooRef=toRef(object,"foo");
로 한다면 foo의 값이 바뀌면 fooRef의 값도 바뀌게 된다.
3. props는 readonly하면서 reactive한 속성을 가지고 있다.
그렇다면 Vue는 어떻게 이런 반응성 속성을 유지하고 있을까?
Vue 2.x와 3.x의 반응성 유지 방식은 다르다.
그 이유는 ECMA script 자바스크립트 버전에 따른 기능 추가와 성능차이에 있다.
Vue 2.x에서는 Object.defineProperty속성을 사용해 모든 브라우저에서
와 같이 반응성을 유지하고 있다. 문서
하지만, Vue 3.x버전 이후부터는 ES6 Proxy의 기능을 도입해 방식이 달라졌다.(Vue는 IE에서 Vue3.x를 사용하기 위해 Vue2.x 처럼 Object.defineProperty를 이용해 반응성을 유지한다.)
실제로 vue/core을 뒤져보면 ref는 다음과 같은 과정으로 생성되고 반응성을 유지한다.
Object.defineProperty와 Proxy 두 버전 모두 겉으로 볼때는 동일한 API를 가지고 있지만, Proxy 버전쪽이 더 가볍게 동작하고, 더 나은 성능을 제공한다.
function ref<T>(value: T): Ref<UnwrapRef<T>>
interface Ref<T> {
value: T
}
export function ref(value?: unknown) {
return createRef(value, false)
}
...
function createRef(rawValue: unknown, shallow: boolean) {
if (isRef(rawValue)) {
return rawValue
}
return new RefImpl(rawValue, shallow)
}
...
class RefImpl<T> {
private _value: T
private _rawValue: T
public dep?: Dep = undefined
public readonly __v_isRef = true
constructor(value: T, public readonly __v_isShallow: boolean) {
this._rawValue = __v_isShallow ? value : toRaw(value)
this._value = __v_isShallow ? value : toReactive(value)
}
get value() {
trackRefValue(this)
return this._value
}
set value(newVal) {
newVal = this.__v_isShallow ? newVal : toRaw(newVal)
if (hasChanged(newVal, this._rawValue)) {
this._rawValue = newVal
this._value = this.__v_isShallow ? newVal : toReactive(newVal)
triggerRefValue(this, newVal)
}
}
}
...
export function triggerRefValue(ref: RefBase<any>, newVal?: any) {
ref = toRaw(ref)
if (ref.dep) {
if (__DEV__) {
triggerEffects(ref.dep, {
target: ref,
type: TriggerOpTypes.SET,
key: 'value',
newValue: newVal
})
} else {
triggerEffects(ref.dep)
}
}
}
...
export function triggerEffects(
dep: Dep | ReactiveEffect[],
debuggerEventExtraInfo?: DebuggerEventExtraInfo
) {
// spread into array for stabilization
for (const effect of isArray(dep) ? dep : [...dep]) {
if (effect !== activeEffect || effect.allowRecurse) {
if (__DEV__ && effect.onTrigger) {
effect.onTrigger(extend({ effect }, debuggerEventExtraInfo))
}
if (effect.scheduler) {
effect.scheduler()
} else {
effect.run()
}
}
}
}
...
export class ReactiveEffect<T = any> {
active = true
deps: Dep[] = []
parent: ReactiveEffect | undefined = undefined
/**
* Can be attached after creation
* @internal
*/
computed?: ComputedRefImpl<T>
/**
* @internal
*/
allowRecurse?: boolean
onStop?: () => void
// dev only
onTrack?: (event: DebuggerEvent) => void
// dev only
onTrigger?: (event: DebuggerEvent) => void
constructor(
public fn: () => T,
public scheduler: EffectScheduler | null = null,
scope?: EffectScope
) {
recordEffectScope(this, scope)
}
run() {
if (!this.active) {
return this.fn()
}
let parent: ReactiveEffect | undefined = activeEffect
let lastShouldTrack = shouldTrack
while (parent) {
if (parent === this) {
return
}
parent = parent.parent
}
try {
this.parent = activeEffect
activeEffect = this
shouldTrack = true
trackOpBit = 1 << ++effectTrackDepth
if (effectTrackDepth <= maxMarkerBits) {
initDepMarkers(this)
} else {
cleanupEffect(this)
}
return this.fn()
} finally {
if (effectTrackDepth <= maxMarkerBits) {
finalizeDepMarkers(this)
}
trackOpBit = 1 << --effectTrackDepth
activeEffect = this.parent
shouldTrack = lastShouldTrack
this.parent = undefined
}
}
stop() {
if (this.active) {
cleanupEffect(this)
if (this.onStop) {
this.onStop()
}
this.active = false
}
}
}
ref를 implement한 Class(RefImpl)에서 get,set할 때 value를 track시켜 depth에 대한 추적을 하면서 반응성을 유지하고 있다.
실제 코드를 찾아보면
export function reactive(target: object) {
// if trying to observe a readonly proxy, return the readonly version.
if (isReadonly(target)) {
return target
}
return createReactiveObject(
target,
false,
mutableHandlers,
mutableCollectionHandlers,
reactiveMap
)
}
...
function createReactiveObject(
target: Target,
isReadonly: boolean,
baseHandlers: ProxyHandler<any>,
collectionHandlers: ProxyHandler<any>,
proxyMap: WeakMap<Target, any>
) {
if (!isObject(target)) {
if (__DEV__) {
console.warn(`value cannot be made reactive: ${String(target)}`)
}
return target
}
// target is already a Proxy, return it.
// exception: calling readonly() on a reactive object
if (
target[ReactiveFlags.RAW] &&
!(isReadonly && target[ReactiveFlags.IS_REACTIVE])
) {
return target
}
// target already has corresponding Proxy
const existingProxy = proxyMap.get(target)
if (existingProxy) {
return existingProxy
}
// only a whitelist of value types can be observed.
const targetType = getTargetType(target)
if (targetType === TargetType.INVALID) {
return target
}
const proxy = new Proxy(
target,
targetType === TargetType.COLLECTION ? collectionHandlers : baseHandlers
)
proxyMap.set(target, proxy)
return proxy
}
...
function createSetter(shallow = false) {
return function set(
target: object,
key: string | symbol,
value: unknown,
receiver: object
): boolean {
let oldValue = (target as any)[key]
if (isReadonly(oldValue) && isRef(oldValue)) {
return false
}
if (!shallow && !isReadonly(value)) {
if (!isShallow(value)) {
value = toRaw(value)
oldValue = toRaw(oldValue)
}
if (!isArray(target) && isRef(oldValue) && !isRef(value)) {
oldValue.value = value
return true
}
} else {
// in shallow mode, objects are set as-is regardless of reactive or not
}
const hadKey =
isArray(target) && isIntegerKey(key)
? Number(key) < target.length
: hasOwn(target, key)
const result = Reflect.set(target, key, value, receiver)
// don't trigger if target is something up in the prototype chain of original
if (target === toRaw(receiver)) {
if (!hadKey) {
trigger(target, TriggerOpTypes.ADD, key, value)
} else if (hasChanged(value, oldValue)) {
trigger(target, TriggerOpTypes.SET, key, value, oldValue)
}
}
return result
}
}
처럼 Proxy의 Trap을 이용해서 객체를 랩핑하고 set될 때 trigger해 반응성을 유지해주고 있다.
Proxy로 reactive 객체를 랩핑하기에 주의해야 할 점
docs
실제로 나 혼자 코드와 의미적으로 이해한 부분은 많은데, 코드가 길다보니까 제대로 풀어쓰지 못한 것 같다. 다음에 시간이 날때 해당 부분에 대해 자세히 다뤄보도록 해야겠다.
또한 Vue가 가상돔을 다루는 방식에 대한 글도 추가할 계획이다.