본 과제는 프로그래머스 2021년 Dev-Matching: 웹 프론트엔드 개발자 (상반기) 과제입니다.
과제 링크
과제 공식해설 링크
↑ 위 페이지에서 과제를 풀어보고 공식 해설로 공부하였습니다.
한번 풀어보기만 하는 것으로는 배우는 것이 적다고 생각하여 글로 정리하여 깊게 남겨보고자 합니다.
github 코드
코드를 완성한 후 과정에 맞게 수정하는 것이라 오류가 있을 수 있습니다.
댓글로 오류를 알려주시거나 새로운 지식을 공유해주신다면 감사하겠습니다.
하나의 스크립트에 모든 코드를 작성하면 코드를 유지보수하기 힘들어지기 때문에 코드를 작은 단위로 나누어 관리하는 것을 말한다.
ES6의 import, export를 사용하면
index.html 에서 모듈의존순서에 맞게 script 전부를 불러올 필요없이 index.js 만 불러오고
필요한 컴포넌트는 index.js에서 import 해서 불러올 수 있다.
<!--index.html-->
<html>
<head>
<title>고양이 사진첩!</title>
<link rel="stylesheet" href="./src/styles/style.css" />
</head>
<body>
<h1>고양이 사진첩</h1>
<main class="App">
</main>
<script src="src/index.js" type="module"></script> <!-- type = "module" -->
</body>
</html>
//index.js
import App from "./App.js";
new App(document.querySelector(".App"));
//api.js
const API_END_POINT = "...";
export const request = async (nodeId) => { // 다른 소스파일에서 사용할 함수는 export
try {
const res = await fetch(`${API_END_POINT}/${nodeId ? nodeId : ""}`);
if (!res.ok) throw new Error("서버의 상태가 이상합니다!");
return await res.json();
} catch (e) {
throw new Error(`무엇인가 잘못 되었습니다. ${e.message}`);
}
};
//App.js
import { request } from "./api.js"; // import 해와서 사용하기
import Breadcrumb from "./Breadcrumb.js";
import Nodes from "./Nodes.js";
import ImageView from "./ImageView.js";
export default function App($app) {
this.state = {
isRoot: false,
nodes: [],
depth: [],
selectedFilePath: null,
isLoading: false,
};
/* ... */
이외에도 Breadcrumb, ImageView, Nodes 등을 모듈화하고,
App.js 에서 사용할 컴포넌트들은 export, import 를 통해 사용한다.
로딩중에 아무런 표시가 되지 않으면 사용자는 로딩중인지, 문제가 발생한 것인지 알 수 없다.
로딩중에 다른 요소들을 클릭했을 때 반응해서는 안된다. 반응중에 또 다른 이벤트가 발생하는 것은 결과를 예측하기 어렵게 만든다.
따라서 (1) 로딩중임을 보여주면서 (2) 다른 요소를 클릭할 수 없게 만들어야한다.
문제에는 원래 어떻게 로딩중 요소를 보여주어야하는지 나와있다.
<div class="Loading Modal">
<div class="content">
<img src="./assets/nyan-cat.gif"/>
</div>
</div>
문제에서 주어진 클래스 Modal의 스타일은 다음과 같다
.Modal {
z-index: 1;
width: 100%;
height: 100%;
position: fixed;
left: 0;
top: 0;
background-color: rgba(0, 0, 0, 0.3);
}
Modal이 페이지 전체를 차지하기 때문에 Modal 의 display 속성이 block
이라면 Modal 이외 다른 요소를 클릭할 수 없을 것이다.
우선 아래와 같이 Loading 컴포넌트를 만든다.
//Loading.js
export default function Loading({ $app, initialState }) {
this.state = initialState;
this.$target = document.createElement("div");
this.$target.className = "Loading Modal";
$app.append(this.$target);
this.setState = (nextState) => {
this.state = nextState;
this.render();
};
this.render = () => {
this.$target.innerHTML = `<div class="content">
<img src="./assets/nyan-cat.gif" width="90%"/>
</div>`;
this.$target.style.display = this.state ? "block" : "none";
};
this.render();
}
Nodes에서 타입이 Directory인 노드를 선택한 경우,
api를 통해 데이터를 받아오는 동안 Loading 컴포넌트를 띄울 것이다.
기존의 request 함수 앞뒤로 Loading 을 실행하고 해제하는 코드를 넣어 래핑하고 loading_request 라고 이름을 지었다.
request는 오직 api를 통해 데이터를 받아오는 것이 관심사인 함수이기 때문에
로딩 실행 및 해제와는 코드를 분리하였다.
//api.js
const API_END_POINT = "...";
const request = async (nodeId) => {
try {
const res = await fetch(`${API_END_POINT}/${nodeId ? nodeId : ""}`);
if (!res.ok) throw new Error("서버의 상태가 이상합니다!");
return await res.json();
} catch (e) {
throw new Error(`무엇인가 잘못 되었습니다. ${e.message}`);
}
};
export const loading_request = async ({
nodeId,
setLoading,
finishLoading,
}) => {
try {
setLoading();
const nodes = await request(nodeId);
return nodes;
} catch (e) {
throw new Error(`무엇인가 잘못 되었습니다. ${e.message}`);
} finally {
finishLoading();
}
};
loading_request 함수가 Loading 컴포넌트에 직접 관여하면 의존성이 생기기 때문에 Loading에 대한 상태관리는 App.js의 state, isLoading 에 맡긴다.
또한 데이터를 받아와야하는 경우 두가지
(1) 어플리케이션을 처음 시작한 경우
(2) 클릭한 노드의 타입이 directory 인 경우
에서 loading_request 함수를 사용하게 되는데 이때 setLoading과 finishLoading 함수를 작성한다.
두 함수들은this.setState
를 통해 App.js의 isLoading 값을 바꾸는데,
그 isLoading 값을 받은 Loading 컴포넌트는 style.display
속성이 바뀐다.
//App.js
import { loading_request } from "./api.js";
import Loading from "./Loading.js";
export default function App($app) {
this.state = {
isRoot: false,
nodes: [],
depth: [],
isLoading: false,
};
const loading = new Loading({ $app, initialState: this.state.isLoading });
const nodes = new Nodes({
$app,
initialState: {/*...*/},
onClick: async (node) => {
try {
if (node.type === "DIRECTORY") {
const nextNodes = await loading_request({
nodeId: node.id,
setLoading: () => {
this.setState({ ...this.state, isLoading: true });
},
finishLoading: () => {
this.setState({ ...this.state, isLoading: false });
},
});
this.setState({
...this.state,
isRoot: false,
depth: [...this.state.depth, node],
nodes: nextNodes,
});
} else if (node.type === "FILE") {
/*...*/
}
} catch (e) {
throw new Error(e.message);
}
},
onBackClick: async () => {},
});
this.setState = (nextState) => {
this.state = nextState;
/*...*/
loading.setState(this.state.isLoading);
};
this.init = async () => {
try {
const rootNodes = await loading_request({
nodeId: null,
setLoading: () => {
this.setState({ ...this.state, isLoading: true });
},
finishLoading: () => {
this.setState({ ...this.state, isLoading: false });
},
});
/*...*/
} catch (e) {
throw new Error(e);
}
};
this.init();
폴더를 클릭할 때마다 api를 통해 데이터를 받아오는 것은 시간이 꽤 소요된다.
전체 데이터가 크지 않기 때문에 데이터를 받아와 저장하는 것이 가능하다.
따라서 처음 받는 데이터인 경우 저장하고, 이미 받은 데이터인 경우 저장된 데이터에서 가져오도록 하여 캐싱을 구현해본다.
우선 데이터를 저장할 객체를 선언해준다.
//App.js
const cache = {}; // 데이터를 nodeid: node 형태로 저장한다.
export default function App($app) {
const nodes = new Nodes({
$app,
initialState: {/*...*/},
onClick: async (node) => {
try {
if (node.type === "DIRECTORY") {
const nextNodes = cache[node.id] // cache에 저장되어있는지 확인
? cache[node.id] // 저장되어 있으면 cache에서 가져오기
: await loading_request({
nodeId: node.id,
setLoading: () => {
this.setState({ ...this.state, isLoading: true });
},
finishLoading: () => {
this.setState({ ...this.state, isLoading: false });
},
});
cache[node.id] = nextNodes; // nextNodes를 cache에 저장하여 처음 받는 데이터인 경우를 대비한다.
this.setState({
...this.state,
isRoot: false,
depth: [...this.state.depth, node],
nodes: nextNodes,
});
} else if (node.type === "FILE") {
/*...*/
}
} catch (e) {
throw new Error(e.message);
}
},
onBackClick: async () => {
try {
const nextState = { ...this.state };
nextState.depth.pop();
const prevNodeId = nextState.depth.length
? nextState.depth[nextState.depth.length - 1].id
: null;
this.setState({
...nextState,
isRoot: !prevNodeId,
nodes: prevNodeId ? cache[prevNodeId] : cache.root, // 상위 노드의 경우 무조건 cache에 저장되어있다.
});
} catch (e) {
throw new Error(e.message);
}
},
});
this.init = async () => {
try {
const rootNodes = await loading_request({
nodeId: null,
setLoading: () => {
this.setState({ ...this.state, isLoading: true });
},
finishLoading: () => {
this.setState({ ...this.state, isLoading: false });
},
});
/*...*/
cache.root = rootNodes; // 어플리케이션을 시작했을때 받은 root 데이터를 cache.root에 저장한다.
} catch (e) {
throw new Error(e);
}
};
this.init();
}
Breadcrumb에 onClick 이벤트를 등록하고, onClick 핸들러 함수는 App.js로부터 받는다.
Breadcrumb에서는 클릭된 요소의 nodeId를 onClick 핸들러에 넘겨주고 실제 처리는 App.js에서 이루어진다.
//Breadcrumb.js
export default function Breadcrumb({ $app, initialState = [], onClick }) {
this.state = initialState;
this.onClick = onClick;
this.$target = document.createElement("nav");
this.$target.className = "Breadcrumb";
$app.append(this.$target);
this.setState = (nextState) => {
this.state = nextState;
this.render();
};
this.render = () => {
this.$target.innerHTML = `<div class="nav-item">root</div>${this.state
.map(
(node, index) =>
`<div class = "nav-item" data-index = "${index}">${node.name}</div>`
)
.join("")}`;
};
//이벤트 위임
this.$target.addEventListener("click", (e) => {
const $navItem = e.target.closest(".nav-item");
if ($navItem) {
const { index } = $navItem.dataset;
this.onClick(index ? parseInt(index, 10) : null);
}
});
this.render();
}
//App.js
import Breadcrumb from "./Breadcrumb.js";
export default function App($app) {
this.state = {
isRoot: false,
nodes: [],
depth: [],
selectedFilePath: null,
isLoading: false,
};
const breadcrumb = new Breadcrumb({
$app,
initialState: this.state.depth,
onClick: (index) => {
if (index === null) { // root인 경우
this.setState({
...this.state,
isRoot: true,
depth: [],
nodes: cache.root,
});
return;
}
if (index === this.state.depth.length - 1) return; // 현재 경로를 클릭한 경우
const nextDepth = this.state.depth.slice(0, index + 1);
this.setState({
...this.state,
depth: nextDepth,
nodes: cache[nextDepth[nextDepth.length - 1].id],
});
},
});
최근 React.js 라는 라이브러리가 굉장한 인기를 누리고있고, 나도 관심을 많이 가지고 있는데 리액트의 동작원리에 대해서는 생각해보지 않았었다.
라이브러리가 아닌 vanilla javascript로 돌아와 컴포넌트를 구현해보면서 왜 컴포넌트 형태로 추상화를 하는지 알 수 있었고 자동화해주는 라이브러리의 필요성을 느끼게 되었다. vanilla javascript를 더 열심히 공부해서 기존의 라이브러리들의 동작원리들을 배우고 구현해보고싶다.