[vanila.js] Single Page Application

woolee의 기록보관소·2023년 2월 18일
0

FE 기능구현 연습

목록 보기
33/33

출처 : '2021 Dev-Matching: 웹 프론트엔드 개발자(하반기)' 기출 문제 해설

url 별 페이지 처리

  • / : 상품 목록 페이지
  • /products/:productId : 상품 상세 페이지
  • /cart : 장바구니 페이지
const { pathname } = location  
  if (pathname === '/') {
    // 상품 목록 페이지 렌더링하기
  } else if (pathname.indexOf('/products/') === 0) {
    const [, , productId] = pathname.split('/')
    // 상품 상세 페이지 렌더링하기
  } else if (pathname === '/cart') {
    // 장바구니 페이지 렌더링하기
 }

프로젝트의 기본 구조는 다음과 같다.

URL 라우팅 책임을 App 컴포넌트가 맡고, 각 기능에 맞는 동작을 구현하는 개별 컴포넌트들을 자식으로 두는 구조를 생성한다.

이렇게 되면 각 페이지는 서로에 대한 의존성 없이 독립적으로 동작할 수 있게 된다.

간단한 서버 생성

express를 사용해서 간단하게 서버를 구축한다.

npm init -y
npm i express 

루트 경로에 server.js 파일을 생성하고 다음과 같이 코드를 작성한다.

server.js

// server.js 

const express = require("express")
const path = require("path")

const app = express()

// express의 기본 미들웨어 함수인 express.static을 사용해서 정적 파일 사용(js, css 등) 
// path.resolve로 경로들을 문자열로 만들어준다. 
// http://localhost:8080/static/js/index.js
// http://localhost:8080/static/css/index.css
app.use("/static", express.static(path.resolve(__dirname, "frontend", "static")))

// 모든 경로에 대한 get 요청으로 index.html 파일을 클라이언트에게 응답한다. 
app.get("/*", (req, res) => {
  res.sendFile(path.resolve(__dirname, "frontend", "index.html"))
})

// port 번호 5050으로 서버 생성
app.listen(process.env.PORT || 5050, () => console.log("listening on port " + 5050))

서버를 열려면 다음 명령어를 터미널에 입력하면 된다.

node server.js

기본적인 routing 처리

폴더 구조는 다음과 같다.

├─ frontend 
	├─ static 
    	├─ css/...
        └─ js/... 
    └─ index.html 
└─ server.js 

  1. index.html에 div.app 요소를 생성한다. 이 요소 안에 여러 컴포넌트들을 삽입하는 구조를 가진다.
  2. static/js/index.js에서 이 div.app 요소를 가지고 App 컴포넌트를 생성한다. App 컴포넌트는 .App.js에서 가져온다.
  3. static/js/App.js에서 각 폴더들에 대한 라우팅 처리를 해준다.
  4. 그리고 static/js/에 CartPage, ProductListPage, ProductDetailPage 에 대한 각각의 컴포넌트 내용을 채워넣고 연결을 해주면 된다.

가장 먼저 ProductListPage를 렌더링 해보자. 아래 과정을 밟으면 된다.

먼저, ProductListPage의 페이지를 생성한다.

static/js/ProductListPage.js

인수로 받아 오는 $target는 div.App이다.

각 페이지의 내용을 $page 변수에 저장하고 이 $page를 인수로 받아온 $target의 자식 요소로 삽입한다.

// ProductListPage.js

export default function ProductListPage({ $target }){
  const $page = document.createElement('div')
  $page.className = 'ProductListPage'
  $page.innerHTML = '<h1>상품 목록</h1>'

  this.render = () => {
    $target.appendChild($page)
  }
}

그리고 이렇게 생성한 ProductListPage를 App.js에서 렌더링을 해준다.

static/js/App.js

App.js는 라우팅을 해준다.

location.pathname이 / 경로일 때
가져온 ProductListPage를 렌더링해준다.

ProductListPage의 render 함수를 실행하면 그 위치에서 페이지가 렌더링 될 것이다.

이렇게 routing 처리해주는 route 함수를 작성하고
this.route() 코드를 통해 App 컴포넌트가 생성되면 1번 실행될 수 있게 코드를 작성해준다.

// App.js

import ProductListPage from "./ProductListPage.js"

export default function App({ $target }){
  this.route = () => {
    const { pathname } = location 

    $target.innerHTML = ''

    if(pathname === '/'){
      new ProductListPage({ $target }).render() 
    }
  }
  this.route() 
}

이렇게 생성한 App 컴포넌트를 index.js에 연결해준다.

static/js/index.js

index.js는 index.html에 연결되어 있으므로 div.app 요소를 바로 찾을 수 있다.

이를 $target으로 넣은 뒤,
이 자리에서 App 컴포넌트를 생성해준다.

// index.js

import App from "./App.js"

const $target = document.querySelector('.app')
new App({ $target })


console.log("JS is loaded")

index.html

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta http-equiv="X-UA-Compatible" content="IE=edge">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Single Page Application</title>
  <link rel="stylesheet" href="static/css/index.css">
  <style>
    h1 {
      color: red; 
    }
  </style>
</head>
<body>
  <main class="app"></main>
  <script type="module" src="/static/js/index.js"></script>
</body>
</html>

나머지 페이지들도 차례대로 생성해보자.

index.html - static/js/index.js - static/js/app.js 의 연결관계를 생성해뒀으니 이제 나머지 페이지들을 생성하고 app.js에 연결만 하면 된다.

static/js/ProductDetailPage.js

각 컴포넌트 페이지들은 다음과 같은 구조를 갖는다.

  • state에는 변수가 담기고
  • $page에서는 렌더링될 현재 컴포넌트 내용들이 담기고
  • render 함수를 통해 $page가 렌더링 된다
// ProductDetailPage.js

export default function ProductDetailPage({ $target, productId }){
  this.state = {
    productId
  }

  const $page = document.createElement('div')
  $page.className = 'ProductDetailPage'
  $page.innerHTML = '<h1>상품 정보</h1>'

  this.render = () => {
    $target.appendChild($page)
  }
}

static/js/CartPage.js

// CartPage.js

export default function CartPage({ $target }){
  const $page = document.createElement('div')
  $page.className = 'CartPage'
  $page.innerHTML = '<h1>장바구니</h1>'

  this.render = () => {
    $target.appendChild($page)
  }
}

이렇게 생성한 컴포넌트들을 App.js에 연결하고 routing 처리를 해준다.

static/js/App.js

pathname이 '/'이거나 '/cart'일 때는 바로바로 연결해주되,

'/products/:id'의 경우
일단 '/products/를 포함하는 경우를 찾은 뒤, split을 통해 productId를 찾아서 매개변수로 넣어준다.

// App.js

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

export default function App({ $target }){
  this.route = () => {
    const { pathname } = location 

    $target.innerHTML = ''

    if(pathname === '/'){
      new ProductListPage({ $target }).render()
    } else if(pathname.indexOf('/products/') === 0){
      const [, , productId] = pathname.split('/')

      new ProductDetailPage({
        $target,
        productId
      }).render() 
    } else if(pathname === '/cart'){
      new CartPage({
        $target
      }).render() 
    }
  }

  this.route()
}

여기까지 코드를 작성하면 다음과 같이 기본적인 라우팅이 완성된다.

참고로 vanilla-js로 import를 할 때는 경로를 완전하게(확장자까지 포함해서) 작성해줘야 한다.

상품 목록 페이지 구현하기

각 페이지를 분리했으니, 페이지 별로 구현할 차례이다.

상품 목록 API 연동

API 연동에 앞서, 반복적인 코드 방지와 예외처리를 위해 api.js 파일에 request 함수를 생성해서 사용한다.

static/js/api.js

나는 jsonplaceholder를 사용했다.
사용법은 여기에서 확인할 수 있다.

무조건 받아오므로 바로 response를 return 처리했다.

// api.js

const API_END_POINT = "https://jsonplaceholder.typicode.com"

export const request = async (url, options = {}) => {
  try {
    const fullUrl = `${API_END_POINT}${url}`
    
    const response = await fetch(fullUrl, options)
      .then((res) => res.json())

    return response 
    // if(response.ok){
    //   const json = await response.json()
    //   return json;
    // }
    // throw new Error('API 통신 실패')
    
  } catch(e){
    alert(e.message)
  }
}

그리고 나서 ProductListPage에 이 request 함수를 추가해서 사용한다.

static/js/ProductListPage.js

해설에서의 /products와 달리 나는 jsonplaceholder를 사용하므로
이미지를 받아올 수 있는 /photos를 사용했다.

이후에 만들 ProductList에 현재 페이지인 $page와 비동기로 받아온 데이터가 담겨 있는 this.state를 매개변수로 넘겨서 생성해준다.

// ProductListPage.js 

import { request } from "./api.js"
import ProductList from "./ProductList.js"

export default function ProductListPage({ $target }){
  const $page = document.createElement('div')
  $page.className = 'ProductListPage'
  $page.innerHTML = '<h1>상품 목록</h1>'

  this.render = () => {
    $target.appendChild($page)
  }

  this.setState = (nextState) => {
    this.state = nextState
  }

  const fetchProducts = async () => {
    const products = await request('/photos')
    this.setState(products)

    const productList = new ProductList({
      $target: $page,
      initialState: this.state 
    })
  }
  
  fetchProducts()
}

이렇게 코드를 작성하면,
new ProductListPage 를 통해 ProductListPage가 생성되면 fetchProducts를 실행해 this.state에 불러온 상품 목록을 가지고 있게 된다.

static/js/ProductList.js

이번엔 ul 요소를 생성한다.

jsonplaceholder로 받아온 데이터가 많으므로, 이중에서 10개만 사용하기 위해 slice 메서드를 사용했다.

렌더링할 때는

  • 배열 데이터를 map으로 순회하고 나서 문자열로 변경해줘야 한다.
  • map 메서드 내에서 화살표 함수 뒤에 괄호를 사용할 거면 return을 반드시 써야 하며, 그렇지 않으면 둘 다 생략하는 쪽으로 작성해야 정상적으로 렌더링된다.
// ProductList.js

export default function ProductList({ $target, initialState }){
  const $productList = document.createElement('ul')
  $target.appendChild($productList)

  this.state = initialState.slice(0, 10)

  this.setState = (nextState) => {
    this.state = nextState 
    this.render() 
  }

  this.render = () => {
    if(!this.state) return 

    $productList.innerHTML = `
      ${this.state.map((product) => 
        `
          <li class="Product">
            <img src="${product.thumbnailUrl}">
            <div class="Product_info">
              <div>${product.title}</div>
            </div>
          </li>
        `
      ).join('')}
    `
  }

  this.render() 
}

static/css/index.css

스타일링은 다음과 같이 간단하게 해준다.

html,
body,
ul,
li {
  margin: 0;
  padding: 0;
} 

body {
  background-color: #eee;
  height: 100vh;
}

.app {
  width: 100%;
  height: 100%;
  max-width: 1100px;
  margin: 0 auto;
  margin-top: 24px;
  margin-bottom: 24px;
}
.app ul li {
  list-style: none;
}

.ProductListPage ul {
  display: flex;
  flex-direction: row;
  flex-wrap: wrap;
}

.Product {
  width: 150px;
  padding: 16px;
  border-radius: 5px;
  background-color: #fff;
  border: 1px solid #ccc;
  margin: 8px;
}
.Product img {
  width: 150px;
  height: 150px;
  object-fit: contain;
}
.Product .Product_info {
  margin-top: 6px;
}

결과는 아래과 같다.

페이지 이동 처리

이렇게 상품 목록 페이지를 만들었으니, 상품 상세 페이지를 만들 차례이다.
상품 목록을 클릭 했을 때, 상품 상세 페이지로 이동하게 만들어야 하고 가장 간단한 방법은 a 태그를 사용하는 것이다.

ProductList.js의 render 함수 내에서 a 태그를 추가해주면 된다.

// ProductList.js 

this.render = () => {
    if(!this.state) return 

    $productList.innerHTML = `
      ${this.state.map((product) => 
        `
          <li class="Product">
            <a href="/products/${product.id}">
              <img src="${product.thumbnailUrl}">
              <div class="Product_info">
                <div>${product.title}</div>
              </div>
            </a>
          </li>
        `
      ).join('')}
    `
  }

이렇게 a 태그로 감싸주는 것만으로도 페이지 이동이 가능하다.

하지만 이렇게 location.href를 사용하면,
브라우저가 해당 페이지로 이동하면서 페이지를 새로 불러오게 된다.

single page application은 매번 페이지를 새로 불러오는 게 아니라, 클라이언트에서 페이지가 변경되는 부분만 새로 그리도록 처리해야 한다.

이를 위해서는 HTML History API를 알아야 한다.

history.pushState를 사용하면 URL만 업데이트하면서 웹 브라우저의 기본적인 페이지 이동 처리가 되는 것을 방지할 수 있다.

DOM의 window 객체는 history 객체를 통해 브라우저의 세션 기록에 접근할 수 있는 방법을 제공한다. history는 사용자를 자신의 방문 기록 앞,뒤로 보내고 기록 스택의 콘텐츠도 조작할 수 있는 유용한 메서드와 속성을 갖는다.
history.pushState를 사용하면 브라우저의 세션 기록 스택에 상태가 추가된다.
history.pushState(state, title, url)

  • state는 브라우저 이동 시 넘길 데이터, title은 변경할 브라우저 제목, url은 변경할 경로.
  • spa를 구현할 때는 앞의 2개 매개변수는 비워두고 세번째 매개변수만 고려하면 된다.

화면 새로고침 없이 다른 페이지로 이동 처리하려면,

  • 이동할 페이지 URL을 history.pushState를 통해 변경
  • App.js의 this.route 함수 실행

pushState를 통해 URL이 변경되는 걸 감지하는 방법은 많지만,
해설에서는 커스텀 이벤트를 통해 처리했다.

static/js/router.js

커스텀 이벤트 디스패치 - 모던 JavaScript 튜토리얼

// router.js

const ROUTE_CHANGE_EVENT = 'ROUTE_CHANGE'

// 커스텀 이벤트를 통해 ROUTE_CHANGE 이벤트 발생 시 onRouteChange 콜백함수를 호출하도록 이벤트를 바인딩 
export const init = (onRouteChange) => {
  window.addEventListener(ROUTE_CHANGE_EVENT, () => {
    onRouteChange()
  })
}

// URL을 업데이트하고 커스텀 이벤트를 발생시키는 함수 
export const routeChange = (url, params) => {
  history.pushState(null, null, url)
  window.dispatchEvent(new CustomEvent(ROUTE_CHANGE_EVENT, params))
}

그리고 App.js 내에 있는 this.route 함수와 router.js의 init 함수를 연결한다.

static/js/App.js

// App.js

import CartPage from "./CartPage.js"
import ProductDetailPage from "./ProductDetailPage.js"
import ProductListPage from "./ProductListPage.js"
import { init } from "./router.js"

export default function App({ $target }){
  this.route = () => {
    // ... 
  }

  init(this.route)
  this.route()
}

이제 ProductList 컴포넌트에서 a 태그를 사용해서 페이지 이동을 구현하는 게 아니라, 아래 코드처럼 onClick 콜백함수를 통해 페이지 이동을 구현한다.

static/js/ProductList.js

data-product-id라는 이름으로 custom attribute를 만들고, event delegation을 통해 productId를 뽑아와서 routeChange 함수를 통해 URL 변경을 처리한다.

이제 상품을 클릭하면, 화면 리로드 없이 페이지가 이동되는 것을 알 수 있다.

Element.closet(selectors) 메서드는 주어진 CSS 선택자와 일치하는 요소를 찾을 때까지, 자기 자신을 포함해 위쪽(부모 방향, 문서 루트까지)으로 문서 트리를 순회한다.

// ProductList.js
import { routeChange } from "./router.js"

export default function ProductList({ $target, initialState }){
  const $productList = document.createElement('ul')
  $target.appendChild($productList)

  this.state = initialState.slice(0, 10)

  this.setState = (nextState) => {
    this.state = nextState 
    this.render() 
  }

  this.render = () => {
    if(!this.state) return 

    $productList.innerHTML = `
      ${this.state.map((product) => 
        `
          <li class="Product" data-product-id="${product.id}">
            <img src="${product.thumbnailUrl}">
            <div class="Product_info">
              <div>${product.title}</div>
            </div>
          </li>
        `
      ).join('')}
    `
  }

  this.render() 

  $productList.addEventListener('click', (e) => {
    const $li = e.target.closest('li')
    const { productId } = $li.dataset 

    if(productId){
      routeChange(`/products/${productId}`)
    }
  })
}

현재까지의 프로젝트 구조는 다음과 같다.

뒤로 가기 처리

popstate를 통해 뒤로가기와 앞으로가기 등으로 인한 브라우저의 URL이 변경된 경우를 감지할 수 있다.

App.js에 popstate 이벤트 발생 시마다 페이지를 재렌더링할 수 있도록 코드를 1줄 추가해준다.

// App.js
import CartPage from "./CartPage.js"
import ProductDetailPage from "./ProductDetailPage.js"
import ProductListPage from "./ProductListPage.js"
import { init } from "./router.js"

export default function App({ $target }){
  // ...

  // 뒤로가기, 앞으로가기 발생 시 popstate 이벤트 발생 
  window.addEventListener('popstate', this.route)
}

상품 상세 페이지 구현

상품 상세 페이지에서는 3가지 기능을 구현해야 한다.

  • 1) 상품 id에 맞는 상품을 불러오고, 상품과 옵션을 렌더링
  • 2) 상품 옵션을 선택할 수 있도록 처리
  • 3) 상품을 선택하고 주문하기 버튼을 누르면 localStorage에 주문한 상품을 저장하고 장바구니 페이지로 이동

1. 상품 id에 맞는 상품 불러오고, 상품과 옵션을 렌더링

static/js/ProductDetailPage.js

기존에 만들어 둔 this.state에 product 속성을 추가한다.

그리고 fetchProduct 함수를 실행해서 productId에 맞는 데이터를 가져온다.
가져온 데이터를 product이라는 변수에 저장하고,
기존 this.state에 product 속성에 가져온 데이터를 setState 함수를 사용해서 저장한다.

setState 함수를 실행해서 가져온 데이터를 this.state에 저장한 뒤에
this.render 함수를 실행해준다.

제품 상세 페이지는 ProductDetail 이라는 컴포넌트를 별도로 생성해서 여기에 구현해준다.
구현하기 위해서 불러온 데이터를 initialState에 저장해주고, 나중에 선택한 상품들을 담아둔 selectedOptions라는 속성도 미리 생성해둔다.

// ProductDetailPage.js 

import ProductDetail from "./ProductDetail.js"
import { request } from "./api.js"

export default function ProductDetailPage({ $target, productId }){
  const $page = document.createElement('div')
  $page.className = 'ProductDetailPage'
  $page.innerHTML = '<h1>상품 정보</h1>'

  this.state = {
    productId,
    product: null 
  }

  this.setState = nextState => {
    this.state = nextState 
    this.render() 
  }

  this.render = () => {
    if(!this.state.product){
      $target.innerHTML = 'Loading..'
    } else {
      $target.innerHTML = ''
      $target.appendChild($page)

      // ProductDetail 렌더링 
      new ProductDetail({
        $target: $page,
        initialState: {
          product: this.state.product,
          // ProductDetail의 initialState에 선택된 상품들을 담아 둘 selectedOptions 추가 
          selectedOptions: []
        }
      })
    }
  }

  this.fetchProduct = async () => {
    const { productId } = this.state 
    const product = await request(`/photos/${productId}`)

    this.setState({
      ...this.state,
      // product: product
      product
    })
  }
  this.fetchProduct()
}

static/js/ProductDetail.js

상품의 상세 페이지를 이렇게 따로 컴포넌트로 분리해서 생성하는 이유는, 상세 페이지를 다른 페이지에서도 재사용할 수 있도록 하기 위함이다(확장성).

state를 initialState로 전달 받아서 저장한다.

내가 사용한 jsonplaceholder에는 product.productOptions라는 속성은 따로 없기에, 임의로 옵션 속성을 생성해서 저장해두었다.

// ProductDetail.js

export default function ProductDetail({ $target, initialState }){
  const $productDetail = document.createElement('div')
  $productDetail.className = 'productDetail'

  $target.appendChild($productDetail)

  this.state = initialState 

  this.setState = nextState => {
    this.state = nextState 
    this.render()
  }

  this.render = () => {
    const { product } = this.state 

    product.productOptions = [
      { name: '옵션1', id: 1, stock: 0, price: '100,000원' },
      { name: '옵션2', id: 2, stock: 1, price: '200,000원' },
      { name: '옵션3', id: 3, stock: 2, price: '300,000원' },
    ]

    $productDetail.innerHTML = `
      <img src="${product.thumbnailUrl}">
      <div class="ProductDetail__info">
        <h2>${product.title}</h2>
        <div class="ProductDetail__price">999,999원</div>
        <select>
          <options>선택하세요.</options>
          ${product.productOptions.map(option => 
            `
              <option value="${option.id}" ${option.stock === 0 ? "disabled" : ""}>
                ${option.stock === 0 ? "(품절)" : ""}${product.title} ${option.name} ${option.price > 0 ? `(+${option.price}원)` : ""}
              </option>
            `
          ).join('')}
        </select>
        <div class="ProductDetail__selectedOptions"></div>
      </div>
    `
  }
  this.render() 
}

여기까지 코드를 작성하면 아래와 같은 UI를 갖게 된다.

2. 상품 옵션 선택하기

상품 옵션을 선택하면 해당 상품이 선택되는 기능을 만들 차례이다.
ProductDetail 컴포넌트 내에서 구현해도 되지만, 해설에서는 SelectedOptions라는 컴포넌트를 따로 구현한 뒤에 ProductDetail에 삽입했다.

이전에 만들어두었던 selectedOptions: [] 코드를 활용할 것이다.

static/js/ProductDetail.js

먼저, 상품을 선택했을 때를 처리해야 하므로 이벤트 리스너를 추가해준다.

그전에 데이터를 조금 수정해뒀는데, product.price 라는 속성을 따로 만들어 가져다 쓸 수 있게 만들어뒀다.

페이지 내에서 최상위 요소인 $productDetail에 change 이벤트를 걸어서 이벤트 위임 기법을 활용한다.
상품의 옵션 데이터가 가능한 옵션 데이터인지 확인하는 동시에 && 이미 선택된 옵션은 아닌지 체크한 뒤에 이를 nextSelectedOptions라는 데이터로 가공한 뒤에,
이를 this.state에 삽입한다.

그리고 ProductDetail에서 페이지를 렌더링할 때 아래에서 selectedOptions 컴포넌트를 같이 렌더링해준다.

// ProductDetail.js 

import SelectedOptions from "./SelectedOptions.js"

export default function ProductDetail({ $target, initialState }){
  const $productDetail = document.createElement('div')
  $productDetail.className = 'ProductDetail'

  $target.appendChild($productDetail)

  this.state = initialState 
  let selectedOptions = null 

  this.setState = nextState => {
    this.state = nextState 
    this.render()

    if(selectedOptions){
      selectedOptions.setState({
        selectedOptions: this.state.selectedOptions 
      })
    }
  }

  this.render = () => {
    const { product } = this.state 

    product.price = 1_000_000

    product.productOptions = [
      { name: '옵션1', id: 1, stock: 0, price: 100_000 },
      { name: '옵션2', id: 2, stock: 1, price: 200_000 },
      { name: '옵션3', id: 3, stock: 2, price: 300_000 },
    ]
    // console.log(product)

    $productDetail.innerHTML = `
      <img src="${product.thumbnailUrl}">
      <div class="ProductDetail__info">
        <h2>${product.title}</h2>
        <div class="ProductDetail__price">${product.price}원</div>
        <select>
          <options>선택하세요.</options>
          ${product.productOptions.map(option => 
            `
              <option value="${option.id}" ${option.stock === 0 ? "disabled" : ""}>
                ${option.stock === 0 ? "(품절)" : ""}${product.title} ${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.render() 

  // 이벤트 바인딩 코드 
  // 이벤트 위임 기법을 이용해 이벤트 자체는 ProductDetail 최상위의 div에서 처리 
  $productDetail.addEventListener('change', e => {
    // 이벤트 발생 주체가 select 태그인 경우에만 
    if(e.target.tagName === 'SELECT'){
      // 상품 옵션을 나타내는 option의 value에는 optionId를 담고 있다. 
      // 이를 가져와서 숫자값을 바꾼다. 
      const selectedOptionId = parseInt(e.target.value)
      const { product, selectedOptions } = this.state 
      // 상품의 옵션 데이터에서 현재 선택한 optionId가 존재하는지 찾는다. 
      const option = product.productOptions.find(option => option.id === selectedOptionId)
      // 이미 선택한 상품인지 선택된 상품 데이터에서 찾는다. 
      const selectedOption = selectedOptions.find(selectedOption => selectedOption.optionId === selectedOptionId)

      // 존재하는 옵션이고 선택된 옵션이 아닌 경우에만 selectOptions에 현재 선택한 옵션을 추가 
      if(option && !selectedOption){
        const nextSelectedOptions = [
          ...selectedOptions,
          {
            productId: product.id,
            optionId: option.id,
            optionName: option.name, 
            optionPrice: option.price,
            quantity: 1
          }
        ]
        this.setState({
          ...this.state,
          selectedOptions: nextSelectedOptions
        })
      }
    }
  })
}

SelectedOptions 컴포넌트를 생성한다.

static/js/SelectedOptions.js

상품 가격 총합을 구하는 getTotalPrice 함수를 구현해주고,
render 함수를 통해 SelectedOptions 컴포넌트를 구현해준다.

// SelectedOptions.js 

export default function SelectedOptions({ $target, initialState }){
  const $component = document.createElement('div')
  $target.appendChild($component)

  this.state = initialState 

  // 상품 가격 총합 구하기 
  this.getTotalPrice = () => {
    const { product, selectedOptions } = this.state 
    const { price: productPrice } = product 

    return selectedOptions.reduce(
      (acc, option) => acc + ((productPrice + option.optionPrice) * option.quantity), 0
    )
  }

  this.setState = (nextState) => {
    this.state = nextState 
    this.render()
  }

  this.render = () => {
    const { product, selectedOptions = [] } = this.state 

    if(product && selectedOptions){
      $component.innerHTML = `
        <h3>선택된 상품</h3>
        <ul>
          ${selectedOptions.map(selectedOption => `
            <li> 
              ${selectedOption.optionName} ${product.price + selectedOption.optionPrice}원 
              <input type="text" data-optionId="${selectedOption.optionId}" value="${selectedOption.quantity}"> 
            </li>
          `).join('')}
        </ul>
        <div class="ProductDetail__totalPirce">총 주문금액 : ${this.getTotalPrice()}원</div>
        <button class="OrderButton">주문하기</button>
      `
    }
  }
  this.render() 
}

여기까지 코드를 작성하면 아래와 같은 화면이 나온다.

현재 컴포넌트 구조

3. 상품 수량 변경하기

해설 코드에 맞게 작성하다가 에러가 발생했다.
SelectedOptions에서 수량을 변경하면(change 이벤트 발생)
this.state의 product 속성이 사라지는 문제였다.

로그를 찍어보다가 발견했는데, ProductDetail 컴포넌트 내에서 selectedOptions 값이 있을 때, 기존에 존재하던 this.state를 제외하고 selectedOptions만 넣어줬더니 product가 사라지는 거였다.

// ProductDetail.js 

//... 
this.setState = nextState => {
    this.state = nextState 
    this.render()

    if(selectedOptions){
      selectedOptions.setState({
        ...this.state, // 이 부분을 빼먹었다..!
        selectedOptions: this.state.selectedOptions 
      })
    }
  }

static/js/SelectedOptions.js

마찬가지로 이벤트 위임을 활용해서 최상위 요소인 $component에 change 이벤트리스너를 건다.

주의할점은 dataset.optionid 에서 html 속성으로 이렇게 만든 커스텀 속성은 소문자로 작성해야 한다는 점이다.

// SelectedOptions.js 

export default function SelectedOptions({ $target, initialState }){
  const $component = document.createElement('div')
  $target.appendChild($component)

  this.state = initialState 

  // 상품 가격 총합 구하기 
  this.getTotalPrice = () => {
    const { product, selectedOptions } = this.state 
    // const { price: productPrice } = product 

    return selectedOptions.reduce(
      (acc, option) => acc + ((product.price + option.optionPrice) * option.quantity), 0
      // (acc, option) => acc + ((productPrice + option.optionPrice) * option.quantity), 0
    )
  }

  this.setState = (nextState) => {
    this.state = nextState 
    this.render()
  }

  this.render = () => {
    const { product, selectedOptions = [] } = this.state 

    if(product && selectedOptions){
      $component.innerHTML = `
        <h3>선택된 상품</h3>
        <ul>
          ${selectedOptions.map(selectedOption => `
            <li> 
              ${selectedOption.optionName} ${product.price + selectedOption.optionPrice}원 
              <input type="text" data-optionId="${selectedOption.optionId}" value="${selectedOption.quantity}"> 
            </li>
          `).join('')}
        </ul>
        <div class="ProductDetail__totalPirce">총 주문금액 : ${this.getTotalPrice()}원</div>
        <button class="OrderButton">주문하기</button>
      `
    }
  }
  this.render() 
  // console.log(this.state)

  // 왜 this.state의 product 속성이 사라지지? => ProductDetail의 setState 코드에서 객체 내 ...this.state 코드 추가 
  $component.addEventListener('change', e => {
    // 이벤트 위임 활용 
    // 이벤트가 INPUT 태그에서 발생한 경우에만 처리 
    if(e.target.tagName === 'INPUT'){
      try {
        const nextQuantity = parseInt(e.target.value)
        const nextSelectedOptions = [ ...this.state.selectedOptions ]
        // input 값이 숫자인 경우에만 처리 
        if(typeof nextQuantity === 'number'){
          const { product } = this.state 

          // console.log(e.target.dataset.optionid, e.target.dataset.optionId) // html 속성이므로 소문자로 찾아야 한다. 
          const optionId = parseInt(e.target.dataset.optionid)
          const option = product.productOptions.find(option => option.id === optionId)
          const selectedOptionIndex = nextSelectedOptions.findIndex(selectedOption => selectedOption.optionId === optionId)
          // input에 입력한 값이 재고 수량을 넘을 경우 재고수량으로 입력한 것으로 강제 교체 
          nextSelectedOptions[selectedOptionIndex].quantity = option.stock >= nextQuantity ? nextQuantity : option.stock 

          this.setState({
            ...this.state,
            selectedOptions: nextSelectedOptions
          })
        }
      } catch(e){
        console.log(e)
      }
    }
  })
}

수량을 변경해도 재고 수량보다 크면 재고 수량으로 강제로 고정된다.
수량을 변경하면 총 주문금액에도 금액이 더해진다. (setState 함수를 실행하면 렌더링도 다시 하므로)

화면 깜빡임 방지

현재 코드에서 상품을 선택할 때마다 setState가 호출되고,
render가 계속 호출되면서 상품 상세 화면이 깜빡이고 있다.

플래그 변수를 하나 두어서 초기 1회에만 렌더링되도록 처리하면 쉽게 해결할 수 있다. (혹은 insertAdjacentHTML 등을 이용하는 방법도 있다.)

static/js/ProductDetail.js

isInitialized가 false 일때만 렌더링 코드를 실행하고,
1번 실행했다면 true로 변경해준다.

// ProductDetail.js 

// ProductDetail.js
export default function ProductDetail({ $target, initialState }) {
  let isInitialized = false
  ... 중간 코드 생략

  this.render = () => {
    const { product } = this.state

    // 아래 코드는 1회만 실행됩니다.
    if (!isInitialized) {
      $productDetail.innerHTML = `
        .. HTML 렌더링 코드 생략
      `
      selectedOptions = new SelectedOptions({
        $target: $productDetail.querySelector('.ProductDetail__selectedOptions'),
        initialState: {
          product: this.state.product,
          selectedOptions: this.state.selectedOptions
        }
      })
      isInitialized = true
    }
  }
  .. 코드 생략
}

주문하기 버튼 클릭 시, 동작 처리

이제 상품을 선택할 수 있고, 수량도 변경할 수 있으니
주문하기 버튼을 눌렀을 때의 동작을 처리해줄 차례이다.

SelectedOptions 컴포넌트 내에 주문하기 버튼이 있으니, 해당 컴포넌트에서 처리하도록 한다.

먼저 localStorage를 다루기 위한 유틸리티 함수들을 만든다.

localStorage는 그대로 다루었다간 에러를 만날 여러 가지 케이스가 있기 때문에, 안전을 위해 여러가지 처리를 해둔 함수 형태로 감싸서 쓰는 것이 좋다고 한다.

static/js/storage.js

// storage.js
export const storage = localStorage

export const getItem = (key, defaultValue) => {
  try {
    const value = storage.getItem(key)
    // key에 해당하는 값이 있다면 parsing하고, 없으면 defaultValue 리턴
    return value ? JSON.parse(value) : defaultValue
  } catch {
    // parsing 하다 에러가 생기면 defaultValue 리턴
    return defaultValue
  }
}

export const setItem = (key, value) => {
  try {
    storage.setItem(key, JSON.stringify(value))
  } catch {
    // ignore
  }
}

export const removeItem = (key) => {
  try {
    storage.removeItem(key)
  } catch {
    // ignore
  }
}

storage.js에서 만든 getItem과 setItem을 사용해서 localStorage에 장바구니 데이터를 만든다.

static/js/SelectedOptions.js

미리 만들어 둔 routeChange 함수를 사용해서
저장까지 했다면 /cart 페이지로 자동으로 이동하도록 한다.

// SelectedOptions.js
import { getItem, setItem } from './storage.js'
import { routeChange} from './router.js'

export default function SelectedOptions({ $target, initialState }) {
  .. 이전 코드 생략
  $component.addEventListener('click', (e) => {
    const { selectedOptions } = this.state
    if (e.target.className === 'OrderButton') {
            // 기존에 담겨진 장바구니 데이터가 있을 수 있으므로 가져와보고 없으면 빈배열 처리
      const cartData = getItem('products_cart', [])
      // 장바구니 데이터 만들기      
      setItem('products_cart', cartData.concat(selectedOptions.map(selectedOption => ({
        productId: selectedOption.productId,
        optionId: selectedOption.optionId,
        quantity: selectedOption.quantity
      }))))

      routeChange('/cart')
    }
  })
}

이제 마지막으로 장바구니 페이지를 구현하면 완성이다.

장바구니 페이지 구현

장바구니 데이터가 없을 경우 튕겨내는 처리

장바구니 데이터가 없을 경우 장바구니가 비어있음을 알리는 alert 출력 뒤 상품 목록 데이터로 가도록 한다.

웹 어플리케이션 탭에서 로컬 스토리지에서 데이터를 비운 뒤
cart 컴포넌트를 다음과 같이 수정한다.

static/js/CartPage.js

// CartPage.js 

import { getItem } from './storage.js'
import { routeChange } from './router.js'

export default function CartPage({ $target }) {
  const $page = document.createElement('div')
  $page.className = 'CartPage'

  $page.innerHTML = '<h1>장바구니</h1>'

  const cartData = getItem('products_cart', [])
  this.state = {
    products: null 
  }

  this.render = () => {
    if (cartData.length === 0) {
      alert('장바구니가 비어있습니다.')
      routeChange('/')
    } else {
      $target.appendChild($page)
    }
  }
}

이제 장바구니 데이터를 비우고 '/cart'로 접근하면
장바구니가 비어 있다는 alert 창과 함께 '/' 페이지로 돌아가게 된다.

장바구니의 상품들 불러오기

다음으로는 로컬 스토리지에서 꺼내온 상품 데이터를 통해 상품 옵션을 불러올 것이다. 상품 옵션 종류만큼 API 호출을 하면 된다.

이 경우 Promise.all 과 async, await 을 활용하면 된다.

Promise.all() 메서드는 순회 가능한 객체에 주어진 모든 프로미스가 이행한 후, 혹은 프로미스가 주어지지 않았을 때 이행하는 Promise를 반환합니다. 주어진 프로미스 중 하나가 거부하는 경우, 첫 번째로 거절한 프로미스의 이유를 사용해 자신도 거부합니다.

const promise1 = Promise.resolve(3);
const promise2 = 42;
const promise3 = new Promise((resolve, reject) => {
  setTimeout(resolve, 100, 'foo');
});
Promise.all([promise1, promise2, promise3]).then((values) => {
  console.log(values);
});
// Expected output: Array [3, 42, "foo"]
// CartPage.js

import { getItem } from './storage.js'
import { routeChange } from './router.js'
import { request } from './api.js'

export default function CartPage({ $target }){
  const $page = document.createElement('div')
  $page.className = 'CartPage'
  $page.innerHTML = '<h1>장바구니</h1>'

  const cartData = getItem('products_cart', [])
  this.state = {
    products: null 
  }

  this.render = () => {

    if(cartData.length === 0){
      alert('장바구니가 비어 있습니다.')
      routeChange('/')
    } else {
      $target.appendChild($page)
    }
  }

  this.setState = (nextState) => {
    this.state = nextState 
    this.render() 
  } 

  this.fetchProducts = async () => {
    const products = await Promise.all(cartData.map(async (cartItem) => {
      const product = await request(`/photos/${cartItem.productId}`)
      // 원래 정상적인 api 요청이었다면 아래 로직을 통해 id로 상품을 찾으면 되지만, 
      // 나는 다른 api를 했기 때문에 products에 productOptions가 없다. 
      const selectedOption = product.productOptions.find(option => option.id === cartItem.optionId)

      return {
        imageUrl: product.imageUrl,
        productName: product.name,
        quantity: cartItem.quantity,
        productPrice: product.price,
        optionName: selectedOption.name,
        optionPrice: selectedOption.price
      }
    }))
    this.setState({ products })
  }

  this.fetchProducts()
}

Cart.js 만들기

실제 장바구니 화면을 만드는 건 Cart 컴포넌트에서 그린다.
이렇게 되면 Page 역할을 하는 컴포넌트들에서는 초기 렌더링을 위한 준비와 데이터만 준비하고, 실제 렌더링은 각 하위 컴포넌트에서 진행하는 형태가 된다.

// Cart.js
export default function Cart({ $target, initialState }) {
  const $component = document.createElement('div')
  $component.className = 'Cart'
  this.state = initialState

  $target.appendChild($component)

  this.setState = nextState => {
    this.state = nextState
    this.render()
  }

  this.getTotalPrice = () => {
    return this.state.reduce(
        (acc, option) => acc + ((option.productPrice + option.optionPrice) * option.quantity),
      0)
  }

  this.render = () => {
    $component.innerHTML = `
      <ul>
        ${this.state.map(cartItem => `
          <li class="Cart__item">
            <img src="${cartItem.imageUrl}">
            <div class="Cart__itemDescription">
              <div>${cartItem.productName} ${cartItem.optionName} ${cartItem.quantity}개</div>
              <div>${cartItem.productPrice + cartItem.optionPrice}원</div>
            </div>
          </li>
        `).join('')}
      </ul>
      <div class="Cart__totalPrice">
        총 상품가격 ${this.getTotalPrice()}원
      </div>
      <button class="OrderButton">주문하기</button>
    `
    return $component
  }

  this.render()
}

이제 이 Cart 컴포넌트를 CartPage에서 데이터 로딩이 끝난 이후 생성이 되게 만든다.

// CartPage.js
import { request } from './api.js'
import { getItem } from './storage.js'
import { routeChange } from './router.js'
import Cart from './Cart.js'

export default function CartPage({ $target }) {
  .. 코드 생략
  let cartComponent = null

  this.render = () => {
    if (cartData.length === 0) {
      alert('장바구니가 비어있습니다.')
      routeChange('/')
    } else {
      $target.appendChild($page)
      // Cart 컴포넌트 생성
      if (this.state.products && !cartComponent) {
        cartComponent = new Cart({
          $target: $page,
          initialState: this.state.products
        })
      }
    }
  }
  .. 코드 생략
}

주문하기 처리

상품 상세 페이지의 주문하기 버튼을 눌렀을 때, 처리와 마찬가지로
Cart.js 내에 아래의 이벤트를 추가한다.

// Cart.js
export default function Cart({ $target, initialState }) {
  ... 이전 코드 생략

  $component.addEventListener('click', e => {
    if (e.target.className === 'OrderButton') {
      alert('주문 되었습니다!')
      removeItem('products_cart')
      routeChange('/')
    }
  })
}

지금까지의 컴포넌트

참고

Build a Single Page Application with JavaScript (No Frameworks)
Adding Client Side URL Params - Build a Single Page Application with JavaScript (No Frameworks)

프로그래머스 쇼핑몰 SPA - 커피주문페이지 만들기 공부 겸 해설 1. 상품 목록 만들기

profile
https://medium.com/@wooleejaan

0개의 댓글