Vue는 어떻게 반응성을 유지하고 있을까?

Jarban·2022년 3월 12일
2

Vue

목록 보기
1/1

도입

훌륭한 개발자분들이 미래 개발자를 위해 더 좋은 프레임워크, 라이브러리등을 만들어주시면서 과거 직접 구현한 내용들이 지금은 많이 추상화 된 것 같다.
실제로 이는 나에게 코딩을 할 때, 내부 구조에 대해 소홀히 만든 것 같다.
이를 해소하고자 오늘은 Vue3.x를 사용하면서 Vue가 어떻게 반응성을 유지하고 있는지 조사한 내용을 기술한다.

ref vs reactive

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를 혼동해서 사용한 점

  • 만약, 어떤 변수를 반응형 객체로 유지하고 싶은데 이를 현 시점의 value를 추적할 것이냐,
    reference로 추적할 것인가에 차이다.
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를 이용해 반응성을 유지한다.)

Proxy에 대한 조사

실제로 vue/core을 뒤져보면 ref는 다음과 같은 과정으로 생성되고 반응성을 유지한다.

Object.defineProperty와 Proxy 두 버전 모두 겉으로 볼때는 동일한 API를 가지고 있지만, Proxy 버전쪽이 더 가볍게 동작하고, 더 나은 성능을 제공한다.

1. ref

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에 대한 추적을 하면서 반응성을 유지하고 있다.

reactive

docs

실제 코드를 찾아보면

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가 가상돔을 다루는 방식에 대한 글도 추가할 계획이다.

Reference

  1. vue docs
profile
안녕하세요 자르반입니다.

0개의 댓글