결과1. 요구사항 이해 투두 리스트 체크2. 컴포넌트 구조 및 state 설계2.1 와이어프레임2.2 컴포넌트 구조2.3 State 설계2.4 메서드2.5 시나리오개발 중 고민 리스트1. server State와 clientState를 하나로 통합시킬 것인가 VS 따로 관리할 것인가 ✅2. flex CSS에 대한 처리 부족3. initialState 처리 필요한 것인가?4. EditPage 의 필요성5. Router의 역할은 무엇인가 ✅ (3/19)6. DocumentList에서 Document를 하나의 컴포넌트로 만들어 넣는 방식으로 변경하는 방법7. 리렌더링을 줄위기 위한 방법 ( router First, stateFirst) (3/19)✅8. 없는 컨텐츠에 접근할 때의 에러처리9. 404에러 처리를, 모든 api를 처리하고 있는 request.js에서 catch 하여 처리하는 것이 맞나?10. focus 처리, contentEditable에서 focus 가장 뒤로 보내기
결과
1. 요구사항 이해
투두 리스트 체크
docs
요구사항 이해 docs
컴포넌트 구조 및 State 설계docs
와이어프레임 작성docs
feature 산출style
init 후 html 및 css 마크업 작성feat
폴더구조 생성 및 더미데이터를 통한 state 렌더링, TODO 작성 커밋 설명
feat: 기본 컴포넌트 구조 생성 후 더미데이터를 통한 렌더 링 확인”
- UI

- App, Sidebar, Editor 컴포넌트 생성 후, 마크업과 더미데이터를 통한 렌더링 상태 확인
- TodoList 작성
1. fetchDocuments(), flatDocuments()
2. addItem, removeItem 처리
3. Editor AutoSave 기능
4. 라우트 관련 처리
5. toggleItem
feat
documentList 재귀함수로 구조 변경 후 렌더링 반영 <Sidebar>
커밋 설명
request.js
모듈 작성- url, options 받아서 response 반환
fetchDocuments()
- Get요청으로 documents를 받아온 후,
flatDocuments()
으로 state형식을 맞춰주어 app의 documentList의 state 변경
flatDocuments()
(serverData) ⇒ flattedDocumentList
- serverData를 받아서, 재귀 호출을 통해 자식Documents 들을 rootDocument 기준 같은 level로 풀어주는 함수
- 과정에서 depth와 isToggled 속성을 추가
- before
const SERVER_DATA = [ { id: 1, title: "1 첫번째 루트 노드", documents: [{ id: 2, title: "1-1 자식 노드", documents: [] }], }, { id: 3, title: "2 두번째 루트 노드", documents: [] }, ];
- After
const FLATTED_DOCUMENTLIST = [ [ { id: 1, title: "1 루트노드", isToggled: true, depth: 0 }, { id: 2, title: "1-1 자식노드", isToggled: false, depth: 1 }, ], [{ id: 3, title: "2 루트노드", isToggled: false, depth: 0 }], ];
feat
Sidebar의 document 추가/삭제 기능커밋 설명
onInsertItem(id)
(id) ⇒ request({title, parent: id}) ⇒ fetchDocuments()
- 해당 id를 parent로 하는 새로운 document 생성 API 요청 후
fetchDocuments()
를 통해 Sidebar를 리렌더링 한다.
onRemoveItem(id)
(id) ⇒ request(.../{id}) ⇒ fetchDocuments()
- 해당 id를 path로 하는 DELETE API 요청 후
fetchDocuments()
를 통해 Sidebar를 리렌더링 한다
feat
Editor 내용 수정 시 server데이터에 디바운스 처리하여 반영<Editor>
커밋 설명
Editor의 state 및 setState 작동 확인
- input event발생할 때마다 setState() 발생하고 새로 render() 함수를 호출 하지 않고, 첫 실행에만 template을 렌더링하고, 이후에는 element의 값만 변경하도록 처리
onEditing()
-basic- title과 content가 변경되면, setState로 반영한다.
onEditing()
- 실제 API 연동- title과 content가 변경되면, server에 반영할 수 있도록 API 수정 요청을 보낸다.
onEditiing()
- debounce- 연속된 요청이 있을 경우 매번 API요청을 하는 것이 아니라, 가장 마지막 요청만 실제 API request를 보내도록 한다.
feat
라우터 처리 - HistoryAPI를 통한 SPA 구성 <EditPage>
커밋 설명
라우터
1.
initRouter(onRoute)
를 통해 ‘route-change’ 이벤트를 등록한다.
- route-change이벤트 발생 시, nextUrl을 받아 history에 push 후, onRoute() 작업실행
2. push(nextUrl)
메소드를 통해, nextUrl을 받은 후 ‘route-change’ 이벤트를 디스패치하여 실행시킨다.
3.onRoute() 함수로 this.route()
를 등록한다.
- 현재 pathname을 가져와 해당 값에 따라 현재 state의 currentDocumentId를 적절히 바꾸어주는 역할페이지 첫 접속 시 조회
initRouter등록
fetchDocuments
API로 fetchData 받아와서 flat된 documentList로 setState()
sideBar 리렌더링
this.route 실행
루트(’/’)라면 Editor는 null Page 렌더링
currentDocumentId 있다면, setState로 currentDocumentId 변경되며 App> Editor 리렌더링
특정 도큐먼트 조회
push(클릭된 id)
route()
함수에서 pathname에 따라 currentDocumentId setState되며 리렌더링특정 도큐먼트 추가
push(추가된 id)
리렌더링
- 추가 API 요청
- 추가된 id로 route함수로 넘겨 Editor 리렌더링
- fetchDocuments를 통해 Sidebar 리렌더링
특정 도큐먼트 삭제
push(부모id || null)
뒤로가기 방지 위해
history.replaceState(부모id || null)
리렌더링
- 삭제 API 요청
- 삭제된 부모의 id를 route함수로 넘겨 Editor 리렌더링
- fetchDocuments를 통해 변경된 Sidebar 리렌더링
Editor
- state.currentId 가 null일 때 예외처리
- route에서 currentDocumentId 를 null 로 처리
- 입력된 documentId로 GET요청을 진행하고, 이때 발생된 404 NotFound error를 캐치하여 alert후,
push
(`/documents/null`)
을 통해 nullPage를 렌더링하게 한다.
1. ‘/’를 통해 접근할 때
2. 존재하지 않는 documentId로 접근할 때
- Editor init 부분에서 currentId가 null일 때는 GET API 요청을 하지 않고, null Page를 바로 리턴하여준다.
temp 코드 보관
App
import Sidebar from "./Sidebar.js"; import Editor from "./Editor.js"; import { initRouter, push } from "../utils/router.js"; import { $ } from "../utils/dom.js"; import { request } from "../utils/request.js"; import { flatDocuments } from "../utils/index.js"; export default function App({ $target }) { this.state = { documentList: [], currentDocumentId: null, }; this.setState = async (nextState) => { this.state = { ...this.state, ...nextState }; console.log("!! state변경 <App>: ", this.state); this.render(); }; this.init = async () => { initRouter(() => this.route()); await fetchDocuments(); this.route(); }; this.template = () => { console.log("<< <App> 렌더링 >>"); return ` <div class="app-container flex"> <section class="sidebar-section flex-col">Sidebar</section> <section class="editor-section flex-col">Editor</section> </div> `; }; this.render = () => { $target.innerHTML = this.template(); this.mounted(); }; this.mounted = () => { new Sidebar({ $target: $(".sidebar-section"), initialState: { documentList: this.state.documentList, id: this.state.currentDocumentId, }, //- TODO_4: url path 및 라우트 관련 처리 onClickItem: (id) => { //- Todo-refactor-routeFirst-렌더링최적화: setState 삭제 // this.setState({ currentDocumentId: id }); push(`/documents/${id}`); }, //- TODO_2-1: add 처리 onInsertItem: async (id) => { const parent = id ? Number(id) : null; const insertedItem = await request(`/documents`, { method: "POST", body: JSON.stringify({ title: "", parent }), }); console.log("insertedItem: ", insertedItem); //- Todo-refactor-routeFirst-렌더링최적화: setState 삭제 // this.setState({ currentDocumentId: insertedItem.id }); push(`/documents/${insertedItem.id}`); await fetchDocuments(); }, //- TODO_2-2: remove 처리 onRemoveItem: async (id) => { const removedItem = await request(`/documents/${id}`, { method: "DELETE", }); console.log("removedItem: ", removedItem); const parentItemId = removedItem.parent?.id; //- TODO-refactor-02: parent가 없을 때 처리 defaultValue 깔끔하게, push(`/documents/${parentItemId || null}`); history.replaceState(null, null, `/documents/${parentItemId || null}`); //- Todo-refactor-routeFirst-렌더링최적화: setState 삭제 // this.setState({ currentDocumentId: parentItemId }); await fetchDocuments(); }, //TODO_05: 토글에 따른 접고펼치기 기능 onToggleItem: (id) => { alert( id + "번 Item 토글 이벤트 발생 -> 해당 document 찾아서 isToggled 처리" ); console.log( `this.state.documentList 순회하며, isToggled 처리 후 setState` ); }, }); //TODO_06: 로컬스토리지 임시데이터 저장 기능 // let documentLocalSaveKey = `DOCUMENT_SAVE_KEY_${this.state.currentDocumentId}`; //Todo-refactor-04: debounce 함수화하여 분리 let timer = null; //Todo-refactor-03: editor state와 app State 구분 new Editor({ $target: $(".editor-section"), initialState: { currentDocumentId: this.state.currentDocumentId, document: { title: "", content: "", }, }, //- TODO_03: Editor serverData 연동 & 디바운스 관련 처리 onEditing: async ({ currentDocumentId, document }) => { if (timer !== null) { clearTimeout(timer); } timer = setTimeout(async () => { console.log("** 수정 API 발생 in OnEditing() : ", { currentDocumentId, document, }); await request(`/documents/${currentDocumentId}`, { method: "PUT", body: JSON.stringify(document), }); // ToDo-refactor-01: title 변경 시에만 fetchDocuments 하도록 fetchDocuments(); }, 2000); }, }); }; this.route = () => { const { pathname } = window.location; console.log("route 실행: ", pathname); if (pathname === "/") { //Todo-advance-01: localStorage에 최근 작업 id 저장 및 불러오기 console.log("maybe localStrorage최신 작업 페이지 불러오기"); this.setState({ currentDocumentId: null, }); } else if (pathname.indexOf("/documents/") === 0) { const [, , currentDocumentId] = pathname.split("/"); //Todo-refactor-02: routeFirst로 렌더링최적화 console.log("In route():", [ "stateId", this.state.currentDocumentId, "pathId", currentDocumentId, ]); if (currentDocumentId === "null") { this.setState({ currentDocumentId: null }); return; } if (this.state.currentDocumentId !== currentDocumentId) { this.setState({ currentDocumentId: currentDocumentId }); } } }; const fetchDocuments = async () => { const fetchData = await request("/documents"); console.log("fetch All Documents: ", fetchData); this.setState({ documentList: flatDocuments(fetchData) }); }; this.init(); }
Sidebar
import { $ } from "../utils/dom.js"; const openToggleSvg = `<svg viewBox="0 0 100 100" class="triangle" style="width: 1rem; height: 1rem; display: block; fill: rgba(55, 53, 47, 0.45); flex-shrink: 0; backface-visibility: hidden; transition: transform 200ms ease-out 0s; transform: rotateZ(180deg); opacity: 1; user-select: auto;"><polygon points="5.9,88.2 50,11.8 94.1,88.2 " style="user-select: auto;"></polygon></svg>`; const closeToggleSvg = `<svg viewBox="0 0 100 100" class="triangle" style="width: 1rem; height: 1rem; display: block; fill: rgba(55, 53, 47, 0.45); flex-shrink: 0; backface-visibility: hidden; transition: transform 200ms ease-out 0s; transform: rotateZ(90deg); opacity: 1; user-select: auto;"><polygon points="5.9,88.2 50,11.8 94.1,88.2 " style="user-select: auto;"></polygon></svg>`; export default function Sidebar({ $target, initialState, onClickItem, onInsertItem, onRemoveItem, onToggleItem, }) { this.state = initialState; // {documentList: [[{id, title, isToggled,depth}, {}], []]} this.template = () => { const { documentList } = this.state; console.log("<< <sideBar> 렌더링 >>"); return ` <div> <span class="workspace-title">워크스페이스</span> <div id="root-insert-button" class="insert-button button">+</div> </div> ${documentList .map((rootDocument) => rootDocument .map((document) => { return ` <div class="document depth${document.depth}" data-id=${ document.id }> <div class="flex"> <div class="svg-button"> ${document.isToggled ? openToggleSvg : closeToggleSvg} </div> <span class="document-title">${ document.title || "제목 없음" }</span> </div> <div class="flex"> <div class="remove-button button flex-end">-</div> <div class="insert-button button flex-end">+</div> </div> </div>`; }) .join("") ) .join("")} `; }; this.render = () => { $target.innerHTML = this.template(); }; this.mounted = () => {}; // Todo-refactor-05: event처리 가독성 증가 this.setEvent = () => { //- TODO_2-1: 추가 처리 $target.addEventListener("click", ({ target }) => { if (target.closest("#root-insert-button")) { onInsertItem(null); return; } if (target.closest(".insert-button")) { const { id } = target.closest(".document").dataset; onInsertItem(id); return; } }); //- TODO_2-2: 삭제처리 $target.addEventListener("click", ({ target }) => { if (target.closest(".remove-button")) { const { id } = target.closest(".document").dataset; onRemoveItem(id); return; } }); //- TODO_03: 에디터 불러오기 처리 $target.addEventListener("click", ({ target }) => { //- Todo-issue: title이 빈 값일 때 element 선택이 안되는 이슈 -> title 빈값안되도록 해결 if (target.closest(`.document-title`)) { const { id } = target.closest(".document").dataset; onClickItem(id); return; } }); // TODO_05: 토글에 따른 블록 접기/열기 처리 $target.addEventListener("click", ({ target }) => { if (target.closest(".svg-button")) { const { id } = target.closest(".document").dataset; onToggleItem(id); return; } }); }; this.render(); this.setEvent(); }
Editor
import { $ } from "../utils/dom.js"; import { request } from "../utils/request.js"; import { push } from "../utils/router.js"; //Todo-refactor-03: app에서 내려온 documentId State와 Editor 내부의 state 구분하기 export default function Editor({ $target, initialState, onEditing }) { this.state = initialState; // { currentDocumentId, document: {title, content} } console.log("Editor 진입: ", this.state.currentDocumentId); this.setState = (nextState) => { this.state = { ...this.state, ...nextState }; console.log("!! State변경 <Editor>: ", this.state); this.render(); }; //- TODO_04: 라우트 처리 this.init = async () => { if (!this.state.currentDocumentId) { console.log("Editor init: currentDocumentId가 없습니다. "); $target.innerHTML = ` <h1>선택된 도큐먼트가 존재하지 않습니다.</h1> <p>워크스페이스에서 새로운 도큐먼트를 생성하거나, 클릭하여 수정하십시오.</p> `; return; } $target.innerHTML = this.template(); try { const fetchedData = await request( `/documents/${this.state.currentDocumentId}` ); const { title, content } = fetchedData; console.log("Editor init - 'fetch Document Data': ", fetchedData); this.setState({ document: { title, content } }); } catch (e) { console.log(e); push(`/documents/null`); } }; this.template = () => { const { title, content } = this.state.document; console.log("<< <Editor> 렌더링 >>"); return ` <input class="editor-input" placeholder="제목 없음" value="${title}" /> <textarea class="editor-content" placeholder="내용 없음">${content}</textarea> `; }; this.render = () => { const { title, content } = this.state.document; $(".editor-input").value = title; $(".editor-content").textContent = content; }; //- TODO_03: server 데이터와 연동 (디바운스 처리) // Todo-refactor-05: event처리 가독성 증가 this.setEvent = () => { $target.addEventListener("keyup", (e) => { if (!e.target.closest(".editor-input")) { return; } const nextState = { document: { ...this.state.document, title: e.target.value }, }; this.setState(nextState); onEditing(this.state); }); $target.addEventListener("input", (e) => { if (!e.target.closest(".editor-content")) { return; } const nextState = { document: { title: this.state.document.title, content: e.target.value, }, }; this.setState(nextState); onEditing(this.state); }); }; this.setEvent(); this.init(); }
기타
utils/index.js flat함수
function flat(document, depth = 0, flattedDocuments = []) { //* 1. 방문한 노드 넣기 flattedDocuments.push({ ...document, depth, isToggled: false, }); //* 2. 탈출조건: 자식이 없을 때 if (document.documents.length === 0) { return flattedDocuments; } //* 3. 자식노드 있다면 재귀 호출 document.documents.forEach((childDocument) => { flattedDocuments = flat(childDocument, depth + 1, flattedDocuments); }); return flattedDocuments; } export const flatDocuments = (fetchData) => { //* fetchData는 rootDocument로 이루어진 배열이다. //* rootDocument는 자식 Document를 트리구조로 가지고 있다. //* rootDocument를 순회하며, 1depth의 documentList로 풀어주고, 이를 합치는 동작 //* Input: [[root1 : [child1, child2]], [root2 : [child3 : [grandChild1]]], ... ] //* Output: [[root1, child1, child2], [root2, child3, grandChild1], ... ] return fetchData.map((rootDocument) => flat(rootDocument, 0, [])); };
utils/router.js router함수
const ROUTE_CHANGE_EVENT_NAME = "route-change"; export const initRouter = (onRoute) => { console.log("router 등록"); window.addEventListener(ROUTE_CHANGE_EVENT_NAME, (e) => { const { nextUrl } = e.detail; if (nextUrl) { history.pushState(null, null, nextUrl); onRoute(); } }); }; export const push = (nextUrl) => { console.log("router push Event발생 url: ", nextUrl); window.dispatchEvent( new CustomEvent("route-change", { detail: { nextUrl, }, }) ); };
feat
document 클릭에 따른 토글기능 구현” <Sidebar>
커밋 설명
- 가독성 증대 신경씀
before
let arr = this.state.documentList; let pIdx; let nIdx; arr.map((root, p) => { root.map((node, i) => { if (node.id === Number(id)) { pIdx = p; nIdx = i; } }); }); console.log(`!!!!!!!!!!!! ToggleEvent발생`); const clicked = arr[pIdx][nIdx]; //* 현재 클릭된 게 열려있다면 아래 것들은 모두 닫아 버려야함 if (clicked.isOpen) { for (let p = 0; p < arr.length; p++) { if (p !== pIdx) continue; // 루트 도큐먼트가 다르면 Pass for (let c = 0; c < arr[p].length; c++) { let current = arr[p][c]; if (c > nIdx && current.depth > clicked.depth) { arr[p][c].isView = false; arr[p][c].isOpen = false; } } break; } } else { //* 현재 클릭된 것이 닫혀있다면 바로 밑 자식만 열어주어야함 for (let p = 0; p < arr.length; p++) { if (p !== pIdx) continue; // 루트 도큐먼트가 다르면 Pass for (let c = 0; c < arr[p].length; c++) { let current = arr[p][c]; if (c > nIdx && current.depth === clicked.depth + 1) { if (arr[p][c].depth < arr[pIdx][nIdx].depth) { break; } arr[p][c].isView = true; } } break; } } arr[pIdx][nIdx].isOpen = !arr[pIdx][nIdx].isOpen;
After - getClickedPosition
, getToggledDocumentList
로 함수화
refactor
가독성 증가를 위한 코드정리 및 세부 구현 util함수화 커밋 설명
가독성 위주로 리팩토링
utils 함수 한 번에 import/export
- getFlattedDocumentList
- getToggledDocumentList
렌더링, state변경, API 요청 관련 콘솔 통해 흐름 파악
<<
: 렌더링,!!
: state변경,**
: API요청,log
: 기타log
event핸들러 정리
- Editor와 Sidebar에서 이벤트바인딩 해주는 부분 click 이벤트를 한 곳에서 처리
API 요청 공통화
- request 요청 한 곳에서 관리하도록 api/index.js 의 CRUD 관한 요청 모아둠
debounce 함수화
잘 안됨
function debounce(func, delay) { let timer = null; return function (...args) { console.log(timer, context, args); if (timer !== null) { clearTimeout(timer); } timer = setTimeout(() => func.apply(...args), delay); }; } const onEdit = async (currentDocumentId, document) => { console.log("here"); await request(`/documents/${currentDocumentId}`, { method: "PUT", body: JSON.stringify(document), }); console.log("** 수정API요청"); await fetchDocuments(); };
feat
현재 document까지 경로 보여주는 Header 추가 <Header>
상위 블록에 대한 링크 처리
sidebar 열고 닫기
메뉴삭제 등 아이콘 등록
style
모든 style 점검 후 맞추기sidebar
아이콘 점검
title 길어 질 때 ... 처리
루트 페이지 추가 처리
워크스페이스, 하위페이지가 없습니다 등 폰트 및 위치 처리
mouse hover시 처리
currentDocument background 색변경
Editor
border 제거
배포
AWS Amplify로 배포해보
기- github 내 repo 새로 만들어서 배포 진행
- 준비할 것 하나 없이 클릭 몇 번으로 배포완료 됨
feat
- 로컬스토리지 임시저장 및 불러오기 기능커밋설명
- route의 페이지 처리
- root(
/
) 접속 시, - localStorage 페이지정보있다면 setState({id})
- 없다면 setState({root})
- /documents/ 접속 시,
- null 이라면, setState(null)로 404 페이지 보내기
- id존재 한다면, setState(id)
feat & fix
- contentEditable 기능 추가 및 관련 버그 fix커밋 설명
contentEditable 기능 추가
Fix List
before
content 수정 시 수정API 이후, updateDocumentList() 실행으로, APP이 전부다 리렌더링 되는 현상
After
updateDocumentList() 를 sidebar의 다른 Item을 클릭할 경우 발생하도록 분리하여, Editor 내부에서 작성 시 끊김이 없도록 수정
before
Editor에 들어갔을 때, 기본적으로 input focus가 title을 지정하도록 함
After
title 여부에 따라 title, content 선택하여 focus
fix
리렌더링 fix 및 로그 제거커밋 설명
chore
배포된 버젼에는 테스트 위해 그대로 log 두고, 리뷰편의성을 위해 PR 레포는 로그 삭제fix
Editor 클릭 시 리렌더링 되어 깜빡이는 현상 수정do
: updateDocumentList를 이벤트핸들러에서 분리하고, router에서 id 변경시에 처리
before
: 이전 커밋에서 sidebar리렌더링을 없애기 위해 updateDocumentList를 onClickItem에 주어, push 이후 state가 다시 변하며 리렌더링
after
: route부분에서 현재 id가 이전과 달라질 때, updateDocument 처리를 해주어 해결
추가
: onInsertItem 시에도 항상 id 가 달라지기 때문에, 해당함수에서 update하지 않고, 라우트에서 sideBar 업데이트 처리로 리렌더링 방지
fix
모바일 한글 깨지는 이슈do
: html의 meta 태그에 UTF-8 추가
fix
: 문서 최대 깊이(4)로 설정하고, 초과 시 alert 알림feat
Advance 작업 Advance List
- 예외처리
- depth 최대 5개 까지 허용, 그 이상은 추가 불가 alert 띄우기
- 낙관적 업데이트 적용
fix
마우스 커서 위치 (title 없다면 title, 있다면 content로)
리렌더링 발생하지 않도록
현재 발견 중 버그
새로 고침이나 sidebar 변경 시 toggle이 전부 펼쳐지는 문제
- default로 토글 닫아놓음, 그러나 view는 open이라 닫혀있는데 보이는 현상
document title이 길어서 넘칠 때, 버튼이 움직이고 있음
document 클릭 시, data를 받아오는 동안 initial content가 잠시 보이는 현상
editor에 작성하는 title이 sidebar의 실시간으로 연동이 되지 않음
- sidebar의 제목은 fetchDocuments()를 할 때만 바뀌게 됨
- debounce 처리 후 수정API를 쏘고 나서만 fetchDocuments를 하고 있기때문
해결
- by낙관적업데이트
- editor에서 state를 변경할 때, data-id 속성을 통해 해당 element에 직접 접근하여, textContent를 바꾸어 준다.
- 단 실제 API를 통한 변경은 디바운스 시간 이후 동작한다.
즉
낙관적 업데이트
를 하고 있음
document 추가나 삭제 시 페이지 이동안되는 상황
삭제 시 404
추가 시 기존 editor 페이지 보여줌
Documents 관계 분석
- 하나의 Document는 여러개의 Document를 자식으로 가질 수 있다.
- 하나의 Document는 자식 Document가 없을 수 있다.
- 여러개의 루트 Document가 존재가능하다.
데이터 조회 시나리오
- 모든 도큐멘트 조회 API
- App 접속 시 최초 호출
<Nav>
- 도큐멘트 추가 btn 클릭 시, state변경 후 리렌더링시 호출
<Nav>
Get/documents
[ { "id": 23431, "title": "첫번째 루트 노드", "documents": [ { "id": 23433, "title": "1-1", "documents": [ { "id": 23436, "title": "1-1-2", "documents": [], "createdAt": "2022-04-12T06:23:07.968Z", "updatedAt": "2022-04-12T06:23:07.975Z" }, { "id": 23437, "title": "1-1-3", "documents": [], "createdAt": "2022-04-12T06:23:32.587Z", "updatedAt": "2022-04-12T06:23:32.592Z" } ], "createdAt": "2022-04-12T06:20:35.597Z", "updatedAt": "2022-04-12T06:20:35.602Z" } ], "createdAt": "2022-04-12T06:16:47.442Z", "updatedAt": "2022-04-12T06:17:53.760Z" }, { "id": 23432, "title": "두번째 루트 노드", "documents": [], "createdAt": "2022-04-12T06:20:11.370Z", "updatedAt": "2022-04-12T06:20:11.374Z" }, ]
- 특정 도큐멘트 조회 API
- 도큐멘트 클릭 시 호출
<Nav>
Get/documents/{documentId}
{ "id": 23431, "title": "첫번째 루트 노드", "createdAt": "2022-04-12T06:16:47.442Z", "updatedAt": "2022-04-12T06:17:53.760Z", "content": "내용수정2", "documents": [ { "id": 23433, "title": "1-1", "documents": [ { "id": 23436, "title": "1-1-2", "documents": [], "createdAt": "2022-04-12T06:23:07.968Z", "updatedAt": "2022-04-12T06:23:07.975Z" }, { "id": 23437, "title": "1-1-3", "documents": [ { "id": 23556, "title": "", "documents": [], "createdAt": "2022-04-12T07:49:52.890Z", "updatedAt": "2022-04-12T07:49:52.895Z" } ], "createdAt": "2022-04-12T06:23:32.587Z", "updatedAt": "2022-04-12T06:23:32.592Z" } ], "createdAt": "2022-04-12T06:20:35.597Z", "updatedAt": "2022-04-12T06:20:35.602Z" } ] }
데이터 생성 시나리오
parent : null
일 경우 루트Document로 생성된다.
- 부모와 자식 노드가 있을 때, 그 사이에 노드를 생성하여, 넣는 것은 불가하다.
- 부모 > 추가노드 > 자식노드
불가
- 부모 > 추가노드, 자식노드
가능
- 도큐멘트 생성 API
- 도큐먼트 추가 btn 클릭 시
Nav
POST/documents, {title, parent}
{ "id": 23612, "title": "세번째 루트 노드", "createdAt": "2022-04-12T08:01:46.438Z", "updatedAt": "2022-04-12T08:01:46.442Z" }
데이터 삭제 시나리오
- 하나의 Document를 삭제할 경우, 해당 Document의 바로 자식 Document들은 루트 Document가 된다.
- 삭제할 Document의 자식 계층에 여러개의 Document가 존재할 때, 자식 Document들은 모두 루트 Document로 이동된다.
- 만약 해당 자식들이 자식Document(삭제기준 손자)를 가지고 있다면, 손자는 자식과 같이 움직이게 됨
즉 바로 밑의 자식들만 하위 노드들을 포함하여 루트 노드로 변경
// before 1 1-1 1-1-1 1-1-1-1 1-1-1-1-자식 1-1-1-2 1-1-2 1-1-3 1-2 2 //After (1-1-1 삭제) 1 1-1 1-1-2 1-1-3 1-2 2 1-1-1-1 1-1-1-1-자식 1-1-1-2
- 도큐멘트 삭제 API
- 마우스hover 후 삭제 btn 클릭 시
Nav
주의
해당 도큐멘트 삭제 후, 삭제된 도큐멘트정보 반환
DELETE/documents/{documentId}
{ "id": 23437, "title": "1-1-3", "content": null, "parent": { "id": 23433, "title": "1-1", "content": null, "parent": 23431, "username": "outwater", "created_at": "2022-04-12T06:20:35.597Z", "updated_at": "2022-04-12T06:20:35.602Z" }, "username": "outwater", "created_at": "2022-04-12T06:23:32.587Z", "updated_at": "2022-04-12T06:23:32.592Z" }
데이터 수정
- 데이터 수정 시 바꾸고 싶은 요소만 넣어서 수정 할 수 있다.
Put/{documentId}, {title, content}
title, content 중 바꿀 요소만 request body에 넣기
주의
이미 생성된 Document의 parent는 변경 불가하다.
- 도큐먼트 수정 API
- Editor내부에서 수정발생 시, 디바운스 이후 수정
Editor
- 도큐먼트에서 더보기 버튼을 통해 이름변경 시
Nav-선택
PUT/documents/{documentId}
{ "id": 23556, "title": "네번째 루트 노드", "content": "내용은 수정했어요.", "parent": {}, "username": "outwater", "created_at": "2022-04-12T07:49:52.890Z", "updated_at": "2022-04-12T08:21:19.800Z" }
2. 컴포넌트 구조 및 state 설계
2.1 와이어프레임

2.2 컴포넌트 구조
컴포넌트 구조

2.3 State 설계
렌더링에 필요한 State를 최상단인 App에서 관리하여 각 컴포넌트로 흐르게 하는 구조
- state in App
{ documentList: [ [ { id: 1, title: "1 루트노드", isToggled: true, depth: 0 }, { id: 2, title: "1-1 자식노드", isToggled: false, depth: 1 }, ], [{ id: 3, title: "2 루트노드", isToggled: false, depth: 0 }], ], currentDocumentId : new || 1 }
documentList
- Sidebar 렌더링에 필요
- 루트노드 단위로 요소를 저장
- 루트, 자식, 손자노드는 depth로 구분하며, 루트노드 기준으로 이중 배열로 구성
[ [루트노드1, 자식노드1, 손자노드1], 루트노드2, 루트노드3, ... ]
- 하나의 노드는
{id, title, isToggled, depth}
4가지 속성을 가짐
currentDocumentId
: 현재 EditPage에 보여주어야 할 documentId, url주소에서 가져옴 - initial 값은 ‘new’를 가지고, route에서 path가 변할 때 마다 반영하여 update 해줌
App 구조 정리
[state]
currentDocumentId
: 현재 url에서 documentId 가져오기
documentList
: fetchItems() 통해 documents 가져와서, 필요한 것만 골라 파싱하기
[mounted (컴포넌트)]
new Sidebar({$target, documentList })
new EditPage({$target, documentId })
[event]
onInsert
onRemove
onToggle
onClick
onEditing
EditPage 구조 정리
currentDocumentId
: App에서 부터 내려받음
document
:currentDocumentId
를 통해getItem API
로 받아옴
2.4 메서드
onInsert(id)
- 해당 Item 밑에 새로운 document를 생성하고 추가한다.1. API 추가 요청 {title:'', parent: id} 2. fetchItems() -> 3. setState(formatFetchItems) -> 4. render()
- (추가) history 고려하여 push & pop
onRemove(id)
- 현재 id값의 Item을 제거하고, state 다시 불러와서 반영된 것 렌더링onItemClick(id)
- 해당 id의 item 값 불러와서 editor에 렌더링onToggle(id)
- 해당 id값에 해당하는 isToggled 값 업데이트2.5 시나리오
렌더링 시나리오
- 모든 렌더링은 App의 State가 흘러 내려가며 차례대로 render 함수가 실행되며 진행
- 초기 접속 시
fetchItems()
로 serverData 받기- serverData를 가공하여 App의 상태 변화(setState)
- 상태가 바뀌며 렌더링 진행
- 추가 발생 시
onInsert(id)
이벤트 발생 → 추가 API 요청fetchItems()
를 통해 추가 반영된 serverData 받기- serverData 반영하여 리렌더링
- 삭제 발생 시
onRemove(id)
이벤트 발생 → 삭제 API 요청fetchItems()
를 통해 삭제가 반영된 serverData 받기- serverData 반영하여 리렌더링
- 토글 발생 시
onToggle(id)
이벤트 발생- 해당 요소가 true인 경우, false로 바꾸고, 해당 루트노드의 모든 자식,손자 요소들의 isToggled를 false로 바꿈
- 해당요소가 false인 경우, true로 변경
- setState로 state 변경 후 렌더링 반영
render함수
- isToggled 상태가 true이면 아무 상태변화 없음
- false라면 class에 ‘toggle-closed’ 추가하기 → css에서 보이지 않도록 처리
- https://www.w3schools.com/howto/howto_js_treeview.asp
개발 중 고민 리스트
1. server State와 clientState를 하나로 통합시킬 것인가 VS 따로 관리할 것인가 ✅
- (3/13) state는 렌더링에 적합한 자료형태로 담아야한다. 즉 isToggled와 같은 clientState를 위한 속성이 있어야하고, documentsState를 순회하면서 심플하게 렌더링할 수 있어야한다.
- 따라서 serverState를 fetch 한 직후에 이를 format하는 유틸함수를 통해 필요한 state형식에 맞게 가공하고 state에 저장한다.
const serverData = await fetchItems(); const nextState = formatToState(serverData) this.setState(nextState)
flatDocuments()
함수는 재귀를 통해 serverData의 자식 document를 루트 도큐먼트를 기준으로 한 배열에 담는다.- Before
[ { id: 1, title: "1 첫번째 루트 노드", documents: [ { id: 3, title: "1-1 자식 노드", documents: []}, ], }, { id: 2, title: "2 두번째 루트 노드", documents: [] }, ],
[ [ {id:1, title:"1", isToggled:false, depth: 0}, {id:3, title:"1-1", isToggled:false, depth: 1} ], [ {id:2, title:"2", isToggled:false, depth: 0}, ] ]
2. flex CSS에 대한 처리 부족
3. initialState 처리 필요한 것인가?
roto의 경우 루트에서 state가 흐르는 것이 아니라, 필요로 하는 state를 해당 컴포넌트에 선언하고, 그 컴포넌트만을 직접 setState 해주는 방식을 사용한다.
나는 그러한 방식이 state를 쉽게 알아보기 어렵고, 흐름제어가 어렵다고 판단하여, 루트인 App에서 state를 흘러내려주는 구조를 취하고 있다.
이 때, roto가 사용하는 initialState를 props로 넘겨주는 방식은 내부 컴포넌트를 짐작할 수 있게 해주는 효과를 가지는데, 내가 사용하는 방식에는 적합하다고 생각하지 않는다.
initialState를 컴포넌트 내부에 선언하는 것 처럼 구현할 수 있는데, 적합한지 의문
4. EditPage 의 필요성
- Editor에서 데이터 보관,저장 처리, 업데이트 등 모든 로직이 동작하고 있는데, 상태관리를 해주는 EditPage 컴포넌트를 만들어 Editor의 역할을 좀 더 분명히 하는 것이 바람직하다는 생각이 든다.
5. Router의 역할은 무엇인가 ✅ (3/19)
Router 모듈은
1. 브라우저의 History관리 역할을 맡는다.
- 페이지 이동 시, 페이지 정보 담기
- 페이지 삭제 시, 접근 불가하도록 페이지 정보 지우기
2. path의 값에 따라 state.currentDocumentId 를 setState하여 리렌더링을 일으킨다.
라우터의 구현
1.
initRouter(onRoute)
를 통해 ‘route-change’ 이벤트를 등록한다.
- route-change이벤트 발생 시, nextUrl을 받아 history에 push 후, onRoute() 작업실행
2. push(nextUrl)
메소드를 통해, nextUrl을 받은 후 ‘route-change’ 이벤트를 디스패치하여 실행시킨다.
3.onRoute() 함수로 this.route()
를 등록한다.
- 현재 pathname을 가져와 해당 값에 따라 현재 state의 currentDocumentId를 적절히 바꾸어주는 역할this.route()
- 현재 pathname을 가져온 후 currentDocumentId를 얻어와서, App의 state인 currentDocumentId를 update 해준다.
path가 ‘/’ 일 경우 예외 처리
path의 documentId로 setState() 해주어 App 전체 리렌더링
6. DocumentList에서 Document를 하나의 컴포넌트로 만들어 넣는 방식으로 변경하는 방법
- 현재는 template에서 document state를 순회하며, 템플릿에 직접넣어주고 있는데, Document를 하나의 컴포넌트로 만들어준다면, 렌더링 작업을 어디서해주어야할까..?
- 상위컴포넌트인 Sidebar의 mount 부분에서?
7. 리렌더링을 줄위기 위한 방법 ( router First, stateFirst) (3/19)✅
현재 발생하는 리렌더링 문제는 path를 통한 리렌더링과 setState를 통한 리렌더링 방식은 혼돈해서 왔다.
”Event함수 → url path 변경 → path에 따른 setState → 리렌더링” 의 흐름제어를 통해 해결
해결방법
- 기존방식
onClickItem
, onInserItem
, onRemoveItem
등의 이벤트가 발생했을 때, setState로 currentDocumentId를 직접 바꾸어주는 방식- 변경 방식
router의 push메소드를 통해 페이지 주소의 변경을 먼저 발생하게 하고, 이후
route()
함수가 실행되며, 현재 pathname을 통해 state의 currentDocumentId를 setState해주는 방식변경을 통한 이점
- 기존 방식에서 setState로 리렌더링 된 이후, path변화를 읽어 또 다시 리렌더링 발생하는 현상 해결
그외 리렌더링 방지를 위해 사용 가능한 방법
- proxy를 통해 Model을 만들어 변경된 state를 가지는 Model만을 리렌더링하는 방법
- nextStep의 poco 사용방식
8. 없는 컨텐츠에 접근할 때의 에러처리
- url path로 없는 documentId를 직접 접근 할 때, 이에 대한 처리를 어떻게 해주어야 하나에 대한 고민
방법1
- path에서 documentId를 가져와 현재 documentList의 state를 순회하며, 존재 여부를 검사한 후 API요청을 진행한다.
방법2
- 입력한 path의 documentId로 GET API 요청을 진행하고, 404에러를 캐치하여, 이에 대한 후속처리를 진행한다.
해결방법
- 처음에는 API 콜을 줄이는 것이 좋지 않을까하여, 방법1 처럼 구현을 시도하였다.
- 하지만, 현재 documentList의 state 구조가 루트 도큐먼트를 기준으로 중첩배열의 형식으로 담겨 있어서,
빠른 탐색에 적합하지 않은 구조
라고 판단하였고, 코드흐름
에 있어서도, API 콜 후 후속 에러처리하는 방식이 적합하다고 생각하였다.
- try - catch
9. 404에러 처리를, 모든 api를 처리하고 있는 request.js에서 catch 하여 처리하는 것이 맞나?
404에 대한 후속 처리( 404pag로 pushState)는 API 요청이 발생한 곳에서 해주는 것이 코드의 흐름을 파악하기에 더 용이하다고 생각했다.
따라서
response.status === 404
를 통해 404에러를 특정해내고, err.name을 지정하여 throw해준다.
이를 API 요청하는 곳 (Editor)에서 catch로 받아 후속처리를 해주도록 하였다.10. focus 처리, contentEditable에서 focus 가장 뒤로 보내기
- 참고링크 (링크1)
function setEndOfContenteditable(contentEditableElement) { var range, selection; range = document.createRange(); //Create a range (a range is a like the selection but invisible) range.selectNodeContents(contentEditableElement); //Select the entire contents of the element with the range range.collapse(false); //collapse the range to the end point. false means collapse to end rather than the start selection = window.getSelection(); //get the selection object (allows you to change selection) selection.removeAllRanges(); //remove any selections already made selection.addRange(range); //make the range you have just created the visible selection }