이번 2편에서는 필수 구현사항에 대한 풀이를 다뤄보겠습니다.
컴포넌트는 클래스형으로 작성하였으며 가산점을 위해 컴포넌트들은 모듈 형태로 사용합니다.
먼저 src
폴더 내에 index.js
와 App.js
파일을 생성하고
App.js
파일에서 App컴포넌트를 생성합니다.
index.js
에서는 document.querySelector(".App")
을 통해 App클래스를 가진 요소를 인자를 넘겨주어 App컴포넌트가 해당 요소를 기준으로 컴포넌트들을 렌더링할 수 있도록 합니다.
index.js
import App from "./App.js";
new App(document.querySelector(".App"));
App.js
export default class App {
constructor($target) {
}
}
ES6 모듈 형태를 사용하므로 꼭 import문에서 파일의 확장자를 적어주어야 합니다.
먼저 유의사항에 맞게 컴포넌트를 BreadCrumb
, Nodes
, ImageView
3개로 나눠주고
이는 App.js
가 import하여 App클래스를 가진 요소에 렌더링할 수 있도록 합니다.
각 컴포넌트의 $target
의 값은 .App
요소가 됩니다.
BradCrumb.js
export default class Breadcrumb {
constructor($target) {
this.nav = document.createElement("nav");
this.nav.className = "Breadcrumb";
$target.appendChild(this.nav);
this.render();
}
setState(nextState) {
this.state = nextState;
this.render();
}
render() {
this.nav.innerHTML = `
<div>root</div>
<div>노란고양이</div>
`;
}
}
Nodes.js
export default class Nodes {
constructor($target) {
this.nodes = document.createElement("div");
this.nodes.className = "Nodes";
$target.appendChild(this.nodes);
this.render();
}
setState(nextState) {
this.state = nextState;
this.render();
}
render() {
this.nodes.innerHTML = `
<div class="Node">
<img src="./assets/prev.png">
</div>
<div class="Node">
<img src="./assets/directory.png">
<div>2021/04</div>
</div>
<div class="Node">
<img src="./assets/file.png">
<div>하품하는 사진</div>
</div>
`;
}
}
ImageView.js
export default class ImageView {
constructor($target) {
this.modal = document.createElement("div");
this.modal.className = "Modal ImageViewer";
$target.appendChild(this.modal);
this.render();
}
setState(nextState) {
this.state = nextState;
this.render();
}
render() {
this.modal.innerHTML = `
<div class="content">
<img src="./assets/sample_image.jpg">
`;
}
}
App.js
import Breadcrumb from "./components/BreadCrumb.js";
import ImageView from "./components/ImageView.js";
import Nodes from "./components/Nodes.js";
export default class App {
constructor($target) {
this.breadCrumb = new Breadcrumb($target);
this.nodes = new Nodes($target);
this.imageView = new ImageView($target);
}
}
API관련 함수는 별도의 모듈로 작성해야 하므로 services
디렉토리에 api.js
파일을 생성합니다.
API의 END_POINT
는 이 파일 내부에서만 사용할 것이므로 const 변수로 선언해주고
API 통신 로직은 응답을 받은 뒤 해당 값을 반환할 수 있도록 async await구문을 넣어줍니다.
api.js
const END_POINT = "https://zl3m4qq0l9.execute-api.ap-northeast-2.amazonaws.com/dev";
export const request = async (nodeId) => {
const targetURL = nodeId ? `${END_POINT}/${nodeId}` : END_POINT;
const res = await fetch(targetURL);
if (res.ok) {
return res.json();
}
}
외부 컴포넌트에서는 request
함수를 통해 요청만 하면 되기 때문에 함수만 export를 해줍니다.
필수 조건
- 노드 클릭시 타입에 따라 알맞은 일이 일어나야 한다.
- 디렉토리 클릭시 하위에 속한 디렉토리와 파일들을 불러와 렌더링한다.
- 파일 클릭시 노드의
filePath
값을 이용해 이미지를 불러와 렌더링한다.- root 경로가 아닌 경우 Nodes 목록 맨 왼쪽에 이전 디렉토리로 이동할 수 있는 기능을 구현해야 한다.
먼저 API 응답에 따라 파일 목록이 렌더링 될 수 있도록 코드를 작성해줘야 합니다.
App컴포넌트에 items
라는 이름의 state를 추가하여 Nodes컴포넌트에 내려주고
setState시에도 변경된 값으로 리렌더링 될 수 있도록 작성해줍니다.
또한 최초 렌더링시에는 init
함수를 통해 root디렉토리를 가져올 수 있도록 코드를 작성해줍니다.
App.js
export default class App {
constructor($app) {
this.state = {
items: []
};
this.nodes = new Nodes({ $app, initialState: this.state.items});
}
===========================================
setState(nextState) {
this.state = nextState;
this.nodes.setState(this.state.items);
}
===========================================
init = async () => {
try {
const data = await request();
if (data) {
this.setState({
...this.state,
items: data
});
}
} catch(e) {
throw new Error(e);
}
}
}
Nodes.js
export default class Nodes {
constructor({ $app, initialState }) {
this.state = initialState;
}
===========================================
setState(nextState) {
this.state = nextState;
}
render() {
this.state.map(({id, name, type, filePath}) => {
if (type === "DIRECTORY") {
return `
<div data-id=${id} data-type=${type} class="Node">
<img src="./assets/directory.png">
<div>${name}</div>
</div>
`;
} else {
return `
<div data-id=${id} data-type=${type} data-src=${filePath} class="Node">
<img src="./assets/file.png">
<div>${name}</div>
</div>
`;
}
}.join(""));
}
map
메소드를 이용해 items
배열을 순환하면서 type에 따라 적절하게 렌더링될 수 있도록 return값을 처리해줍니다.
이벤트 바인딩을 최소화해야 한다는 가산점을 받기 위해 이벤트 위임 방식을 사용합니다.
.Nodes
요소에만 하나의 이벤트를 등록하여 해당 범위내에서 발생하는 모든 클릭이벤트를 관리합니다.
해당 이벤트의 콜백 함수는 이벤트를 감지하여 타겟이 directory
인지 file
인지 확인해야 합니다.
directory
라면 request
함수를 호출하여 items
를 업데이트하고 file
이라면 이미지를 띄울 수 있도록
App컴포넌트에서 Nodes컴포넌트로 onClick
콜백함수를 전달합니다.
App.js
this.nodes = new Nodes({
$target,
onClick: target => {
const targetId = target.dataset.id;
const currentNode = this.state.path[this.state.path.length-1];
if(targetId !== currentNode.id) {
const targetIndex = parseInt(target.dataset.index);
const path = [...this.state.path];
path.splice(targetIndex + 1);
this.setState({
...this.state,
items: this.cache[targetId],
isRoot: path.length === 1 ? true : false,
path
});
}
}
});
이후 Nodes컴포넌트에서 onClick
함수를 인자로 받아 .Nodes
요소에 addEventListener
로 클릭 이벤트를 등록해줍니다.
Nodes.js
export default class Nodes {
constructor({ $app, initialState, onClick }) {
this.state = initialState;
this.target = document.createElement("div");
this.target.className = "Nodes";
this.target.addEventListener("click", e => {
const target = e.target.closest(".Node");
if (target) {
onClick(target);
}
});
$app.appendChild(this.target);
this.render();
}
이벤트리스너 내부의 로직을 잠깐 살펴보자면 closest
메소드는 이벤트가 발생했을 때
자신을 포함한 상위요소 중 인자로 들어온 선택자에 맞는 요소가 있을 경우 해당 요소를 반환하는 메소드입니다.
따라서 .Node
요소의 하위에 있는 img
태그 혹은 디렉토리 및 파일의 이름이 포함된 div
태그가 클릭되더라도 .Node
클래스를 가진 요소를 특정할 수 있습니다.
이 메소드를 통해 알맞는 .Node요소
를 target
으로 잡아내고 없다면 애초에 onClick
메소드가 호출되지 않도록 합니다.
뒤로가기 버튼은 현재 디렉토리가 root일 경우 표시되지 않도록 해야하므로
현재 디렉토리가 root인지의 여부를 알려줄 수 있는 isRoot
라는 state를 App컴포넌트에서 추가해주겠습니다.
App.js
export default class App {
constructor($app) {
this.state = {
items: [],
isRoot: true,
};
위에서 items
를 추가했던것처럼 Nodes컴포넌트에 isRoot
를 전달합니다.
따라서 Nodes 컴포넌트는 items
와 isRoot
를 state로 가지며
isRoot
의 값을 기준으로 render함수 호출시 조건부로 렌더링될 수 있도록 작성합니다.
뒤로가기 버튼은 Nodes 내부에서만 쓰이기 때문에 최상단에 const변수로 템플릿을 정의해놓겠습니다.
Nodes.js
const PREVBTN_TEMPLATE = `<div data-type="PREVBTN" class="Node"><img src="./assets/prev.png"></div>`;
export default class Nodes {
constructor({ $app, initialState, onClick, onBackClick }) { // onBackClick 추가
this.state = initialState;
this.target = document.createElement("div");
this.target.className = "Nodes";
this.target.addEventListener("click", e => {
const target = e.target.closest(".Node");
if (target) {
target.dataset.type === "PREVBTN" // type에 맞는 함수를 호출할 수 있도록 if문 사용
? onBackClick()
: onClick(target);
}
});
---------------------------------------------------------
render() {
const nodes = `
${this.state.items.map(({id, name, type, filePath}) => {
if (type === "DIRECTORY") {
return `
<div data-id=${id} data-type=${type} class="Node">
<img src="./assets/directory.png">
<div>${name}</div>
</div>
`;
} else {
return `
<div data-id=${id} data-type=${type} data-src=${filePath} class="Node">
<img src="./assets/file.png">
<div>${name}</div>
</div>
`;
}
}).join("")}
`;
// isRoot가 false일 때 PREVBTN추가
this.target.innerHTML = this.state.isRoot ? nodes : `${PREVBTN_TEMPLATE}${nodes}`;
}
필수 조건
- 파일 클릭시 Modal을 띄우며 파일의 이미지를 렌더링한다.
App컴포넌트에 클릭된 파일의 경로를 알아야하므로 selectedImage
라는 state를 추가하여
ImageView
컴포넌트로 전달합니다.
이는 Nodes의 onClick
이벤트 내부에서 type
이 파일일경우 해당 파일의 filePath
를 selectedImage
의 값으로 할당하고 ImageView
에서는 이 값을 기준으로 모달을 제어합니다.
App.js
export default class App {
constructor($app) {
this.state = {
items: [],
selectedImage: null, // selectedImage 추가
isRoot: true
};
----------------------------------------------
this.nodes = new Nodes({
$app,
initialState: {
items: this.state.items,
isRoot: this.state.isRoot
},
onClick: async (target) => {
if (target.dataset.type === "DIRECTORY") {
this.setState({
...this.state,
isLoading: true
});
const name = target.innerText;
const id = target.dataset.id;
let data;
try {
const path = [...this.state.path];
path.push({name, id});
if (this.cache[id]) {
data = this.cache[id]
} else {
data = await request(id);
this.cache[id] = data;
}
this.setState({
...this.state,
items: data,
isRoot: false,
path
});
} catch (e) {
alert("에러가 발생했습니다.");
throw new Error(`에러가 발생했습니다. ${e.message}`);
} finally {
this.setState({
...this.state,
isLoading: false
});
}
} else if(target.dataset.type === "FILE") {
// 파일을 클릭했을 때 해당 이미지의 filePath를 selectedImage의 값으로 할당
this.setState({
...this.state,
selectedImage: target.dataset.src
});
} else {}
ImageVIew.js
export default class ImageView {
constructor({ $app, initialState }) {
this.state = initialState;
this.target = document.createElement("div");
this.target.className = "Modal ImageViewer";
$app.appendChild(this.target);
this.render();
}
render() {
if (this.state) {
this.target.style.visibility = "visible";
this.target.innerHTML = `
<div class="content">
<img src=${BASE_URL}${this.state}>
<div>
`;
} else {
this.target.style.visibility = "hidden";
}
}
}
필수 조건
- root를 가장 왼쪽에 넣어야하며 탐색하는 폴더 순서대로 경로를 나타낸다.
- 디렉토리 이동에 따라
Breadcrumb
영역에도 탐색한 디렉토리 순서에 맞게 업데이트 되어야 한다.
App컴포넌트의 state에서 경로를 나타내기 위한 path
라는 state를 추가합니다.
App.js
export default class App {
constructor($app) {
this.state = {
items: [],
path: [],
selectedImage: null,
isRoot: true
};
path
는 BreadCrumb
의 state가 되며 init
함수에서 성공적으로 응답을 받으면 path
에 root가 추가되도록 합니다.
BreadCrumb.js
export default class Breadcrumb {
constructor({ $app, initialState }) {
this.state = initialState;
this.target = document.createElement("nav");
this.target.className = "Breadcrumb";
$app.appendChild(this.target);
this.render();
}
setState(nextState) {
this.state = nextState;
this.render();
}
render() {
this.target.innerHTML = `
${this.state.map(({name}, index) => {
return `<div data-index=${index}>${name}</div>`;
}).join("")}
`;
}
}
App.js
init = async () => {
this.setState({
...this.state,
isLoading: true
});
try {
const data = await request();
if (data) {
this.setState({
...this.state,
items: data,
path: [{
name: "root"
}],
isRoot: true
});
}
} catch (e) {
throw new Error(`에러가 발생했습니다. ${e.message}`);
} finally {
this.setState({
...this.state,
isLoading: false
});
}
}
여기까지 필수 구현사항에 대한 풀이를 해보았습니다.
다음 편에서는 옵션 구현사항과 가산점 항목에 대해서 풀이해보겠습니다.