데브코스 3주차 과제로 바닐라 JS로 TodoList만들기가 주어졌다.
바닐라 JS라는 제목을 달고 있는 강의도 들었는데, 바닐라 JS가 뭔지 가르쳐주진 않는다. 그래서 바닐라 JS가 뭔데욥...? 하는 생각에 찾아 보는 글
외부 라이브러리나 프레임워크를 쓰지 않는 순수 JavaScript를 이르는 말이다. 빠르고 호환성이 좋지만 코드 길이가 길어질 수 있다. 하지만 디버그할 때에 연산기능이 짧아진다는 장점이 있다.
출처: https://namu.wiki/w/Vanilla%20JS
왠지 강사님이 투두 리스트를 만드는데 html파일도 없이 자바스크립트로만 화면을 구현하시더라니... 이것이 바닐라 JS구나, 하는 걸 깨달았다.
<html>
<head>
<title>KDT</title>
</head>
<body>
<main class="app"></main>
<script src="./src/storage.js"></script>
<script src="./src/Header.js"></script>
<script src="./src/TodoForm.js"></script>
<script src="./src/TodoList.js"></script>
<script src="./src/TodoCount.js"></script>
<script src="./src/App.js"></script>
<script src="./src/main.js"></script>
</body>
</html>
index.html
웹 사이트를 만들 때 가장 먼저 필요한 것은, 웹 페이지를 문서로 저장하는 것이다. 이때, index.html이란 페이지가 방문자 사이트를 요청할 때 다른 페이지가 지정되지 않은 경우 웹페이지의 기본 페이지로 사용되는 일반적인 이름이라고 한다. 즉, index.html 파일에는 웹페이지의 기본 화면을 구현하는 코드를 작성해야 한다!
코드 설명
script태그는 html파일 안에 자바스크립트 코드를 삽입할 수 있도록 해준다. 이때, 코드의 길이가 너무 길어진다면 자바스크립트 코드를 다른 파일로 빼줄 수 있다. 그 결과가 바로 위 코드이다.
script파일은 head태그보다 페이지의 하단에 위치시키는 것이 더 좋은 방법이라고 생활코딩에서 설명하고 있다.
생활코딩: HTML에서 JavaScript 로드하기
script태그의 위치가 왜 중요한지 이해하기 위해서는 브라우저의 동작 방식을 알고 있어야 한다.
브라우저의 동작 방식
HTML을 읽고-> HTML 파싱 -> DOM 트리 생성 -> Render트리 생성 -> display
HTML을 읽는 중, script태그를 만나면 파싱이 중단되고 자바스크립트 파일이 로드된 후 자바스크립트 코드가 파싱된다. 파싱이 완료되면 중단되었던 HTML 파싱 지점부터 다시 파싱이 시작된다.
따라서 script태그는 body태그의 최하단에 위치하는 것이 display가 빠르게 진행되도록 하는 지름길!
script 순서
위 코드에서 TodoForm.js를 불러오는 script를 TodoList.js를 불러오는 script보다 아래에 쓴다면, 이 코드는 작동하지 않는다. TodoList는 TodoForm에서 정의된 변수나 함수를 갖다 쓰고 있기 때문에, TodoList가 먼저 로딩 되면 아직 선언되지도 않는 변수들을 대거 사용하게 되기 때문이다! 따라서 script의 순서가 중요하다.
const storage = (function (storage) {
const setItem = (key, value) => {
try {
storage.setItem(key, value);
} catch (e) {
console.log(e);
}
};
const getItem = (key, defaultValue) => {
try {
const storedValue = storage.getItem(key);
if (storedValue) {
return JSON.parse(storedValue);
}
return defaultValue;
} catch (e) {
alert("storage에서 getItem할 때가 문제입니다");
return defaultValue;
}
};
return {
setItem,
getItem,
};
})(window.localStorage);
window.localStorage
개발자 도구를 눌러 보시오... Application 탭에서 Local Storage를 확인할 수 있다.
그리고 해당 웹페이지의 도메인을 클릭해보면, Key와 Value 쌍으로 칸이 나누어져 있는 걸 볼 수 있다.
localStorage는 브라우저에 데이터를 저장할 수 있는, 즉 클라이언트 측에 데이터를 저장할 수 있는 저장소라고 할 수 있다. Session Storage 탭도 있는데, 이 저장소는 페이지를 닫으면 저장했던 데이터들이 사라지는 반면 Local Storage에 저장된 데이터는 만료되지 않는다.
setItem
: storage객체에 접근한 후 storage.setItem()을 이용해 항목을 추가할 수 있다.
const setItem = (key, value) => {
try {
storage.setItem(key, value);
} catch (e) {
console.log(e);
}
};
key와 value 형태로 데이터를 받아서 storage에 key, value형태로 저장한다.
getItem
: localStorage 항목을 가져올 땐 storage.getItem()을 이용할 수 있다.
const getItem = (key, defaultValue) => {
try {
const storedValue = storage.getItem(key);
if (storedValue) {
return JSON.parse(storedValue);
}
return defaultValue;
} catch (e) {
alert("storage에서 getItem할 때가 문제입니다");
return defaultValue;
}
};
JSON
: JavaScript Object Notation의 약자다. JSON은 네트워크를 통해 데이터를 주고 받는 데에 자주 사용되는 경량의 데이터 형식으로, 기계가 분석하고 생성하는 데에 용이하다고 한다. JSON은 두 개의 구조를 기본으로 두고 있는데, 하나는 name/value형태의 쌍으로 collection 타입(object, struct, hash table, list 등)이 있고, 다른 하나는 값들의 순수화된 리스트(array, vector, list 등)이다.
//JSON의 생김새
{ "users":[
{
"name": "Lee",
"birth": {
"month": "January",
"day": 12,
"year": 1997,
}
},
{
"name": "Kim",
"birth": {
"month": "October",
"day": 10,
"year": 1997,
}
},
]}
JSON.parse()
: JSON 형태의 데이터를 활용할 수 있도록 데이터를 가공하는 것!
JSON.parse('[1, 5, "false"]'); // [1, 5, "false"]
JSON.parse()를 해주게 되면 문자열이 배열로 변환된다. 짱짱맨
즉 storage.js에서는 storage에서 가져온 JSON형식의 데이터들을 parse()를 통해 iterable한 객체로 만들어 주어 반환하는 것이다. 그럼 getItem으로 데이터를 마음껏 가공할 수 있다.
storage.js의 storage변수는 즉시실행함수로부터 값을 반환 받는다. 강사님의 설명으로는 전역변수가 오염될 가능성을 막기 위해서 즉시실행함수를 사용했다고 하였다.
즉시실행함수
함수 정의와 동시에 즉시 호출되는 함수. 단 한 번만 호출되며 다시 호출할 수 없다.//익명 즉시 실행 함수 (function () { var a=3; var b=5; return a*b; }());
//즉시 실행 함수도 일반 함수처럼 값을 반환할 수 있고 인수를 전달할 수도 있다. var res=(function (){ var a=3; var b=5; return a*b; }()); console.log(res); //15 //즉시 실행 함수에도 일반 함수처럼 인수를 전달할 수 있다. res=(function(a,b){ return a*b; }(3, 5)); console.log(res); //15
즉시 실행 함수 내에 코드를 모아두면 혹시 있을 수도 있는 변수나 함수 이름의 충돌을 방지할 수 있다.
function Header({ $target, text }) {
if (!new.target) {
alert("Header컴포넌트를 new로 생성해주세요!");
return;
}
const $header = document.createElement("h1");
$target.appendChild($header);
this.render = () => {
$header.textContent = text;
};
this.render();
}
//App.js
function App({ $target, initialState }) {
new Header({
$target,
text: "Simple Todo List",
});
...
}
//main.js
const initialState = storage.getItem("todos", []);
const $app = document.querySelector(".app");
new App({
$target: $app,
initialState,
});
new.target
: 함수 또는 생성자가 new 연산자를 사용하여 호출됐는지를 감지할 수 있다. new 연산자로 인스턴스화 된 생성자/함수에서 new.target은 생성자 또는 함수 참조를 반환한다. 일반 함수 호출에서는 undefined이다.
과제 요구사항으로, new를 이용해서 컴포넌트를 생성하지 않은 경우는 에러가 나도록 방어코드를 설계하라고 하였다.
그래서 처음엔 new를 붙여 생성한 컴포넌트에서 this를 찍어보고, new을 떼고 this도 찍어봤다. 그 결과 new를 붙이지 않고 생성한 컴포넌트에서 this는 window를 가리키고 있고, new를 붙이면 this는 해당 컴포넌트 객체를 가리킨다!
if (this === window) {
alert("Header컴포넌트를 new로 생성해주세요!");
return;
}
//위 코드를 아래처럼 바꾸어서 써줬다.
if (!new.target) {
alert("Header컴포넌트를 new로 생성해주세요!");
return;
}
document.createElement()
: HTML 문서에서 document.createElement() 메서드는 지정한 tagName의 HTML 요소를 만들어 반환한다. tagName을 인식할 수 없으면 HTMLUnknownElement를 대신 반환한다.
const $header = document.createElement("h1");
즉, 위 코드는 $header변수에 h1이라는 HTML요소를 생성하는 것이다.
appendChild
: Node.appendChild() 메서드는 한 노드를 특정 부모 노드의 자식 노드 리스트 중 마지막 자식으로 붙인다.
$target.appendChild($header);
새로운 h1요소를 생성하고, $target요소의 끝에 붙이는 것!
참고로 여기서 받는 $target은 const $app = document.querySelector(".app");
<= 이 친구다.
document.querySelector()
: document.queryrSelector()는 제공한 선택자 또는 선택자 뭉치와 일치하는 문서 내 첫 번째 element를 반환한다. 일치하는 요소가 없다면 null 반환.
//index.html
<html>
<head>
<title>KDT</title>
</head>
<body>
<main class="app"></main>
<script src="./src/storage.js"></script>
<script src="./src/Header.js"></script>
...
선택자가 .app이니 class이름이 app인 선택자를 반환하는 친구겠죠?
그말인 즉슨, app 밑에다가 헤더 갖다 붙여라! 라는 뜻이다.
render()
: 가상의 DOM을 조작한다. render()의 결과는 실제 DOM을 의미하지 않고 그 형태를 본딴 자바스크립트 객체를 반환한다.
실제DOM
: 실제 DOM은 html을 파싱한 결과를 의미한다. 가상DOM이 없다면 DOM이 변경될 때마다 reflow가 발생하여 비효율성을 초래한다. 즉, render()의 결과는 실제 DOM을 조작하지 않고 변경사항을 확인하여 가상 DOM을 조작한 뒤 변경사항이 누적된 가상 DOM을 적용하여 reflow 과정이 한 번만 수행되도록 한다.
Node.textContent
: Node 인터페이스의 textContent 속성은 노드와 그 자손의 텍스트 콘텐츠를 표현한다. innerText처럼 화면에 보여주는 것 같은데... 왜 여기선 textContent를 썼을까? $header의 텍스트 콘텐츠가 자주 변하지 않는 타이틀이라 reflow가 거의 발생하지 않기 때문일까...
const $header = document.createElement("h1");
$target.appendChild($header);
this.render = () => {
$header.textContent = text;
};
this.render();
Node.textContent와 HTMLElement.innerText의 차이점
textContent는 script태그와 style요소를 포함한 모든 요소의 콘텐츠를 가져오는 반면, innerText는 "사람이 읽을 수 있는" 요소만 처리한다.
textContent는 노드의 모든 요소를 반환하는 반면, innerText는 스타일링을 고려하며, "숨겨진" 요소의 텍스트는 반환하지 않는다. 또한 innerText는 reflow를 발생시킨다.
function TodoForm({ $target, onSubmit }) {
if (!new.target) {
alert("TodoForm컴포넌트를 new로 생성해주세요!");
return;
}
const $form = document.createElement("form");
$target.appendChild($form);
let isInit = false;
this.render = () => {
$form.innerHTML = `
<input type="text" name="todo" placeholder="할 일을 써주세요"/>
<button>Add</button>
`;
if (!isInit) {
$form.addEventListener("submit", (e) => {
e.preventDefault();
const $todo = $form.querySelector("input[name=todo]");
const text = $todo.value;
if (text.length > 2) {
$todo.value = "";
onSubmit(text);
} else {
alert("세 글자 이상 입력해주세요!");
return;
}
});
isInit = true;
}
};
this.render();
}
//App.js
function App({ $target, initialState }) {
//...
new TodoForm({
$target,
onSubmit: (text) => {
const nextState = [
...todoList.state,
{
text,
id: Date.now(),
isCompleted: false,
},
];
try {
todoList.setState(nextState);
storage.setItem("todos", JSON.stringify(nextState));
todoCount.render();
} catch (e) {
alert("App의 TodoForm에서 onSubmit할 때 문제입니다");
}
},
});
//...
})
Element.innerHTML
: innerHTML은 요소(element) 내에 포함된 HTML 또는 XML 마크업을 가져오거나 설정한다.
그니까, form 태그 다음으로 HTML 마크업이 써져서 화면에 출력되는 것!
addEventListener
: addEventListener() 메서드는 지정한 유형의 이벤트를 대상이 수신할 때마다 호출할 함수를 설정한다. 하나의 이벤트 유형에 대해 다수의 수신기를 부착할 수 있다는 장점이 있다. 이 메서드는 EventListener를 구현한 함수 또는 객체를 추가하는 방식으로 동작한다는데... 추가하려는 함수 또는 객체가 이미 수신기 리스트에 포함되어 있는 경우에는 추가하지 않으므로 수신기는 중복으로 등록되지 않는다.
$form.addEventListener("submit", (e) => {
e.preventDefault();
//...
}
그니까 위 코드에서 $form에 또 다른 eventListener를 추가해도 문제가 발생하지 않는다. 쵝오...
위 코드에서 수신할 이벤트 유형은 "submit"이고, e는 listener로, 지정한 이벤트를 수신할 객체이다. 여기선 콜백함수로 받고 있다.
preventDefault()
: preventDefault() 메서드는 어떤 이벤트를 명시적으로 처리하지 않은 경우, 해당 이벤트에 대한 브라우저의 기본 동작을 실행하지 않도록 지정한다.
위 코드를 예시로 들어보자면, submit 역할을 하는 버튼을 눌렀을 때 preventDefault가 없다면 입력한 텍스트가 submit되면서 창이 다시 실행되어 초기 화면으로 돌아가게 된다(submit태그는 태그의 값을 전송하면서 해당 페이지를 새로고침하는 기능을 가지고 있다). 그렇기 때문에 입력한 할 일을 text로 받아서 리스트로 render해준 것을 보여주기 위해서는 preventDefault를 사용해주어야 한다.
const $todo = $form.querySelector("input[name=todo]");
이 코드에서는 input의 name이 todo인 모든 셀렉터들을 모아 $todo 변수에 할당해주고 있다. 그리고 $todo의 value를 text변수에 할당해준다. 그 다음엔 text를 onSubmit으로 넘겨줌!
JSON.stringify
: JSON.stringify() 메서드는 JavaScript 값이나 객체를 JSON 문자열로 변환한다. storage에 들어 있는 값들은 JSON문자열로 구성되어 있기 때문에 객체배열 데이터를 그대로 넣어주면 에러가 나므로 꼭 이 메서드를 통해 storage에 값을 업데이트해줘야 한다.