본문으로 건너뛰기

거르기와 개수

자바스크립트 목차할 일 앱 만들기레슨 75 / 76

목차

세 번째 단계에서는 상단의 **필터 단추 셋('전체' · '진행 중' · '완료')**과 하단의 '완료한 일 지우기'(#clear-done) 단추를 연결합니다. 가장 중요한 핵심은 필터를 바꿀 때 원본 todos 배열을 덮어쓰지 않고, render() 안에서 화면에 보일 줄(visible)만 따로 골라낸다는 점입니다.

앞 레슨에서 만든 app.js 펼치기
// app.js (13-2까지)
'use strict';

let todos = [
  {
    id: 1,
    text: '우유 사기',
    done: false,
  },
  {
    id: 2,
    text: '복습하기',
    done: true,
  },
];
todos.push({
  id: 3,
  text: '산책하기',
  done: false,
});
let nextId = 4;
const listEl = document
  .querySelector(
    '#todo-list',
  );
const emptyEl = document
  .querySelector('#empty');
const countEl = document
  .querySelector('#count');
const formEl = document
  .querySelector(
    '#todo-form',
  );
const inputEl = document
  .querySelector(
    '#todo-input',
  );

function makeRow(t) {
  const li = document
    .createElement('li');
  li.dataset.id =
    String(t.id);
  li.classList.toggle(
    'done',
    t.done,
  );
  const box = document
    .createElement('input');
  box.type = 'checkbox';
  box.checked = t.done;
  box.setAttribute(
    'aria-label',
    '완료 표시',
  );
  const span = document
    .createElement('span');
  span.className = 'text';
  span.textContent = t.text;
  const btn = document
    .createElement('button');
  btn.type = 'button';
  btn.className = 'delete';
  btn.textContent = '×';
  li.append(box, span, btn);
  return li;
}
function render() {
  listEl.textContent = '';
  for (const t of todos) {
    const r = makeRow(t);
    listEl.append(r);
  }
  emptyEl.hidden =
    todos.length > 0;
  const left = todos.filter(
    (t) => !t.done,
  ).length;
  countEl.textContent =
    `전체 ${todos.length}개` +
    ` 중 ${left}개 남음`;
}
formEl.addEventListener(
  'submit',
  (event) => {
    event.preventDefault();
    const text =
      inputEl.value.trim();
    if (text === '') return;
    const item = {
      id: nextId++,
      text,
      done: false,
    };
    todos.push(item);
    inputEl.value = '';
    render();
  },
);
listEl.addEventListener(
  'click',
  (event) => {
    const li = event.target
      .closest('li');
    if (!li) return;
    const id = Number(
      li.dataset.id,
    );
    const del = event.target
      .closest('.delete');
    if (del) {
      todos = todos.filter(
        (t) => t.id !== id,
      );
    } else {
      const item =
        todos.find(
          (t) => t.id === id,
        );
      if (item) {
        item.done =
          !item.done;
      }
    }
    render();
  },
);
render();

5단계 중 1단계. 처음에는 filter = 'all'이므로 getVisible()이 원본 todos 세 줄을 모두 돌려줍니다.

1필터 상태 변수 filter와 보일 줄 계산

현재 어떤 필터가 켜져 있는지를 나타내는 상태 변수 let filter = 'all';('all', 'active', 'done' 중 하나)을 추가합니다.

그리고 render() 안에서 todos를 바로 도는 대신, filter 값에 맞춰 화면에 그릴 배열 **visible**을 먼저 골라냅니다.

// filter 값에 따라 보일 줄 고르기
function getVisible(list, f) {
  if (f === 'active') {
    return list.filter(
      (t) => !t.done,
    );
  }
  if (f === 'done') {
    return list.filter(
      (t) => t.done,
    );
  }
  return list;
}

이어서 세 가지 필터 값에서 getVisible이 무엇을 돌려주는지 Node로 확인해 봅니다.

const sample = [
  { id: 1, done: false },
  { id: 2, done: true },
  { id: 3, done: false },
];
console.log(
  getVisible(sample, 'all')
    .length,
);
console.log(
  getVisible(sample, 'active')
    .length,
);
console.log(
  getVisible(sample, 'done')
    .length,
);
console.log(sample.length);
실행 결과
3
2
1
3

getVisible은 새 배열을 만들어 돌려줄 뿐 원본 배열 sample의 길이 3은 전혀 건드리지 않습니다. 만약 필터 단추를 누를 때 todos = todos.filter(...)⁠로 원본 todos에 덮어써 버리면, 다시 '전체' 단추를 눌렀을 때 아까 숨겼던 항목들이 영영 사라져 돌아오지 못합니다.

2render() 고치기와 필터 단추 연결

app.js에서 필터 단추 묶음(#filters)과 완료 지우기 단추(#clear-done)를 추가로 찾고, render()가 visible 배열과 필터 단추의 .active 클래스를 함께 갱신하도록 고칩니다.

let filter = 'all';
const filtersEl = document
  .querySelector('#filters');
const clearEl = document
  .querySelector(
    '#clear-done',
  );

이어서 render() 함수를 아래처럼 바꿉니다.

function render() {
  const visible = getVisible(
    todos,
    filter,
  );
  listEl.textContent = '';
  for (const t of visible) {
    listEl.append(makeRow(t));
  }
  emptyEl.hidden =
    visible.length > 0;

이어서 필터 단추의 .active 표시와 남은 개수를 갱신합니다.

  const btns = filtersEl
    .querySelectorAll(
      'button',
    );
  for (const b of btns) {
    const on =
      b.dataset.filter ===
      filter;
    b.classList.toggle(
      'active',
      on,
    );
  }
  const left = todos.filter(
    (t) => !t.done,
  ).length;
  countEl.textContent =
    `전체 ${todos.length}개 중 ` +
    `${left}개 남음`;
}
  • classList.toggle('active', on): 세 필터 단추를 돌며 data-filter가 지금 filter와 같은 단추에만 .active 클래스를 켜고 나머지는 끕니다.
  • emptyEl.hidden: visible.length > 0을 대입해 완료한 일이 하나도 없을 때 '완료' 탭을 누르면 visible.length가 0이 되어 '할 일이 없습니다.' 안내가 보입니다.

3#filters 위임과 #clear-done 리스너

이제 #filters 클릭 리스너와 #clear-done 클릭 리스너를 등록합니다.

filtersEl.addEventListener(
  'click',
  (event) => {
    const btn = event.target
      .closest('button');
    if (!btn) return;
    filter =
      btn.dataset.filter;
    render();
  },
);

이어서 '완료한 일 지우기' 단추 리스너를 등록합니다.

clearEl.addEventListener(
  'click',
  () => {
    todos = todos.filter(
      (t) => !t.done,
    );
    render();
  },
);
실행 결과
- '진행 중' 클릭 → 2줄만 표시
- '전체' 클릭 → 3줄 모두 표시
- '완료한 일 지우기' → 완료 줄 삭제

자주 틀리는 곳

  1. 필터를 바꿀 때 todos = todos.filter(...)⁠로 원본을 덮어쓴다: 원본 todos를 덮어쓰면 필터링이 아니라 영구 삭제가 됩니다. 원본 todos를 바꾸는 곳은 개별 삭제(×)와 '완료한 일 지우기'(#clear-done) 두 곳뿐이고, 필터는 render() 안에서 visible만 골라 그립니다.
  2. 클릭한 필터 단추에만 classList.add('active')⁠를 부른다: 이전에 켜져 있던 단추에서 active를 빼지 않으면 단추 세 개가 모두 시꺼멓게 켜집니다. render()에서 classList.toggle('active', on)⁠으로 한 번에 맞추면 겹치지 않습니다.

확인 문제

  1. '진행 중' 필터 단추를 눌렀을 때 원본 todos 배열을 직접 바꾸지 않고 별도의 visible 배열을 계산해 그리는 까닭은 무엇인가요?
정답

원본 todos 배열을 덮어쓰면 완료된 항목들이 메모리에서 아예 지워져서, 나중에 '전체'나 '완료' 단추를 눌렀을 때 다시 보여 줄 수 없기 때문입니다.

  1. '완료한 일 지우기'(#clear-done) 단추를 눌렀을 때 todos 배열에서 아직 끝나지 않은(!t.done) 항목만 남기려면 어떤 배열 메서드를 써야 하나요?
정답

todos.filter((t) => !t.done)⁠을 불러 todos에 다시 대입합니다.

끝까지 내려오면 저절로 표시됩니다. 이 기기에만 저장합니다.

자바스크립트 목차로