이 글의 목적은 JSX와 유사한 구문을 직접 구현하여 React의 컴포넌트 렌더링 방식과 유사한 방식으로 UI를 구성해 보고, 프레임워크에 의존하지 않고도 선언적 UI를 구현하는 방법을 이해하는 것이다.
JSX는 JavaScript XML의 약자로, React 개발에 주로 사용되는 JavaScript를 확장한 문법이다. React에서 JavaScript 파일을 HTML과 비슷하게 마크업을 작성할 수 있도록 해 준다. UI 구성요소를 보다 직관적이고 읽기 쉽게 만들어, 개발자가 UI 로직을 효과적으로 표현할 수 있게 해 준다.
원래의 코드는 첫 번째 블록과 같고, 우리는 JSX 구현 후 이를 적용시켜 두 번째 블록처럼 조금 더 React스러워 보이게 만들 것이다.
// JSX 적용 전 코드
export default class MainPage extends Component {
constructor(props: ComponentProps) {
super(props);
this.state = { count: 1 };
}
componentDidMount() {
document.getElementById('root')?.addEventListener('click', (event) => {
const target = event.target as HTMLElement;
if (target.id === 'go-to-login') {
const router = Router.getInstance();
router.navigate('/login');
} else if (target.id === 'increment') {
this.setState({ count: this.state.count + 1 });
}
});
}
render() {
const element = document.getElementById('root');
if (element) {
element.innerHTML = `
<div>
<button id="go-to-login">Go to Login</button>
<div>
<p>Count: ${this.state.count}</p>
<button id="increment">Click me!</button>
</div>
</div>
`;
}
}
}
// JSX 적용 후 코드
export default class MainPage extends Component {
private router: Router;
constructor(props: ComponentProps) {
super(props);
this.state = { count: 1 };
this.router = Router.getInstance();
}
goToLogin() {
this.router.navigate('/login');
}
incrementCount() {
this.setState({ count: this.state.count + 1 });
}
render() {
const contents = jsx`
<div>
<buttontoken interpolation">${() => this.goToLogin()}">Go to Login</button>
<div>
<p>Count: ${this.state.count}</p>
<buttontoken interpolation">${() => this.incrementCount()}">
Click me!
</button>
</div>
</div>
`;
const element = document.getElementById('root');
if (element) {
element.innerHTML = '';
element.appendChild(contents);
}
}
}
템플릿 리터럴과 삽입된 값을 인자로 받아 DOM 요소를 생성하는 함수를 만든다.
strings 배열과 args 배열을 결합하여 템플릿을 만든다. 각 args 항목 앞에는 일련번호를 붙여 특정 인덱스를 나타내는 텍스트(tempindex:x:)를 삽입한다. 이 텍스트는 나중에 실제 값으로 교체될 템플릿 변수 역할을 한다.
let template = document.createElement('div');
template.innerHTML = strings
.map((str, i) => `${str}${i < args.length ? `${TEMP.PREFIX}${i}:` : ''}`)
.join('');
생성된 DOM 트리를 순회하면서 텍스트 노드와 요소 노드를 처리한다. 텍스트 노드는 processTextNode 함수를 사용하여 처리하고, 요소 노드는 bindEventHandler 함수를 사용하여 처리한다.
let walker = document.createNodeIterator(template, NodeFilter.SHOW_ALL);
let node: Node | null;
while ((node = walker.nextNode())) {
if (node.nodeType === Node.TEXT_NODE) {
processTextNode(node, args);
} else if (node.nodeType === Node.ELEMENT_NODE) {
let element = node as Element;
Array.from(element.attributes).forEach(({ name, value }) => {
if (value.includes(TEMP.PREFIX)) {
const match = TEMP.REGEX.exec(value);
if (match)
bindEventHandler(name, args[parseInt(match[1], 10)], element);
}
});
}
}
텍스트 노드 내의 템플릿 변수를 실제 값으로 대체하는 함수를 만든다.
텍스트 노드의 값을 TEMP.SEPARATOR_REGEX_G 정규식으로 분할하여, 템플릿 변수와 일반 텍스트를 구분하고, 분할된 각 텍스트 조각에 대해 템플릿 변수를 실제 값으로 교체한다. TEMP.REGEX를 사용하여 인덱스를 추출하고, 해당 인덱스에 따라 args 배열에서 값을 가져와 convertJsxArgToNode 함수를 통해 DOM 노드로 변환한다.
실제 DOM을 직접 수정하는 대신, DocumentFragment를 사용하여 메모리에서 DOM 조작을 먼저 수행하고, 최종 결과만을 실제 DOM에 반영한다. 이 방법은 불필요한 DOM 접근을 줄이고, 페이지의 렌더링 성능을 향상시킨다.
function processTextNode(node: Node, args: JsxArg[]): void {
if (
node.nodeType !== Node.TEXT_NODE ||
!node.nodeValue?.includes(TEMP.PREFIX)
)
return;
const texts = node.nodeValue.split(TEMP.SEPARATOR_REGEX_G);
const fragment = document.createDocumentFragment();
texts.forEach((text) => {
const tempindex = TEMP.REGEX.exec(text)?.[1];
if (!tempindex) {
fragment.appendChild(document.createTextNode(text));
} else {
fragment.appendChild(convertJsxArgToNode(args[Number(tempindex)]));
}
});
node.parentNode?.replaceChild(fragment, node);
}
JsxArg 타입의 인자를 받아 DOM 노드로 변환하고, 이를 DocumentFragment에 추가하는 함수이다. DocumentFragment는 DOM에 직접적으로 추가되기 전에 노드를 효율적으로 조립할 수 있게 해주는 경량의 DOM 트리이다.
인자 arg의 타입에 따라 다르게 처리해 준다.
type JsxArg = Node | string | null | boolean | JsxArg[];
function createTextFragment(str?: string): DocumentFragment {
const fragment = document.createDocumentFragment();
if (str) fragment.appendChild(document.createTextNode(str));
return fragment;
}
function convertJsxArgToNode(arg: JsxArg): DocumentFragment {
const fragment = document.createDocumentFragment();
if (arg instanceof Node) {
fragment.appendChild(arg);
} else if (Array.isArray(arg)) {
arg.forEach((item) => {
if (item instanceof Node) {
fragment.appendChild(item);
} else {
let container = document.createElement('div');
container.innerHTML = item as string;
while (container.firstChild) {
fragment.appendChild(container.firstChild);
}
}
});
} else if (arg !== null && arg !== false) {
fragment.appendChild(createTextFragment(String(arg)));
}
return fragment;
}
요소의 속성 이름을 검사하여 on으로 시작하는 이벤트 핸들러가 있다면 해당 이벤트를 요소에 바인딩한다. on 뒤에 오는 문자열을 이벤트 타입으로 변환하고, 속성 값으로 제공된 함수를 해당 이벤트 리스너로 설정한다.
function bindEventHandler(name: string, value: any, element: Element) {
if (typeof value === 'function') {
element.addEventListener(name.replace('on', '').toLowerCase(), value);
element.removeAttribute(name);
}
}

조건부 렌더링과 리스트 렌더링까지 잘 구현된 모습이다.