현재 실습은 Counter 라고, +를 누르면 +1가 되고, -를 -1이 되며 0을 누르면 0으로 초기화 되는 간단한 실습이다.
1. HTML + JS
이는 HTML 과 JS를 통해 구현한 방법이다. 아마 내가 자바스크립트를 어떤 것을 만든다면 이렇게 구성을 했을 것이다.
<body>
<div id="app">
<header>
<h1>Counter - HTML + JS</h1>
<p>파일 경로: <span id="filepath"></span></p>
</header>
<div id="counter">
<button type="button" onclick="handleDown()">-</button>
<button type="button" onclick="handleReset(event)">0</button>
<button type="button" onclick="handleUp()">+</button>
<span>0</span>
</div>
</div>
이렇게 body 부분은 이러한 형식으로 구현을 했을 것이다. 각 버튼에 이벤트를 걸고, 그것에 맞춰서 함수를 만들어 구현 했다.
아래는 자바스크립트 부분이다. 중간에 filepath의 경우는 파일 경로를 동적으로 출력해주는 것이다.
이제 화면 갱신하는 부븐에서는
id가 counter인 요소의 자식 요소 중 span 요소를 선택하고, 그 요소를 화면에 출력하는 것이다. handleDown을 누르면 -1, handleUp은 +1, handleReset은 0으로 초기화 시키는 것이다. 가장 무난하게 하는 구현하는 방법이다.
<script type="text/javascript">
document.querySelector('#filepath').textContent = `ch${document.URL.split('/ch')[1]}index.html`;
</script>
<script type="text/javascript">
let count = 0;
const handleDown = () => {
// TODO: 데이터 갱신
count--;
// TODO: 화면 갱신
const counterSpan = document.querySelector('#counter > span');
counterSpan.textContent = count;
};
const handleUp = () => {
count++;
const counterSpan = document.querySelector('#counter > span');
counterSpan.textContent = count;
};
const handleReset = event => {
count = 0;
const counterSpan = document.querySelector('#counter > span');
counterSpan.textContent = count;
};
</script>
2. JS로 UI 구성하기
1번에서는 Html과 JS로 화면을 출력했다면 이제는 JS로만 화면을 출력하는 것이다. 각 요소를 선택하여 노드를 생성하는 것이다.
기존의 html을 주석 처리하고, 그리고 JS에서 이를 출력하는 것이다.
<script type="text/javascript">
// <h1>Counter - HTML + JS</h1>
const h1 = document.createElement('h1');
const h1Txt = document.createTextNode('Counter - JS로 UI 구성 ');
h1.appendChild(h1Txt);
// <p>파일 경로: <span id="filepath"></span></p>
const p = document.createElement('p');
const pTxt = document.createTextNode('파일 경로: ')
const filepath = document.createElement('span');
filepath.setAttribute('id', 'filepath');
p.appendChild(pTxt);
p.appendChild(filepath);
// <button type="button" onclick="handleDown()">-</button>
const downBtn = document.createElement('button');
const downBtnTxt = document.createTextNode('-');
downBtn.setAttribute('type', 'button');
downBtn.setAttribute('onclick', 'handleDown()');
downBtn.appendChild(downBtnTxt);
// <button type="button" onclick="handleReset(event)">0</button>
const resetBtn = document.createElement('button');
const resetBtnTxt = document.createTextNode('0');
resetBtn.setAttribute('type', 'button');
resetBtn.setAttribute('onclick', 'handleReset(event)');
resetBtn.appendChild(resetBtnTxt);
// <button type="button" onclick="handleUp()">+</button>
const upBtn = document.createElement('button');
const upBtnTxt = document.createTextNode('+');
upBtn.setAttribute('type', 'button');
upBtn.setAttribute('onclick', 'handleUp()');
upBtn.appendChild(upBtnTxt);
// <span>0</span>
const zeroSpan = document.createElement('span');
const zeroSpanTxt = document.createTextNode('0');
zeroSpan.appendChild(zeroSpanTxt);
// <header>
const Header = document.createElement('header');
Header.appendChild(h1);
Header.appendChild(p);
// <div id="counter">
const Counter = document.createElement('div');
Counter.setAttribute('id', 'counter');
Counter.appendChild(downBtn);
Counter.appendChild(resetBtn);
Counter.appendChild(upBtn);
Counter.appendChild(zeroSpan)
// <div id="app">
const App = document.createElement('div');
App.setAttribute('id', 'app');
App.appendChild(Header);
App.appendChild(Counter);
document.getElementById('root').appendChild(App);
</script>
위는 오로지 출력을 위한 코드이다. createElement로 요소 생성 후 변수에 저장, 그리고 type이 있다면, setAttribute로 타입을 설정, 그리고 변수에 저장된 요소를 타입 설정 혹은 텍스트가 설정 된 것을 appendChild로 합쳐주는 것이다.
그래서 위에 나와있듯이 버튼과, 숫자를 나타내는 span 태그를 JS로만 출력한 것이다.
그리고 기능 같은 경우는 중복이라 제외 시키겠다. 이렇게 노가다 식으로 생성을 해봤는데, 처음에는 사실 이해가 되지 않았다. 전 프로젝트를 진행할 때 class를 이용하여 핸들링을 했는데, 이렇게 노가다 식으로 요소 생성 후 하는 방법도 추후에 진행을 해봐야겠다.
3. createElement() 라이브 러리 사용
JS 파일을 하나 생성하여, 여기에 이제 라이브러리 환경을 세팅해주는 것이다. 하나의 틀을 생성해주는 것인데,
const index = {
// 지정한 속성과 자식 노드를 가지는 요소 노드를 생성해서 반환
// <button type="button" onclick="handleUp()">+</button>
// createElement('button', {type: 'button', onclick: 'handleUp()'}, '+');
createElement: (tag, props, ...children) => {
// 요소 노드 생성
const elem = document.createElement(tag);
// 속성 추가
if (props) {
// 배열이면 for of
// 객체면 for in
// 객체 속성의 개수만큼
for (const attrName in props) {
elem.setAttribute(attrName, props[attrName]);
}
}
// 자식 노드 추가
for (let child of children) {
if (typeof child === 'string' || typeof child === 'number') {
child = document.createTextNode(child);
}
elem.appendChild(child);
}
return elem;
}
};
export default index;
createElement 메서드로 HTML 요소를 선택하고, 속성 및 자식 노드를 지정해서 반환한다.
const elem = document.createElement(tag); 이 부분은 전달 받은 tag 인수를 사용하여 요소 노드를 생성한다. 예를 들면 tag가 button이라면 <button>을 생성하는 것이다.
그리고 props가 존재할 경우, props 객체의 각 속성을 setAttribute를 사용하여 요소에 추가한다.
예를 들면 props 객체가 { type: 'button', onclick: 'handleUp()' } 인 경우에는 속성이 type="button"과 onclick="handleUp()"를 요소로 설정한다. 이는 속성의 갯수만큼 설정해주는 것이다.
여기서 for in 반복문은 객체, for of는 배열이라고 생각하면 된다.
if (props) {
for (const attrName in props) {
elem.setAttribute(attrName, props[attrName]);
}
}
그리고 여기에 자식 노드를 요소 추가하는 것인데, ...children 인수를 통해서 여러 가지 노드를 받을 수 있으며, 각각 자식 노드를 요소에 추가한다. 이제 여기서 요소가 이제 type이 string 혹은 number 라면 Textnode로 변환하여 추가한다.
예를 들면 + 문자열이 자식 노드에 전달되면 <button>+</button> 과 같이 +가 텍스트로 버튼에 추가 된다.
만약 자식이 다른 html 요소나 텍스트 일 경우네는 자동으로 appendChild를 통해 자식 노드로 추가가 된다.
for (let child of children) {
if (typeof child === 'string' || typeof child === 'number') {
child = document.createTextNode(child);
}
elem.appendChild(child);
}
아래는 위에 예시라고 보면 된다.
const downBtn = Index.createElement('button', { type: 'button', onclick: 'handleDown()' }, '-');
그리고 이렇게 된 것을 elem 요소를 통해 반환하는 것이다. 이 코드를 바탕으로 HTML 요소를 동적으로 생성할 수 있다.
이게 이제 동적으로 생성한 것인데, 우리가 만들었던 것을 사용하려면 type을 module로 고쳐야한다. <script type="text/javascript"> 한다면 html 코드를 파싱하고 js코드를 파싱하는데, 가장 마지막에 파싱한다고 보면 된다.
<script type="module">
// module은 defer 속성을 지저한 것처럼 지연 실행됨
// HTML 파싱을 멈추지 않고, HTML 파싱이 끝난 이후에 실행됨
// module 끼리는 선언한 순서대로 실행이 된다.
// 모듈을 쓰는 이유, 모듈안에서만 전역 변수이므로 모듈끼리 충돌나지 않는다.
// src로 그냥 가지고 온다면, 라이브러리끼리 전역변수 충돌이 날 수 있다.
import Index from './index.js';
// <h1>Counter - HTML + JS</h1>
const h1 = Index.createElement('h1', null, 'Counter - createElement() 라이브러리 사용')
// <p>파일 경로: <span id="filepath"></span></p>
const p = Index.createElement('p', null, '파일 경로: ', Index.createElement('span', { id: 'filepath' }));
// <button type="button" onclick="handleDown()">-</button>
// 모듈화해서 만든 코드, 실질적인 내용은 index.js에 있음
const downBtn = Index.createElement('button', { type: 'button', onclick: 'handleDown()' }, '-');
// <button type="button" onclick="handleReset(event)">0</button>
const resetBtn = Index.createElement('button', { type: 'button', onclick: 'handleReset(event)' }, '0')
// <button type="button" onclick="handleUp()">+</button>
const upBtn = Index.createElement('button', { type: 'button', onclick: 'handleUp()' }, '+')
// <span>0</span>
const zeroSpan = Index.createElement('span', null, 0);
// <header>
const Header = Index.createElement('header', null, h1, p);
// <div id="counter">
const Counter = Index.createElement('div', { id: 'counter' }, downBtn, resetBtn, upBtn, zeroSpan);
// <div id="app">
const App = Index.createElement('div', { id: 'app' }, Header, Counter)
document.getElementById('root').appendChild(App);
</script>
그리고 여기서 우리는 모듈을 사용했는데, 모듈를 사용하는 이유는 라이브러리를 src를 통해서 한다면, 여기서 선언된 변수들이 덮어쓰여지는 경우도 있어, 자칫 잘못하면 변수가 충돌 문제가 발생할수도 있다. 하지만 모듈을 사용한다면, 이 변수는 모듈 파일 안에서만 전역 변수로 사용되기 때문에 다른 모듈 파일의 변수와 충돌하는 문제가 발생하지 않는다. 그래서 만약 모듈이 여러가지가 있다면, 선언한 순서대로 실행이 된다.
근데 여기에 문제가 원래 하나 있었다.
중간에 있는 68, 69번째 줄 인 코드인데, 여기서 실행을 하다보면
<script type="text/javascript">
document.querySelector('#filepath').textContent = `ch${document.URL.split('/ch')[1]}index.html`;
위와 같은 에러를 내게 된다. 이 이유는 설정 오류인데, 사용이 되었으나 해당 요소가 아직 존재하지 않아 null을 반환하는 문제인데, DOM에 로드가 되지 않았기 때문이다. 위쪽 코드에서 아직 저 요소가 DOM에 로드가 되지 않았는데, JS를 실행하게 되면, null을 반환하며, null은 textContent을 설정할 수 없기 때문에 에러가 나는것이다. 이를 해결하기 위해서는 module, 혹은 defer의 사용이다.
4. defer와 type="module"의 차이
4-1. defer
defer는 script 태그에 설정한 속성으로, HTML 파싱이 끝난 후 스크립트 파일을 실행한다.
- defer가 적용된 스크립트는 선언된 순서대로 실행하게 된다.
- 이는 일반적인 JS 파일에서 사용되며, 모듈을 사용하지 않는 경우에 HTML 파싱이 끝난 후 스크립트를 실행하고자 할 때 사용한다.
- head 태그에 위치해도, 페이지 로드 완료 후에 실행되기 때문에, 일반적으로 페이지 로딩 성능을 개선하는데 도움이 된다.
4-1. type="module"
type="module"은 script 태그에 지정하여, JS 파일을 모듈로 바꿔주는 속성이다. 이는 기본적으로 지연 실행되므로, defer 속성처럼 HTML 파싱이 끝난 후 실행이 된다.
- 모듈 파일은 독립적인 스코프를 가지고 있으며, 모듈 파일 내부에서 선언된 변수는 전역 스코프에 노출 되지 않는다.
- 모듈 파일은 기본적으로 지연 실행이 된다. 즉, HTML 파싱이 완료된 후에 실행이 된다.
- 같은 HTML 파일에서 여러 개의 모듈이 선언되었다면, 순서대로 로드 및 실행이 된다.
- 모듈의 import와 export 기능을 통해 코드 재사용성을 높이고, 충돌 방지할 수 있다.
5. createRoot(), render() 함수 만들기
<body>
<div id="root"></div>
<script type="module">
import Index from './index.js';
// 애플리케이션의 시작점
function App() {
return (
Index.createElement('div', { id: 'app' },
Index.createElement('header', null,
Index.createElement('h1', null, 'Counter - createRoot(), render() 함수 만들기'),
Index.createElement('p', null, '파일 경로: ',
Index.createElement('span', { id: 'filepath' },
`ch${document.URL.split('/ch')[1]}index.html`))),
Index.createElement('div', { id: 'counter' },
Index.createElement('button', { type: 'button', onclick: 'handleDown()' }, '-'),
Index.createElement('button', { type: 'button', onclick: 'handleReset(event)' }, '0'),
Index.createElement('button', { type: 'button', onclick: 'handleUp()' }, '+'),
Index.createElement('span', null, 0)))
);
}
// document.getElementById('root').appendChild(App);
Index.createRoot(document.getElementById('root')).render(App);
위와 같이 저번에 했던 모듈화를 하고, 그리고 App() 함수를 만들어 출력한다.
render 함수를 만들어야 하는데, index.js에 createElement 아래 만들어준다.
이는 루트 노드를 관리하는 객체를 생성하는 것인데, 자세히 보면 이는 클로저를 사용한다.
// 루트 노드를 관리하는 객체를 생성
// createRoot(document.getElementById('root')).render(App);
// 클루저를 사용하게 됨
createRoot: (rootNode) => {
return {
// 루트노드 하위에 지정한 함수를 실행해서 받은 컴포넌트를 렌더링 한다.
render(appFn) {
rootNode.appendChild(appFn());
}
};
}
};
이는 createRoot 함수가 실행되면, rootNode는 내부 객체가 생성될 때 클로저를 통해 기억이 된다. 이렇게 하면, render 메서드가 호출될 때마다 rootNode를 참조할 수 있다.
render(appFn) {
rootNode.appendChild(appFn());
}
그래서 이 객체는 render메서드를 통해서 appFn 이라는 함수를 실행할 수 있다.
여기 render 메서드의 역할은
appFn이라는 함수를 매개변수로 받고, 이 appFn은 보통 컴포넌트를 생성하는 함수이다.
간단하게 말하자면,
createRoot(rootNode)를 호출하면 rootNode를 클로저로 기억하는 객체가 반환된다.
반환된 객체의 render 메서드는 App함수를 전달하여 실행한다.
render 메서드는 App 함수를 호출하고, 그 결과로 얻어진 DOM요소를 rootNode의 하위에 추가한다.
6. 컴포넌트 분리
위 코드를 보면 재사용이 안좋고, 가독성이 좋지 않다. 그래서 이를 컴포넌트화를 할건데, 여기서 크게 쓰이는 것이, Header, Counter이다. Header는 title, 파일의 경로 등을 나타내며, Counter는 이제 숫자를 감소, 증가, 0으로 만들어주는 부분이다.
이는 매우매우 간단하다 함수를 하나 만들어 거기에 역할에 맞게 넣어주면 된다. 근데 여기서 이름을 대문자로 시작해야한다.
function Header() {
return (
Index.createElement('header', null,
Index.createElement('h1', null, 'Counter - 컴포넌트로 분리'),
Index.createElement('p', null, '파일 경로: ',
Index.createElement('span', { id: 'filepath' },
`ch${document.URL.split('/ch')[1]}index.html`)))
);
}
이 부분 Header이고, 아래는 Counter인데, 원래는 HTML 출력과 기능이 따로 되어 있었는데, 재사용하기 위해서는 기능도 같이 넣어주는것이 좋다. 그래서 이 함수는 다른 파일에서도 사용할 수 있다. 고치는 것도 이 부분만 고치면 되기에 재사용성에서도 좋다.
function Counter() {
let count = 0;
const handleDown = () => {
// TODO: 데이터 갱신
count--;
// TODO: 화면 갱신
const counterSpan = document.querySelector('#counter > span');
counterSpan.textContent = count;
};
const handleUp = () => {
count++;
const counterSpan = document.querySelector('#counter > span');
counterSpan.textContent = count;
};
const handleReset = event => {
count = 0;
const counterSpan = document.querySelector('#counter > span');
counterSpan.textContent = count;
};
return (
Index.createElement('div', { id: 'counter' },
Index.createElement('button', { type: 'button', onclick: handleDown }, '-'),
Index.createElement('button', { type: 'button', onclick: (event) => handleReset(event) }, '0'),
Index.createElement('button', { type: 'button', onclick: handleUp }, '+'),
Index.createElement('span', null, 0))
);
}
여기 위에서 onclick 부분에 함수를 바로 호출하는데, 이때 이를 handleUp()이렇게 안해도 된다 이는 js파일에서 할 수 있다.
아래와 같이 type이 function이라면 child()를 자동으로 해주는 것이다.
for (let child of children) {
if (typeof child === 'string' || typeof child === 'number') {
child = document.createTextNode(child);
} else if (typeof child === 'function') {
child = child();
}
elem.appendChild(child);
}
그리고 함수를 바로 사용하는거기에, onclick이 아니라 addEventListener로 만들어줘야 하는데, 여기서 onclick 속성으로 이벤트를 지정할 경우, 기존의 이벤트 리스너가 덮어씌워질 수도 있어, addEventListener를 사용하여 여러 리스너를 추가할 수 있도록 한다.
// 객체 속성의 개수만큼
for (const attrName in props) {
const value = props[attrName];
if (attrName.toLowerCase().startsWith('on')) {
elem.addEventListener(attrName.toLowerCase().substring(2), value);
} else {
elem.setAttribute(attrName, value);
}
}
}
- 속성 루프
- for (const attrName in props) 구문을 통해 props 객체의 속성 이름을 순회한다.
- attrName에는 props의 각 속성 이름이 담기고, props[attrName]을 통해 그 속성의 값을 얻는다.
- const value = props[attrName];
- 이벤트 리스너인지 확인
- attrName.toLowerCase().startsWith('on')를 통해 속성 이름이 on으로 시작하는지 확인한다.
- 예를 들어, onclick이나 onchange처럼 on으로 시작하는 속성은 이벤트 리스너로 간주한다.
- 이벤트 리스너로 설정
- 만약 attrName이 on으로 시작하면 addEventListener 메서드를 사용해 해당 요소에 이벤트 리스너를 추가한다.
- elem.addEventListener(attrName.toLowerCase().substring(2), value);
- attrName.toLowerCase().substring(2)는 on 접두사를 제거하여 이벤트 유형만 가져온다.
- 예를 들어, onclick에서 on을 제거하면 click이라는 이벤트 이름이 된다.
- value는 실제 실행할 함수이므로, 해당 이벤트가 발생할 때 value에 담긴 함수가 실행된다.
- 일반 속성으로 설정
- 속성 이름이 on으로 시작하지 않는다면, setAttribute 메서드를 사용해 일반 속성으로 추가한다.
- 예를 들어, type, id와 같은 속성은 setAttribute로 설정한다.
- elem.setAttribute(attrName, value);
요약하자면, props 객체의 각 속성이 이벤트인지 일반 속성 인지를 확인하여, 이벤트 리스너는 addEventListener로 추가하고, 일반 속성은 setAttribute로 추가하는 역할을 한다. 이를 통해 속성의 유형에 따라 유연하게 요소에 속성을 추가할 수 있다.
'JavaScript' 카테고리의 다른 글
map, forEach, for of, ArrowFunction (0) | 2024.11.13 |
---|---|
[vanilla practice] Counter - 2 (0) | 2024.11.08 |
[vanilla practice] Todo List 실습 (1) | 2024.11.06 |
key 저장 하는 법 (0) | 2024.10.25 |
[모던자바스크립트 Deep Dive] 47장. 에러 처리 (2) | 2024.10.10 |