본문 바로가기

FrontEnd

Vue3가 DOM 업데이트를 비동기적으로 수행하는 방법

반응형

프론트엔드 프레임워크(Vue3)는 어떻게 UI 업데이트를 비동기적으로 순서를 보장하여 처리할까요?

Vue3의 Scheduler 소스 코드를 읽다가 아래와 같은 함수를 발견했습니다.

export function nextTick<T = void>(
  this: T,
  fn?: (this: T) => void
): Promise<void> {
  const p = currentFlushPromise || resolvedPromise
  return fn ? p.then(this ? fn.bind(this) : fn) : p
}

해당 함수의 동작이 아리까리하여 관련 자료를 찾다가 좋은 내용을 발견해서 정리해둡니다.


nextTick?

정의: 다음 DOM 업데이트 주기가 끝난 후 실행할 지연된 콜백입니다.
데이터를 수정한 직후에 이 방법을 사용하여 업데이트된 DOM을 가져옵니다.

다음과 같은 내용들이 명확하지 않습니다.
  • 다음 DOM 업데이트 주기가 끝난 후?
  • 실행 지연 콜백?
  • 업데이트된 DOM?
위의 세 가지 질문이 대한 대답을 추측해 봅시다.
  • Vue는 동기식이 아니라 전략적으로 DOM을 업데이트합니다.
  • nextTick은 함수를 매개변수로 받을 수 있습니다.
  • 최신 데이터는 nextTick 이후에 얻을 수 있습니다.
해당 함수가 어떻게 사용되는지 봅시다.
import { createApp, nextTick } from 'vue'
const app = createApp({
  setup() {
    const message = ref('Hello!')
    const changeMessage = async newMessage => {
      message.value = newMessage
	// 여기서 DOM에서 얻은 값은 이전 값입니다.
      await nextTick()
	// nextTick이 업데이트된 값이 된 후 DOM 값을 가져옵니다.
      console.log('Now DOM is updated')
    }
  }
})
해당 함수의 실행 메커니즘부터 차근차근 살펴봅시다.

JS 실행 매커니즘

우리 모두는 JS가 단일 스레드 언어라는 것을 알고 있습니다. 즉, 한 번에 한 가지 작업만 수행할 수 있습니다.
멀티 스레드는 동시에 여러 작업을 수행할 수 있습니다.

 

다중 스레드 여부는 언어의 목적에 따라 다릅니다.
매우 간단한 예를 들어, DOM에 동시에  추가와 삭제가 일어난다면, 결과를 결정할 수 없습니다.
애플리케이션 시나리오의 관점에서 볼 때 JS는 단일 스레드인 것이 맞습니다.
단일 스레드는 모든 작업이 대기열(큐)에 있어야 하며
후속 작업은 실행되기 전에 이전 작업이 완료될 때까지 기다려야 함을 의미합니다.
이전 작업이 오래 걸리면 사용자 입장에서 대기할 필요가 없는 일부 작업은 영원히 대기하게 됩니다.
이는 경험상 용납할 수 없는 일이므로 JS에서 비동기라는 개념이 나타납니다.
 

개념

  • 기본 스레드에서 실행을 위해 대기 중인 작업을 동기화합니다. 이전 작업이 완료된 경우에만 다음 작업을 실행할 수 있습니다.
  • 메인 스레드에 아닌"태스크 큐"에 들어간 비동기 작업은 "태스크 큐"가 메인 스레드에 비동기 태스크를 실행할 수 있음을 알리는 경우에만 실행을 위해 메인 스레드에 들어갑니다.

동작 매커니즘

  • (1) 모든 동기화 작업은 메인 스레드에서 실행되며, 실행 컨텍스트 스택을 형성합니다.
  • (2) 메인 스레드 외에 "태스크 큐"도 있습니다. 비동기 작업의 실행 결과가 있다면 이벤트는 "태스크 큐"에 배치됩니다.
  • (3) "실행 컨텍스트 스택"의 모든 동기 작업이 실행된 후 시스템은 "태스크 큐"을 읽어 어떤 이벤트가 있는지 확인합니다.
    • 해당 비동기 작업은 대기 상태를 종료하고 실행 컨텍스트 스택에 들어가 실행을 시작합니다.
  • (4) 메인 스레드는 위의 세 단계를 계속 반복합니다.
이벤트 큐의 동작

nextTick의 구현

위의 개념을 기반으로 작업을 비동기화 하기 위한 수단입니다.

const p = Promise.resolve()
export function nextTick(fn?: () => void): Promise<void> {
  return fn ? p.then(fn) : p
}

이것을 보고 다시 질문할 수 있습니다.
DOM 업데이트도 이제 비동기 작업인데 실행 순서를 어떻게 보장할 수 있을까요?


queueJob and queuePostFlushCb

Vue3의 Scheduler 모듈은 작업 큐와 콜백 큐를 별도로 유지합니다.

  • queueJob
    • 작업 큐잉
    • 작업 중복 제거
    • 태스크의 고유성을 보장하기 위해 각 작업 큐잉 시에 queueFlush 호출
  • queuePostFlushCb
    • 콜백 큐잉
    • 콜백 중복 제거
    • 각 콜백 큐잉 시 callqueueFlush 실행

  해당 큐 들을 별도로 유지하는 이유는 실행 시점 때문입니다. queuePostFlushCb 함수는 Flush 

const queue: (Job | null)[] = []
export function queueJob(job: Job) {
  // 去重 
  if (!queue.includes(job)) {
    queue.push(job)
    queueFlush()
  }
}

export function queuePostFlushCb(cb: Function | Function[]) {
  if (!isArray(cb)) {
    postFlushCbs.push(cb)
  } else {
    postFlushCbs.push(...cb)
  }
  queueFlush()
}

queueFlush

nextTick 함수를 통해 flushJob 함수를 비동기적으로 수행합니다.
function queueFlush() {
  // 避免重复调用flushJobs
  if (!isFlushing && !isFlushPending) {
    isFlushPending = true
    nextTick(flushJobs)
  }
}

flushJobs

  • 먼저 작업 queue을 정렬 한 후 처리합니다.
    • 플러시 전에 대기열을 정렬합니다. 이는 다음을 보장합니다.
      • 컴포넌트는 부모에서 자식으로 업데이트됩니다. (왜냐면 부모는 항상 자식보다 먼저 생성되므로 렌더 이펙트의 우선순위가 높습니다.)
      • 상위 컴포넌트 업데이트 중에 컴포넌트가 마운트 해제된 경우 업데이트를 건너뛸 수 있습니다.
        • 작업은 다른 플러시된 작업이 실행되는 동안 무효화 되기만 하며 플러시 시작 전에 null이 될 수 없습니다.
  • 그 다음 지연 콜백 큐인 postFlushCbs를 처리합니다.
    • postFlushCbs 처리가 작업을 트리거 할 수 있습니다.
    • 이 경우 재귀적으로 같은 작업을 반복합니다.
function flushJobs(seen?: CountMap) {
  isFlushPending = false
  isFlushing = true
  let job
  if (__DEV__) {
    seen = seen || new Map()
  }

  // This ensures that:
  // 1. Components are updated from parent to child. (because parent is always
  //    created before the child so its render effect will have smaller
  //    priority number)
  // 2. If a component is unmounted during a parent component's update,
  //    its update can be skipped.
  // Jobs can never be null before flush starts, since they are only invalidated
  // during execution of another flushed job.
  queue.sort((a, b) => getId(a!) - getId(b!))

  while ((job = queue.shift()) !== undefined) {
    if (job === null) {
      continue
    }
    if (__DEV__) {
      checkRecursiveUpdates(seen!, job)
    }
    callWithErrorHandling(job, null, ErrorCodes.SCHEDULER)
  }
  flushPostFlushCbs(seen)
  isFlushing = false
  // some postFlushCb queued jobs!
  // keep flushing until it drains.
  if (queue.length || postFlushCbs.length) {
    flushJobs(seen)
  }
}
queueJob과 queuePostFlushCb가 어떻게 호출되는지 봅시다.
//  renderer.ts
function createDevEffectOptions(
  instance: ComponentInternalInstance
): ReactiveEffectOptions {
  return {
    scheduler: queueJob,
    onTrack: instance.rtc ? e => invokeArrayFns(instance.rtc!, e) : void 0,
    onTrigger: instance.rtg ? e => invokeArrayFns(instance.rtg!, e) : void 0
  }
}

// effect.ts
const run = (effect: ReactiveEffect) => {
  ...

  if (effect.options.scheduler) {
    effect.options.scheduler(effect)
  } else {
    effect()
  }
}

반응성 객체가 변경되면 스케줄러를 통해 effect를 실행합니다.
effect는 또 다른 복잡한 주제임으로 나중에 별개로 다루어 보겠습니다.


nextTick인가

이제 모든 내용을 간단하게 정리해 보겠습니다.

 

먼저 이벤트 루프에 대한 배경 지식입니다.

  • 이벤트 루프는 기술적으로 js의 구성요소가 아닙니다.
    • JS 실행을 위해 JS 엔진을 사용하는 단일 스레드 입니다.
  • 이벤트 루프는 각 페이즈가 있으며, 페이즈 별로 각각의 큐가 있습니다.
  • nextTick을 통해 마이크로테스크 큐에 resolved된 프로미스를 큐잉합니다.
    • 해당 프로미스의 콜백은 이벤트 루프의 각 페이즈 전환 시에 우선적으로 처리됩니다.

UI 이벤트가 배치 처리되는 방식입니다.

  • 이벤트 루프는 I/O 이벤트를 우선하여 폴링하여 처리하며, 해당 작업들을 우선하여 동기적으로먼저 처리합니다.
    • 블로킹이 일어나는 Poll phase입니다
    • I/O 큐가 빌 때까지 계속해서 처리합니다.
  • poll 페이즈가 끝난 후 페이즈 전환 시 마이크로태스크 큐의 job들을 비동기적으로 배치 처리합니다.
    • 이는 nextTick에 의해 지연 실행되었습니다.

React와 다르게, Vue3 소스 코드에서 VDom의 조정 프로세스를 중단하고 다시 처음부터 작업하는 부분은 찾지 못했습니다.

물론 Vue3은 해당 코드는 불필요할 수도 있습니다.

  • 컴파일러에 의한 조정 최적화가 굉장히 많이 들어가 있습니다.
  • 리액트처럼 항상 루트부터 조정하는 작업을 하지는 않습니다.

이번에는 Vue3의 Scheduler와 관련한 코드들을 알아보았습니다.

다음번에는 Scheduler가 실행하는 Job과 관련된 Vue3의 effect에 대한 좀 더 자세한 내용을 다루어 볼 수 있도록 하겠습니다(;;)

그리고 그 다음에는 renderer에 대한 내용을 다루고 싶습니다만, 시간이 꽤 걸릴 듯 합니다.

참고

https://vue3js.cn/global/nextTick.html

 

nextTick | Vue3

nextTick 定义: 在下次 DOM 更新循环结束之后执行延迟回调。在修改数据之后立即使用这个方法,获取更新后的 DOM 看完是不是有一堆问号?我们从中找出来产生问号的关键词 下次 DOM 更新循环结束

vue3js.cn

https://itchallenger.tistory.com/764

 

반응형