페이스북이 임의로 만든 언어로서 다양한 속성을 가진 트리 구조를 토큰화해 ECMA Script로 변환하는데 초점을 두고 있다.
→ 표현하기 까다로웠던 XML 스타일의 트리 구분을 작성하는 데 많은 도움을 주는 새로운 문법
JSX를 구성하는 기본 요소로 HTML의 요소와 비슷한 역할을 한다.
<JSXElement JSXAttributes(optional)></JSXElement><JSXElement JSXAttributes(optional) /><>JSXChildren(optional)</>JSXIdentifier
function Valid1() {
return <_></_>
}
JSX 내부에서 사용할 수 있는 식별자. 함수명, 변수명과 같이 JSXElement 사이에 구분할 수 있는 이름을 말한다. 숫자 또는 $ 와 _ 이외에 다른 특수문자로 시작할 수 없다.
JSXNameSpacedName
function Valid() {
return <foo:bar></foo:bar>
}
JSXIdentifier:JSXIdentifier 의 조합을 의미하며, : 로 묶을 수 있는 것은 한 번 뿐이다.
JSXMemberExpression
function Valid() {
return <foo.bar></foo.bar>
}
JSXIdentifier.JSXIdentifier의 조합을 의미하며, . 여러 개를 활용하여 묶을 수 있다. 대신 JSXNameSpacedName와 혼합하여 사용하는 것은 불가능하다.
JSXSpreadAttributes
{…AssignmentExpression} : 객체 뿐만 아니라 AssignmentExpression에서 취급할 수 있는 모든 표현식이 존재할 수 있다.
JSXAttribute
속성을 나타내는 키(JSXAttributeName)와 값(JSXAttributeValue)의 짝으로 표현한다.
<$></$>)와 JSXNamespacedName(foo:bar) 를 통해 키를 나타낼 수 있다.JSXElement의 자식 값을 나타낸다. JSX가 트리 구조를 효과적으로 보여주기 위한 목적이 있기 때문에 JSX로 부모/자식에 대한 표현이 가능하고 이 자식을 JSXChildren이라고 한다.
JSXChild
→ JSXChildren을 구성하는 기본 단위 JSXChildren은 0개 이상의 JSXChild가 필요하다. 0개 이상의 의미처럼 JSXElement는 JSXChildren은 JSXChild가 필요하다.
{ , < , > , } 를 제외한 문자열HTML에서 사용 가능한 문자열을 JSXStrings에서 사용 가능하다. 여기서의 문자열은 '큰 따옴표', '작은 따옴표' 또는 JSXText를 의미한다.
JSX 스펙에서 문자열 처리와 관련해서 최근에는 논의가 있었는지 궁금해서 타임라인과 함께 간단히 정리해봤습니다.
JSX는 HTML4의 252개 문자 엔티티( , & 등)만 지원하고, HTML5의 2,231개 확장 문자는 의도적으로 제외하고 있습니다. HTML과의 복사-붙여넣기 호환성을 위해서죠.
HTML Entities vs JavaScript Escaping (2014~)
\u1234)를 사용하자는 제안JSX 2.0 제안 무산 (2016)
보안 이슈 (2022)
� 같은 특수 문자 참조의 동작 논의Babel 8 변경사항
}, > 문자 금지 예정{'}'}와 {'>'}로 대체JSX는 안정성을 최우선으로 하여 의도적으로 변경을 최소화하고 있습니다. 향후 TC39 제안으로 진행될 가능성이 언급되고 있지만, 2022년 이후로는 활발한 논의가 없는 상태입니다.
참고
아래 JSX 코드는
const ComponentA = <A required={true}>Hello World</A>
const ComponentB = <>Hello World</>
const ComponentC = (
<div>
<span>hello world</span>
</div>
)
@babel/plugin-transform-react-jsx 로 변환하면 아래와 같이 바뀐다.
'use strict'
var ComponentA = React.createElement(
A,
{
required: true,
},
'Hello World',
)
var ComponentB = React.createElement(
React.Fragment,
null,
'Hello World',
)
var ComponentC = React.createElement(
'div',
null,
React.createElement('span', null, 'hello world')
)
JSXElement(A, React.Fragment, 'div')가 먼저 인자로 들어가고 이후 옵셔널 요소인 JSXChildren( React.createElement('span', null, 'hello world'), JSXAttributes({ required: true }), JSXStrings('Hello World') 들이 다음 인자들로 들어간다.
💡
A는 저대로 두는건가..? A는 중첩된 컴포넌트일텐데 변환될 때도 중첩된 구조로 바뀌는 건가?
- Babel은 JSX 문법만 변환합니다
<A>→ React.createElement(A, ...)- A 자체는 건드리지 않음
- 런타임에 실행됩니다
- React.createElement(A, ...)가 호출되면
- React가 A 함수를 실행하고
- 그 안의 JSX도 이미 변환된 상태
- 중첩 구조는 함수 호출로 해결
- A 컴포넌트 내부의 JSX는 A 함수 안에서 변환됨
- 각 컴포넌트는 독립적으로 변환됨
위 코드를 트랜스파일하면 아래와 같이 결과가 나온다.
'use strict'
var _jsxRuntime = require('custom-jsx-library/jsx-runtime')
var ComponentA = (0, _jsxRuntime.jsx)(A, {
required: true,
children: 'Hello World',
})
var ComponentB = (0, _jsxRuntime.jsx)(_jsxElement.Fragment, {
children: 'Hello World',
});
var ComponentC = (0, _jsxRuntime.jsx)( 'div', {
children: (0, _jsxRuntime.jsx)( 'span', {
children: 'Hello World',
}),
})
그래서 동일한 props를 갖지만 children 요소만 달라지는 경우, 두 개의 컴포넌트를 만들고 삼항연산자를 할 필요 없고, 아래와 같이 createElement의 옵션을 통해서 간결하게 처리할 수 있다.
function TextOrHeading({
isHeading,
children
}: PropsWithChildren<{isHeading: boolean}>) {
return isHeading ? <h1 {...args}>{children}</h1> : <span {...args}>{children}</span>
}
function TextOrHeading({
isHeading,
children
}: PropsWithChildren<{isHeading: boolean}>) {
return React.createElement(
isHeading ? 'h1' : 'span',
{ className: 'text' },
children,
)
}
DOM: 웹 페이지에 대한 인터페이스로 브라우저가 웹페이지의 콘텐츠와 구조를 어떻게 보여줄지에 대한 정보를 담고 있다.
렌더링 과정
❓ display:none 말고 렌더 트리 형성에 영향을 주는 옵션은 어떤 것이 있을까.
렌더 트리에서 완전히 제외되는 속성
display: none
- DOM 트리에는 존재하지만 렌더 트리에서 완전히 제외
- 레이아웃 계산 및 페인팅 과정에서 제외
<head>,<script>,<meta>등
- 시각적으로 표시되지 않는 요소들
렌더 트리에는 포함되지만 특별하게 처리되는 속성
visibility: hidden
- 렌더 트리에 포함됨 (display: none과 다름)
- 레이아웃 공간은 차지하지만 화면에 그려지지 않음
- 자식 요소가 visibility: visible이면 보임
opacity: 0
- 렌더 트리에 포함됨
- 레이아웃 공간 차지, 투명하게 렌더링
- 이벤트는 여전히 받을 수 있음
position: absolute/fixed+left: -9999px
- 렌더 트리에는 포함
- 화면 밖으로 이동시킴
clip-path,transform: scale(0)
- 렌더 트리에 포함
- 시각적으로만 숨김
추가 렌더링 과정은 SPA에서 많이 발생하게 된다. SPA는 페이지 전환시 새롭게 HTML 파일을 다운로드 받는 것이 아니라 하나의 HTML 파일 내부에서 리플로우, 리페인트 과정을 반복하면서 새로운 요소들을 보여주는 방식이다. 요소를 삭제, 삽입, 위치 계산 등의 작업이 반복되니 사용자 경험상으로는 뛰어나지만 DOM을 관리하는 비용이 커지고 있었다.
하나의 인터렉션을 통해서 내부 DOM의 수많은 요소가 변경이 일어난다. 개발자는 DOM 요소 하나하나의 변경점을 아는 것보다, 결과적으로 만들어지는 DOM 결과물에 더 포커싱이 되어있다. 그래서 인터렉션의 최종 결과물 DOM을 간편하게 제공하는 것은 개발자에게도 유용한 방법이다.
가상 DOM은 실제 브라우저 DOM이 아닌 리액트가 관리하는 가상의 DOM을 의미한다. 상태나 props가 변경될 때마다 새로운 가상 DOM이 생성되고, 그 변화가 기존 가상 DOM과 비교(diff)된다. DOM 계산을 브라우저가 아닌 메모리에서 수행함으로써 실제 DOM의 렌더링 과정을 최소화할 수 있다.
리액트 파이버는 재조정자(reconciler)가 관리하며, 가상 DOM과 실제 DOM을 비교해서 변경사항을 수집한다. 파이버가 가지고 있는 둘 사이 차이점에 대해서 화면에 렌더링을 요청하는 역할을 한다.
여기서 재조정(reconciliation)은 가상 DOM을 활용한 렌더링 과정을 최적화하는데 도와주는 리액트의 아키텍처 또는 알고리즘을 말한다.
⇒ 이런 과정이 비동기적으로 일어난다.
파이버의 작업은 크게 두 단계로 나뉜다.
렌더 단계(Render Phase)
사용자에게 노출되지 않는 모든 비동기 작업을 수행한다. 이 단계에서 앞서 언급한 파이버의 작업, 즉 우선순위를 지정하거나 중지시키거나 버리는 등의 작업이 이루어진다.
이 단계의 특징:
beginWork(), completeWork() 등이 실행됨커밋 단계(Commit Phase)
DOM에 실제 변경 사항을 반영하기 위한 작업으로, commitWork()가 실행된다. 이 과정은 동기적으로 수행되며 중단될 수도 없다.
이 단계의 특징:
💡 왜 커밋 단계는 동기적일까?
렌더 단계에서는 여러 번 작업을 중단하고 재개해도 사용자에게 보이지 않기 때문에 문제가 없다. 하지만 커밋 단계에서 DOM 업데이트를 중단하면 사용자는 절반만 그려진 화면을 보게 되므로, 이 단계는 한 번에 동기적으로 완료되어야 한다.
리액트 파이버는 실제로 어떻게 구현되어 있을까?
자바스크립트 객체로 관리
리액트 파이버는 하나의 자바스크립트 객체로 관리된다. 각 파이버는 리액트 엘리먼트에 1:1로 대응되며, 해당 컴포넌트에 대한 정보를 문자열, 숫자, 배열과 같은 값으로 저장한다.
관계 정의를 통한 트리 구조
파이버는 child, sibling, return 과 같은 속성을 통해 다른 파이버와의 관계를 정의하고 있다.
child: 첫 번째 자식 파이버를 가리킴sibling: 다음 형제 파이버를 가리킴 return: 부모 파이버를 가리킴 (작업 완료 후 돌아갈 곳)이러한 구조를 통해 부모는 하나의 자식만 참조하고, sibling을 통해 형제들을 연결함으로써 1대다 관계를 효율적으로 표현한다.
// 파이버 객체의 간소화된 예시
const fiber = {
type: 'div', // 컴포넌트 타입
key: null,
props: { className: 'container' },
// 트리 구조를 위한 포인터
child: childFiber, // 첫 번째 자식
sibling: siblingFiber, // 다음 형제
return: parentFiber, // 부모
// 작업 관련
alternate: currentFiber, // current ↔ workInProgress
effectTag: 'UPDATE', // 어떤 작업을 해야 하는지
// ...
}
이렇게 링크드 리스트 형태로 구현함으로써 재귀 없이 트리를 순회할 수 있고, 작업을 언제든 중단하고 재개할 수 있는 유연성을 확보했다.
따라서 가상 DOM은 매번 모든 변경 사항을 저장하는 것이 아니라, 변경이 일어날 때마다 새롭게 생성되고, 이 변경 사항들을 React Fiber가 추적하고 관리하여, 최적화된 방식으로 실제 DOM에 업데이트하는 것이다.
리액트 내에는 두 개의 파이버 트리가 존재한다.
리액트는 파이버의 작업이 완료되고 나면, 커밋 단계에서 workInProgress 트리의 포인터를 변경해 current 트리로 바꾼다. 이를 더블 버퍼링이라고 한다.
더블 버퍼링 - 주로 컴퓨터 그래픽에서 사용되는 용어로, 내부 버퍼에서 그림을 지웠다가 그리는 과정을 수행한 다음에 외부 버퍼로 보내 화면이 부드럽게 보이도록 하는 방법이다.
beginWork() 함수를 실행해서 파이버 작업을 수행한다. 더 이상 자식이 없는 파이버를 만날 때까지 트리 형식으로 시작한다.completeWork()를 실행해 파이버 작업을 완료한다.React Fiber는 재귀 대신 Singly Linked List를 사용한 Parent-first, Depth-first traversal을 수행한다.
Fiber 구조체 내에서 자식, 형제, 부모에 대한 정보를 구조체로 관리하고 있다.
// packages/react-reconciler/src/ReactInternalTypes.js
export type Fiber = {
return: Fiber | null, // 부모
child: Fiber | null, // 첫 자식
sibling: Fiber | null, // 형제
alternate: Fiber | null, // current ↔ workInProgress
// ...
};
리액트 내부의 performUnitOfWork에서는 재귀보다 Linked List로 순회하는 방식을 활용한다.
function performUnitOfWork(unitOfWork: Fiber): void {
const current = unitOfWork.alternate;
const next = beginWork(current, unitOfWork, renderLanes);
unitOfWork.memoizedProps = unitOfWork.pendingProps;
if (next === null) {
// 자식이 없으면 complete 단계로
completeUnitOfWork(unitOfWork);
} else {
// 자식이 있으면 자식을 다음 작업으로
workInProgress = next;
}
}
과거 Stack Reconciler에서는 재귀 방식으로 동기적으로 업데이트를 수행했기 때문에 중단할 수 없었다. 현재는 Linked List 기반 DFS로 개선하여 작업을 중단/재개할 수 있고, 작업 단위와 우선순위를 정할 수 있게 되었다.
새롭게 업데이트가 생기면 workInProgress 트리를 다시 빌드한다. 최초 렌더링 시에는 모든 파이버를 새롭게 만들어야 했지만, 이후에는 파이버가 이미 존재하므로 되도록 새로 생성하지 않고 기존 파이버에 업데이트된 props를 받아 파이버 내부에서 처리한다. 파이버 객체를 계속 생성하는 것은 리소스 낭비이므로, 기존의 파이버 객체를 재사용해서 내부 속성값만 초기화하고 변경하여 트리를 업데이트한다.