링크: https://programmers.co.kr/skill_check_assignments/199
개요
Vanila JS를 사용해서 쇼핑몰 사이트를 구현했다.
- 상품 목록페이지에서 상품 목록을 조회할 수 있고, 상품을 클릭 시 해당 상품의 상세 페이지로 이동한다.
- 상품 상세페이지에서 상품의 상세 정보를 조회할 수 있고, 상품의 옵션을 선택한 후 주문하기를 클릭하여 장바구니에 담을 수 있다.
- 장바구니 페이지에서 장바구니 에 담은 상품들을 조회할 수 있고, 주문하기 버튼을 클릭하여 해당 상품들을 주문할 수 있다.
라우팅 기본 - 주소에 따라 다른 화면 보이기
먼저, 주소에 따른
앱 내부를 해당 페이지로 변경하는 render 함수를 만들었다.
그 다음에 location의 pathname을 활용해서 주소가 변경되면 해당 주소에 맞는 페이지가 화면에 보이도록 했다.
이렇게 해서 기본적으로 주소에 따라 다른 화면을 보이게 처리했다.
const App = document.querySelector(".App")
function render(page, target) {
target.innerHTML = ''
target.appendChild(page)
}
function route() {
const { pathname } = location
if(pathname === "/web/") {
getProductList().then((products) => {
render(ProductListPage({products}),App);
})
} else if(pathname === "/web/cart") {
// 장바구니에 없으면 목록으로 이동
if(!localStorage.getItem("products_cart")) {
alert("장바구니가 비어 있습니다")
history.pushState(null,null,"/web/")
route()
} else {
getCartList().then((product_details) => {
render(CartPage({product_details}),App);
})
}
} else if(pathname.indexOf("/products/") !== -1) {
const productId = pathname.split("/")[3];
getProductDetail(productId).then((product) => {
render(ProductDetailPage({product}),App);
})
}
}
상품 목록 화면
먼저 상품 목록을 받아오는 getProductList 함수를 fetch를 활용해서 만들었다.
async function getProductList() {
try {
const response = await fetch(BASE_URL);
if(response.ok) {
const json = await response.json();
return json;
}
throw Error("통신 실패")
} catch(e) {
console.error(e.message);
}
}
try catch를 활용해 통신 실패시에 콘솔에서 에러를 확인할 수 있게 했다.
비동기 작업인 fetch를 동기적으로 처리하기 위해 async await을 활용했다.
그 다음 해당 함수가 return json을 하지만 이 json은 async함수의 특성 상 프로미스로 감싸져서 리턴된다.
이 프로미스의 then 메소드를 활용해 콜백으로 해당 상품목록을 ProductListPage에 전달해서 화면을 그렸다.
getProductList().then((products) => {
render(ProductListPage({products}),App);
})
ProductListPage에서는 DOM element를 생성하고 상품목록들은 배열의 map 메소드를 사용하여 DOM element를 생성해서 넣었다.
productList.innerHTML = `${products.map((product)=>
`<li class="Product" data-product-id="${product.id}">
<img src=${product.imageUrl}>
<div class="Product__info">
<div>${product.name}</div>
<div>${priceWithComma(product.price)}원~</div>
</div>
</li>`
).join(' ')}`
벡틱을 활용해서 Javascript 변수가 들어간 DOM element를 생성했다.
이렇게 해서 기본적인 목록 화면을 만들었다.
상품 상세 화면
목록에 품목을 클릭하면 상품 상세 화면으로 이동한다. 이동하는 부분은 추후에 따로 모아서 얘기하고,
일단 상품의 ID를 받으면 걔를 활용해서 정보를 받고, 해당 정보를 ProductDetailPage에 전달하여 화면을 다시 그리게 했다.
상품 정보를 보여주는 부분은 상품 목록을 그릴 때 했던 것과 같다.
상품을 선택하는 부분은 window의 event를 활용해서 구현했다.
상세정보에서 옵션들을 select를 활용해서 보여줬다.
<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>
`)}
</select>
<select>
는 선택한 값이 변하면 change
이벤트를 발생시킨다.
그래서 addEventListener를 활용해서 값이 변할 시 함수에 선언한 변수 selectedOptions
에 {optionId, count:1}
이렇게 옵션 id와 개수를 1로 지정해서 담아줬다.
그 다음 DOM을 제작해서 해당 옵션을 주문 목록에 추가해줬다.
똑같은 옵션이 여러번 추가되는 것을 막기 위해서 이벤트 발생시 얻은 optionId
가 selectedOptions
안에 있는지 검사하고 없는 경우에만 목록에 추가하게 했다.
productDetail.addEventListener("change", (event) => {
if(event.target.closest("select") !== null) {
const optionId = parseInt(event.target.value)
if(!selectedOptions.find(option => option.optionId === optionId)) {
selectedOptions.push({optionId, count:1})
console.log(selectedOptions)
// console.log(selectedOptions)
// DOM에 직접 추가
const domSelectedOptions = document.querySelector(".ProductDetail__selectedOptions")
const ulDomSelectedOptions = domSelectedOptions.querySelector("ul")
const li = document.createElement("li")
const option = findOptionById(optionId, product)
li.innerHTML = `${product.name} ${option.name} ${option.price !== 0 ? `(+${option.price}원)` : ""} <div><input type="number" id=${option.id} value="0"></div>`
ulDomSelectedOptions.appendChild(li)
}
})
주문 목록의 항목들은 수량을 변경할 수 있는데, 위의 로직과 비슷하게 구현했다. 수량 입력칸에 숫자가 아닌 값이나 재고를 넘는 값을 입력하는 경우를 방지하기 위해서 해당 함수내에서 입력값을 검사해서 숫자가 아니면 입력되지 않게 하고, 재고보다 큰 값이면 재고로 입력값을 변경했다. 그 다음 DOM요소를 변경해줬다.
위 2가지 경우를 구현할 때 이벤트 위임을 활용했다. 요소 하나하나에 전부 eventlistener
를 추가하는 것이 아니라 상위 항목에 추가하고, target을 검사해서 조건이 맞을 때에만 작동하게 했다.
주문하기 클릭 시 localStorage에 주문목록을 저장했다. selectedOptions
를 활용했고, JSON
의 메소드들을 활용해서 json형태로 저장하고 불러왔다.
let products_cart = localStorage.getItem("products_cart")
if(products_cart === null) {
products_cart = selectedOptions.map((option) => {
return {
productId: product.id,
optionId: option.optionId,
quantity: option.count
}
})
// console.log(products_cart)
localStorage.setItem("products_cart", JSON.stringify(products_cart))
} else {
products_cart = JSON.parse(localStorage.getItem("products_cart")).concat(
selectedOptions.map((option) => {
return {
productId: product.id,
optionId: option.optionId,
quantity: option.count
}
}
))
localStorage.setItem("products_cart", JSON.stringify(products_cart))
}
장바구니 화면
장바구니 화면은 간단하다. localStorage에서 품목들을 가져와서 보여줬다. 주문하기 클릭시 localStorage의 값을 지웠다.
라우팅 완성
여기까지 구현했을 때 목록->상세,장바구니->목록과 같은 이동과 뒤로가기, 앞으로 가기 시 제대로 작동하지 않는 문제점이 있었다.
이를 해결하기 위해 history api와 맨 처음 만든 route
함수를 활용했다.
상품 클릭시 목록 -> 상세, 상세 에서 주문 시 상세 -> 목록, 장바구니에서 주문시 장바구니 -> 목록
이런 식으로 페이지를 이동하는 경우에 hisotry.pushState를 활용해서 url를 바꿔줬다.
그 다음에 route
함수를 호출해서 url에 맞게 화면을 다시 그렸다.
이렇게 해서 클릭시 이동등 페이지간 이동을 구현했다.
뒤로 가기, 앞으로 가기를 하면 popState 이벤트가 발생한다. 그래서 addEventListener
를 활용해서 해당 이벤트 발생 시에route
함수를 호출해서, url이 바뀜에 따라 화면을 다시 그리게 했다.
리뷰
모든 요구 사항들을 구현했다.
하지만 여러가지 문제점이 있다.
1) index.js
에서 라우팅을 담당하는 코드들이 있는데, 이름이랑 매치가 안된다. router.js를 하나 더 만들어서 해당 내용을 넣어야겠다. 그리고, 라우팅 하는 부분에서 통신하고, 렌더하는 부분이 둘 다 있는데, 보기 안 좋다. 라우팅은 페이지로 딱 보내는 내용만 있어야 하는데 그렇지 못했다.
if(pathname === "/web/") {
getProductList().then((products) => {
render(ProductListPage({products}),App);
})
}
2) index.js
에서 만든 route함수를 딴 데서 다 가져다 쓰고 딴 데서 만든 함수들을 index.js
에서 가져다 쓴다. 약간 흐름이 엉켜있어서 안 좋다. 얘도 약간 route.js
에서 라우터를 다 가져다 쓰는 식으로 분리를 해야한다.
3) utils.js
에서 getProductList
, getProductDetail
, getCartList
은 인자받는 것만 다르고 똑같은 내용인 함수이다. 내용이 중복된다. 하나로 줄여서 중복을 없애야 한다.
4) 페이지 이동시
history.pushState(null,null,url)
route()
이 2줄을 활용해서 이동하는데 얘를 함수로 만들어서 쓰면 가독성도 좋아지고 중복도 줄어들 것이다. 라우터에 추가해 놓으면 좋을듯
5) 전체적으로 너무 코드가 길고 가독성이 떨어진다. 적절히 변수를 선언하고 함수를 만들어서 가독성을 좋게 만들어야 한다.
총 상품가격 ${priceWithComma(JSON.parse(localStorage.getItem("products_cart")).reduce((acc,product) => {
return acc + ((getDetailById(product.productId).price + getDetailById(product.productId).productOptions.find(option => option.id===product.optionId).price)*product.quantity)
},0))}원
6) 상품 상세에서 제품 옵션 선택시 selectedOptions
에 넣는데, 얘를 활용해서 렌더링을 하면 좋겠다. 지금은 selectedOptions
는 중복되는지 검사하는 역할만 하고 DOM을 직접 만들어서 넣는데, 얘를 리액트 상태 변경에 따른 업데이트처럼 바꿔서 값이 바뀌면 다시 화면을 그리게 하면 중복이 줄고 가독성이 많이 올라갈 것이다.
7) 6)에서 말한 대로 변수가 변할 때마다 다시 화면을 그리게 할려면 지금의 함수에 정보를 인자로 주면 DOM을 리턴하는 방식에서 다른 방식으로 변경을 해야한다. new를 활용해서 객체를 만들고 얘한테 this.render를 만들어서 활용할 수 있게 한다던가 클래스를 쓴다던가 클로져를 활용해서 DOM 리턴대신에 {render: f() , getInfo: f()} 이렇게 리턴시켜서 활용할 수 있게 한다던가 여러 방법을 시도해봐야 겠다.
이 리뷰한 내용을 바탕으로 리팩토링을 진행할 예정이다.
'프론트엔드 > JavaScript, TypeScript' 카테고리의 다른 글
?. - Optional chaining 연산자 / ?? - Null 병합 연산자 (0) | 2022.08.22 |
---|---|
Promise 일정 시간 초과시 대기 취소하고 에러 처리하기 (1) | 2022.08.07 |
디바운스와 쓰로틀 (0) | 2022.07.24 |
Vanila Js로 사진첩 사이트 만들기 (0) | 2022.03.16 |
Vanila Js로 SPA 만들기 - 리팩토링 (0) | 2022.03.15 |