데브코스

[Day 14] history API

라다디 2022. 11. 6. 20:30

history api

브라우저에서 페이지 로딩을 하면 세션 히스토리를 가진다.

세션 히스토리는 페이지를 이동할 때마다 쌓이며, 이를 통해 앞으로 가기나 뒤로 가기가 가능하다.

pushState, replaceState 함수로 '화면 이동 없이' 현재 url를 업데이트할 수 있다.

  • pushState: 세션 히스토리에 새 url 상태를 쌓는다.
  • replaceState: 세션 히스토리에 새 url 상태를 쌓지 않고, 현재 url을 대체한다.

SPA(Single Page Application) 구현 가능

 

history API를 사용하면 hashbang으로 했던 url을 바꿔줄 수 있다.

  • / → HomePage
  • /list → ListPage
  • /detail/1 → DetailPage

일반 url 형식을 따르기 때문에 queryString도 자유롭게 붙일 수 있다.

  • list?page=2&limit=10

 

🎯 hashbang?

 

[Javascript] 해시뱅 (HashBang)

해시뱅?얼마 전 신기한 URL을 봤다. 123www.test.com/#/www.test.com/#/settingswww.test.com/#/register 이런 식으로 URL에 #이 붙는 것이다. 처음에는 뭔지 몰라 그냥 지나갔는데 오늘 이것의 정체가 해시뱅이라는

seonghui.github.io

해시뱅을 사용하는 이유는 단일 페이지 애플리케이션(SPA) 구현을 위해서이다.

해시뱅을 사용하면 페이지 갱신 없이 URL을 변경할 수 있다.

 

history.pushState

history.pushState(state, title, url)

  • state: history.state에서 꺼내쓸 수 있는 값
  • title: 변경될 페이지의 title을 가리키는 값 같지만, 대부분의 브라우저에서 지원하지 않으므로 빈 string을 넣으면 된다.
  • url : 세션 히스토리에 새로 넣을 url이다. 해당 url이 변경된다고 해서 화면이 리로드 되거나 하지 않는다.

 

history.replaceState

history.replaceState(state, title, url)

  • state : history.state에서 꺼내쓸 수 있는 값
  • title:변경될 페이지의 title을 가리키는 값 같지만, 대부분의 브라우저에서 지원하지 않으므로 빈 string을 넣으면 된다.
  • url : 세션 히스토리에서 현재 url과 대체할 url이다. 해당 url이 변경된다고 해서 화면이 리로드 되거나 하지 않는다.

replaceState 같은 경우는 뒤로 가기가 불가능한 페이지에서 사용한다.

ex) 글을 작성하고 뒤로 가기를 눌렀을 때 다시 작성 페이지로 가지 않도록 할 때

 

 

🛑 주의

history API로 url을 변경한 후 새로고침을 하면 변경된 url의 실제 파일을 찾으려고 하기 때문에 404 에러가 발생한다. 

따라서 404 에러가 났을 경우 root의 index.html로 요청을 돌려주는 처리가 필요하다. 

 

npx serve -s
s를 붙이면 404가 났을 때 무조건 index.html로 돌려준다.

 

예제 코드

<body>
  <div class="container"></div>
  <a class="LinkItem" href="/product-list">product list</a>
  <a class="LinkItem" href="/article-list">article list</a>
  <script>
    window.addEventListener("click", (e) => {
      function route() {
        const { pathname } = location;
        const container = document.querySelector(".container");
        if (pathname === "/") container.innerHTML = `<h1>Home</h1>`;
        if (pathname === "/product-list")
          container.innerHTML = `<h1>상품목록</h1>`;
        if (pathname === "/article-list")
          container.innerHTML = `<h1>게시글목록</h1>`;
      }
      route();
      
      if (e.target.className === "LinkItem") {
        e.preventDefault();

        const { href } = e.target;
        const path = href.replace(window.location.origin, "");

        history.pushState(null, null, path);
        route();
      }

      // popstate는 앞으로 가기와 뒤로 가기에만 발생하는 이벤트
      window.addEventListener("popstate", () => route());
    });
  </script>
</body>

SPA를 만들 때 중요한 점 3가지

  1. 현재 location의 pathname으로 어떤 것을 그릴지 결정하는 라우팅 로직 필요
  2. pushState를 호출시킬 이벤트 처리
  3. 앞으로 가기, 뒤로 가기 할 때 popstate 이벤트를 핸들링하여 route 함수 호출

 

url routing 처리하기

  • url path별 화면을 각 페이지 컴포넌트로 정의
  • route 함수
    • path에 따라 페이지 컴포넌트 렌더링
    • location.pathname으로 현재 path 얻어오기
  • url이 변경되는 경우, route 함수 호출

 

이전 포스팅의 실습에 history api를 적용해보자.

← 폴더 구조

 

[ 컴포넌트 구조 ]

App - HomePage

App - ProductPage - ProductOptions

App - ProductPage - Cart

 

 

SPA 주의점

index.html에서 js를 부를 때 경로를 상대 경로로 하게 되면 해당 상대 경로를 기준으로 다른 path가 불러와지므로 절대 경로로 작성해야 한다. 

+ js 파일 import 하는 부분은 상대 경로로 작성해도 괜찮다.

 

index.html

<script src="/src/main.js" type="module"></script> // 절대 경로로 변경

main.js

import App from "./App.js";

const $target = document.querySelector("#app");

new App({ $target });

App.js

import Homepage from "./pages/HomePage.js";
import ProductPage from "./pages/ProductPage.js";

export default function App({ $target }) {
  const homePage = new Homepage({ $target });
  const productPage = new ProductPage({ $target, initialState: {} });

  this.route = () => {
    // pathname에 따라 Page Component 렌더링 처리
    const { pathname } = location;

    $target.innerHTML = "";

    if (pathname === "/") {
      // HomePage 그리기
      homePage.render();
    } else if (pathname.indexOf("/products/") > -1) {
      // ProductPage 그리기
      // url에서 productId 뽑기
      const [, , productId] = pathname.split("/");

      productPage.setState({
        productId,
      });
    } else {
      // 404 처리
      $target.innerHTML = "<h1>404 Not Found</h1>";
    }
  };

  this.init = () => {
    this.route();
  };

  window.addEventListener("click", (e) => {
    if (e.target.className === "link") {
      e.preventDefault();
      const { href } = e.target;
      history.pushState(null, null, href.replace(location.origin, ""));
      this.route();
    }
  });

  window.addEventListener("popstate", () => this.route());

  this.init();
}

HomePage.js

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

export default function HomePage({ $target }) {
  const $home = document.createElement("div");

  this.render = () => {
    request("/products").then((products) => {
      $home.innerHTML = `
        <h1>Home Page</h1>
        <ul>
          ${products.map((product) => 
            `<li>
              <a class='link' href="/products/${product.id}">
                ${product.name}
              </a>
            </li>`
            ).join("")}
        </ul>
      `;

      $target.appendChild($home);
    });
  };
}

ProductPage.js

import ProductOptions from "../ProductOptions.js"; // 경로 수정
import Cart from "../Cart.js"; // 경로 수정
import { request } from "../api.js"; // 경로 수정

/** state 구조
 * {
 *  productId:1,
 *  product: Product,
 *  optionData: [],
 *  selectedOption: []
 * }
 */
export default function ProductPage({ $target, initialState }) {
  const $product = document.createElement("div");
  
  // $target.appendChild($product); 코드 render 함수 안으로 이동

  this.state = initialState;

  const productOptions = new ProductOptions({
    $target: $product,
    initialState: [],
    onSelect: (option) => {
      const nextState = { ...this.state };
      const { selectedOptions } = this.state;

      const selectedOptionIndex = selectedOptions.findIndex(
        (selectedOption) => selectedOption.optionId === option.optionId
      );

      if (selectedOptionIndex > -1) {
        nextState.selectedOptions[selectedOptionIndex].ea++;
      } else {
        nextState.selectedOptions.push({
          optionId: option.optionId,
          optionName: option.optionName,
          optionPrice: option.optionPrice,
          ea: 1,
        });
      }

      this.setState(nextState);
    },
  });

  const cart = new Cart({
    $target: $product,
    initialState: {
      productName: "",
      basePrice: 0,
      selectedOptions: [],
    },
    onRemove: (selectedOptionIndex) => {
      const nextState = { ...this.state };
      nextState.selectedOptions.splice(selectedOptionIndex, 1);

      this.setState(nextState);
    },
  });

  this.setState = (nextState) => {
    if (nextState.productId !== this.state.productId) {
      fetchOptionData(nextState.productId);
      return;
    }

    this.state = nextState;

    const { product, selectedOptions, optionData } = this.state;
    productOptions.setState(optionData);
    cart.setState({
      productName: product.name,
      basePrice: product.basePrice,
      selectedOptions: selectedOptions,
    });

    this.render(); // 이동
  };

  this.render = () => {
    $target.appendChild($product); // 이동
  };
  // this.render(); 코드 setState 안으로 이동

  const fetchOptionData = (productId) => {
    return request(`/products/${productId}`)
      .then((product) => {
        this.setState({
          ...this.state,
          product,
          optionData: [],
          selectedOptions: [],
        });
        return request(`product-options?product.id=${product.id}`);
      })
      .then((productOptions) => {
        return Promise.all([
          Promise.resolve(productOptions),
          Promise.all(
            productOptions
              .map((productOption) => productOption.id)
              .map((id) => {
                return request(`/product-option-stocks?productOption.id=${id}`);
              })
          ),
        ]);
      })
      .then((data) => {
        const [productOptions, stocks] = data;
        const optionData = productOptions.map((productOption, i) => {
          const stock = stocks[i][0].stock;

          return {
            optionId: productOption.id,
            optionName: productOption.optionName,
            optionPrice: productOption.optionPrice,
            stock,
          };
        });

        this.setState({
          ...this.state,
          optionData,
        });
      });
  };
  
  // fetchOptionData(this.state.productId); 코드 제거
}

 

정리

history api를 이용하면 화면 이동을 일으키지 않고도 브라우저의 url을 변경 가능하다.

history api로 url을 변경한 후 새로고침하면, 변경된 url의 실제 파일을 찾으려고 하기 때문에 404 에러 발생하므로 root의 index.html로 요청을 돌려주는 처리가 필요하다. 

 


출처: 프로그래머스 프론트엔드 데브코스 

[Day 14] VanillaJS를 통한 자바스크립트 기본 역량 강화 I (6)