파크로그
article thumbnail

 

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 를 가독성있게 만들 수 있게 되었으니 상태관리는 어떻게 할 수 있을지 더 알아보자.

 

소스코드 보러가기

profile

파크로그

@파크park

포스팅이 좋았다면 "좋아요❤️" 또는 "구독👍🏻" 해주세요!