프론트엔드 프레임워크(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가 단일 스레드 언어라는 것을 알고 있습니다. 즉, 한 번에 한 가지 작업만 수행할 수 있습니다.
멀티 스레드는 동시에 여러 작업을 수행할 수 있습니다.
개념
- 기본 스레드에서 실행을 위해 대기 중인 작업을 동기화합니다. 이전 작업이 완료된 경우에만 다음 작업을 실행할 수 있습니다.
- 메인 스레드에 아닌"태스크 큐"에 들어간 비동기 작업은 "태스크 큐"가 메인 스레드에 비동기 태스크를 실행할 수 있음을 알리는 경우에만 실행을 위해 메인 스레드에 들어갑니다.
동작 매커니즘
- (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
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)
}
}
// 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
https://itchallenger.tistory.com/764
'FrontEnd' 카테고리의 다른 글
[Vue3] v-model props로 사용하기: modelValue (0) | 2022.12.16 |
---|---|
[Vue3] props와 컴포넌트 상태 동기화 (0) | 2022.12.16 |
Vue3 반응성 완벽 이해 3편 : vue3 core 코드 직접 읽어보기 (0) | 2022.12.11 |
Vue3의 반응성 완벽 이해 2편 : ref와 computed (0) | 2022.12.11 |
Vue3의 반응성 완벽 이해 1편 : 종속성 추적 (4) | 2022.12.10 |