[Project] JavaScript로 라우트 구현하기

young_pallete·2021년 8월 31일
0

no#tation

목록 보기
3/11
post-thumbnail

시작하며 🌈

후! 어제 오늘 거의 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로 해주었답니다.

페이지를 살펴볼까요?!

MainPage.js

어제와 엄~청 많이 바뀌었답니다.
고민 끝에, 노션처럼 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);
  };
}

PostEditPage.js

여기도 정~말 많이 바뀌긴 했는데, 고민 끝에 렌더링을 다음과 같이 생각하니, 한결 편해지더라구요.

렌더링: 화면 위에 그리는 것.

이 렌더링의 주도권을 누가 갖고 있어야 되냐!라고 물었을 때,
결국 페이지가 들고 있는 게 좀 더 확실한 것 같아서 페이지에서 그려내기로 결심했어요.

왜냐하면, 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.js

대신 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 });
  });
}

router

아, 결국에는 컴포넌트는 이렇게 설정했는데, 어떻게 라우팅을 했냐구요?!
저는 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를 연동하여 본격적인 노션을 만들어봅시다!!

다들, 즐거운 코딩하시길!😃😄😝

profile
People are scared of falling to the bottom but born from there. What they've lost is nth. 😉

0개의 댓글