후! 어제 오늘 거의 10시간 동안 헤매다가 결국 구현해냈어요! 🖐🏻🖐🏻🖐🏻
사실 원리는 얼추 알고 있었는데, 제 코드를 다 뒤엎어야 하는 케이스라,
결국 다른 꼼수(?)를 찾다가 바꾸게 됐네요.
하지만 결국 귀한 걸 얻었어요.
망설일까, 말까할 때는 그냥 질러버리는 거죠 👍👍
그렇다면 어떻게 구현했는지 살펴 볼까요?!
저는 일단 먼저 App
에서 다음과 같이 페이지를 놓았어요!
여기서 잘 살펴야 하는 것이, 바로 SPA
의 특징인데요,
저렇게 라우트와 별개로 위쪽에 페이지를 인스턴스로 쫙~호출해줌으로써, 나중에 편하게 라우트 이동에 따라 갖다 붙일 수 있는 것이죠.
덕분에 초기 로딩은 좀 걸리지만, 나중에는 빠르다는 이점이 있습니다! 👍👍
사실 이걸 포기하려다, 취지상 일관성을 위해, 시간을 더 투자했네요!😅
코드는 다음과 같아요.
import router from './apis/router.js';
import MainPage from './pages/MainPage.js';
import PostEditPage from './pages/PostEditPage.js';
import { READ_POST_ROUTE } from '../src/utils/constants.js';
import removeAllChildNodes from './utils/removeAllChildNodes.js';
export default function App({ $target }) {
const postEditPage = new PostEditPage({
$target,
initialState: {
postId: 'new',
},
});
const mainPage = new MainPage({
$target,
initialState: {
username: 'jengyoung',
documents: [],
},
onClick: id => {
history.pushState(null, null, READ_POST_ROUTE + `/${id}`);
this.route();
},
});
this.route = () => {
removeAllChildNodes($target); // App 초기화
const { pathname } = window.location;
const splitedPath = pathname.split('/');
if (pathname === undefined || pathname === '/') {
mainPage.setState();
} else if (pathname.indexOf(READ_POST_ROUTE + '/') === 0) {
const postId = splitedPath[2];
postEditPage.setState({ postId });
}
};
this.route();
router(() => this.route());
}
여기서 특이한 점이라면... 음 removeAllChildNodes
라고 해서, 노드들을 모두 지우는 함수를 하나 만들었네요! 별 거 없답니다. 그냥 while
문 써서 다 지워냈어요.
export default function removeAllChildNodes(node) {
while (node.hasChildNodes()) {
node.removeChild(node.lastChild);
}
}
특히 page에 설정된 state
가 새롭게 바뀔 때마다, 렌더링을 해주기 때문에 (정확히 말하자면, 같은 페이지 컴포넌트로 이동하는 경우(posts/1 -> posts/2)에 대한 대비겠죠)
저는 다음과 같이 setState
로 해주었답니다.
페이지를 살펴볼까요?!
어제와 엄~청 많이 바뀌었답니다.
고민 끝에, 노션처럼 SideBar
에 이름을 달기 보다, 그냥 개성 있게 헤더에 걸어주기로 했답니다. 나중에 이는 또, 따로 만들어볼 거에요!
import request from '../apis/request.js';
import Header from '../components/common/Header.js';
import SideBar from '../components/SideBar.js';
/*
{
username: string
documents: [<object>]
}
*/
export default function MainPage({
$target,
initialState = { username: '', documents: [] },
onClick,
}) {
this.state = initialState;
const $page = new DocumentFragment();
// const $page = document.createElement('div');
const sideBar = new SideBar({
$target: $page,
initialState,
onClick,
});
const header = new Header({
$target: $page,
headerSize: 'h5',
initialState: {
content: this.state.username,
},
});
this.setState = () => {
const posts = request();
this.state = {
...this.state,
documents: posts,
};
const { username, documents } = this.state;
header.setState({ content: username });
sideBar.setState({
username,
documents,
});
this.render();
};
this.render = () => {
$target.appendChild($page);
};
}
여기도 정~말 많이 바뀌긴 했는데, 고민 끝에 렌더링을 다음과 같이 생각하니, 한결 편해지더라구요.
렌더링: 화면 위에 그리는 것.
이 렌더링의 주도권을 누가 갖고 있어야 되냐!라고 물었을 때,
결국 페이지가 들고 있는 게 좀 더 확실한 것 같아서 페이지에서 그려내기로 결심했어요.
왜냐하면, setState
로 렌더링을 다시 그려낼 때, 결국 postForm
이 독립적으로 컴포넌트 내부에서 렌더링 되면 결국 다시 render
가 호출되지 않기 때문이죠!
import PostForm from '../components/PostForm.js';
import debounce from '../utils/debounce.js';
import { getItem, setItem } from '../utils/storage.js';
/*
* this.state = {
* postId: string,
* }
*/
export default function PostEditPage({
$target,
initialState = { postId: 'new' },
}) {
const $page = document.createDocumentFragment();
this.state = initialState;
const { postId } = this.state;
const defaultValue = { title: '', content: '' };
const post = getItem(getLocalPostKey(postId), defaultValue);
const postForm = new PostForm({
$target: $page,
initialState: {
...post,
},
onEdit: post => {
debounce(setItem, 2000)(getLocalPostKey(this.state.postId), { ...post });
},
});
// postId가 바뀔 때 페이지의 상태가 변화합니다!
this.setState = nextState => {
this.state = nextState;
const post = getItem(getLocalPostKey(this.state.postId), defaultValue);
postForm.setState(post);
this.render();
};
this.render = () => {
if ($target.querySelector('form') === null) {
postForm.render(); // 에디터의 경우 여기서 렌더링을 해줘야, setState할 때 다시 렌더링되지 않습니다.
}
$target.appendChild($page);
};
}
const getLocalPostKey = postId => {
return `temp-save-${postId}`;
};
대신 PostForm 컴포넌트의 경우에는 기존에 value
를 렌더링에서 바꿔줬는데, 컴포넌트 렌더링의 측면에서 이는 부적절한 것 같아 setState
에서 해주기로 했답니다.
대신, 이제는 컴포넌트 렌더링의 의존성이 좀 더 명확하게 파악이 되니, 나름 만족해요!😄
import Input from './common/Input.js';
export default function PostForm({
$target,
initialState = {
title: '',
content: '',
},
onEdit,
}) {
// 초기 컴포넌트를 DOM에 추가하고, 상태를 초기화합니다.
const $editor = document.createElement('form');
/*
* this.state = {
* title: string
* content: string
* }
*/
this.state = initialState;
/*************************************
* component *
*************************************/
const postTitle = new Input({
$target: $editor,
initialState: this.state.title,
onChange: title => {
const nextState = {
...this.state,
title,
};
this.setState(nextState);
postTitle.setState(title);
onEdit(this.state);
},
});
const $postContent = document.createElement('textarea');
this.setState = nextState => {
this.state = {
...this.state,
...nextState,
};
const { content } = this.state;
$postContent.value = content;
postTitle.setState(this.state.title);
};
this.render = () => {
$editor.appendChild($postContent);
$target.appendChild($editor);
};
$postContent.addEventListener('keyup', e => {
this.setState({
...this.state,
content: e.target.value,
});
onEdit({ ...this.state });
});
}
아, 결국에는 컴포넌트는 이렇게 설정했는데, 어떻게 라우팅을 했냐구요?!
저는 historyAPI
를 사용했답니다.
같이 공부하는 종현님께 도움도 받아서, 뒤로가기도 어ㅡ썸하게 구현했답니다!
이 시대 최고의 개발자 종현님께 아낌 없는 박수를 👏👏👏
간단히 설명하자면, app
에서 설정한 this.route
를 매개변수로 받아서, 이를 이벤트에 따라 처리해줍니다!
// route change라는 이벤트를 발생시킵니다.
const DISPATCH_ROUTE_CHANGE = 'route-change';
export default function router(onRoute) {
window.addEventListener(DISPATCH_ROUTE_CHANGE, e => {
const { nextUrl } = e.detail;
if (nextUrl) {
history.pushState(null, null, nextUrl);
console.log('nextUrl', nextUrl);
onRoute();
}
});
window.addEventListener('popstate', () => {
onRoute();
});
}
export const push = nextUrl => {
window.dispatchEvent(
new CustomEvent(DISPATCH_ROUTE_CHANGE, {
detail: {
nextUrl,
},
}),
);
};
결과를 살펴 보죠!!
HTML 개발자로서 꽤나 이쁜 디자인에 어썸하군요!😅😅
일단 지금 라우트까지 해냈네요! 그렇다면 이제 기존에 구현되어 있는, 데브코스에서 제공한 api
를 연동하여 본격적인 노션을 만들어봅시다!!
다들, 즐거운 코딩하시길!😃😄😝