HTML 태그를 JavaScript 로 표현하기
이전에 카카오페이지 클론 을 할 때, HTML 태그를 모두 작성하고, 그 태그를 JavaScript 로 옮겨 사용했던 적이 있다.
React 를 맛만봤던 그 당시에는 JSX 처럼 HTML 태그 자체를 템플릿 리터럴(``) 으로 반환하여 사용하면 createElement, appendChild 등의 DOM API 노가다를 하지 않아도 되는 편리함에 취했다.
이전
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Document</title>
</head>
<body>
<div id="app">
<h1>Hello World</h1>
<ul>
<li>밥먹기</li>
<li>설거지하기</li>
</ul>
<input />
<button>Add</button>
</div>
<script src="../src/index.js"></script>
</body>
</html>
이후
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Document</title>
</head>
<body>
<div id="app"></div>
<script src="../src/index.js"></script>
</body>
</html>
const app = document.getElementById('app');
app.innerHTML = `
<h1>Hello World</h1>
<ul>
<li>밥먹기</li>
<li>설거지하기</li>
</ul>
<input />
<button>Add</button>
`;
이걸 컴포넌트로 나누어 모듈화 한다면 아래처럼 된다.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Document</title>
</head>
<body>
<div id="app"></div>
<!-- type="module" 을 추가한다 -->
<script type="module" src="../src/index.js"></script>
</body>
</html>
// src/components/todos.js
const Todos = () => {
return `
<ul>
<li>밥먹기</li>
<li>설거지하기</li>
</ul>
`;
};
export default Todos;
// src/index.js
import Todos from './components/todos.js';
const app = document.getElementById('app');
app.innerHTML = `
<h1>Hello World</h1>
${Todos()}
<input />
<button>Add</button>
`;
이벤트 등록은 어떻게?
이제 만들어진 HTML 을 화면에 뿌리는건 쉽게 할 수 있지만, 사용자와의 상호작용을 적용시켜야 한다.
button 에 간단한 onClick 을 붙여주려하면 어떻게 해야할까?
innerHTML 내부에서 onClick attribute 를 주는 방법도 있긴 하겠지만 html elements 에서 내가 뭘 할건지 주구절절 보여주는건 좋지않아보인다.
결국 Button Element 에 DOM API 인 addEventListener 를 사용하는 것이 좋다고 생각이 든다.
이를 위한 해결방법으로 2가지를 떠올렸다.
1. DOM API 를 통해서 Button Element 를 만들어 이벤트 등록을 하던지,
2. innerHTML 이후 querySelector 로 Button Element 를 찾아 이벤트 등록을 하던지
2번의 방법을 통해 이벤트 등록을 해보자.
import Todos from './components/todos.js';
const app = document.getElementById('app');
const handleButtonClick = () => {
const input = document.querySelector('input');
const todos = document.querySelector('ul');
const newTodo = document.createElement('li');
newTodo.innerHTML = input.value;
todos.appendChild(newTodo);
};
app.innerHTML = `
<h1>Hello World</h1>
${Todos()}
<input />
<button>Add</button>
`;
const button = app.querySelector('button');
button.addEventListener('click', handleButtonClick);
이벤트 등록 방법에 대한 고민
App 의 DOM Element 들이 모두 그려진 이후 이벤트를 일괄적으로 등록해주면 되긴 하겠지만, 만약 이벤트 등록을 하고자 하는 Button 이 무수히 많아졌을 때 원하는 DOM Element 를 어떻게 select 할 것인지 고민이 늘고 이벤트 등록을 하기 위해 DOM 을 찾기위한 속성( class 이름 부여 등 )이 들어가게 된다.
사용되는 컴포넌트를 모듈화 한다면 Button 컴포넌트에 이벤트를 달아주는 행위가 Button 컴포넌트를 사용하는 곳에서 선언할 수 있어야 개발 사용성이 높아지지 않을까?
import Todos from './components/todos.js';
const app = document.getElementById('app');
const handleButtonClick = () => {
...
};
app.innerHTML = `
<h1>Hello World</h1>
${Todos()}
<input />
<button onClick={${handleButtonClick}}>Add</button>
`;
innerHTML 에서 HTML Element 가 기본으로 가지고 있는 Attribute 일 경우에는 위에서 작성한 것 처럼 elements 에 주구절절 함수의 내부 구현사항이 보여지므로 Button 컴포넌트로 모듈화를 해서 사용하자.
app.innerHTML = `
<h1>Hello World</h1>
${Todos()}
<input />
${Button({
onClick: handleButtonClick,
children: 'Add',
})}
`;
// button.js
const Button = ({ onClick, children }) => {
return `<button>${children}</button>`;
};
export default Button;
여기서 onClick 을 달아줘야 하는데 어떻게 달아줘야 할 지 어렵다.
button 이 렌더링 된 이후 addEventListener 가 가능해지는데, 함수가 렌더링 될 태그 문자열을 반환하는 형태이기 때문에 Button 이 자체적으로 event 를 달아주기가 어렵다.
결국 Button 컴포넌트는 createElement 등으로 자기 자신의 태그를 생성하여 그 element 를 반환하는 형식으로 사용되어야 할 것이다.
const Button = ({ onClick, children }) => {
const $button = document.createElement('button');
$button.addEventListener('click', onClick);
// children 이 단순 string 이라면 appendChild 를 할 수 없다. Node 로 만들어준다.
$button.appendChild(
typeof children === 'string' ? document.createTextNode(children) : children
);
return $button;
};
export default Button;
createElement 만들기, innerHTML 삭제
그럼 이제 app 에 innerHTML 하는 곳이 문제다. Button 함수는 HTMLElement 를 반환하기 때문에 [object HTMLButtonElement] 가 반환되어 보여진다.
HTML 태그를 만드는 형태를 Button 컴포넌트를 만드는 것 처럼 통일시켜 app 에 appendChild 하는 형식으로 화면에 보여져야 하므로 innerHTML 은 버리고 태그를 만드는 형식도 통일시킨다.
위에서 Button Element 를 만들 때 다음의 순서대로 만들었다. 이를 button 뿐 만이 아니라 다양한 Element 를 만들 때에도 동일하게 사용될 수 있도록 함수를 추상화하여 만들어본다.
1. 내가 원하는 tag 로 element 를 만든다.
2. 이벤트 등록, 혹은 부여할 Attribute 를 받고 등록한다.
3. 자식 태그를 append 한다.
export const createElement = (tag, props, ...children) => {
const $element = document.createElement(tag);
if (props) {
Object.entries(props).forEach(([key, value]) => {
if (key.slice(0, 2) === 'on') {
$element.addEventListener(key.slice(2).toLowerCase(), value);
} else {
$element.setAttribute(key, value);
}
});
}
children.forEach((child) => {
if (!child) return;
if (typeof child === 'string') {
$element.appendChild(document.createTextNode(child));
} else {
$element.appendChild(child);
}
});
return $element;
};
import { createElement } from '../core.js';
const Button = ({ onClick, children }) =>
createElement('button', { onClick }, children);
export default Button;
import { createElement } from '../core.js';
const Todos = () =>
createElement(
'ul',
null,
createElement('li', null, '밥먹기'),
createElement('li', null, '설거지하기')
);
export default Todos;
import Todos from './components/todos.js';
import Button from './components/button.js';
import { createElement } from './core.js';
const app = document.getElementById('app');
const handleButtonClick = () => {
const input = document.querySelector('input');
const todos = document.querySelector('ul');
const newTodo = document.createElement('li');
newTodo.innerHTML = input.value;
todos.appendChild(newTodo);
};
const $title = createElement('h1', null, 'Hello World');
const $input = createElement('input');
const $root = createElement(
'div',
null,
$title,
Todos(),
$input,
Button({ onClick: handleButtonClick, children: 'Add' })
);
app.appendChild($root);
JSX 문법으로 가독성 높이기
지금은 태그갯수가 적어 return 되는 createElement 으로 인한 가독성이 나쁘지는 않지만, 만약 하나의 컴포넌트에서 사용되는 Element 가 많아진다면 children 으로 사용되는 Element 들이 모두 createElement 를 통해서 만들어져야 하므로 가독성이 굉장히 나빠지게 된다.
// 이렇게 만들일은 거의 없겠지만, 왜 가독성이 나빠진다는지는 알 수있을 것이라 생각한다.
const Hell = () =>
createElement(
'div',
null,
createElement(
'div',
null,
createElement(
'div',
null,
createElement(
'div',
null,
createElement(
'div',
null,
createElement('div', null, createElement('div', null, 'hell'))
)
)
)
)
);
React 에서도 DOM Element 를 만들 때 createElement 라는 함수를 사용하고, JSX 문법을 사용한다면 createElement 로 트랜스파일링된다.
Babel 과 JSX 문법을 사용하면 기본적으로 React.createElement 로 트랜스파일링을 해주는데, 만약 우리가 만든 createElement 도 똑같이 적용하여 사용하고 싶다면 Babel 에서 제공하는 플러그인인 @babel/plugin-transform-react-jsx 를 사용하면 된다.
npm install -D @babel/plugin-transform-react-jsx
// babel.config.json
{
"plugins": ["@babel/plugin-transform-react-jsx"]
}
// package.json
{
...
"scripts": {
"babel": "babel -w src --out-dir dist",
"start": "live-server --entry-file=\"public/index.html\" --port=3000"
},
...
}
<!DOCTYPE html>
<html lang="en">
...
<body>
<!-- <div id="app"></div> -->
<!-- 트랜스파일된 dist 에서 사용하도록 변경 -->
<script type="module" src="../dist/index.js"></script>
</body>
</html>
그럼 이제 어디서 많이 본 코드가 된다.
// 해당 주석과 함수를 import 하여 jsx 문법에 어떤 함수를 사용할 것인지 모든 컴포넌트에 명시한다.
/** @jsx createElement */
import { createElement } from './core.js';
import Todos from './components/todos.js';
import Button from './components/button.js';
const App = () => {
const handleButtonClick = () => {
...
};
return (
<div id='app'>
<h1>Hello World</h1>
<Todos />
<input />
<Button onClick={handleButtonClick}>Add</Button>
</div>
);
};
export default App;
/** @jsx createElement */
import { createElement } from './core.js';
import App from './app.js';
const $root = document.querySelector('body');
$root.appendChild(<App />);
createElement 도 tag 가 컴포넌트(함수) 가 받아지는 경우를 추가하여 사용한다.
export const createElement = (tag, props, ...children) => {
const _children = children.flat();
if (typeof tag === 'function') {
return tag({ ...props, children: _children });
}
const $element = document.createElement(tag);
if (props) {
Object.entries(props).forEach(([key, value]) => {
if (key.slice(0, 2) === 'on') {
$element.addEventListener(key.slice(2).toLowerCase(), value);
} else {
$element.setAttribute(key, value);
}
});
}
// _children 으로 변경
_children.forEach((child) => {
if (!child) return;
if (typeof child === 'string') {
$element.appendChild(document.createTextNode(child));
} else {
$element.appendChild(child);
}
});
return $element;
};
children 을 flat 하는 이유는 컴포넌트 사용시 children 을 넘겨줄 때 rest params 로 받기 때문에 children 이 배열이 되는데, 넘겨받은 children 이 배열이고 아래와 같이 쓸 경우 createElement 에서 children 을 [[]] 의 형태로 바꾸어 이중배열이 되기 때문에 flat 을 해준다.
/** @jsx createElement */
import { createElement } from '../core.js';
const Button = ({ onClick, children }) => {
return <button onClick={onClick}>{children}</button>;
};
이제 DOM Element 를 가독성있게 만들 수 있게 되었으니 상태관리는 어떻게 할 수 있을지 더 알아보자.