이전 포스팅에서 목록을 만들었다면 이젠 디테일 페이지로 이동해야한다
참조 👉 문제풀이1. 제품 목록 만들기
내가 풀 때 생각했던 방법은 바로 a 태그를 사용하는 것
그러나 생각처럼 잘 실행되지 않았다. 바로 아래와 같은 이유 때문!
이렇게 하면 페이지 이동 시마다 페이지를 매번 새로 불러오게 됩니다. SPA라는 말을 잘 생각해보면, 이런 식으로 매번 페이지를 새로 불러오는 방식이 아니라 클라이언트에서 페이지가 변경되는 부분만 새로 그리도록 처리를 해야 하는 것을 떠올릴 수 있을 것입니다.
ㅎㅎ역시나 이걸 생각 못했다
그래서 풀이에서 제안한 방법은 HTML History API를 사용하는 것이다.
history.pushState를 이용하면 URL만 업데이트하면서 웹 브라우저의 기본적인 페이지 이동은 처리되지 않는다고 한다
DOM의 Window 객체는 history 객체를 통해 브라우저의 세션 기록에 접근할 수 있는 방법을 제공합니다. history는 사용자를 자신의 방문 기록 앞과 뒤로 보내고 기록 스택의 콘텐츠도 조작할 수 있는, 유용한 메서드와 속성을 가집니다.
1. 이동할 페이지 url을 history.pushState를 통해 변경하기
2. App.js의 this.route 함수 실행하기
History.pushState()
=> 화면 전환! 페이지 이동 없이 주소만 바꿔줌
: HTML 문서에서, history.pushState() 메서드는 브라우저의 세션 기록 스택에 상태를 추가합니다.
구문
: history.pushState(state, title[, url]);
- State : 브라우저 이동 시 넘겨줄 데이터
- Title : 변경할 브라우저 제목
- Url : 변경할 주소
먼저 URL이 변경되는 것을 감지하기 위해 커스텀 이벤트를 사용해보자
// 커스텀 이벤트를 통해 처리하기
const ROUTER_CHANGE_EVENT = "ROUTER_CHANGE";
export const init = (onRouteChange) => {
// 커스텀 이벤트를 통해 ROUTER_CHANGE 이벤트 발생 시
window.addEventListener(ROUTER_CHANGE_EVENT, () => {
// onRouteChange 콜백 함수를 호출하도록 이벤트 바인딩
onRouteChange();
});
};
// URL을 업데이트하고 커스텀 이벤트를 발생시키는 함수
export const routeChange = (url, params) => {
// history.pushState(state: 브라우저 이동 시 넘겨줄 데이터, title: 변경할 브라우저 제목, url: 변경할 주소)
history.pushState(null, null, url);
window.dispatchEvent(new CustomEvent(ROUTER_CHANGE_EVENT, params));
};
dispatcgEvent 참조 자료
다음으로 App.js 코드를 수정한다
// App.js
import CartPage from "./pages/CartPage.js";
import DetailPage from "./pages/DetailPage.js";
import ListPage from "./pages/ListPage.js";
import { init } from "./router.js";
function App({ $app }) {
this.route = () => {
const { pathname } = location;
$app.innerHTML = "";
if (pathname === "/coffee/index.html") {
new ListPage({ $app }).render();
} else if (pathname.includes("/products/")) {
const [, , productId] = pathname.split("/");
new DetailPage({ $app, productId }).render();
} else if (pathname === "/coffee/cart") {
new CartPage({ $app }).render();
}
};
// 코드가 추가된 부분:
// ROUTE_CHANGE 이벤트 발생 시 App의 this.route함수가 호출되게 하는 효과
init(this.route);
// const init = (onRouteChange) => {
// window.addEventListener(ROUTER_CHANGE_EVENT, () => {
// onRouteChange();
// });
// };
this.route();
}
export default App;
마지막으로 ProductList를 변경해보자
import { routeChange } from "../router.js";
function ProductList({ $target, initialState }) {
const $productList = document.createElement("ul");
$target.appendChild($productList);
this.state = initialState;
this.render = () => {
if (!this.state) {
return;
}
// data-product-id: product-id -> custom attribute
const list = this.state
.map(
(item) => `<li class="Product" data-product-id="${item.id}">
<img src="${item.imageUrl}">
<div class="Product__info">
<div>${item.name}</div>
<div>${item.price}~</div>
</div>
</li>`
)
.join(``);
$productList.innerHTML = list;
};
this.render();
$productList.addEventListener("click", (event) => {
const $li = event.target.closest("li");
const { productId } = $li.dataset;
if (productId) {
routeChange(`/products/${productId}`);
// const routeChange = (url, params) => {
// history.pushState(null, null, url);
// window.dispatchEvent(new CustomEvent(ROUTER_CHANGE_EVENT, params));
// };
// => ROUTE_CHANGE 이벤트 발생 시 App의 this.route함수가 호출
}
});
}
export default ProductList;
dataset
: data-속성명="속성값", HTML5 표준 속성처럼 접근할 수 있음
HTML에 추가의 속성이나 데이터를 표기하는 표준이 없어 비표준적인 방법으로 데이터를 표기하던 라이브러리들이 표준적인 방법을 사용할 수 있도록 개선되었으며, 자바스크립트 또한 표준화된 DOM 메서드로 데이터셋 속성에 접근할 수 있습니다.
이제 디테일 페이지로 이동도 완성이다!
그러나 이렇게 구현하면 상세페이지에서 뒤로 가기를 했을 때 렌더링이 안되는 것을 확인할 수 있을 것이다.
(뒤로가기로 url은 바뀌었지만 화면 렌더링에 문제 발생)
이 경우엔 popstate 이벤트를 통해 처리할 수 있다고 한다.
popstate-mdn
: The popstate event of the Window interface is fired when the active history entry changes while the user navigates the session history. -> 현재 활성화된 기록 항목이 바뀔 때 발생
It changes the current history entry to that of the last page the user visited or, if history.pushState() has been used to add a history entry to the history stack, that history entry is used instead. -> 만약 활성화된 엔트리가 history.pushState() 메서드나 history.replaceState() 메서드에 의해 생성되면, popstate 이벤트의 state 속성은 히스토리 엔트리 state 객체의 복사본을 갖게 됨
이 이벤트를 통해 뒤로 가기나 앞으로 가기 등으로 통해 브라우저 url이 변경된 경우를 감지할 수 있다
import CartPage from "./pages/CartPage.js";
import DetailPage from "./pages/DetailPage.js";
import ListPage from "./pages/ListPage.js";
import { init } from "./router.js";
function App({ $app }) {
this.route = () => {
const { pathname } = location;
$app.innerHTML = "";
if (pathname === "/coffee/index.html") {
new ListPage({ $app }).render();
} else if (pathname.includes("/products/")) {
const [, , productId] = pathname.split("/");
new DetailPage({ $app, productId }).render();
} else if (pathname === "/coffee/cart") {
console.log(pathname);
new CartPage({ $app }).render();
}
};
init(this.route);
this.route();
// 뒤로가기, 앞으로 가기 발생 시 popstate 이벤트 발생
window.addEventListener("popstate", this.route);
}
export default App;
불러오기는 앞에서 이미 구현한 목록 불러오기와 유사하다
api에 id에 맞는 product 정보를 불러오기 추가
...
export const getIdProduct = async (id) => await request(`/products/${id}`);
DetailPage에서 불러오기 기능 추가
import { getIdProduct } from "../apis/api.js";
import ProductDetail from "../components/ProductDetail.js";
function DetailPage({ $app, 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) {
$app.innerHTML = "Loading";
} else {
$app.innerHTML = "";
$app.appendChild($page);
new ProductDetail({ $target: $page, initialState: this.state });
}
};
this.fetchProduct = async () => {
const { productId } = this.state;
const product = await getIdProduct(productId);
this.setState({
...this.state,
product,
});
};
this.fetchProduct();
}
export default DetailPage;
ProductDetail 컴포넌트 구현
function ProductDetail({ $target, initialState }) {
const $product = document.createElement("div");
$product.className = "ProductDetail";
$target.appendChild($product);
this.state = initialState;
this.render = () => {
const { product } = this.state;
const detail = `<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(
(opt) =>
`<option value="${opt.id}" ${opt.stock === 0 ? "disabled" : ""}>${opt.stock === 0 ? "(품절)" : ""} ${
opt.name
} ${opt.price > 0 ? `(+ ${opt.price}원 )` : ""}</option>`
)
.join()}
</select>
<div class="ProductDetail__selectedOptions">
</div>
</div>`;
$product.innerHTML = detail;
};
this.render();
}
export default ProductDetail;
여기선 option 불러오는 부분 빼곤 무난하게 넘어갈 수 있었다
먼저 선택 된 상품들을 담아 둘 selectedOptions를 DetailPage에 추가한다
// import 생략
function DetailPage({ $app, productId }) {
// ... 코드 생략
this.render = () => {
if (!this.state.product) {
$app.innerHTML = "Loading";
} else {
$app.innerHTML = "";
$app.appendChild($page);
new ProductDetail({
$target: $page,
initialState: {
product: this.state.product,
// 이 부분 수정! ProductDetail의 initialState에 선택된 상품들을 담아둘 곳
selectedOptions: [],
},
});
}
};
this.fetchProduct = async () => {
const { productId } = this.state;
const product = await getIdProduct(productId);
this.setState({
...this.state,
product,
});
};
this.fetchProduct();
}
export default DetailPage;
이제 ProductDetail에서 상품이 선택되었을 때 옵션 중복을 막는 처리를 해준다
import SelectedOptions from "./SelectedOptions.js";
function ProductDetail({ $target, initialState }) {
const $product = document.createElement("div");
$product.className = "ProductDetail";
$target.appendChild($product);
// ... 생략
this.render();
// change 이벤트가 발생했을 때
$product.addEventListener("change", (event) => {
if (event.target.tagName === "SELECT") {
// option value에 optionId를 담음, 이를 가져와 Int로 변경
const selectedOptionId = parseInt(event.target.value);
const { product, selectedOptions } = this.state;
// 상품 옵션 데이터에서 현재 선택한 optionId가 존재하는지 찾기
const option = product.productOptions.find((option) => option.id === selectedOptionId);
// 이미 선택한 상품인지 선택된 상품 데이터에서 선택한 optionId 찾기
const seletedOption = selectedOptions.find((selectedOption) => selectedOption.id === selectedOptionId);
// option이고 이미 선택한 옵션이 아닌 경우 옵션을 추가함
if (option && !seletedOption) {
const nextSeletedOptions = [
...selectedOptions,
{
productId: product.id,
optionId: option.id,
optionName: option.name,
optionPrice: option.price,
quantity: 1,
},
];
this.setState({
...this.state,
selectedOptions: nextSeletedOptions,
});
}
}
});
}
export default ProductDetail;
다음에는 ProductDetail에서 selectedOptions를 그려주기 위한 컴포넌트 SelectedOptions를 만들어보자
원본 html
<h3>선택된 상품</h3> <ul> <li> 커피잔 100개 번들 10,000원 <div><input type="number" value="10" />개</div> </li> <li> 커피잔 1000개 번들 15,000원 <div><input type="number" value="5" />개</div> </li> </ul> <div class="ProductDetail__totalPrice">175,000원</div> <button class="OrderButton">주문하기</button> </div>
function SelectedOptions({ $target, initialState }) {
const $component = document.createElement("div");
$target.appendChild($component);
// initialState: { product: this.state.product, selectedOptions: this.state.selectedOptions }
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) {
const selected = `
<h3>선택된 상품</h3>
<ul>
${selectedOptions
.map(
(option) => `
<li>
${option.optionName} ${product.price + option.optionPrice}원
<div><input type="text" data-optionId="${option.optionId}" value="${option.quantity}"/>개</div>
</li>`
)
.join("")}
</ul>
<div class="ProductDetail__totalPrice">${this.getTotalPrice()}원</div>
<button class="OrderButton">주문하기</button>`;
$component.innerHTML = selected;
}
};
this.render();
}
export default SelectedOptions;
마지막으로 ProductDetail에서 SelectedOption 컴포넌트를 생성해 사용한다
import SelectedOptions from "./SelectedOptions.js";
function ProductDetail({ $target, initialState }) {
// ... 이전 코드 생략
this.state = initialState;
// fetchProduct 이후 화면이 렌더링 되었을 때 동작할 수 있도록 let으로 생성
let selectedOptions = null;
this.setState = (nextState) => {
this.state = nextState;
this.render();
// selectedOptions에 값이 있으면 값 업데이트, 재렌더링
if (selectedOptions) {
selectedOptions.setState({
product: this.state.product,
selectedOptions: this.state.selectedOptions,
});
}
};
this.render = () => {
const { product } = this.state;
const detail = `<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(
(opt) =>
`<option value="${opt.id}" ${opt.stock === 0 ? "disabled" : ""}>${opt.stock === 0 ? "(품절)" : ""} ${
opt.name
} ${opt.price > 0 ? `(+ ${opt.price}원 )` : ""}</option>`
)
.join()}
</select>
<div class="ProductDetail__selectedOptions">
</div>
</div>`;
$product.innerHTML = detail;
selectedOptions = new SelectedOptions({
$target: $product.querySelector(".ProductDetail__selectedOptions"),
initialState: { product: this.state.product, selectedOptions: this.state.selectedOptions },
});
};
this.render();
// ... 이후 코드 생략
}
export default ProductDetail;
옵션 수량은 SelectedOptions의 input값을 통해 변경한다
function SelectedOptions({ $target, initialState }) {
// ... 이전 코드 생략
this.render();
// change이벤트가 발생했을 때 tagname이 input이면
$component.addEventListener("change", (event) => {
if (event.target.tagName === "INPUT") {
try {
// 새로운 수량
const nextQuantity = parseInt(event.target.value);
const nextSelectedOptions = [...this.state.selectedOptions];
// input 값이 숫자인 경우만 처리
if (typeof nextQuantity === "number") {
const { product } = this.state;
const optionId = parseInt(event.target.dataset.optionid);
const option = product.productOptions.find((opt) => opt.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 (err) {
console.log(err);
}
}
});
}
export default SelectedOptions;
이렇게 수량이 바뀌면서 총 가격도 바뀌는걸 확인할 수 있다
이제 정말 다왔다!
주문할 상품을 localStorage에 저장해야하기 떄문에 localStorage를 다루기 위한 유틸리티 함수를 만들어야 한다
export const storage = localStorage;
export const getItem = (key, defaultValue) => {
try {
const value = storage.getItem(key);
// key에 해당하는 값이 있다면 파싱, 없으면 디폴트값을 리턴한다
return value ? JSON.parse(value) : defaultValue;
} catch {
// 에러가 생길시에도 디폴트값 리턴!
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
}
};
이제 주문하기 버튼이 있는 SelectedOptions에서 사용해주면 된다
import { routeChange } from "../router.js";
import { getItem, setItem } from "../storage.js";
function SelectedOptions({ $target, initialState }) {
// ... 이전 코드 생략
$component.addEventListener("click", (event) => {
const { selectedOptions } = this.state;
if (event.target.className === "OrderButton") {
// 주문하기 버튼을 누르면
// 먼저 기존에 담겨진 장바구니 데이터가 있을 수 있으므로 가져와본다 -> 없으면 빈배열
const cartData = getItem("products_cart", []);
// 장바구니 데이터 만들기
setItem(
"products_cart",
cartData.concat(
selectedOptions.map((opt) => ({
productId: opt.productId,
optionId: opt.optionId,
quantity: opt.quantity,
}))
)
);
routeChange("/coffee/cart");
}
});
}
export default SelectedOptions;
그럼 로컬스토리지에 저장되고 카트로 이동한 것을 확인할 수 있다!
https://github.com/ChangHyun2/programmers-vanillaJS-SPA
https://prgms.tistory.com/113