[VanilaJS] 바닐라JS 로 SPA 구현하기

한음·2022년 11월 13일
0
post-thumbnail

프로그래머스 데브매칭 웹 프론트엔드 기출문제를 풀어봤다.
문제 보기

내가 푼 방식과 해설에서 적용한 방식을 비교해보는 글.

문제

기능 명세에 따라 별도의 라이브러리 없이 SPA 를 구현하는 문제.

핵심적인 요구사항은

  1. URL 에 맞는 라우팅 처리 (총 3페이지)
    (상품 세부정보 페이지는 /products/:productID 구조로 파라미터를 받을 수 있어야했다.
  2. 상품 정보 fetching 및 렌더링
  3. localStorage 이용한 장바구니 기능

정도로, 이외의 자잘한 세부 요구사항이 있다.

풀이

요구사항대로 완성은 했는데, 문제 해설과 동작하는 방식이 다르다.

나는 문제의 제한된 조건에 맞춰 동작하는데 초점을 맞춰 진행했고, 해설은 일반적인 SPA 라이브러리가 동작하는 방식대로 작성하려는 시도가 느껴졌다.

가장 큰 차이는 라우터 동작 방식에서 나타난다.

내 방식의 핵심 개념은 각 컴포넌트(스크린) 함수가 DOM Element 를 반환하고, 라우터 함수가 URL 변경에 대응해 App 내의 기존의 스크린을 비우고 해당 컴포넌트 함수의 반환값을 App 내에 넣어주는 것이다.
라우터 함수는 라우팅만 책임지고, 컴포넌트 함수들은 컴포넌트만 반환하도록 하는게 좋겠다고 생각해 적용한 방식이었다.

해설의 방식은 컴포넌트 별로 자체적으로 this.render() 함수를 가지며 라우터가 컴포넌트를 new 키워드를 이용해 생성만 하는 방식이다. 부모 DOM을 인자로 넘겨 컴포넌트 내에서 부모 내부에 렌더링한다.

=> 렌더링의 주체를 누구로 두는가의 차이가 있었다.

추가적으로 해설의 방식은 setState() 를 통한 상태 변경과 이에 대응해 render()가 호출되는 전형적인 데이터 중심의 SPA 라이브러리처럼 동작했지만 (컴포넌트 별 동작 방식의 통일)

나는 문제의 요구사항에 맞춰 리스너를 작성하고 변경 시 <li> 태그를 추가해주거나, 특정 값을 변경해주는 등 기능 별로 동작을 다르게 구성했다.

이 역시 글 머리에 썼듯이 내 방식은 요구사항에 맞춰 개발한 느낌이고, 해설의 방식은 확장성을 고려한 것처럼 보인다. 상태 변경에 대응한 렌더링으로 컴포넌트 구조가 통일화 되어 있기에 작성자 이외의 사람이 코드를 본 다면 해설의 방식이 이해하기 편할 거 같다.

동작 방식 외적인 차이는, JS 자체와 함수형 프로그래밍에 대한 이해 부족으로 코드 자체가 세련되지 못했음..
해설 보면서 많이 배웠다.
나는 매 이동마다 라우트 함수를 실행했지만 window 객체에 커스텀 이벤트를 생성하고 리스너를 달아 라우팅 처리를 한게 인상깊었다.

내가 푼 방식

프로젝트 구조

src
  - index.html
  - styles.css
  - index.js
  - router.js
  - api.js

  - component
      - Product.js
  - screens
      - CartPage.js
      - ProductDetailPage.js
      - ProductListPage.js

상세코드

router.js

import CartPage from "./screens/CartPage.js";
import ProductDetailPage from "./screens/ProductDetailPage.js";
import ProductListPage from "./screens/ProductListPage.js";

const app = document.querySelector(".App");
const pathToRegex = (path) =>
  new RegExp(
    "^" + path.replace(/\//g, "\\/").replace(/:\w+/g, "[a-zA-Z0-9-_]*") + "$"
  );

const routes = [
  { path: "/web", screen: ProductListPage },
  { path: "/web/products/:productId", screen: ProductDetailPage },
  { path: "/web/cart", screen: CartPage },
];
async function Router(isFirstLoaded = false) {
  const currentPath = window.location.pathname.replace(/\/$/, "");
  const currentScreen = routes.find((route) =>
    currentPath.match(pathToRegex(route.path))
  );
  if (!isFirstLoaded) {
    app.children[0].remove();
  }
  app.appendChild(await currentScreen.screen());
}
export default Router;

/products/:id 처리를 위해 정규표현식을 사용해, /products/:id/123 같은 URL로 접근할 시
접근을 막았다.
라우트 변경이 일어나 스크린이 변경될 때는 remove() 를 이용해 내부 노드를 제거하고 해당하는 스크린을 불러와 appendChild() 로 삽입했다.

ProductListPage.js

import Product from "../components/Product.js";
import { fetchProducts } from "../api.js";

async function ProductListPage() {
  const products = await fetchProducts();
  const DOMText = `
    <div class="ProductListPage">
    <h1>상품목록</h1>
    <ul>
    </ul>
    </div>
    `;

  const parser = new DOMParser();
  const DOM = parser.parseFromString(DOMText, "text/html").querySelector("div");
  const ul = DOM.querySelector("ul");
  products.forEach((product) => {
    ul.appendChild(Product(product));
  });

  return DOM;
}
export default ProductListPage;

텍스트로 된 HTML 코드를 DOMParser 를 통해 DOM 객체로 변환하고, fetch 해온 상품들을 순회하며 Product 컴포넌트를 appendChild() 처리했다.

Product.js

import Router from "../Router.js";

function Product({ id, name, imageUrl, price }) {
  const onClick = (id) => {
    history.pushState({}, null, `/web/products/${id}`);
    Router();
  };
  const DOMText = `
    <li class="Product">
        <img src="${imageUrl}">
        <div class="Product__info">
            <div>${name}</div>
            <div>${price
              .toString()
              .replace(/\B(?=(\d{3})+(?!\d))/g, ",")}원~</div>
        </div>
    </li>
    `;
  const parser = new DOMParser();
  const DOM = parser.parseFromString(DOMText, "text/html").querySelector("li");
  DOM.addEventListener("click", () => onClick(id));
  return DOM;
}
export default Product;

마찬가지로 DOM 객체를 생성하고 클릭 이벤트에 대응한 리스너를 작성했다.
pushState 로 URL을 변경하고, 라우터의 라우팅 함수를 호출하는 구조다.

ProductDetailPage.js

import Router from "../Router.js";
import { fetchProduct } from "../api.js";

const parsePrice = (price) => {
  return price.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ",");
};

async function ProductDetailPage() {
  const id = window.location.pathname.split("/").at(-1);
  const { imageUrl, name, price, productOptions } = await fetchProduct(id);
  const state = {
    cart: [],
    total: 0,
  };
  const DOMText = `
        <div class="ProductDetailPage">
            <h1>${name} 상품 정보</h1>
            <div class="ProductDetail">
                <img src="${imageUrl}">
                <div class="ProductDetail__info">
                <h2>${name}</h2>
                <div class="ProductDetail__price">${parsePrice(price)}원~</div>
                <select>
                    <option>선택하세요.</option>
                    ${productOptions
                      .map((productOption) => {
                        return productOption.stock === 0
                          ? `
                        <option value=${productOption.id} disabled>(품절) ${name} ${productOption.name}</option>
                        `
                          : productOption.price === 0
                          ? `
                        <option value=${productOption.id}>${name} ${productOption.name}</option>
                        `
                          : `
                        <option value=${productOption.id}>${name} ${
                              productOption.name
                            } (+${parsePrice(productOption.price)}원)</option>
                        `;
                      })
                      .join("")}
                </select>
                <div class="ProductDetail__selectedOptions">
                    <h3>선택된 상품</h3>
                    <ul>
                    </ul>
                    <div class="ProductDetail__totalPrice">0원</div>
                    <button class="OrderButton">주문하기</button>
                </div>
                </div>
            </div>
        </div>    
    `;
  const parser = new DOMParser();
  const DOM = parser.parseFromString(DOMText, "text/html").querySelector("div");
  const cartDOM = DOM.querySelector("ul");
  const selectDOM = DOM.querySelector("select");
  const totalPriceDOM = DOM.querySelector(".ProductDetail__totalPrice");
  selectDOM.addEventListener("change", (e) => {
    const value = e.target.value;
    if (value === "선택하세요") return;
    const option = productOptions.find((productOption) => {
      return productOption.id === parseInt(value);
    });
    if (state.cart.find((c) => c.id === value)) return;
    state.cart.push({
      id: value,
      amount: 1,
      optionId: option.id,
      optionName: option.name,
      price: option.price + price,
    });
    const cartItemDOMText = `
        <li>
        ${name} ${option.name} <div><input min="0" max="${option.stock}" type="number" value="1">개</div>
        </li>        
        `;
    const cartItemDOM = parser
      .parseFromString(cartItemDOMText, "text/html")
      .querySelector("li");
    const inputDOM = cartItemDOM.querySelector("input");
    inputDOM.addEventListener("change", (e) => {
      const inputVal = e.target.value.trim();
      if (inputVal === "") return;
      let amount = parseInt(inputVal);
      if (amount > option.stock) {
        inputDOM.value = option.stock;
        amount = option.stock;
      } else if (amount < 0) {
        inputDOM.value = 0;
        amount = 0;
      }
      const currentState = state.cart.find((c) => c.id === value);
      const amountDiff = amount - currentState.amount;
      currentState.amount = amount;
      state.total += (option.price + price) * amountDiff;
      totalPriceDOM.innerHTML = `${parsePrice(state.total)}`;
    });
    state.total += option.price + price;
    cartDOM.appendChild(cartItemDOM);
    totalPriceDOM.innerHTML = `${parsePrice(state.total)}`;
  });
  DOM.querySelector("button").addEventListener("click", () => {
    const currentCartItem =
      JSON.parse(localStorage.getItem("products_cart")) || [];
    const newCartItem = [];
    state.cart.forEach((c) => {
      newCartItem.push({
        productId: c.id,
        optionId: c.optionId,
        quantity: c.amount,
        imageUrl: imageUrl,
        optionName: c.optionName,
        productName: name,
        price: c.price,
      });
    });
    localStorage.setItem(
      "products_cart",
      JSON.stringify([...currentCartItem, ...newCartItem])
    );
    history.pushState({}, null, `/web/cart`);
    Router();
  });
  return DOM;
}
export default ProductDetailPage;

위에서 언급한 내용처럼 상태 변경에 대응해 다시 렌더링 하는 구조가 아닌, 필요한 이벤트별로 로직이 작성된 구조다.


답안 방식

프로젝트 구조

src

- index.html
- index.js
- styles.css
- Router.js
- api.js
- storage.js
- App.js

- components
  - Cart.js
  - Product.js
  - ProductDetailComponent.js
  - SelectedOptions.js

- screens
  - CartPage.js
  - ProductDetail.js
  - ProductList.js

크게 차이나는 동작방식과 인상깊었던 부분 위주로 기술.

상세 코드

index.js

import "./App.js";
import App from "./App.js";

new App({ $target: document.querySelector(".App") });

기존 코드는 Router.js 내에서 라우팅과 렌더링을 같이 처리했지만, 라우팅은 Router.js 에서, 렌더링은 App.js 에서 처리하는걸로 변경했다. 그 부분은 가져가고 싶었다.

App.js

import Router, { init } from "./Router.js";

function App({ $target }) {
  this.router = new Router({ $target });
  this.render = () => {
    const component = this.router.getComponent();
    $target.innerHTML = ""; // APP 비워주기
    component.render(); // 컴포넌트 받아와 렌더링
  };
  init(this.render);
  window.addEventListener("popstate", this.render);
  this.render();
}
export default App;

getComponent() 를 통해 컴포넌트를 받아오고, 해당 컴포넌트의 renderApp.js 내에서 실행시킨다.

Router.js

import CartPage from "./screens/CartPage.js";
import ProductDetail from "./screens/ProductDetail.js";
import ProductList from "./screens/ProductList.js";

const ROUTE_CHANGE_EVENT = "ROUTE_CHANGE";
const routes = [
  { path: "/", component: ProductList },
  { path: "/products/:id", component: ProductDetail },
  { path: "/cart", component: CartPage },
];

function Router({ $target }) {
  this.getComponent = () => {
    const { pathname } = location;
    console.log(`current path: ${pathname}`);
    let currentComponent;
    let componentProps = {};
    if (pathname.startsWith("/products")) {
      currentComponent = routes[1];
      componentProps.productId = pathname.split("/")[2];
    } else {
      currentComponent = routes.find((route) => {
        return route.path.startsWith(pathname);
      });
    }
    return new currentComponent.component({ $target, ...componentProps });
  };
}

export const init = (onRouteChange) => {
  window.addEventListener(ROUTE_CHANGE_EVENT, () => {
    onRouteChange();
  });
};

export const routeChange = (url, params) => {
  history.pushState(null, null, url);
  // window 에 커스텀 이벤트 ROUTE_CHANGE_EVENT 를 발생시켜 이벤트리스너가 동작하도록함
  window.dispatchEvent(new CustomEvent(ROUTE_CHANGE_EVENT, params));
};

export default Router;

현재 경로에 맞는 컴포넌트를 생성해 리턴한다. 나와 다른 점은 부모가 되는 DOM 객체를 인자$target 으로 받아 컴포넌트 내부에서 렌더링을 처리한다는 것이다.

첫 생성시에 App.js 에서 실행되는 init() 을 통해 커스텀 이벤트에 대한 라우팅 처리를 하는게 인상깊었다.

ProductDetailComponent.js

import SelectedOptions from "./SelectedOptions.js";

function ProductDetailComponent({ $target, initialState }) {
  const $productDetail = document.createElement("div");
  // SelectedOption 컴포넌트
  let selectedOptions = null;
  $productDetail.className = "ProductDetail";
  $target.appendChild($productDetail);

  this.state = initialState;
  this.setState = (nextState) => {
    this.state = nextState;
    this.render();
    if (selectedOptions) {
      selectedOptions.setState({
        product: this.state.product,
        selectedOptions: this.state.selectedOptions,
      });
    }
  };
  this.render = () => {
    const { product } = this.state;
    if (!this.state.renderedFlag) {
      $productDetail.innerHTML = `
      <img src="${product.imageUrl}">
      <div class="ProductDetail__info">
        <h2>${product.name}</h2>
        <div class="ProductDetail__price">
          ${product.price}원~
        </div>
        <select>
          <option>선택하세요.</option>
          ${product.productOptions
            .map(
              (option) => `
            <option value="${option.id}" ${
                option.stock === 0 ? "disabled" : ""
              }>
              ${option.stock === 0 ? "(품절)" : ""}${product.name} ${
                option.name
              } ${option.price > 0 ? `(+${option.price}원)` : ""}
            </option>
          `
            )
            .join("")}
        </select>
        <div class="ProductDetail__selectedOptions"></div>
      </div>
    `;
      selectedOptions = new SelectedOptions({
        $target: $productDetail.querySelector(
          ".ProductDetail__selectedOptions"
        ),
        initialState: {
          product: this.state.product,
          selectedOptions: this.state.selectedOptions,
        },
      });
    }
    this.state.renderedFlag = true;
  };
  this.render();
  // 옵션 선택 이벤트
  // 부모에게 달아서 자식 노드에서 일어난 이벤트 감지
  $productDetail.addEventListener("change", (event) => {
    if (event.target.tagName !== "SELECT") return;
    const { product, selectedOptions } = this.state;
    const optionId = event.target.value;
    // 이미 추가된 옵션이면 추가 중지
    if (selectedOptions.find((option) => +option.optionId === +optionId)) {
      return;
    }
    const targetOption = product.productOptions.find(
      (productOption) => +productOption.id === +optionId
    );
    const selectedOption = {
      optionId: event.target.value,
      optionPrice: targetOption.price,
      optionName: targetOption.name,
      quantity: 1,
    };
    selectedOptions.push(selectedOption);
    // setState 호출해 SeletedOptions 컴포넌트의 setState 도 호출
    this.setState({ ...this.state });
  });
}
export default ProductDetailComponent;

상위 컴포넌트인 ProductDetail 에서 해당 DOM 만 받아와 그 내에서만 렌더링을 처리한다. 업데이트가 일어난 곳만 다시 렌더링하는 보통의 SPA 라이브러리와 유사한 동작방식.

상기 내용대로 상태 변경에 대응해 setState() 를 호출하고 이와 연계된 render() 함수가 실행되는 구조이다. 자식 컴포넌트 또한 독립적으로 존재하기 위해 initialFlag 를 달아 한번만 생성하고 그 내부 처리는 자식 컴포넌트에서 담당하도록 한다.

api 공통 함수를 작성하거나, localstorage 관련 getter/setter 를 설정하는 부분이 노련하게 느껴졌다.

api.js

const API_END_POINT = "http://localhost:8000";
export const request = async (url, options = {}) => {
  try {
    const fullUrl = `${API_END_POINT}${url}`;
    const response = await fetch(fullUrl);
    if (response.ok) {
      const json = await response.json();
      return json;
    }
    throw new Error("통신 실패");
  } catch (e) {
    alert(e);
  }
};
export const fetchProducts = async () => {
  const data = await request("/products");
  return data;
};
export const fetchProduct = async (id) => {
  const data = await request(`/product/${id}`);
  return data;
};
storage.js

export const storage = localStorage;

export const getItem = (key, defaultValue) => {
  try {
    const value = storage.getItem(key);
    return value ? JSON.parse(value) : defaultValue;
  } catch (e) {
    console.error(e);
    return defaultValue;
  }
};

export const setItem = (key, value) => {
  try {
    storage.setItem(key, JSON.stringify(value));
  } catch (error) {
    console.error(error);
  }
};

export const removeItem = (key) => {
  try {
    storage.removeItem(key);
  } catch (e) {
    console.error(e);
  }
};

이렇게 함으로써 예외 처리를 매번 하지 않고도 손쉽게 가능하다.

최근 진행중인 프로젝트에서도 그렇고, 이번 과제에서도 그렇고, JS 에 대한 깊은 이해가 점점 필요해져감을 느낀다.
내 코드

profile
https://github.com/0hhanum

1개의 댓글

comment-user-thumbnail
2023년 2월 2일

신의 영역처럼 느껴집니다..

답글 달기