NextJS AppRouter router.events 구현

khxxjxx·2024년 3월 9일
1
post-thumbnail
  • nextjs 13 버전부터 기존 pages 디렉토리에서 라우팅 되던 방식과 다르게, app 디렉토리로 라우팅 하는 방식으로 변경되었다
  • 그래서 useRouter 사용시 import 하는 path의 위치도 변경되었는데 이로인해 기존에 사용하던 router.events를 사용할 수 없게 되었다...
import { useRouter } from 'next/router'; // 기존 pages 라우팅
import { useRouter } from 'next/navigation'; // app 라우팅
  • 공식문서를 찾아본 결과 라우터 이벤트에 대한 문서가 있었지만 해당 문서는 router 변경을 감지하는 로직으로 라우터 변경 이전에 미리 알 수 있는 방법은 나와있지 않았다..
  • 웹앱을 만들다보면 페이지 이동 전 팝업을 띄워야 하는 등 라우터 변경 전 감지를 해야하는 상황이 종종 발생하는데 이번에 새로 진행하는 프로젝트에서도 역시나 해당 기능 구현이 필요한 상황,,,
  • nextjs 깃허브에 방문해보니 나와 같은 사람이 많았고 여러 사람들이 해결 방안을 알려주었지만 맘에 드는 해결방안이 보이지 않았음..
  • 결국 코드를 직접 짜게 되는데 솔직히 이게 맞는지 잘 모르겠지만 nextjs에서 router.events를 추가해주지 않는이상 기능 구현은 필요했고 부족하지만 혹시 나처럼 router.events 기능을 찾는 사람을 위해 기록을 남기기로 결정!
  • 아래는 구현 코드 ↓↓↓↓

Nextjs router events

  • 24.04.05 브라우저 뒤로가기와 async 함수 작동안되는 문제로 코드 수정

// app/ContextConsumer.tsx

'use client';

import { useCallback, useEffect, useRef } from 'react';
import {
  AppRouterContext,
  AppRouterInstance,
} from 'next/dist/shared/lib/app-router-context.shared-runtime';
import _ from 'lodash';

import type { RouterEvent } from '@@types/AppRouterInstance';

const coreMethodFields = ['push', 'replace', 'refresh', 'back', 'forward'] as const;

const routerEvents: Record<RouterEvent, Array<(url: string, options?: any) => void>> = {
  routeChangeStart: [],
  routeChangeComplete: [],
};

const ContextConsumer = ({ children }: { children: React.ReactNode }) => {
  const index = useRef(0);
  const routeEventInfo = useRef<{ direction: 'back' | 'forward'; url: string }>();

  const events: AppRouterInstance['events'] = {
    on(event: RouterEvent, cb: (url: string, options?: any) => void) {
      routerEvents[event] = [...routerEvents[event], cb];
    },
    off(event: RouterEvent, cb: (url: string, options?: any) => void) {
      routerEvents[event] = _.without(routerEvents[event], cb);
    },
  };

  function proxy(router: AppRouterInstance, field: (typeof coreMethodFields)[number]) {
    const method = router[field];

    Object.defineProperty(router, field, {
      get: () => {
        return async (url: string, options?: any) => {
          try {
            if (!_.isEmpty(routerEvents.routeChangeStart)) {
              const promiseList = routerEvents.routeChangeStart.map((cb) => cb(url, options));
              await Promise.all(promiseList);
            }
            method(url, options);
          } catch (e) {
            console.error(e);
          }
        };
      },
    });
  }

  const eventListenerHandler = useCallback(
    (listener: EventListenerOrEventListenerObject) => async (event: Event) => {
      const eventListener = 'handleEvent' in listener ? listener.handleEvent : listener;

      if (event.type === 'popstate') {
        if (!_.isEmpty(routerEvents.routeChangeStart)) {
          const historyIndex = window.history.state?.index ?? 0;
          const routeInfo = routeEventInfo.current;

          if (routeInfo && historyIndex === index.current) {
            try {
              const { url, direction } = routeInfo;

              const promiseList = routerEvents.routeChangeStart.map((cb) => cb(url, event));
              await Promise.all(promiseList);

              return window.history[direction]();
            } catch (e) {
              routeEventInfo.current = undefined;
              return console.error(e);
            }
          } else if (routeInfo) {
            routeEventInfo.current = undefined;
          } else {
            const backEvent = index.current > historyIndex;
            const forwardEvent = index.current < historyIndex;

            const pathname = window.location.pathname;
            const query = window.location.search;
            const url = pathname + query;

            routeEventInfo.current = { direction: backEvent ? 'back' : 'forward', url };
            if (backEvent) window.history.forward();
            else if (forwardEvent) window.history.back();

            return;
          }
        }
      }

      eventListener(event);
    },
    []
  );

  useEffect(() => {
    const originAddEventListener = window.addEventListener;

    window.addEventListener = function <K extends keyof WindowEventMap>(
      type: K,
      listener: EventListenerOrEventListenerObject,
      options?: boolean | AddEventListenerOptions
    ) {
      originAddEventListener(type, eventListenerHandler(listener), options);
    };

    return () => {
      window.addEventListener = originAddEventListener;
    };
  }, [eventListenerHandler]);

  useEffect(() => {
    const originalPushState = window.history.pushState;
    const originalReplaceState = window.history.replaceState;

    index.current = window.history.state?.index ?? 0;

    window.history.pushState = (data: any, _: string, url?: string | URL | null) => {
      const historyIndex = window.history.state?.index ?? 0;
      const nextIndex = historyIndex + 1;
      const state = { ...data, index: nextIndex };

      index.current = nextIndex;

      return History.prototype.pushState.apply(window.history, [state, _, url]);
    };
    window.history.replaceState = (data: any, _: string, url?: string | URL | null) => {
      const historyIndex = window.history.state?.index ?? 0;
      const state = { ...data, index: historyIndex };

      index.current = historyIndex;

      return History.prototype.replaceState.apply(window.history, [state, _, url]);
    };

    return () => {
      window.history.pushState = originalPushState;
      window.history.replaceState = originalReplaceState;
    };
  }, []);

  return (
    <AppRouterContext.Consumer>
      {(router) => {
        if (router) {
          router.events = events;
          coreMethodFields.forEach((field) => proxy(router, field));
        }
        return children;
      }}
    </AppRouterContext.Consumer>
  );
};

export default ContextConsumer;
  • 초기에 history에 index를 가져와 index.current 값을 할당해주는데 새로고침시 항상 0으로 셋팅는 문제가 있어 확인해보니 nextjs에서 history를 덮어쓰는것이엇다 그래서 찾아보니 14.0.3버전에 history를 커스텀할 수 있도록 실험적인 기능이 릴리즈 되었다
  • 아래처럼 next.config.js에 추가해주면 된다
// next.config.js

/** @type {import('next').NextConfig} */
const nextConfig = {
  experimental: {
    windowHistorySupport: true,
  }
};

module.exports = nextConfig;
  • 다른 방법도 많겠지만 기존에 사용하던 router.events.on 방식으로 사용하고 싶어서 router.events에 새롭게 만든 events를 추가했다
  • 나는 typescript를 사용하기 때문에 router에 events를 추가하면 AppRouterInstance 형식에 events 속성이 없다며 에러가 나기 때문에 d.ts 파일에 declare module 형태로 이미 정의되어 있는 AppRouterInstance 형식을 확장해 주었다
// @types/AppRouterInstance.d.ts

import 'next/dist/shared/lib/app-router-context.shared-runtime';

export type RouterEvent = 'routeChangeStart' | 'routeChangeComplete';

declare module 'next/dist/shared/lib/app-router-context.shared-runtime' {
  interface AppRouterInstance {
    events: {
      on: (event: RouterEvent, cb: (url: string, options?: any) => void) => void;
      off: (event: RouterEvent, cb: (url: string, options?: any) => void) => void;
    };
  }
}
  • RouterEvent 타입에 'routerChangeComplete'도 있지만 현재 필요한 기능은 아니어서 따로 구현x
  • 이후 layout 파일에서 body(children)를 만들어둔 ContextConsumer 파일로 감싸주면 완료
// app/layout.tsx

...
<ContextConsumer>
  <body>
	{children}
  </body>
</ContextConsumer>
  • 이제 라우터 변경 감지가 필요한 곳에서 router.events.on('routerChangeStart', fn) 호출을 해주면 라우터 변경전 넘겨준 fn이 호출되게 되고 fn 안에서 throw를 해주면 라우터 변경이 일어나지 않게된다
  const testRouteChangeStart = async (url: string) => {
    if (url !== '/routeTest/save') {
      const isConfirmed = await Modal.confirm({ title: '주의!', content: '내용이 사라짐!' });

      if (!isConfirmed) throw Error;
    }
  };

  useEffect(() => {
    router.events.on('routeChangeStart', testRouteChangeStart);
    return () => router.events.off('routeChangeStart', testRouteChangeStart);
  }, [router.events]);
  • 이벤트의 콜백을 받을 때 첨엔 하나의 콜백만 등록가능하게 해두었으나 혹시 몰라 콜백을 저장하는 곳의 type을 배열로 변경한 후 하나의 이벤트에 여러개의 콜백을 등록할 수 있게 변경하였다
  • 그렇기 때문에 off 할때 off 할 콜백을 다시 전달해주어야 하고 다른 페이지에 들어가서도 콜백이 실행되는걸 막기 위해선 didmount시 events를 꼭!! off 해주어야 한다
  • 위 코드를 짜면서 가장 어려운 부분이 popstate처리 부분이었는데 써치해보니 뒤로가기 막는 로직은 대부분 history.pushState를 이용하는거 같았다. 해당 방법을 사용하면 state가 이중으로 쌓여있어서 뒤로가기 했을 경우에 url이 깜빡거리는 증상은 없겠지만 pushState로 인해 새로운 히스토리가 쌓여 앞으로 가기가 사라진다는 것이다.. 그거 때문에 몇시간을 잡고 있었던거 같은데.. 결국 완벽하진 않아서 우려했던 url 깜빡 거리는 현상이 있다...ㅠ
profile
코린이

0개의 댓글