최신 클라이언트 측 라우팅: Navigation API

싱글 페이지 애플리케이션 빌드를 완전히 개편하는 새로운 API를 통해 클라이언트 측 라우팅을 표준화합니다.

Browser Support

  • Chrome: 102.
  • Edge: 102.
  • Firefox: 147.
  • Safari: 26.2.

Source

단일 페이지 애플리케이션(SPA)은 사용자가 사이트와 상호작용할 때 서버에서 완전히 새로운 페이지를 로드하는 기본 방법 대신 콘텐츠를 동적으로 다시 작성하는 핵심 기능으로 정의됩니다.

SPA는 History API를 통해 (또는 제한된 경우 사이트의 #hash 부분을 조정하여) 이 기능을 제공할 수 있었지만, 이는 SPA가 일반화되기 훨씬 전에 개발된 투박한 API이며 웹에서는 완전히 새로운 접근 방식이 필요합니다. Navigation API는 History API의 거친 부분을 단순히 패치하는 대신 이 공간을 완전히 개편하는 제안된 API입니다. (예를 들어 스크롤 복원은 History API를 재창조하려고 하지 않고 패치를 적용했습니다.)

이 게시물에서는 Navigation API를 개략적으로 설명합니다. 기술 제안서를 읽으려면 WICG 저장소의 초안 보고서를 참고하세요.

사용 예

Navigation API를 사용하려면 먼저 전역 navigation 객체에 "navigate" 리스너를 추가합니다. 이 이벤트는 기본적으로 중앙 집중식입니다. 사용자가 링크를 클릭하거나, 양식을 제출하거나, 뒤로/앞으로 이동하는 등의 작업을 수행한 경우 또는 탐색이 프로그래매틱 방식으로(즉, 사이트의 코드를 통해) 트리거된 경우 등 모든 유형의 탐색에 대해 발생합니다. 대부분의 경우 코드가 해당 작업에 대한 브라우저의 기본 동작을 재정의할 수 있습니다. SPA의 경우 사용자를 동일한 페이지에 유지하고 사이트 콘텐츠를 로드하거나 변경하는 것이 좋습니다.

NavigateEvent는 대상 URL과 같은 탐색에 관한 정보를 포함하는 "navigate" 리스너에 전달되며, 이를 통해 한 중앙 위치에서 탐색에 응답할 수 있습니다. 기본 "navigate" 리스너는 다음과 같습니다.

navigation.addEventListener('navigate', navigat>eEvent = {
  // Exit early if this navigation shouldn't be intercepted.
  // The properties to look at are discussed later in the article.
  if (shouldNotIntercept(navigateEvent)) return;

  const url = new URL(navigateEvent.destination.url);

  if (url.pathname === '/') {
    navigateEvent.intercept({handler: loadIndexPage});
  } else if (url.pathname === '/cats/') {
    navigateEvent.intercept({handler: loadCatsPage});
  }
});

다음 두 가지 방법 중 하나로 탐색을 처리할 수 있습니다.

  • 탐색을 처리하기 위해 intercept({ handler })를 호출합니다 (위 설명 참고).
  • preventDefault()를 호출하여 탐색을 완전히 취소할 수 있습니다.

이 예시에서는 이벤트에서 intercept()를 호출합니다. 브라우저가 handler 콜백을 호출하며, 이 콜백은 사이트의 다음 상태를 구성해야 합니다. 이렇게 하면 다른 코드에서 탐색 진행 상황을 추적하는 데 사용할 수 있는 전환 객체 navigation.transition가 생성됩니다.

intercept()와 preventDefault()는 일반적으로 허용되지만 호출할 수 없는 경우도 있습니다. 탐색이 교차 출처 탐색인 경우 intercept()를 통해 탐색을 처리할 수 없습니다. 또한 사용자가 브라우저에서 뒤로 또는 앞으로 버튼을 누르는 경우 preventDefault()를 통해 탐색을 취소할 수 없습니다. 사용자가 사이트에 갇히지 않도록 해야 합니다. (이 문제는 GitHub에서 논의 중입니다.)

탐색 자체를 중지하거나 가로챌 수 없더라도 "navigate" 이벤트는 계속 발생합니다. 정보를 제공하므로 코드가 예를 들어 사용자가 사이트를 떠나는 것을 나타내는 애널리틱스 이벤트를 로깅할 수 있습니다.

플랫폼에 다른 이벤트를 추가하는 이유는 무엇인가요?

"navigate" 이벤트 리스너는 SPA 내에서 URL 변경사항 처리를 중앙 집중화합니다. 이전 API를 사용하면 이 제안이 어렵습니다. History API를 사용하여 자체 SPA의 라우팅을 작성한 적이 있다면 다음과 같은 코드를 추가했을 수 있습니다.

function updatePage(event) {
  event.preventDefault(); // we're handling this link
  window.history.pushState(null, '', event.target.href);
  // TODO: set up page based on new URL
}
const links = [...document.querySelectorAll('a[href]')];
>links.forEach(link = link.addEventListener(9;click', updatePage));

이 방법은 괜찮지만 완전하지는 않습니다. 링크는 페이지에 표시되기도 하고 표시되지 않기도 하며, 사용자가 페이지를 탐색할 수 있는 유일한 방법은 아닙니다. 예를 들어 양식을 제출하거나 이미지 맵을 사용할 수 있습니다. 페이지에서 이러한 문제를 처리할 수도 있지만, 새로운 탐색 API를 통해 간소화할 수 있는 가능성이 많습니다.

또한 위에서는 뒤로/앞으로 탐색을 처리하지 않습니다. 이와 관련된 다른 이벤트가 있습니다("popstate").

개인적으로는 History API가 이러한 가능성을 지원하는 데 도움이 될 수 있다고 생각합니다. 하지만 브라우저에서 사용자가 뒤로 또는 앞으로를 누르는 경우에 응답하고 URL을 푸시하고 바꾸는 두 가지 표면 영역만 있습니다. 위에서 설명한 것처럼 클릭 이벤트 리스너를 수동으로 설정하는 경우를 제외하고 "navigate"에 대한 비유가 없습니다.

탐색 처리 방법 결정

navigateEvent에는 특정 탐색을 처리하는 방법을 결정하는 데 사용할 수 있는 탐색에 관한 많은 정보가 포함되어 있습니다.

주요 속성은 다음과 같습니다.

canIntercept
이 값이 false이면 탐색을 가로챌 수 없습니다. 크로스 오리진 탐색 및 크로스 문서 트래버설은 가로챌 수 없습니다.
destination.url
탐색을 처리할 때 고려해야 할 가장 중요한 정보일 것입니다.
hashChange
탐색이 동일한 문서이고 해시가 현재 URL과 다른 URL의 유일한 부분인 경우 true입니다. 최신 SPA에서 해시는 현재 문서의 여러 부분에 연결하는 데 사용해야 합니다. 따라서 hashChange가 true이면 이 탐색을 가로채지 않아도 됩니다.
downloadRequest
이 값이 true이면 탐색이 download 속성이 있는 링크에 의해 시작된 것입니다. 대부분의 경우 이를 가로챌 필요가 없습니다.
formData
이 값이 null이 아니면 이 탐색이 POST 양식 제출의 일부입니다. 내비게이션을 처리할 때 이 점을 고려해야 합니다. GET 탐색만 처리하려면 formData이 null이 아닌 탐색을 가로채지 마세요. 이 도움말의 뒷부분에 있는 양식 제출 처리 예시를 참고하세요.
navigationType
"reload", "push", "replace", "traverse" 중 하나입니다. "traverse"인 경우 preventDefault()을 통해 이 탐색을 취소할 수 없습니다.

예를 들어 첫 번째 예에 사용된 shouldNotIntercept 함수는 다음과 같을 수 있습니다.

function shouldNotIntercept(navigationEvent) {
  return (
    !navigationEvent.canIntercept ||
    // If this is just a hashChange,
    // just let the browser handle scrolling to the content.
    navigationEvent.hashChange ||
    // If this is a download,
    // let the browser perform the download.
    navigationEvent.downloadRequest ||
    // If this is a form submission,
    // let that go to the server.
    navigationEvent.formData
  );
}

가로채기

코드가 "navigate" 리스너 내에서 intercept({ handler })를 호출하면 브라우저에 이제 새 업데이트된 상태를 위해 페이지를 준비하고 있으며 탐색에 시간이 걸릴 수 있다고 알립니다.

브라우저는 현재 상태의 스크롤 위치를 캡처하여 나중에 선택적으로 복원할 수 있도록 한 후 handler 콜백을 호출합니다. handler가 프라미스를 반환하면 (비동기 함수를 사용하면 자동으로 발생) 해당 프라미스는 브라우저에 탐색이 얼마나 걸리는지, 성공 여부를 알려줍니다.

navigation.addEventListener('navigate', navigat>eEvent = {
  if (shouldNotIntercept(navigateEvent)) return;
  const url = new URL(navigateEvent.destination.url);

  if (url.pathname.startsWith('/articles/')) {
    navigateEvent.intercept({
      async handler() {
        const articleContent = await getArticleContent(url.pathname);
        renderArticlePage(articleContent);
      },
    });
  }
});

따라서 이 API는 브라우저가 이해하는 시맨틱 개념을 도입합니다. SPA 탐색이 현재 진행 중이며 시간이 지남에 따라 이전 URL과 상태에서 새 URL과 상태로 문서가 변경됩니다. 이를 통해 접근성을 비롯한 여러 가지 이점을 얻을 수 있습니다. 브라우저에서 탐색의 시작, 끝 또는 잠재적 실패를 표시할 수 있습니다. 예를 들어 Chrome은 기본 로드 표시기를 활성화하고 사용자가 중지 버튼과 상호작용할 수 있도록 허용합니다. (현재 사용자가 뒤로/앞으로 버튼을 통해 탐색하는 경우에는 발생하지 않지만 곧 수정될 예정입니다.)

탐색을 가로챌 때 새 URL은 handler 콜백이 호출되기 직전에 적용됩니다. DOM을 즉시 업데이트하지 않으면 이전 콘텐츠가 새 URL과 함께 표시되는 기간이 발생합니다. 이는 데이터를 가져오거나 새 하위 리소스를 로드할 때 상대 URL 확인과 같은 항목에 영향을 미칩니다.

URL 변경을 지연하는 방법이 GitHub에서 논의되고 있지만, 일반적으로 들어오는 콘텐츠의 자리표시자로 페이지를 즉시 업데이트하는 것이 좋습니다.

navigation.addEventListener('navigate', navigat>eEvent = {
  if (shouldNotIntercept(navigateEvent)) return;
  const url = new URL(navigateEvent.destination.url);

  if (url.pathname.startsWith('/articles/')) {
    navigateEvent.intercept({
      async handler() {
        // The URL has already changed, so quickly show a placeholder.
        renderArticlePagePlaceholder();
        // Then fetch the real data.
        const articleContent = await getArticleContent(url.pathname);
        renderArticlePage(articleContent);
      },
    });
  }
});

이렇게 하면 URL 해결 문제가 방지될 뿐만 아니라 사용자에게 즉시 응답하므로 속도도 빠릅니다.

중단 신호

intercept() 핸들러에서 비동기 작업을 할 수 있으므로 탐색이 중복될 수 있습니다. 다음과 같은 경우에 발생합니다.

  • 사용자가 다른 링크를 클릭하거나 일부 코드가 다른 탐색을 실행합니다. 이 경우 새 탐색이 우선시되어 이전 탐색은 사용되지 않습니다.
  • 사용자가 브라우저에서 '중지' 버튼을 클릭합니다.

이러한 가능성을 처리하기 위해 "navigate" 리스너에 전달된 이벤트에는 AbortSignal인 signal 속성이 포함됩니다. 자세한 내용은 중단 가능한 가져오기를 참고하세요.

간단히 말해 작업을 중지해야 할 때 이벤트를 실행하는 객체를 제공합니다. 특히 탐색이 선점되면 진행 중인 네트워크 요청을 취소하는 AbortSignal를 fetch()에 대한 호출에 전달할 수 있습니다. 이렇게 하면 사용자의 대역폭이 절약되고 fetch()에서 반환된 Promise가 거부되어 이제 유효하지 않은 페이지 탐색을 표시하도록 DOM을 업데이트하는 등의 후속 코드가 실행되지 않습니다.

다음은 이전 예시에서 getArticleContent가 인라인으로 표시되어 AbortSignal를 fetch()와 함께 사용하는 방법을 보여줍니다.

navigation.addEventListener('navigate', navigat>eEvent = {
  if (shouldNotIntercept(navigateEvent)) return;
  const url = new URL(navigateEvent.destination.url);

  if (url.pathname.startsWith('/articles/')) {
    navigateEvent.intercept({
      async handler() {
        // The URL has already changed, so quickly show a placeholder.
        renderArticlePagePlaceholder();
        // Then fetch the real data.
        const articleContentURL = new URL(
          '/get-article-content',
          location.href
        );
        articleContentURL.searchParams.set('path', url.pathname);
        const response = await fetch(articleContentURL, {
          signal: navigateEvent.signal,
        });
        const articleContent = await response.json();
        renderArticlePage(articleContent);
      },
    });
  }
});

스크롤 처리

탐색을 intercept()하면 브라우저에서 자동으로 스크롤을 처리하려고 시도합니다.

새 기록 항목으로 이동하는 경우 (navigationEvent.navigationType이 "push" 또는 "replace"인 경우) URL 프래그먼트 (# 뒤에 오는 부분)로 표시된 부분으로 스크롤하거나 스크롤을 페이지 상단으로 재설정하려고 시도합니다.

새로고침 및 탐색의 경우 이는 이 기록 항목이 마지막으로 표시되었을 때의 스크롤 위치를 복원하는 것을 의미합니다.

기본적으로 이는 handler에서 반환된 프로미스가 해결된 후에 발생하지만 더 일찍 스크롤하는 것이 적절한 경우 navigateEvent.scroll()을 호출할 수 있습니다.

navigation.addEventListener('navigate', navigat>eEvent = {
  if (shouldNotIntercept(navigateEvent)) return;
  const url = new URL(navigateEvent.destination.url);

  if (url.pathname.startsWith('/articles/')) {
    navigateEvent.intercept({
      async handler() {
        const articleContent = await getArticleContent(url.pathname);
        renderArticlePage(articleContent);
        navigateEvent.scroll();

        const secondaryContent = await getSecondaryContent(url.pathname);
        addSecondaryContent(secondaryContent);
      },
    });
  }
});

또는 intercept()의 scroll 옵션을 "manual"로 설정하여 자동 스크롤 처리를 완전히 선택 해제할 수 있습니다.

navigateEvent.intercept({
  scroll: 'manual',