오늘의 핵심은 "함수를 다른 함수에게 넘긴다"는 발상이었다.
To Do List를 만들며 이벤트를 한곳에 모으는 법을 배우고, 콜백 함수와 비동기, 스프레드 구문까지 이어졌다.
학습 여덟 번째 날이다. 오늘 배운 내용은 이렇다.
change 이벤트...): 배열과 객체자바스크립트에서 키 입력 값을 받으려면 키 코드 목록을 확인해 두면 좋다. 오늘은 그중 엔터 키(13)를 썼다.
할 일을 입력하고 엔터를 누르면 목록에 추가되고, 체크박스를 누르면 취소선이 그어지고, 휴지통 아이콘을 누르면 지워지는 화면이다.
<head>
<style>
/* CDN으로 부트스트랩 아이콘을 가져온다 */
@import url("https://cdn.jsdelivr.net/npm/bootstrap-icons@1.13.1/font/bootstrap-icons.min.css");
/* 체크하면 취소선 + 배경색 */
.checked {
text-decoration: line-through;
background-color: palegreen;
}
</style>
</head>
<body>
<div id="main">
<h2>To Do List</h2>
<input type="text" id="in" placeholder="할 일 입력">
<div id="list"></div>
</div>
<script>
let list = document.querySelector('#list');
let text = document.querySelector('#in');
const KEY_ENTER = 13; // enter의 keycode는 13이다
text.addEventListener('keypress', function (e) {
if (e.keyCode === KEY_ENTER) {
insertTodo();
text.value = ''; // 엔터 입력 후 입력창을 비운다
}
});
function insertTodo() {
let todoDiv = document.createElement('div');
todoDiv.innerHTML = `${text.value} <input type="checkbox" class="che"><i class="bi bi-trash"></i>`;
// innerHTML은 태그를 인식하므로 div 안에 체크박스와 아이콘이 함께 들어간다
list.appendChild(todoDiv);
}
// 휴지통 클릭 → 그 줄 삭제
list.addEventListener('click', function (e) {
if (e.target.classList.contains('bi-trash')) {
let parent = e.target.parentElement; // 아이콘의 부모 div (할 일 한 줄)
parent.remove();
}
});
// 체크박스 상태 변화 → 취소선
list.addEventListener('change', function (e) {
if (e.target.classList.contains('che')) {
let parent = e.target.parentElement; // 체크박스의 부모 div (할 일 한 줄)
parent.classList.toggle('checked'); // 있으면 제거, 없으면 추가
}
});
</script>
</body>
짚어 둘 부분은 이렇다.
① 이벤트를 목록 전체에 한 번만 건다. 할 일 줄은 엔터를 칠 때마다 새로 생긴다. 줄마다 이벤트를 붙이려면 매번 addEventListener를 호출해야 한다. 대신 부모인 #list에 이벤트를 한 번만 걸고, 안에서 어느 요소에서 이벤트가 일어났는지를 e.target으로 확인한다. 나중에 추가된 줄에도 자동으로 적용된다. (이런 방식을 이벤트 위임이라고 한다.)
② e.target은 이벤트가 실제로 일어난 하나의 요소다. 리스트 안의 수많은 요소 중에서 클릭된 바로 그 요소를 집어낸다. 휴지통 아이콘 <i>에는 bi와 bi-trash 두 클래스가 있으므로, classList.contains('bi-trash')로 "휴지통을 눌렀는가"를 판별한다.
③ 체크박스, 라디오 버튼의 상태 변화는 change 이벤트로 확인한다. 체크박스가 체크되거나 해제되는 순간 발생한다. classList.toggle('checked')로 취소선 클래스를 붙였다 뗀다.
④ 삭제는 요소.remove(). Day.6에서 배운 parent.removeChild(child)와 달리, 지울 요소에 직접 remove()를 호출하면 된다.
바로잡기: 필기에서는
keypress와e.keyCode를 썼는데, 둘 다 현재는 사용을 권장하지 않는 기능이다. 요즘은keydown이벤트에서e.key === 'Enter'로 확인한다. 수업에서는keyCode로 배웠고, 엔터가 13이라는 점을 기억해 두면 된다. 또 체크박스마다id='che'를 붙였는데, 줄이 늘어나면 같은 id가 여러 개 생겨서 규칙에 어긋난다. 위 코드는class="che"로 바꿔서 판별했다. 마지막으로 입력한 글을innerHTML로 넣으면 입력에 섞인 태그가 그대로 해석되므로, 실제 서비스라면 글자는textContent로 따로 넣는 편이 안전하다.
<ul>
<li>데이터베이스</li>
<li>빅데이터</li>
<li>Ai</li>
</ul>
<script>
let li = document.querySelectorAll('li');
li.forEach(function (item) {
item.onclick = function () {
this.style.fontSize = '1.2em';
};
});
</script>
문제는 "각각의 글자를 클릭하면 1.2배씩 커진다"였다.
바로잡기: 필기의 코드는
fontSize = '1.2em'을 대입하는 방식이라, 몇 번을 눌러도 1.2배에서 멈춘다. 누를 때마다 1.2배씩 커지게 하려면 현재 크기를 읽어서 곱해야 한다. 또querySelectorAll결과를for in으로 돌면 Day.5에서 본 것처럼length같은 속성까지 돌게 되므로,forEach로 바꿨다.
li.forEach(function (item) {
item.onclick = function () {
let size = parseFloat(getComputedStyle(this).fontSize); // 현재 글자 크기(px)
this.style.fontSize = size * 1.2 + 'px'; // 1.2배
};
});
getComputedStyle(요소).fontSize는 '16px' 같은 문자열을 돌려주고, parseFloat가 뒤의 px를 떼고 숫자만 읽는다. 문자열 숫자를 읽는 parseInt/parseFloat는 Day.2에서 배운 내용 그대로 다시 쓰인 셈이다.
문제는 "해당 <p> 태그에 대해서만 마우스가 올라갈 때 밑줄이 그어지고, 내려갈 때 사라진다"였다.
이 문장에서 <p>마우스</p>가 올라가면 밑줄이 그어지고, <p>마우스</p>가 내려가면 밑줄이 사라진다.
<script>
let p = document.querySelectorAll('p');
p.forEach(function (el) {
el.onmouseover = function (e) {
e.target.style.textDecoration = 'underline';
};
el.onmouseout = function () {
this.style.textDecoration = 'none';
};
});
</script>
필기의 forEach((a, b, c) => { p[b]... })는 요소를 a로 바로 받을 수 있는데도 인덱스 b로 다시 찾고 있었다. 요소를 그대로 받아 쓰면 더 짧다. 이벤트 안에서 e.target과 this는 이 예제에서 같은 요소를 가리켜서 둘 중 어느 쪽을 써도 결과가 같다.
console.log('one');
setTimeout(() => {
console.log('two'); // 5초 뒤 실행
}, 5000);
console.log('three');
출력 순서는 one → three → (5초 뒤) two다. setTimeout은 5초를 기다리는 동안 프로그램 전체를 멈추지 않고, 일단 다음 줄인 three를 먼저 출력한다. Day.7에서 setInterval, setTimeout을 배웠는데, 이 둘이 바로 대표적인 비동기 함수다.
function test(value) {
value(); // 전달받은 함수를 실행
}
function test2() {
console.log('test2');
}
test(test2); // test2
test2()가 아니라 test2를 넘긴다. 괄호를 붙이면 그 자리에서 바로 실행되어 버리고, 괄호 없이 이름만 넘겨야 함수 자체가 인자로 전달된다. Day.6에서 head.onclick = on;에 괄호를 붙이지 않았던 것과 같은 이유다.
function test(callback) {
console.log('작업 중...');
callback(); // 전달받은 함수 실행
}
function test2() {
console.log('작업 완료!');
}
test(test2);
// 작업 중...
// 작업 완료!
이렇게 함수를 다른 함수의 인자로 전달해서, 특정 시점에 실행되도록 하는 함수를 콜백 함수(callback)라고 한다.
function add(a, b, callback) {
setTimeout(() => {
let sum = a + b;
callback(sum); // 계산이 끝난 뒤 callback을 호출해서 결과를 전달
}, 3000);
}
// add(1, 2, 함수)
add(1, 2, (num) => {
console.log(num); // 3초 뒤 3
});
흐름을 따라가 보면 이렇다.
add(1, 2, 함수)를 호출하면서 세 번째 인자로 함수 자체를 넘긴다.add 안에서 setTimeout이 3초를 기다린다.sum을 계산하고, 넘겨받은 callback(sum)을 호출한다.num으로 3이 들어가 console.log(3)이 실행된다.콜백이 필요한 이유는, setTimeout처럼 시간이 걸리는 비동기 작업은 끝나기를 기다렸다가 return할 수가 없기 때문이다.
function add(a, b) {
setTimeout(() => {
return a + b; // 이 return은 setTimeout 안의 함수가 하는 것이라 add에 닿지 않는다
}, 3000);
}
let result = add(1, 2);
console.log(result); // undefined
add는 setTimeout을 등록하자마자 끝나 버려서 result는 undefined가 된다. 그래서 "작업이 끝난 뒤에 이 함수를 실행해 줘"라고 함수를 맡겨 두는 방식을 쓴다. 비동기 작업이 끝난 뒤 그 결과를 원하는 곳에서 쓸 수 있게 하려는 것이다.
고차 함수(higher-order function)는 함수를 매개변수로 받거나, 함수를 리턴하는 함수를 말한다. 위의 add, test가 함수를 인자로 받으므로 고차 함수다.
function cook(food, callback) {
console.log(`${food} 요리 시작한다~`);
setTimeout(() => {
console.log(`${food} 요리 끝`);
callback();
}, 10000);
}
cook('누룽지', () => {
console.log('요리가 끝난 뒤 실행되는 함수');
});
// 누룽지 요리 시작한다~
// (10초 뒤) 누룽지 요리 끝
// 요리가 끝난 뒤 실행되는 함수
cook 안에서 callback()이 호출되는 시점에 두 번째 인자로 넘긴 함수가 실행되므로, 출력은 위 순서가 된다.
function getNum(callback) {
setTimeout(() => {
let num = Math.floor(Math.random() * 10); // 0~9
callback(num); // 3초 뒤 num을 콜백에 전달
}, 3000);
}
getNum(function (num) {
alert(num);
});
getNum에게 넘기는 매개변수가 함수다. 3초가 지나면 getNum 안에서 그 함수에 num을 넣어 호출하므로, 우리가 쓴 함수의 num이 곧 난수 값이다.
function coffee(callback) {
setTimeout(() => {
const coffee1 = 'latte';
callback(coffee1); // 2초 뒤 'latte'를 콜백에 전달
}, 2000);
}
coffee((value) => {
console.log(value); // latte
});
값을 return하지 못하는 대신, 값을 콜백의 인자로 넣어 호출해서 바깥으로 전달하는 구조다. 이름 없는 화살표 함수 (value) => console.log(value)를 coffee의 인자로 직접 써 넣은 것이 포인트다.
const pro = {
age: 22,
name: 'tom'
};
const { name, age } = pro; // pro 객체에서 같은 이름의 속성을 꺼낸다
console.log(age); // 22
console.log(name); // tom
// 객체에 없는 속성은 기본값을 지정할 수 있다
const { age2 = 33 } = pro;
console.log(age2); // 33 (pro에 age2가 없으므로 기본값)
console.log(pro); // { age: 22, name: 'tom' } (원본은 그대로)
const { 속성명 = 기본값 } = 객체로 쓰면, 그 이름의 속성이 없을 때(undefined일 때)만 기본값이 들어간다. 속성이 있다면 기본값은 무시된다. 위에서 age2는 pro에 없는 이름이라 33이 들어갔다. 원본 객체 pro에는 아무 영향이 없다.
......은 배열 안의 값을 순서대로 펼친다.
const ary = [1, 2];
console.log(ary); // [1, 2]
console.log(...ary); // 1 2 (펼쳐진 값들이 따로따로 출력)
let func1 = (a, b, c) => {
console.log(a + b + c);
};
func1(...ary); // NaN
func1(...ary)는 func1(ary[0], ary[1], ary[2])와 같다. ary에는 요소가 2개뿐이라 세 번째 매개변수 c가 undefined가 되고, 1 + 2 + undefined는 NaN이다.
ary.push(3); // 요소를 추가했으니
func1(...ary); // 6
const ary2 = [1, 2, 3, 4, 5];
const [a, ...c] = ary2; // 첫 값은 a에, 나머지는 배열로 c에
console.log(a); // 1
console.log(c); // [2, 3, 4, 5]
console.log(typeof(c)); // object (배열은 객체)
const [a1, b1, c1, d1, e1] = ary2; // 5개를 각각 a1~e1에 배정
console.log(e1); // 5
구조 분해에서 ...변수는 "남은 요소를 이 변수에 모은다"는 뜻이라, 위치는 항상 맨 뒤여야 한다.
const ary3 = [10, 20];
const ary4 = [30, 40];
const ary5 = [...ary3]; // ary3의 값을 펼쳐서 새 배열에 담는다 (복사)
console.log(ary3); // [10, 20]
console.log(ary5); // [10, 20]
ary5[0] = 100; // 복사본만 수정
console.log(ary3); // [10, 20] (원본 그대로)
console.log(ary5); // [100, 20]
const ary7 = [...ary3, ...ary4]; // 두 배열을 합친다
console.log(ary7); // [10, 20, 30, 40]
[...ary3]는 값을 펼쳐 새 배열을 만드는 것이라 복사본이 된다. 그래서 ary5를 바꿔도 ary3은 바뀌지 않는다. 그냥 const ary5 = ary3;로 대입하면 같은 배열을 가리키게 되어 하나를 바꾸면 둘 다 바뀐다.
배열 이름에 + 숫자를 붙이면 어떻게 될지도 실험해 봤다.
console.log(ary3 + 1); // 10,201 (문자열)
const ary6 = [...ary3 + 1];
console.log(ary6); // ['1', '0', ',', '2', '0', '1']
+는 배열을 문자열로 바꿔서 이어 붙이므로, [10, 20] + 1은 '10,20'과 1이 이어진 문자열 '10,201'이 된다. 문자열도 펼칠 수 있어서 ...이 글자 하나하나로 쪼갠 것이다. 마지막 요소에 1이 더해진 것이 아니라, 통째로 문자열이 된 것이다.
스프레드는 배열뿐 아니라 객체, 문자열처럼 순회할 수 있는 구조에 쓸 수 있다.
const obj = { v1: 10, v2: 20 };
const obj2 = { v3: 30, v4: 40 };
const obj3 = { ...obj, ...obj2 };
console.log(obj3); // { v1: 10, v2: 20, v3: 30, v4: 40 }
const obj4 = { ...obj2, ...obj };
console.log(obj4); // { v3: 30, v4: 40, v1: 10, v2: 20 }
속성 이름이 서로 다르면 합쳐진다. 순서만 다르게 펼쳤을 때는 합쳐진 속성의 나열 순서만 바뀐다. 속성 이름이 같으면 나중에 펼친 값이 덮어쓴다.
const o1 = { v1: 10, v2: 20 };
const o2 = { v1: 30, v2: 40 };
console.log({ ...o1, ...o2 }); // { v1: 30, v2: 40 } (뒤의 o2가 덮어씀)
console.log({ ...o2, ...o1 }); // { v1: 10, v2: 20 } (뒤의 o1이 덮어씀)
필기에는 처음에
obj3의 결과를v1:30, v2:40처럼 예상했다가, 이름이 같을 때만 덮어쓰고 다르면 합쳐진다는 것을 직접 확인해서 정리했다. 위 코드는 그 확인 내용을 두 개의 예제로 나눈 것이다.
배열을 객체로 펼치면 인덱스 번호가 속성 이름이 된다.
const ary3 = [10, 20];
const ary8 = { ...ary3 };
console.log(ary8); // { 0: 10, 1: 20 }
[]로 감싸면 배열, {}로 감싸면 객체가 되고, 객체가 될 때는 인덱스가 키가 되는 것이다.
obj3.v3 = 70; // obj3의 v3만 변경
console.log(obj3); // { v1: 10, v2: 20, v3: 70, v4: 40 }
console.log(obj); // { v1: 10, v2: 20 } (원본 그대로)
console.log(obj2); // { v3: 30, v4: 40 } (원본 그대로)
스프레드로 만든 obj3은 새 객체라서, 값을 바꿔도 원본 obj, obj2에는 영향이 없다. 다만 속성 값이 객체나 배열이면 그 안쪽은 원본과 같은 것을 공유한다. 이 복사는 한 단계만 새로 만드는 얕은 복사다.
문제는 이렇다. 수식이 적힌 줄마다 입력칸이 있고, 결과 버튼을 누르면 입력한 답이 맞는지 확인해서 틀리면 수식에 취소선을 긋고, 맞은 개수를 출력한다.
<span class="s">5*3</span> <input type="text"><br>
<span class="s">1+2*3</span> <input type="text"><br>
<span class="s">12*2</span> <input type="text"><br>
<button>결과</button> 맞은 개수: <span id="r"></span>
<script>
let s = document.querySelectorAll('.s');
let r = document.querySelector('#r');
let input = document.querySelectorAll('input');
let btn = document.querySelector('button');
btn.onclick = function () {
let sum = 0; // 누를 때마다 0부터 다시 센다
input.forEach((a, b) => {
if (eval(s[b].textContent) == input[b].value) {
s[b].style.textDecoration = 'none'; // 맞으면 취소선 제거
sum += 1;
} else {
s[b].style.textDecoration = 'line-through'; // 틀리면 취소선
}
});
r.innerHTML = sum; // 맞은 개수 출력
};
</script>
| 수식 | 정답 |
|---|---|
5*3 | 15 |
1+2*3 | 7 |
12*2 | 24 |
eval(s[b].textContent): 수식 글자를 계산한다. 곱셈이 먼저라서 1+2*3은 7이다.== input[b].value: 계산 결과(숫자)와 입력값(문자열)을 ==로 비교한다. 타입이 달라도 값을 변환해서 비교해 주기 때문에 15 == '15'가 참이다.sum을 버튼을 누를 때마다 0으로 되돌리는 것이 중요하다. 이전 점수가 남아 있으면 계속 누적된다.수업 코드는 수식
span세 개에 모두id="s"를 붙였는데, id는 한 문서에 하나씩만 있어야 해서 이 글에서는class="s"로 바꿨다. 결과를innerHTML로 쓰는 부분은, 맞은 개수를 맞든 틀리든 한 번만 출력하면 되므로if/else양쪽에 있던 것을 반복문 밖으로 꺼냈다.
오늘의 큰 줄기는 함수도 값이라서 다른 함수에게 넘길 수 있다는 것이다.
e.target으로 가려내면, 나중에 생긴 요소에도 적용된다.change 이벤트로 잡는다.setTimeout은 비동기라서 기다리는 동안 다음 코드가 먼저 실행된다.return으로 못 받으므로, 콜백 함수의 인자로 전달받는다.{ 이름 = 기본값 }으로 값이 없을 때를 대비할 수 있다....는 배열과 객체를 펼쳐서 복사하거나 합친다. 객체에서는 같은 이름이면 뒤에 오는 것이 이긴다.Tags: JavaScript 콜백함수 비동기 스프레드 구조분해할당 이벤트위임 ToDoList 개발자 학습기록