react
코어 패키지는 컴포넌트 정의와 관련된 패키지입니다. 대표적으로 React Element를 만드는 createElement
를 갖고 있습니다.
react
코어 패키지는 다양한 플랫폼에서 사용될 수 있습니다. 이는 다른 패키지들에게 의존하지 않기 때문에 가능합니다.
renderer 역할을 하는 패키지로는 react-dom
, react-native-renderer
패키지가 존재합니다. 해당 패키지들은 호스트(웹)와 react
를 연결시키는 역할을 합니다.
renderer 패키지들은 reconciler
, legacy-event
패키지에 의존하고 있습니다.
SyntheticEvent라는 명칭으로 내부적으로 개발된 이벤트 시스템이며, 기존 event를 Wrapping하여 추가적인 기능을 갖도록 구현되어 있습니다.
리액트는 여러 가지 이유로 작업을 비동기로 실행시켜야 합니다. 이때 Task라는 이름으로 우선순위에 따라 스케줄링이 됩니다.
Task 실행 시점을 scheduler
패키지가 위임하여 관리합니다. scheduler
패키지는 호스트 환경에 의존하고 있습니다.
React v15 이전에는 Stack 아키텍처로 구현되어 있었지만 v16 부터는 Fiber라는 아키텍처를 채택하였습니다.
reconciler
패키지는 Virtual DOM의 재조정과 컴포넌트 호출등을 담당하는 패키지입니다.
컴포넌트 호출은 reconciler
패키지가 담당하고 있습니다. 컴포넌트가 호출된 이후 Virtual DOM 재조정 작업이 이루어지고 renderer
패키지가 실제 DOM에 마운트하는 과정을 거치게 됩니다.
이때 컴포넌트를 호출하는 작업과 DOM에 반영하는 작업은 서로 다른 패키지들이 담당하고 있으며, DOM에 반영하는 작업과 화면에 Paint처리하는 과정 또한 별개의 역할로 구분할 수 있습니다.
렌더링은 컴포넌트를 호출하여 반환된 React Element를 VDOM에 적용하는 일련의 과정까지로 정의하고, 실제 DOM에 반영하는 작업을 마운트, 브라우저 화면에 그려지는 과정을 페인트라고 정의하겠습니다.
React Element는 컴포넌트의 정보를 갖고 있는 객체입니다. 컴포넌트를 호출하여 반환하는 것은 React Element이며 이는 자바스크립트 객체입니다.
JSX 문법으로 작성한 코드는 Babel에 의해 react
패키지의 createElement
호출문으로 컴파일됩니다.
fiber는 VDOM을 구성하고 있는 노드 객체입니다. 이 객체는 React Element를 VDOM에 반영하기 위해 확장한 객체이며, fiber를 통해 컴포넌트의 상태, 훅, 라이프사이클 등 대부분이 관리됩니다.
JSX 문법으로 작성한 어트리뷰트들 중
key
,ref
를 제외한 나머지는 모두 props 객체로 관리됩니다. 즉, props로key
와ref
는 사용할 수 없습니다.
VDOM은 두 개의 트리(current, workInProgress)로 설계되어 있습니다. 이러한 모델을 Double Buffering Model이라고 합니다.
current 트리는 실제 DOM에 마운트가 끝난 트리이며, workInProgress 트리는 현재 업데이트가 적용 중인 트리입니다.
Render Phase는 VDOM을 재조정하는 단계, 즉 조작하는 단계입니다. 이는 React Element가 추가, 삭제, 수정 같은 변경점을 VDOM에 반영하는 단계입니다.
VDOM 재조정을 담당하는 reconciler
패키지의 설계가 stack 아키텍처에서 fiber 아키텍처로 변경됨으로써 VDOM 재조정 순서를 세부적으로 abort, stop, restart 할 수 있게 되었습니다. 해당 기능은 concurrent mode에서 비동기와 함께 이루어집니다.
컴포넌트 호출은 Render phase에서 실행되며 VDOM에 반영을 거치게 됩니다.
Commit phase는 Render phase에서 재조정된 VDOM을 실제 DOM에 적용하고 라이프 사이클을 실행하는 단계입니다.
Commit Phase는 동기적으로 실행되며, DOM 조작을 일괄 처리합니다.
위에서 설명했듯이 VDOM은 두 개의 트리로 구성된 더블 버퍼링 모델을 사용하고 있습니다.
current 트리는 실제 DOM에 마운트된 트리와 동일한 구조를 갖고 있으며, workInProgress 트리는 Render Phase에서 작업중인 트리를 의미합니다.
Render Phase와 Commit Phase를 거치게 되면 workInProgress 트리는 current 트리로 관리됩니다. 해당 과정이 반복되며 VDOM이 관리됩니다.
즉, 실제 DOM을 복제한 트리가 current가 되며, current 트리를 복제하여 workInProgress 트리를 생성합니다.
current와 workInProgress의 같은 계층의 노드들은 서로 alternate로 참조하고 있습니다. 각 트리의 들의 fiber 노드의 자식 노드들은 첫 자식 노드만을 child로 참조하고 있으며, 나머지 자식 노드들은 형제끼리 sibling으로 참조하고 있습니다. 부모 노드와 연결된 자식 노드들은 return으로 부모 노드를 참조하고 있습니다.
리액트의 훅들은 react/src/ReactHooks.js
에서 import하고 있습니다.
/* react/src/React.js */
import { useState, useEffect, ... } from './ReactHooks'
import ReactSharedInternals from './ReactSharedInternals' // 의존성 inject 받는 파일
const React = {
useState,
useEffect,
__SECRET_INTERNALS_DO_NOT_USE_OR_YOU_WILL_BE_FIRED: ReactSharedInternals,
/*...*/
}
export default React
ReactHooks
라는 파일에서 리액트 훅들을 import하고 있는 것을 확인할 수 있습니다.
/* react/src/ReactHooks.js */
import ReactCurrentDispatcher from './ReactCurrentDispatcher'
function resolveDispatcher() {
const dispatcher = ReactCurrentDispatcher.current
return dispatcher
}
export function useState(initialState) {
const dispatcher = resolveDispatcher()
return dispatcher.useState(initialState)
}
export function useEffect(create, inputs) {
const dispatcher = resolveDispatcher()
return dispatcher.useEffect(create, inputs)
}
/*...*/
리액트 훅들은 dispatcher
라는 객체의 메서드로 바인딩되어 있는 것을 확인할 수 있습니다.
dispatcher
는 ReactCurrentDispatcher.current
의 참조가 바인딩되어 있는 것도 확인할 수 있습니다.
/* react/src/ReactCurrentDispatcher */
const ReactCurrentDispatcher = {
current: null,
}
export default ReactCurrentDispatcher
ReactCurrentDispatcher
파일에서 ReactCurrentDispatcher.current
에는 아무것도 없는 것을 확인할 수 있습니다.
react
패키지는 컴포넌트 즉, React Element에 대한 정보만을 갖고 있습니다. 훅에 대한 정보를 갖게되는 시점을 fiber 노드로 확장된 이후이며, fiber 노드로 확장을 담당하는 패키지는 reconciler
패키지입니다.
즉, reconciler
패키지에서 훅에 대한 정보를 react
패키지 내부로 inject 받아 사용합니다. 이때 직접적으로 inject받지 않고 중간에 shared
라는 패키지를 통해 inject 받게 됩니다.
/* react/src/ReactSharedInternals */
import ReactCurrentDispatcher from './ReactCurrentDispatcher'
import ReactCurrentBatchConfig from './ReactCurrentBatchConfig'
import ReactCurrentOwner from './ReactCurrentOwner'
/*...*/
const ReactSharedInternals = {
ReactCurrentDispatcher,
ReactCurrentBatchConfig,
ReactCurrentOwner,
/*...*/
}
export default ReactSharedInternals
react
패키지의 react/src/ReactSharedInternals
파일에서 shared
패키지의 inject을 받게 됩니다.
앞서 살펴본 ReactCurrentDispatcher
또한 해당 파일에서 inject 받게 됩니다.
/* shared/ReactSharedInternals */
import React from 'react'
const ReactSharedInternals =
React.__SECRET_INTERNALS_DO_NOT_USE_OR_YOU_WILL_BE_FIRED // react의 ReactSharedInternals.js
if (!ReactSharedInternals.hasOwnProperty('ReactCurrentDispatcher')) {
ReactSharedInternals.ReactCurrentDispatcher = {
current: null,
}
}
/*...*/
export default ReactSharedInternals
즉, 훅은 reconciler -> shared/ReactSharedInternal -> react/ReactSharedInternal -> react/ReactCurrentDispatcher -> react/ReactHooks -> react 의 흐름으로 전달됩니다.
/* react-reconciler/src/ReactFiberHooks */
export function renderWithHooks(
current: Fiber,
workInProgress: Fiber,
Component: any,
props: any,
refOrContext: any,
nextRenderExpirationTime: ExpirationTime
) {
/*...*/
currentlyRenderingFiber = workInProgress // 현재 작업 중인 fiber를 전역으로 잡아둠
nextCurrentHook = current !== null ? current.memoizedState : null
ReactCurrentDispatcher.current =
nextCurrentHook === null ? HooksDispatcherOnMount : HooksDispatcherOnUpdate
let children = Component(props, refOrContext)
/*컴포넌트 재호출 로직..*/
const renderedWork = currentlyRenderingFiber
renderedWork.memoizedState = firstWorkInProgressHook
ReactCurrentDispatcher.current = ContextOnlyDispatcher
currentlyRenderingFiber = null;
/*...*/
}
/*...*/
// mount용 Hook 구현체
const HooksDispatcherOnMount = {
useState: mountState,
useEffect: mountEffect,
/*...*/
};
// update용 Hook 구현체
const HooksDispatcherOnUpdate: = {
useState: updateState,
useEffect: updateEffect,
/*...*/
};
// invalid hook call
export const ContextOnlyDispatcher: Dispatcher = {
useState: throwInvalidHookError,
useEffect: throwInvalidHookError,
/*...*/
};
훅의 inject는 renderWithHooks
함수를 통해 이루어집니다. 이때 컴포넌트의 호출 또한 renderWithHooks
내부에서 이루어집니다.
즉, Render Phase 단계에서 renderWithHooks
함수가 호출됩니다.
nextCurrentHook = current !== null ? current.memoizedState : null
코드를 보면 current 값이 null인 경우는 마운트되기 전 상태이며, null이 아닌 경우는 마운트된 경우를 의미합니다.
current
는 앞서 VDOM에서 살펴본 current 트리이며 fiber 아키텍처를 갖고 있습니다.
컴포넌트가 처음 호출되는 경우(마운트 전 상태) 전역변수
firstInProgressHook
에 훅 리스트가 생성되어 바인딩되고,firstInProgressHook
변수는 fiber 즉,current.memoized
에 바인딩합니다.
ReactCurrentDispatcher.current = nextCurrentHook === null ? HooksDispatcherOnMount : HooksDispatcherOnUpdate
코드는 마운트되기 전과 마운트된 이후의 훅 구현체를 구분되어 바인딩되는 것을 확인할 수 있습니다.
컴포넌트가 마운트될 때 useState()
훅 호출을 하는 경우 current
는 null 값을 갖고 있기 때문에 current.memoizedState
또한 null 값을 갖고 있습니다.
그러므로 HooksDispatcherOnMount
훅 구현체를 사용하며, useState
로는 mountState
를 사용하게 됩니다.
/* react-reconciler/src/ReactFiberHooks */
function mountState(initialState) {
const hook = mountWorkInProgressHook(); // 훅 객체를 생성한다.
if (typeof initialState === 'function') {
// 생성자 함수일 경우
initialState = initialState()
}
hook.memoizedState = hook.baseState = initialState
const queue = (hook.queue = {
last: null, // 마지막 update
dispatch: null, // push 함수
lastRenderedReducer: basicStateReducer,
lastRenderedState: initialState,
})
const dispatch = (queue.dispatch = dispatchAction.bind(
null,
currentlyRenderingFiber,
queue
))
return [hook.memoizedState, dispatch]
}
function mountWorkInProgressHook(): Hook {
// hook 객체에 대해서는 업데이트 구현체에서 자세히 다룹니다.
const hook: Hook = {
memoizedState: null, // 컴포넌트에 적용된 마지막 상태 값
queue: null, // 훅이 호출될 때마다 update를 연결 리스트로 queue에 집어넣습니다.
next: null, // 다음 훅을 가리키는 포인터
// 업데이트 구현체에서 설명
baseState: null,
baseUpdate: null,
}
if (workInProgressHook === null) {
// 맨 처음 실행되는 훅인 경우 연결 리스트의 head, tail로 잡아둠
firstWorkInProgressHook = workInProgressHook = hook
} else {
// 두번 째부터는 연결 리스트에 추가
workInProgressHook = workInProgressHook.next = hook
}
return workInProgressHook
}
mountState
는 일단 mountWorkInProgressHook
호출을 통해 훅에 대한 정보를 갖는 훅 객체부터 생성합니다.
firstInProgressHook
전역변수는 훅 연결 리스트의 head 포인트이며, workInProgressHook
전역변수는 현재 실행중인 훅이며 tail 포인트가 됩니다.
즉, 훅들은 연결 리스트로 관리되는 것을 확인할 수 있습니다.
코드의 if 문에서 workInProgressHook
전역변수가 null이라는 것은 현재 훅 리스트의 tail 포인트에 아무것도 없다는 의미이며 이는 호출된 훅이 없고 비어있다는 의미이기 때문에 head 포인터인 firstWorkInProgressHook
와 tail인 workInProgressHook
에 호출된 훅 객체가 바인딩됩니다.
else 문의 경우에는 현재 훅 연결 리스트의 tail 포인터에 다른 훅 객체가 존재함을 의미하며 tail 포인터 뒤인 workInProgressHook.next
에 호출된 훅 객체를 연결하게 됩니다.
mountState
내부에서 훅 객체를 생성한 뒤 이후 상태를 훅 객체에 저장합니다.
그리고 queue의 경우에는 이후 상태를 업데이트하기 위해 사용되며, dispatch의 경우에는 상태를 업데이트하는 dispatchAction
함수가 바인딩되어 있습니다. dispatchAction
는 현재 훅을 호출한 컴포넌트인 currentlyRenderingFiber
와 업데이트를 위한 queue
를 갖고 있습니다.
이후 return문은 배열을 반환하고 있으며 상태값과 상태값 업데이트 함수를 배열로 반환하고 있습니다.
/* react-reconciler/src/ReactFiberHooks */
function dispatchAction(fiber, queue, action) {
const alternate = fiber.alternate
if (
fiber === currentlyRenderingFiber ||
(alternate !== null && alternate === currentlyRenderingFiber)
) {
// Render phase update
} else {
const update = {
expirationTime,
action, // setState()의 인자
next: null, // 노드 포인터
// 이하 최적화에 사용되는 속성
eagerReducer: null,
eagerState: null,
}
// update를 queue에 추가
const last = queue.last
if (last === null) {
// This is the first update. Create a circular list.
update.next = update
} else {
const first = last.next
if (first !== null) {
// Still circular.
update.next = first
}
last.next = update
}
queue.last = update
/*...*/
}
}
상태를 업데이트하는 함수인 dispatchAction
함수는 queue에 상태 업데이트 정보를 갖고 있는 update 객체를 push하고 scheduler
에게 Work를 예약하는 함수입니다.