데브코스

[Day 13] fetch API

라다디 2022. 11. 5. 21:34

fetch api

  • 비동기 http 요청을 좀 더 쓰기 편하게 해주는 API
  • XMLHttpRequest를 대체
  • Promise 기반으로 동작

fetch api 사용하기

fetch("http request를 날릴 URL")
  .then((res) => {
    return res.json(); // response는 json이나 text로 바꿔서 사용해야 함
  })
  .then((data) => { // 조회된 json 데이터가 들어옴
    console.log(data); 
  });

 

fetch api 특징

1. fetch의 기본 응답 결과는 Response 객체이다.

따라서 Response 객체를 얻은 뒤에는 응답을 json으로 바꾸거나 text로 바꾸는 등의 처리가 필요하다.

 

+ 참고로 Response에는 json, text 외에도 많은 method가 있다. 

ex) blob 메소드는 이미지를 처리하는데 사용할 수 있다. 

 

2. fetch는 HTTP error가 발생하더라도 reject되지 않는다

네트워크 에러나 요청이 완료되지 못한 경우에만 reject된다. 

그러므로 서버 요청 중에 에러가 생겨도 then으로 가므로, response의 status codeok를 체크해주는 것이 좋다. 

fetch("https://.../undefined-api") // 존재하지 않는 api
  .then((res) => {
    if (res.ok) { // res.ok 체크
      return res.json();
    }
    throw new Error(`${res.status}! 요청 처리 실패`);
  })
  .then((data) => {
    console.log(data);
  })
  .catch((e) => {
    console.log(e.message);
  });

이렇게 해야 api 요청이 실패했을 때의 처리를 할 수 있다. 

주의할 점은 res.okstatus가 200~299사이인 경우에만 true가 된다. 

 

3. fetch의 두 번째 인자로 옵션을 줄 수 있다. 

const headers = new Headers();
headers.append("x-auth-token", "TOKEN");

fetch("https://.../product", {
  method: "POST",
  headers,
  body: JSON.stringify(product),
});

 

실습

← 폴더 구조

 

컴포넌트 구조

App - ProductPage - ProductOption

App - ProductPage - Cart

 

index.html

<!DOCTYPE html>
<html lang="en">
  <head>
    <title>fetch api 연습</title>
  </head>
  <body>
    <main id="app"></main>
    <script src="./src/main.js" type="module"></script>
  </body>
</html>

api.js

const API_END_POINT = "...";

export const request = (url) => {
  return fetch(`${API_END_POINT}${url.indexOf("/") === 0 ? url : `/${url}`}`)
    .then((res) => {
      if (res.ok) {
        return res.json();
      }
      throw new Error(`${res.status} Error`);
    })
    .catch((e) => alert(e.message));
};

main.js

import ProductPage from "./ProductPage.js";

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

new ProductPage({
  $target,
  initialState: {
    productId: 1,
  },
});

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);

  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();

  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);
}

ProductOption.js

export default function ProductOptions({ $target, initialState, onSelect }) {
  const $select = document.createElement("select");
  $target.appendChild($select);

  /**
   * 상품 옵션 이름 렌더링시 상품명 + 옵션명 + 재고: n개 이런 형식으로 보여줘야 함
   * 재고가 0인 상품의 경우 옵션을 선택하지 못하게 함
   * [
   *  {
   *    optionId: 1,
   *    optionName: '옵션 상품',
   *    optionPrice: 1000,
   *    stock: 10
   *  },
   *  ...
   * ]
   */
  this.state = initialState;

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

  const createOptionFullName = ({ optionName, optionPrice, stock }) => {
    return `${optionName} 
    ${optionPrice > 0 ? `(옵션가 ${optionPrice})` : ""} | 
    ${stock > 0 ? `재고 ${stock}` : "재고 없음"}`;
  };

  $select.addEventListener("change", (e) => {
    const optionId = parseInt(e.target.value);
    const option = this.state.find((option) => option.optionId === optionId);

    if (option) {
      onSelect(option);
    }
  });

  this.render = () => {
    if (this.state && Array.isArray(this.state)) {
      $select.innerHTML = `
        <option>선택하세요</option>
        ${this.state
          .map(
            (option) =>
              `<option ${option.stock === 0 ? "disabled" : ""} 
              value="${option.optionId}">
              ${createOptionFullName(option)}</option>`
          )
          .join("")}
      `;
    }
  };

  this.render();
}

Cart.js

/** state 구조
 * {
 *    productName: 상품명,
 *    basePrice: 상품 기본 가격,
 *    selectedOptions: [Option]
 * }
 */
export default function Cart({ $target, initialState, onRemove }) {
  const $cart = document.createElement("div");
  $target.appendChild($cart);

  this.state = initialState;

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

  const calculateTotalPrice = () => {
    const { basePrice, selectedOptions } = this.state;
    return selectedOptions.reduce(
      (acc, option) => acc + (basePrice + option.optionPrice) * option.ea,
      0
    );
  };

  const renderOption = (option, index) => {
    const { productName, basePrice } = this.state;

    return `<li data-index="${index}" class="cartItem">
    ${productName} - ${option.optionName} | ${basePrice + option.optionPrice}, 
    ${option.ea}개 <button class="remove">X</button></li>`;
  };

  this.render = () => {
    const { selectedOptions } = this.state;

    $cart.innerHTML = `
      <ul>
        ${
          Array.isArray(selectedOptions) &&
          selectedOptions
            .map((option, index) => renderOption(option, index))
            .join("")
        }
      </ul>
      <div>${calculateTotalPrice()} 원</div>
    `;

    $cart.querySelectorAll(".remove").forEach(($button) => {
      $button.addEventListener("click", (e) => {
        const $li = e.target.closest(".cartItem");

        if ($li) {
          const { index } = $li.dataset;
          onRemove(parseInt(index));
        }
      });
    });
  };

  this.render();
}

 


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

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