[JS] Udemy 문벅스 카페 메뉴 앱 만들기 - Section3 : 문벅스 카페의 메뉴판 여러개 만들기

요들레이후·2022년 12월 26일
1

Javascript

목록 보기
9/11
post-thumbnail

📌 step2. 요구사항 분석 - 상태 관리로 메뉴 관리

사고방식의 변화에 집중

TODO localStorage Read & Write

  • localStorage 에 데이터를 저장한다.
  • 메뉴를 추가할 때 저장
  • 메뉴를 수정할 때 저장
  • 메뉴를 삭제할 때 저장
  • localStorage 에 있는 데이터를 읽어온다.

TODO 카테고리별 메뉴판 관리

  • 에스프레소 메뉴판 관리
  • 프라푸치노 메뉴판 관리
  • 블렌디드 메뉴판 관리
  • 티바나 메뉴판 관리
  • 디저트 메뉴판 관리

TODO 페이지 접근시 최초 데이터 Read & Rendering

  • 페이지에 최초로 로딩될 때 localStorage에 에스프레소 메뉴를 읽어온다.
  • 에스프레소 메뉴를 페이지에 그려준다.

TODO 품절 상태 관리

  • 품절 상태인 경우를 보여줄 수 있게, 품절 버튼을 추가하고 sold-out class를 추가하여 상태를 변경한다.
  • 품절 버튼을 추가한다.
  • 품절 버튼을 클릭하면 localStorage에 상태값이 저장된다.
  • 클릭이벤트에서 가장 가까운 li태그의 class속성 값에 sold-out을 추가한다.

📌 상태 & 로컬스토리지 개념에 대한 소개

localStorage

브라우저에 저장할 수 있는 간단한 저장소, url별로 저장이 되는 저장소이다.

  • localStorage.setItem(”menu”, ”espresso”) → menu라는 키에 espresso라는 메뉴가 저장됨

chrome에서 개발자 도구의 application 탭에서 storage 탭에 해당 url로 들어오면 저장이 된 것을 확인할 수 있다.

  • localStorage.getItem(”menu”) → 저장된 localStorage의 데이터를 가져올 수 있다.

📌 로컬스토리지에 메뉴 정보 저장, 불러오기

store라는 객체를 따로 만들어 로컬스토리지에 저장하고 불러온다.

set(저장)

data를 저장할 때 파라미터로 메뉴 정보들을 전달해주면 된다.

전달하는 data가 객체나 배열이 될 수도 있을텐데, 전달되는 object형태 그대로 관리할 수가 없어서, 실제 localStorage에는 문자열로 저장을 해야한다.

❗이때 JSON객체 형태를 문자열로 저장하기 위해서 stringify메서드를 사용하여 해당 데이터를 문자열로 저장할 수 있다.

setLocalStorage(menu) {
    localStorage.setItem("menu", JSON.stringify(menu));
},

get(불러오기)

getLocalStorage() {
    localStorage.getItem("menu");
},

📌 localStorage에 메뉴 상태를 저장하여 관리하기

localStorage저장소에 데이터를 저장해야하는데, 변할 수 있는 데이터를 상태라는 단어로 표현을 한다.

  • 이 앱에서 변하는 것 : 메뉴명

갯수같은 경우는 메뉴명이 배열로 있으면 자동으로 구할 수 있는 부분이다. 이런 것 같은 경우에는 메뉴명만 알아도 알 수 있는 것이니 따로 관리를 안해도 된다. → 갯수는 localStorage에 따로 저장을 하지 않아도 됨

메뉴명은 앱이라는 함수 객체가 가지고 있는 상태이기 때문에 this를 이용해서 선언을 해준다.

this.menu = []

초기화를 안하게 되면 이 상태가 어떤 데이터가 들어오는 지 모르므로, push 메서드를 사용 못한다.

또한 협업을 할 때, 어떤 형태로 데이터가 관리가 되는 지 명확하게 알 수 있다.

메뉴를 추가, 수정, 삭제할 때 저장한다.


📌 메뉴 추가 - addMenuName()

사용자가 메뉴 네임을 입력하고 나면 메뉴 네임을 받아와서 template에 이름을 넣어서 html태그를 직접 넣어주는 방식을 사용했는데, 이제 그것을 상태로 가지게 하기 위해서 this.menu.push라는 메서드를 사용해서 새로운 객체를 담을 수 있다.

메뉴가 추가가 되어 변경이 됐으면 화면이 새롭게 업데이트가 되어야 한다.

map

상태가 배열이고, 이게 계속해서 추가가 되면 여러개인 아이템들이 랜더링 되어야하기 때문에 배열을 순회하며 아이템별로 html마크업을 만들 수 있게 할 것이다. 이는 map이라는 메서드를 이용해서 배열을 순회할 것이다.

순회하면서 return한 값들을 모아서 또 하나의 새로운 배열을 만들어준다. 각각의 item별로 마크업이 만들어지는데, item하나당 하나의 li태그 마크업이 원소로 추가가 되어 하나의 최종적인 배열을 만들어준다.

[<li></li>, <li></li>, <li></li>,….] → 이런식으로 메뉴에 있는 갯수만큼 추가가 된다.

join

실제로 우리가 html태그에 넣으려면 이 객체 형태로 넣을 수가 없고, 하나의 마크업이 되어야 한다. join메서드를 사용하여 문자열들을 하나로 합쳐준다.

배열 형태로 있던 것을 하나의 마크업으로 이어준다.

innerHTML

#espresso-menu-list에 새롭게 그려지는 마크업을 넣어줘야하는데, 기존에 insertAdjacentHTML은 끝에 계속 하나씩 붙여지게끔 한 것이었는데, 지금은 메뉴 갯수만큼 한꺼번에 바꿔주면 되기에 innerHTML메서드를 사용하면 된다.

localStorage에 상태값 저장

다시 돌아와서 이 상태값을 localStorage에 저장해야하는데, 상태가 변경되었을 떄 바로 저장하고 읽어오는 형태로 해야지 일관되게 가져올 수 있다.

store라는 객체에 setLocalStorage에 menu데이터를 담으면 된다.

❗에러 발생 : this와 new의 관계성

💡 일반함수 this는 window, new 키워드를 사용하여 생성자 함수를 호출하게 되면 이때의 this는 “만들어질 객체”를 참조한다.

// 웹 브라우저에선 window 객체가 전역 객체
console.log(this === window); // true

a = 37;
console.log(window.a); // 37

this.b = "MDN";
console.log(window.b) // "MDN"
console.log(b)        // "MDN"
  • new 연산자와 생성자 함수

함수 이름의 첫 글자는 대문자로 시작, 반드시 “new”연산자 붙여 실행

function User(name) {
	this.name = name;
	this.isAdmin = false;
}

let user = new User("보라");

new User()를 써서 함수를 실행하면 아래와 같은 알고리즘 동작

  1. 빈 객체를 만들어 this에 할당한다.
  2. 함수 본문을 실행, this에 새로운 프로퍼티를 추가해 this를 수정
  3. this를 반환

  • new 키워드를 사용해 함수를 실행하면 에러 없이 잘 실행됨.


📌 메뉴 수정 - updateMenuName()

메뉴의 이름을 가져와서 수정 업데이트를 시키는데, 실제 this.menu에서도 수정을 해줘야 한다.

일단 this.menu에서 클릭한 메뉴 아이템을 찾아야하는데, 클릭한 메뉴 아이템이 this.menu에 어떤 원소인지 어떻게 알 수 있을까?

addMenuName()에서 li html태그에 유일하게 식별할 수 있는 속성값인 id값을 부여하려고 한다. 사용자가 선택하고 이벤트 핸들링 한 유일한 값을 확인하기 위해서 유일한 id값을 추가할 것이다.

item별로 li태그가 계속해서 만들어지는 형태인데, data-menu-id라는 형태로 유일한 값을 넣어보려고 한다.

메뉴 상태에서 배열의 index를 해당 메뉴 아이템의 유일한 값을 체크한다.

💡 data ⇒ data저장하고 싶을 때 사용하는 표준 속성, menu-id ⇒ 개발자가 원하는 이름

e.target.closest(”li”) 가장 가까이 있는 부모 li 태그를 찾은 다음,

데이터 속성을 부여한 것을 dataset이라는 속성으로 접근한다.

실제 element객체에 속성이 동적으로 만들어져서 활용을 할 수 있다.

this.menumenuId index값의 nameupdatedMenuName으로 바꿀 수 있다.

업데이트된 이름으로 다시 화면을 랜더링, localStorage에 업데이트 해야한다.

최대한 데이터를 변경하는 역할은 최소한의 light한 로직을 만들어야지, 여기서도 변경하고 다른 곳에서도 변경을 하면 데이터 로직이 꼬일 수 있다.

한 함수의 역할을 분리를 해주고, 한 함수는 하나의 역할만 하는 것이 좋다.


📌 메뉴 삭제 - removeMenuName()

삭제하는 경우도 배열에서 해당 메뉴를 삭제해야하기 때문에, 배열에서 어떤 메뉴인지 확인해야하기 때문에 menuID를 가져오고, this.menu에서 배열의 특정 원소를 삭제하는 메서드로는 splice라는 메서드가 있다.

splice()

배열의 기존 요소를 삭제 또는 교체하거나 새 요소를 추가하여 배열의 내용을 변경하는 것.

첫번째 인자 : 배열의 변경을 시작할 인덱스

  • 음수 지정 : 배열의 끝에서부터 요소를 센서
  • 배열의 길이보다 큰 수 : 실제 시작 인덱스는 배열의 길이로 설정
  • 배열의 길이보다 큰 경우 : 0으로 세팅

두번째 인자 : 배열에서 제거할 요소의 수

  • 생략 & 첫번째 인자보다 큰 경우 : start부터의 모든 요소 제거
  • 0이하 : 어떤 요소도 제거되지 않는다.


📌 localStorage에 메뉴 상태를 불러오기

페이지가 최초로 접근했을 때, App이라는 함수가 하나의 객체로 인스턴스로 생성이 될 때 그때 localStorage에 있는 데이터를 불러오는 것이 좋다.

앱이 생성될 때 실행을 하기 위해서 초기화한다는 의미로 init이라는 메서드를 만든다.

로컬스토리지에 데이터가 없을 수도 있으니 맨 처음에는 로컬스토리지 길이가 1 이상일 때 menu에 로컬스토리지를 담을 수 있게

app.init()으로 메서드를 실행시키면, 맨 처음에 페이지를 접근했을 때 app이라는 객체를 생성해내고, 그 객체에 init이라는 메서드를 불러와서 해당 로직이 실행될 수 있게끔 확인

데이터는 불러왔는데 사용자 입장에서 화면에 그려줘야하는데, li태그를 만드는 로직을 재사용해야하기 때문에 분리를 시켜준다.

addmenu하는 부분에 render함수를 호출, init부분에도 render함수를 호출한다.

에러 발생 : JSON.parse()

menu item을 가져온 것을 하나의 문자열로 가져온 것이라 JSON객체로 변환해줘야한다. JSON객체로 변환해주는 메서드는 JSON.parse()이다.

저장할때는 JSON.stringify()메서드로 문자열로 저장했고, 문자열로 저장된 데이터를 파싱해서 다시 JSON객체로 만들어줘야 한다.

문자열은 배열이 아니기에 그것을 순회하지 못해 발생하는 에러이다.

에러 발생 : length → null일 때의 TypeError

console창에서 조건문에서 길이를 체크하는 length 함수에서 에러가 발생한다.

변수에 데이터가 있는지 없는지 체크가 필요하다. undefined인 경우면 저렇게 에러가 뜬다.

값을 입력하면 콘솔창에 에러가 사라진다.

업데이트 변수가 배열인데 null이 반환되어 위의 오류가 발생하는 것이다. Null에는 길이 속성이 없기 때문이다.

오류를 수정하려면 배열이 null이 아닌지 확인해야 한다. 이를 위해 가장 간단한 방법은 optional chaining을 사용하는 것, 변수 뒤에 물음표 추가하여 사용 가능하다.

// ✔️ Even if updates is null, this will work
if (!updates?.length) {
    console.log('You are up to date! 🎉')
}

// The above translates to:
if (!null?.length) { ... }
  • ✨참고✨

Fix 'cannot read properties of null (reading length)' in JS

여기서는 조건문에 store.getLocalStorage()가 존재할 때의 조건으로 수정하였다.

this.init = () => {
    if (store.getLocalStorage()) {
      this.menu = store.getLocalStorage();
    }
    render();
  };

📌 카테고리별 메뉴 추가하기

카테고리를 클릭했을 때 이벤트 처리, 각각의 button태그들에 이벤트리스너를 붙이는 것은 비효율적이다.

상위 nav태그에 이벤트를 달아놓으면 더 효율적으로 관리가 가능하다.

하지만 이렇게 했을 때, 버튼 사이사이를 눌러도 이벤트가 들어오는 문제점이 발생한다. 카테고리만 받고 싶으니까 예외처리를 해준다.

$("nav").addEventListener("click", (e) => {
    const isCategoryButton = e.target.classList.contains("cafe-category-name");
    if (isCategoryButton) {
      const categoryName = e.target.dataset.categoryName;
      console.log(categoryName);
    }
  });
  • classList.contains 메서드 : 해당 클래스가 있는 지 boolean값으로 체크해준다.
  • dataset.categoryName : e.target을 통해 현재 이벤트가 일어난 요소를 가져오고, 그 요소의 data-id로 저장한 데이터 값을 dataset속성으로 가져온다.

→ 카테고리 별로 클릭했을 때 이벤트가 들어오는 것을 확인할 수 있다.

✨ 요구사항 확인

1. 카테고리 별 각각 다른 메뉴를 가지고 있어야 함.

메뉴에 어떤 형태로 데이터가 들어있어야 관리가 가능할까? ⇒ 하나의 객체로 만들고 속성별로 관리를 하면 된다.

2. 이전에 espresso저장한 부분들에 대한 코드의 업데이트가 필요함

현재 어떤 카테고리인지 알 수 있는 정보가 필요하다. 현재 카테고리에 대한 정보도 상태값으로 관리하기 위해 상태값을 선언한다.

3. addMenuName(), render() ⇒ menu라는 객체에서 현재 카테고리 속성값의 key값의 value를 추가

// 1.
this.menu = {
    espresso: [],
    frappuccino: [],
    blended: [],
    teavana: [],
    desert: [],
  };

// 2.
this.currentCategory = "espresso";

...
// 3.
const addMenuName = () => {
    ...
    this.menu[this.currentCategory].push({ name: espressoMenuName });
    ...
  };

... 

const render = () => {
    const template = this.menu[this.currentCategory]
      .map((item, index) => {
..... 
		}
}

📌 카테고리별 메뉴 수정, 삭제 그리고 리팩터링

updateMenuName(), removeMenuName() 에서도 현재 카테고리 속성값의 key값의 value를 추가

const updateMenuName = (e) => {
		...
    this.menu[this.currentCategory][menuId].name = updatedMenuName;
		...
};

...

const removeMenuName = (e) => {
		...
    this.menu[this.currentCategory].splice(menu, 1);
		...
};

기존에는 바로 배열에 에스프레소 메뉴만 넣었는데 카테고리화해서 상태의 계층화를 만들었다.

카테고리 버튼을 클릭했을 때 상태값을 업데이트하는 로직을 선택하면 좋을 것 같다. 이에 따른 상태값 업데이트 요구사항은 다음과 같다.

1. currentCategory가 categoryName으로 업데이트 되어야 한다.
2. 카테고리를 클릭했을 때 타이틀 명이 바뀌어야 한다. 클릭했을 때 클릭한 버튼 안의 text를 가져오면 된다.
3. 카테고리 별 메뉴 내용도 변경이 되어야 한다.

$("nav").addEventListener("click", (e) => {
    const isCategoryButton = e.target.classList.contains("cafe-category-name");
    if (isCategoryButton) {
      const categoryName = e.target.dataset.categoryName;
			// 1.
      this.currentCategory = categoryName;
			// 2.
      $("#category-title").innerText = `${e.target.innerText} 메뉴 관리`;
			// 3.
      render();
    }
  });

📌 품절 상태 관리

품절 버튼을 클릭했을 때 sold-out class 추가, this.menu의 데이터에서도 품절된 상태를 추가해야한다.

menu-list에서 이벤트 위임으로 상위 ul태그에 이벤트 위임을 한 부분에 클릭한 것이 품절이라면 sold-out해주는 로직을 추가한다.

$("#menu-list").addEventListener("click", (e) => {
    if (e.target.classList.contains("menu-edit-button")) {
      updateMenuName(e);
      return;
    }
    if (e.target.classList.contains("menu-remove-button")) {
      removeMenuName(e);
      return;
    }
    if (e.target.classList.contains("menu-sold-out-button")) {
      soldOutMenu(e);
      return;
    }
  });

참고로 if문이 연속으로 있을 때, 함수가 끝날 때마다 return을 해주는 습관을 들이면 좋다. 불필요한 연산을 줄이기 위해 → 나중에 side effect나 버그를 안 만드는 중요한 습관이다.

soldOutMenu()

1. menuId를 가져와야한다.
2. 메뉴의 soldout이라는 상태값을 가지게 하여 soldout이 ture.
3. setLocalStorage로 this.menu로 업데이트 해준다.
4. 화면에 soldout을 보여준다.

const soldOutMenu = (e) => {
    const menuId = e.target.closest("li").dataset.menuId;
    this.menu[this.currentCategory][menuId].soldOut = true;
    store.setLocalStorage(this.menu);
    render();
  };

5. 상위 li태그에 삼항연산자를 사용하여 클래스를 추가해준다. 상태값에 따라서 클래스를 가감하기 위함이다.

6. 토글 버튼 처럼 다시 재클릭했을 때 품절 상태가 false가 되게끔 만드려면 → 맨 처음 soldOut은 undefined가 리턴이 되고, !undefined이니 true로 값이 들어가는 것이고, true인 상태에서 다시 클릭하면 false로 바뀐다. 이런 식으로 클릭할 때마다 값이 계속 바뀌게 할 수 있다.

const soldOutMenu = (e) => {
    const menuId = e.target.closest("li").dataset.menuId;
    this.menu[this.currentCategory][menuId].soldOut =
      !this.menu[this.currentCategory][menuId].soldOut;
    store.setLocalStorage(this.menu);
    render();
  };

📌 리팩터링, 전체 코드

  1. 랜더링하는 부분이 일관되었으면 좋겠다.
    → 메뉴를 추가할 때는 render()로 item을 render() 하고 있는데, update하거나 삭제할 때는 실제 Dom을 없애거나 그 안에 text값을 바꾸는 형태로 하고 있는 것 같아서 똑같이 render()를 사용
  2. eventListener부분들을 하나의 initEventListeners함수로 만들어서 init함수에 넣어줌
  3. 최대한 한 파일에 객체는 하나만 있는 것이 좋기에, 파일을 분리한다.

디렉토리 구조

src/index.js

import { $ } from "./utils/dom.js";
import store from "./store/index.js";

function App() {
  this.menu = {
    espresso: [],
    frappuccino: [],
    blended: [],
    teavana: [],
    desert: [],
  };
  this.currentCategory = "espresso";

  this.init = () => {
    if (store.getLocalStorage()) {
      this.menu = store.getLocalStorage();
    }
    render();
    initEventListeners();
  };

  const render = () => {
    const template = this.menu[this.currentCategory]
      .map((item, index) => {
        return `<li data-menu-id="${index}" class="menu-list-item d-flex items-center py-2">
      <span class="w-100 pl-2 menu-name ${item.soldOut ? "sold-out" : ""} ">${
          item.name
        }</span>
      <button
        type="button"
        class="bg-gray-50 text-gray-500 text-sm mr-1 menu-sold-out-button"
      >
        품절
      </button>
      <button
        type="button"
        class="bg-gray-50 text-gray-500 text-sm mr-1 menu-edit-button"
      >
        수정
      </button>
      <button
        type="button"
        class="bg-gray-50 text-gray-500 text-sm menu-remove-button"
      >
        삭제
      </button>
    </li>`;
      })
      .join("");

    $("#menu-list").innerHTML = template;
    updateMenuCount();
  };

  const updateMenuCount = () => {
    const menuCount = this.menu[this.currentCategory].length;
    $(".menu-count").innerText = `${menuCount}`;
  };

  const addMenuName = () => {
    if ($("#menu-name").value === "") {
      alert("값을 입력해주세요.");
      return;
    }
    const MenuName = $("#menu-name").value;
    this.menu[this.currentCategory].push({ name: MenuName });
    store.setLocalStorage(this.menu);
    render();
    $("#menu-name").value = "";
  };

  const updateMenuName = (e) => {
    const menuId = e.target.closest("li").dataset.menuId;
    const $menuName = e.target.closest("li").querySelector(".menu-name");
    const updatedMenuName = prompt("메뉴명을 수정하세요", $menuName.innerText);
    this.menu[this.currentCategory][menuId].name = updatedMenuName;
    store.setLocalStorage(this.menu);
    render();
  };

  const removeMenuName = (e) => {
    if (!confirm("정말 삭제하시겠습니까?")) return;
    const menuId = e.target.closest("li").dataset.menuId;
    this.menu[this.currentCategory].splice(menuId, 1);
    store.setLocalStorage(this.menu);
    render();
  };

  const soldOutMenu = (e) => {
    const menuId = e.target.closest("li").dataset.menuId;
    this.menu[this.currentCategory][menuId].soldOut =
      !this.menu[this.currentCategory][menuId].soldOut;
    store.setLocalStorage(this.menu);
    render();
  };

  const initEventListeners = () => {
    $("#menu-list").addEventListener("click", (e) => {
      if (e.target.classList.contains("menu-edit-button")) {
        updateMenuName(e);
        return;
      }
      if (e.target.classList.contains("menu-remove-button")) {
        removeMenuName(e);
        return;
      }
      if (e.target.classList.contains("menu-sold-out-button")) {
        soldOutMenu(e);
        return;
      }
    });

    $("#menu-form").addEventListener("submit", (e) => {
      e.preventDefault();
    });

    $("#menu-submit-button").addEventListener("click", addMenuName);

    $("#menu-name").addEventListener("keypress", (e) => {
      if (e.key !== "Enter") {
        return;
      }
      addMenuName();
    });

    $("nav").addEventListener("click", (e) => {
      const isCategoryButton =
        e.target.classList.contains("cafe-category-name");
      if (isCategoryButton) {
        const categoryName = e.target.dataset.categoryName;
        this.currentCategory = categoryName;
        $("#category-title").innerText = `${e.target.innerText} 메뉴 관리`;
        render();
      }
    });
  };
}

const app = new App();
app.init()

src/js/store/index.js

const store = {
  setLocalStorage(menu) {
    localStorage.setItem("menu", JSON.stringify(menu));
  },
  getLocalStorage() {
    return JSON.parse(localStorage.getItem("menu"));
  },
};

export default store;

src/js/utils/dom.js

export const $ = (selector) => document.querySelector(selector);

❤INSIGHT❤

  • 상태값의 중요성
  • 사용자 관점에서 페이지 렌더링 될 때 어떻게 렌더링 되는 지
  • localStorage에 배열로 넣을 때, JSON.stringify로 문자열로 넣을 수 있다.
  • espresso-menu ⇒ 다양한 카테고리가 추가되면서 object로 넣을 때 객체의 키값으로 넣을 때
    this.menu[this.currentCategory][menuId].name = updatedMenuName
  • this → 기본적으로 객체 자신을 가리키는 의미, 객체가 인스턴스로 여러개 생성될 수 있는데, 각각의 인스턴스가 가지고 있는 상태나 메서드를 객체 외부에서 사용하는데 유용하게 해줌
    • 실행되는 호출 맥락에 따라 달라지기도 함, 지금은 객체 자기 자신을 가리킨다. 그거에 맞지 않는 상황이 발생했을 때 추가적으로 학습 필요
profile
성공 = 무한도전 + 무한실패

0개의 댓글