[TS] 노션 클로닝 - Router 구현하기

수연·2024년 6월 7일
0

JavaScript

목록 보기
2/3

0. Router

메인페이지를 구현하기 전에 각 페이지로 이동할 수 있도록 라우터를 만들어야 한다. 라우터를 만들기 전 필요한 기능을 먼저 정의해보자.

라우터 기능 정의

  1. 라우터 생성

    라우터를 만들어서 경로별로 렌더링할 페이지를 지정해줘야 한다.

  2. navigate & SPA

    링크를 전달하면 해당 링크의 페이지로 이동한다. 이동할 때 페이지는 SPA 처럼 새로고침을 하지 않고 이동한다.

  3. Outlet & children

    각 라우터에서 하위 요소를 지정하고 중첩 라우팅을 할 수 있어야 한다.

    이때 하위 요소는 React Router 의 Outlet 처럼 URL에 따라 달라지는 컴포넌트를 편하게 렌더링할 수 있어야 한다.

  4. 라우팅

    페이지 경로가 바뀌면 이전 라우트 요소들과 현재 라우트 요소들을 비교해 필요한 부분만 렌더링한다.

1. 라우터 생성

라우터 생성 시 각 경로에 맞게 렌더링할 컴포넌트를 지정해줘야 한다.

여러 형태가 있었지만 React Router DOM 의 형태가 가장 편리할 것 같아서 다음과 같은 형태로 라우터를 선언할 수 있도록 했다.

경로명 path 를 지정해주면 element 에 지정해준 요소를 렌더링한다. 라우터는 createRouter 를 통해 생성한다.

const router = createRouter([
  {
    path: '/',
    element: MainPage,
  },
  {
    path: '/documents',
    element: EditorPage,
  }
]);

처음에 createRouter 를 호출하면 Router 클래스는 전달해준 routes 들을 저장한다.

routes 변수는 createRouter 에서 받아온 라우트들이 저장된다. URL 이 변경되면 routes 변수를 참조해서 렌더링할 컴포넌트를 currRoutes 에 저장하게 된다.

class Router {
  private routes: Route[] | undefined;
  currRoutes: CurrRoute[];

  constructor() {
    this.currRoutes = [];
  }

  createRouter(routes: Route[]) {
    this.routes = routes;
    this.routing();

    return this;
  }
  // ...
}

그리고 createRouter 는 라우터 생성과 동시에 현재 경로에 맞는 목록들을 currRoutes 에 저장하기 위해 this.routing() 을 실행한다. 즉 createRouter 의 역할은 2가지이다.

  1. 라우터 초기화시 받은 값을 this.routes 에 저장한다.
  2. createRoute 가 호출될 당시의 URL 로 라우팅한다. 라우팅이 완료되면 this.currRoutes 에 렌더링할 컴포넌트들이 담긴다.

2. Navigate & SPA

이제 라우터의 경로이동 메서드를 구현해야한다. React Router 의 navigate 처럼 이동하고자 하는 경로를 받으면 해당 경로로 이동한다.

navigate('/documents');

navigate 메서드를 구현하기 위해 history API 를 사용해서 구현한다. history API 를 사용하면 현재 URL 을 새로고침 없이 변경할 수 있다.

navigate(url: string) {
  if (url === location.pathname) return;

  history.pushState(null, '', url);
  this.routing();
}

navigate 함수가 하는 역할은 두가지이다.

  • history API 로 url 을 변경한다.
  • routing 함수를 호출해서 새로운 경로에 맞는 컴포넌트를 currRoutes 에 넣는다.

여기서 navigate 함수를 호출하면 routing 함수가 호출되어 다시 새로 라우팅을 진행하지만, 뒤로가기, 앞으로 가기 시에도 navigate 같은 동작을 시켜주기 위해 popstate 이벤트를 등록해준다.

class Router {
  private routes: Route[] | undefined;
  currRoutes: CurrRoute[];

  constructor() {
    this.currRoutes = [];
		// popstate 이벤트 시 라우팅하기
    window.addEventListener('popstate', () => {
      this.routing();
    });
  }

  createRouter(routes: Route[]) {
    this.routes = routes;
    this.routing();

    return this;
  }

  navigate(url: string) {
    if (url === location.pathname) return;

    history.pushState(null, '', url);
  }
  
  routing() {
	  // ... 
  }
}

추가로 컴포넌트가 Router 클래스에 있는 메서드에 직접 접근하는 것을 막기 위해서 Service 와 Router 클래스를 분리해놓았다.

📦router
 ┣ 📜Router.ts
 ┣ 📜Service.ts
 ┗ 📜index.ts

그래서 Service 의 navigate 는 단순히 Router 의 navigate 를 한번 감싸서 호출해주는 역할을 한다.

export function navigate(url: string) {
  return Router.navigate(url);
}

3. Outlet & Children

원래 children 은 예정에 없었지만… 세진님이 열심히 구현하신 것을 보고 자극을 받아서 새로 구현하게 되었다.

세진님이 구현한 TS 라우터 글은 여기로 가면 볼 수 있다.

react-dom-router 처럼 children 을 프로퍼티로 받아서 부모 URL + 자식 URL 을 합쳐 현재 보여줄 페이지를 렌더링한다.

createRouter([{
	path: '/',
	element: MainPage,
	children: [
		{
			path: '/page1',
			element: Page,
		},
		{
			path: '/page2',
			element: Page2
		}
	]
}])

어떻게 컴포넌트에 children 을 보여줄 것인가?

위에서 this.currRoutes 에 렌더링할 컴포넌트를 담아놓는다고 했다. 그럼 여기 담긴 자식 컴포넌트를 부모 컴포넌트에게 알맞게 전달하면 된다.

세진님의 블로그를 참고했을 때 React Router 의 Outlet 컴포넌트를 사용한 것을 확인했다. 구현은 어려워보였지만 Outlet 을 컴포넌트마다 호출하면 코드를 읽기 쉬워질 것 같았다.

// .main 요소에 Oultet 을 반환받은 값을 전달해달라는 로직
this.children(Outlet(), '.main');

이때 라우팅 최적화를 URL 이 달라지더라도 필요한 컴포넌트만 렌더링 시키는 것을 고려하고 있었기 때문에

Outlet 을 호출하는 컴포넌트를 Router 에서 어떻게 관리하고, 다음 라우팅 시 필요한 컴포넌트만 리렌더링 시키지? 라는 고민을 했다.

고민 1.) 옵저버 패턴을 사용해서 컴포넌트에 Outlet 보여주기

처음엔 옵저버 패턴을 사용해보려고 했다.

Outlet 으로 각 컴포넌트의 render 메서드를 전달해주면, 메서드들을 모두 Router 에서 구독 해놨다가 currRoutes 가 변경되면 필요한 컴포넌트의 render 메서드만 실행시키고자 함이었다.

가장 먼저 컴포넌트 단에서 아래와 같이 Outlet 함수를 호출하며 자신의 render 함수를 전달한다.

// 컴포넌트 단

update() {
	this.children(Outlet(() => {
		this.render();
	}), '#outlet');
}

Outlet 함수에선 이런 render 함수를 Router 의 observes 에 저장하는 동시에, currRoutes 를 반환한다.

// Outlet

export function Oultet(observe: () => void) {
	const $outlet = Router.currRoutes.pop();
	Router.observes.push(observe);
	
	return $outelt;
}

이렇게 Outlet 이 중간자 역할을 해주면 Router 는 Outlet 을 호출하는 컴포넌트들의 render 함수를 가지게 된다.

하지만 이렇게 개발을 하면 다음과 같은 문제가 있었다.

  1. Outlet 호출마다 렌더링 함수를 일일이 전달해줘야 한다.
  2. 화살표 함수가 아닌 일반 함수를 이용하면 Router 컴포넌트에서 this.render 를 호출할 때 this 를 정확히 인지하지 못한다.
  3. this.render 를 호출하면 Outlet 부분만 다시 그려지는 것이 아니라 해당 컴포넌트가 전부 그려진다.

이런 문제로… 옵저버 방식으로 Outlet 을 반환하는 방식은 포기했다.

고민2.) 위 부분을 개선해서 Outlet 보여주기

  1. Outlet 호출마다 렌더링 함수를 일일이 전달하는 문제 & 화살표 함수가 아니면 this 를 인지하지 못하는 문제

    이 문제를 해결하기 위해 우선 컴포넌트단에서 Outlet 을 호출할 땐 다음과 같은 방식이어야 한다.

    update() {
    	this.children(Outlet(), '#outlet');
    }

    예시로 /page/:item 같은 경로가 있다고 가정해보자. 그렇다면 Router 클래스의 urrRoutes 에는 다음과 같은 값들이 담긴다.

    const currRoutes = [{
    	path: '/123',
    	element: Item
    	}, {
    	path: 'page',
    	element: Page
    }];

    그리고 페이지가 그려질 땐 Page 컴포넌트가 먼저 그려진 뒤, Item 이 그려지게 될 것이다.

    그리고 Page 컴포넌트가 Item 컴포넌트를 호출하기 전, 다음요소를 렌더링한다.

    template<() {
    	return `
    		<div>Page</div>
    		<div id='outlet'></div>
    	`
    }

    그렇다면 상위 컴포넌트가 Outlet 을 호출하면 할수록 #outlet 아이디를 가진 요소가 증가하게 된다.

    이때 Outlet 의 개수를 인덱스로 생각할 수 있다는 걸 이용했다.

    컴포넌트에서 Outlet 함수를 호출하면 가장 먼저 현재까지의 outlet 요소를 센다. 그리고 currRoutes 에서 인덱스에 해당하는 element 를 반환한다.

    export function Outlet() {
      const outletIndex = document.querySelectorAll('#outlet').length;
    
      if (outletIndex < Router.currRoutes.length) {
        const { element } = Router.currRoutes[Route.currRoutes.length - outletIndex];
    
        return element;
      }
    
      return null;
    }

    currRoutes 는 상위 요소일 수록 뒤쪽에 위치하게 되므로 currRoutes 의 크기에서 outlet 의 개수만큼 빼준다.

  2. Outlet 부분 뿐만 아니라 전체 컴포넌트가 렌더링되는 문제

    이 부분은 렌더링 함수를 전달하는 방식을 포기하며 자연스레 해결이 되었다. 정확히는 this.routing() 에서 라우팅 최적화를 하며 함께 처리하는 부분이기 때문에 아래에서 소개하겠다.

4. Routing

routing 메서드는 경로가 변경되면 렌더링해야할 요소들을 컴포넌트로 전달할 수 있도록 currRoutes 값을 새로 설정함으로써 화면을 다시 그리는 데 관여하는 메서드이다.

currRoutes 에 들어간 요소는 URL 에 맞게 렌더링해주어야 할 컴포넌트의 배열이다.

예를 들어 현재 URL 이 /page/item 이면 currRoutes 는 다음 요소들을 포함하고 있다. 자식 요소가 더 먼저 저장되는 stack 의 형태를 띠고 있다.

const currRoutes = [{
	path: '/item',
	element: Item
	}, {
	path: 'page',
	element: Page
}];

Component 를 currRoutes 에 저장하기

currRoutes 엔 경로에 따른 컴포넌트들이 저장되어야 한다.

경로에 맞는 컴포넌트를 어떻게 저장하나 고민하다 답이 나오지 않아서, 세진님의 블로그를 많이 참조했다.

우선 현재 경로에 맞는 컴포넌트를 저장하기 위해 findRoute 라는 유틸 함수를 Util 파일에 구현한다. Util 파일에 있는 함수는 모두 Router 에서만 호출한다.

export function findRoute(URL: string, startPath: string, routes: Route[]) {
	// ... 
}

findRoute 함수는 현재 URL 과 route 로 지정해준 경로인 path 를 비교해서 해당하는 컴포넌트 목록을 반환한다.

/page/:item 에 맞는 컴포넌트 반환하기

예를 들어 /page/:item 이라는 경로가 있다면 URL 은 /page/123, 등에 해당할 것이다.

createRouter 로 다음과 같이 라우터를 생성했다고 하자.

createRoute([{
	path: '/page',
	element: Page,
	children: [{
		path: '/:item'
		element: Item
	}]
}])

findRoute 에서 route 를 순회할 때 다음 요소를 검사한다.

  1. path 가 현재 URL 과 일치하는가?
  2. children 요소가 있는가?

만약 현재 탐색중인 path 가 URL 과 일치하면 해당 라우트를 바로 currRoutes 에 저장하고, 일치하지 않으면 children 을 재귀적으로 탐색해야 한다. 대략적인 로직은 이렇다.

export function findRoute(URL: string, startPath: string, routes: Route[]) {
	const currRoutes: CurrRoute[] = [];
	
	function find(prevPath: string, subRoutes: Route[]) {
		for (const route of routes) {
			const { path, children } = route;
			const currPath = (prevPath + path).replace('//', '/');
			
			if (isMatchedPath(currPath, URL)) { // 일치하는 경우 자식 노드 반환
				currRoutes.push(route);
				return route;
			}
			if (children && children.length > 0) { // 자식이 있는 경우 재귀 탐색
				const matchedRoute = find(currPath, children);
				
				if (mathcedRoute) {
					currRoutes.push(route);
					return route;
				}
			}
		}
		return null; // 일치하지 않으면 null 반환
	}
}

URL이 일치하는지 확인하는 isMatchedPath

findRoutes 함수에서 URL 과 현재까지 탐색한 경로인 currPath 가 일치하는지 확인하는 isMatchedPath 함수는 / 문자열을 기준으로 토큰을 분리하여 경로가 일치하는지 확인한다.

반환값으론 다음을 포함한다.

  const result: {
    params: Params; // 동적 파라미터에 하당하는 params 값
    isMatched: boolean; // 일치하는지 여부
    paramId: string | undefined; // 동적 파라미터의 ID
  } = { params: {}, isMatched: false, paramId: undefined };

params 와 paramId 는 동적 라우팅을 위해 사용한다. 예를 들어 /page/:item 이 있다면 params 와 paramId 엔 각각 다음 값이 담긴다.

{ 
	params: { item: 123 },
	isMatched: true,
	paramId: 'item'
}

이렇게 params 값을 따로 반환하는 이유는 React 의 useParams 처럼 동적 파라미터로 렌더링되는 각 컴포넌트에서 파라미터에 접근할 수 있도록 하기 위함이다.

전체 로직은 다음과 같다.

function isMathcedPath(URL: string, startPath: string) {
  const result: {
    params: Params;
    isMatched: boolean;
    paramId: string | undefined;
  } = { params: {}, isMatched: false, paramId: undefined };

	// 1. '/' 문자을 기준으로 토큰화한다.
  const URLTokens = URL.split('/');
  const startPathTokens = startPath.split('/');

	// 2. 예외처리 (URL 이 일치하는 경우 토큰 검사 생략)
  if (URL === startPath) {
    result.isMatched = true;
    return result;
  }

	// 3. isMatched 는 다음과 같이 결정된다.
		// 3-1. 토큰의 개수가 같은지
		// 3-2. 각 토큰의 문자열이 일치하는지
		// 3-3. ':' 으로 시작하는 문자열은 동적 파라미터이므로 따로 예외처리
  result.isMatched =
    startPathTokens.length === URLTokens.length &&
    startPathTokens.every((token, index) => {
      if (token.startsWith(':')) {
        const [param, value] = [token.slice(1), URLTokens[index]];
        result.params[param] = value;
        result.paramId = token.slice(1);

        return true;
      }
      if (token === URLTokens[index]) {
        return true;
      }

      return false;
    });

  return result;
}

isMatchedPath 값을 이용한 후처리

findRoute 에서 일치하는 URL 인 경우 currRoutes 에 저장하면 된다고 했다.

if (isMatchedPath(currPath, URL)) { // 일치하는 경우 자식 노드 반환
	currRoutes.push(route);
	return route;
}

이를 다시 isMatchedPath 에서 반환하는 값을 사용하여 수정해준다.

const { isMatched, params, paramId } = isMathcedPath(URL, currPath);
const purePath = getPurePathString(path);
const pathString = paramId ? `${purePath}/${params[paramId]}` : purePath;

if (isMatched) {
  currRoutes.push({ ...route, path: pathString, params });
  return route;
}

여기서 purePathpathString 을 따로 추가해주었다. 그 이유인 즉슨 createRouter 로 path 값을 다양하게 받을 수 있게 해주고 싶어서다.

  • 이전 URL : /page/1
  • 변경할 URL : /page/2

이라고 가정해보자. 라우터를 초기화할 때, 아래와 같이 작성할 수 있다.

createRoute([{
	path: '/page',
	element: Page,
	children: [{
		path: '/:item'
		element: Item
	}]
}])

하지만 내 경우는 아래와 같이 Page 컴포넌트를 불러오고 있었다.

createRoute([{
	path: '/page/:item',
	element: Page
}]);

라우팅을 할 때 nextRoutes 와 currRoutes 의 경로를 비교해서 필요한 부분만을 렌더링 하는데, '/page/:item' 가 하나의 묶음인 이상 /page/1/page/2 전체를 비교해야한다.

/page 경로에 맞는 컴포넌트나 /1 나, /2 에만 해당하는 컴포넌트가 없기 때문이다.

findRoutes 전체 로직

findRoutes 의 전체 로직은 다음과 같다.

function getPurePathString(path: string) {
  const pathToken = path.split('/');
  const purePath: string[] = [];

  for (const token of pathToken) {
    if (token.startsWith(':')) {
      break;
    }
    purePath.push(token);
  }

  return purePath.join('/');
}

export function findRoute(URL: string, startPath: string, rootRoutes: Route[]) {
  const currRoutes: CurrRoute[] = [];

  function find(prevPath: string, subRoutes: Route[]) {
    for (const route of subRoutes) {
      const { path, children } = route;
      const currPath = (prevPath + path).replace('//', '/');
      const { isMatched, params, paramId } = isMathcedPath(URL, currPath);
      const purePath = getPurePathString(path);
      const pathString = paramId ? `${purePath}/${params[paramId]}` : purePath;

      if (isMatched) {
        currRoutes.push({ ...route, path: pathString, params });
        return route;
      }
      if (children) {
        const matchedRoute = find(currPath, children);

        if (matchedRoute !== null) {
          currRoutes.push({
            ...route,
            path: pathString,
            params,
          });

          return route;
        }
      }
    }
    return null;
  }
  find(startPath, rootRoutes);

  return currRoutes;
}

라우팅 최적화 하기

findRoute 는 URL 에 일치하는 컴포넌트 배열을 넘겨준다고 했다. 그리고 이를 currRoutes 에 저장한다.

여기서 세진님께서 필요한 부분만을 렌더링 할 수 있도록 라우터를 최적화하신 것을 보고 나도 욕심이 났다.

그래서 단순히 currRoutes 에 있는 컴포넌트를 모두 App 에서 다시 렌더링하는 것이 아니라, nextRoutes 와 currRoutes 를 비교하고자 했다.

this.routing() {
	const nextRoutes = findRoute(location.pathname, '/', this.routes);
	// 1. 달라지는 부분을 저장한다
	const [renderNode, $element] = findRenderNode(nextRoutes, this.currRoutes);
	// 2. currRoutes 에 새로운 라우팅 정보를 덮어씌운다
	this.currRoutes = nextRoutes;
	
	// 3. 달라지는 부분만 렌더링한다
	//	...
}

findRenderNode 는 새로운 라우팅과 이전의 라우팅을 상위 요소부터 비교하여 새로 그려야하는 노드와, 노드에 들어갈 컴포넌트를 반환한다. 예를 들어 다음 경로가 있다고 가정하자.

  • /main/page2/item2
  • /main/page3/item1

그럼 여기서 다시 그릴 부분은 /main 경로 아래에 있는 Page 컴포넌트다.

따라서 findRenderNode 노드는 /main 아래 DOM 엘리먼트와, Page 컴포넌트를 반환해준다.

export function findRenderNode(
  nextRoutes: CurrRoute[],
  currRoutes: CurrRoute[]
) {
  const outlets = document.querySelectorAll('#outlet');
  let depth = 0;
  let nextPointer = nextRoutes.length - 1;
  let currPointer = currRoutes.length - 1;

  while (
    outlets.length > depth &&
    currRoutes &&
    nextRoutes[nextPointer] &&
    currRoutes[currPointer]
  ) {
    if (nextRoutes[nextPointer].path !== currRoutes[currPointer].path) {
      break;
    }
    nextPointer -= 1;
    currPointer -= 1;
    depth += 1;
  }

  return [outlets[depth], nextRoutes[nextPointer].element] as const;
}

findRenderNode 로 부터 반환받은 DOM 요소와 컴포넌트를 반환받아 달라지는 부분만 렌더링하는 코드를 더하면, routing 의 전체 코드가 완성된다.

this.routing() {
	const nextRoutes = findRoute(location.pathname, '/', this.routes);
	// 1. 달라지는 부분을 저장한다
	const [renderNode, $element] = findRenderNode(nextRoutes, this.currRoutes);
	// 2. currRoutes 에 새로운 라우팅 정보를 덮어씌운다
	this.currRoutes = [...nextRoutes];

	if (renderNode instanceof HTMLElement) {
	  renderNode.innerHTML = '';
	  new $element({ $target: renderNode });
	} else {
	  const $app = document.querySelector('.App');
	  new $element({ $target: $app });
	}
}

완성!

처음엔 이런 라우터가 아니었는데… children 을 렌더링하기 위해 Outlet 을 도입하고, Outlet 을 도입하기 위해 최적화를 도입하고… 하다 보니 라우터의 덩치가 커졌다.

children 컴포넌트를 렌더링하고 싶다는 욕심 하나로 오랜 시간을 들여 라우터를 완성하게 되었다. 그리고 이 과정에서 세진님의 블로그를 많이 참조했는데, 역시 혼자 코드를 짜는 것보다 남의 코드를 보면서 많이 배우는 것 같다. 그리고 이 모든 걸 혼자 고민하고 구현한 세진님이 너무 대단해보였다. 🥹 

이제 라우터를 완성함으로써 한단계 앞으로 나아간 건데도 수많은 고민을 했다. 노션 클로닝 완성까지 많은 걸 배우게 될 것 같다는 예감이 든다.

0개의 댓글

관련 채용 정보