新型客户端路由:Navigation API

通过全新 API 实现客户端路由标准化,彻底改造单页应用的构建方式。

Jake Archibald
Jake Archibald

Browser Support

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

Source

单页应用 (SPA) 的核心功能是:在用户与网站互动时动态重写其内容,而不是采用从服务器加载全新网页的默认方法。

虽然 SPA 一直能够通过 History API(或在少数情况下通过调整网站的 #hash 部分)为您提供此功能,但这是一个在 SPA 成为常态之前很久就已开发的笨拙的 API,而 Web 迫切需要一种全新的方法。 Navigation API 是一项提议的 API,它完全颠覆了这一领域,而不是试图简单地修补 History API 的不足之处。(例如,滚动恢复功能修补了 History API,而不是尝试重新发明它。)

本文简要介绍了 Navigation API。 如需阅读技术提案,请参阅 WICG 存储区中的报告草稿

用法示例

如需使用 Navigation API,请先在全局 navigation 对象上添加 "navigate" 监听器。此事件从根本上来说是集中式的:它会针对所有类型的导航触发,无论用户是执行了操作(例如点击链接、提交表单或后退和前进),还是以程序化方式(即通过您网站的代码)触发了导航。 在大多数情况下,它允许您的代码替换浏览器针对相应操作的默认行为。对于 SPA,这可能意味着让用户停留在同一页面上,并加载或更改网站的内容。

系统会将 NavigateEvent 传递给 "navigate" 监听器,其中包含有关导航的信息(例如目标网址),让您可以在一个集中位置响应导航。 一个基本的 "navigate" 监听器可能如下所示:

navigation.addEventListener('navigate', navigateEvent => {
  // 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" 事件仍会触发。它具有信息性,因此您的代码可以记录一个 Analytics 事件,以表明用户正在离开您的网站。

为何要向平台添加其他活动?

"navigate" 事件监听器可集中处理 SPA 内的网址更改。使用旧版 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('click', updatePage));

这没问题,但并不详尽。 网页上的链接可能会时有时无,而且用户可以通过多种方式浏览网页。 例如,他们可能会提交表单,甚至使用图片地图。 您的网页可能需要处理这些情况,但还有很多可能性可以简化,而新的 Navigation API 正好可以实现这一点。

此外,上述代码不处理返回/前进导航。还有另一个事件可用于此目的,即 "popstate"

就我个人而言,History API 似乎在一定程度上可以帮助实现这些可能性。不过,它实际上只有两个方面:响应用户在浏览器中按“返回”或“前进”键的操作,以及推送和替换网址。它没有与 "navigate" 类似的方面,除非您手动设置点击事件的监听器(如上例所示)。

确定如何处理导航

navigateEvent 包含许多有关导航的信息,可用于决定如何处理特定导航。

关键属性包括:

canIntercept
如果此值为 false,您将无法拦截导航。 无法拦截跨源导航和跨文档遍历。
destination.url
在处理导航时,这可能是最重要的信息。
hashChange
如果导航是同一文档导航,并且哈希是与当前网址不同的唯一网址部分,则为 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 返回一个 promise(对于 async 函数,这是自动发生的),该 promise 会告知浏览器导航需要多长时间以及是否成功。

navigation.addEventListener('navigate', navigateEvent => {
  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 导航,随着时间的推移,文档会从之前的网址和状态更改为新的网址和状态。 这具有许多潜在优势,包括可访问性:浏览器可以显示导航的开始、结束或潜在失败。 例如,Chrome 会激活其原生加载指示器,并允许用户与停止按钮互动。(目前,当用户通过“返回”/“前进”按钮进行导航时,不会发生这种情况,但很快就会修复。)

在拦截导航时,新网址会在调用 handler 回调之前生效。如果您不立即更新 DOM,则会有一段时间显示旧内容以及新网址。这会影响获取数据或加载新子资源时的相对网址解析等操作。

目前正在 GitHub 上讨论延迟网址更改的方法,但一般建议立即更新网页,并使用某种占位符来表示即将到来的内容:

navigation.addEventListener('navigate', navigateEvent => {
  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);
      },
    });
  }
});

这不仅避免了网址解析问题,还因为您能立即响应用户而让用户感觉速度很快。

中止信号

由于您可以在 intercept() 处理程序中执行异步工作,因此导航可能会变得冗余。发生这种情况的原因如下:

  • 用户点击了另一个链接,或者某些代码执行了另一次导航。 在这种情况下,系统会舍弃旧导航,转而使用新导航。
  • 用户点击浏览器中的“停止”按钮。

为了应对上述任何一种可能性,传递给 "navigate" 监听器的事件包含一个 signal 属性,该属性是一个 AbortSignal。如需了解详情,请参阅可中止的提取

简而言之,它基本上提供了一个对象,当您应该停止工作时,该对象会触发一个事件。值得注意的是,您可以将 AbortSignal 传递给您对 fetch() 进行的任何调用,如果导航被抢占,则会取消正在进行的网络请求。 这样既可以节省用户带宽,又可以拒绝 fetch() 返回的 Promise,防止任何后续代码执行操作(例如更新 DOM 以显示现在无效的网页导航)。

以下是之前的示例,但其中内嵌了 getArticleContent,展示了如何将 AbortSignalfetch() 搭配使用:

navigation.addEventListener('navigate', navigateEvent => {
  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" 时),这意味着尝试滚动到网址片段(# 后面的部分)所指示的部分,或将滚动重置到页面顶部。

对于重新加载和遍历,这意味着将滚动位置恢复到上次显示此历史记录条目时的位置。

默认情况下,当 handler 返回的 promise 解析后,系统会执行此操作,但如果需要提前滚动,您可以调用 navigateEvent.scroll()

navigation.addEventListener('navigate', navigateEvent => {
  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',
  async handler() {
    // …
  },
});

焦点处理

handler 返回的 promise 解析后,浏览器会将焦点放在设置了 autofocus 属性的第一个元素上,如果没有元素设置了该属性,则将焦点放在 <body> 元素上。

您可以通过将 intercept()focusReset 选项设置为 "manual" 来选择不执行此行为:

navigateEvent.intercept({
  focusReset: 'manual',
  async handler() {
    // …
  },
});

成功和失败事件

当您的 intercept() 处理程序被调用时,可能会出现以下两种情况之一:

  • 如果返回的 Promise 得到兑现(或者您未调用 intercept()),Navigation API 将触发 "navigatesuccess" 并返回 Event
  • 如果返回的 Promise 被拒绝,API 将触发 "navigateerror" 并显示 ErrorEvent

借助这些事件,您的代码可以集中处理成功或失败情况。 例如,您可以通过隐藏之前显示的进度指示器来处理成功情况,如下所示:

navigation.addEventListener('navigatesuccess', event => {
  loadingIndicator.hidden = true;
});

或者,您也可以在失败时显示错误消息:

navigation.addEventListener('navigateerror', event => {
  loadingIndicator.hidden = true; // also hide indicator
  showMessage(`Failed to load page: ${event.message}`);
});

接收 ErrorEvent"navigateerror" 事件监听器非常实用,因为它可以保证接收到设置新网页的代码中的任何错误。您可以放心地使用 await fetch(),因为如果网络不可用,错误最终会路由到 "navigateerror"

navigation.currentEntry 提供对当前条目的访问权限。 这是一个对象,用于描述用户当前所在的位置。 此条目包含当前网址、可用于随时间推移识别此条目的元数据,以及开发者提供的状态。

元数据包括 key,这是每个条目的唯一字符串属性,表示当前条目及其位置。即使当前条目的网址或状态发生变化,此键也会保持不变。 它仍位于同一插槽中。 相反,如果用户按“返回”按钮,然后重新打开同一网页,则 key 会发生变化,因为此新条目会创建一个新 slot。

对于开发者来说,key 非常有用,因为 Navigation API 可让您直接将用户导航到具有匹配键的条目。即使在其他条目的状态下,您也可以按住它,以便轻松在页面之间跳转。

// On JS startup, get the key of the first loaded page
// so the user can always go back there.
const {key} = navigation.currentEntry;
backToHomeButton.onclick = () => navigation.traverseTo(key);

// Navigate away, but the button will always work.
await navigation.navigate('/another_url').finished;

Navigation API 引入了“状态”的概念,即开发者提供的信息,这些信息会持久存储在当前历史记录条目中,但不会直接向用户显示。 这与 History API 中的 history.state 非常相似,但有所改进。

在 Navigation API 中,您可以调用当前条目(或任何条目)的 .getState() 方法来返回其状态的副本:

console.log(navigation.currentEntry.getState());

默认情况下,此值为 undefined

设置状态

虽然可以更改状态对象,但这些更改不会保存回历史记录条目,因此:

const state = navigation.currentEntry.getState();
console.log(state.count); // 1
state.count++;
console.log(state.count); // 2
// But:
console.info(navigation.currentEntry.getState().count); // will still be 1

设置状态的正确方法是在脚本导航期间进行设置:

navigation.navigate(url, {state: newState});
// Or:
navigation.reload({state: newState});

其中,newState 可以是任何可克隆的对象

如果您想更新当前条目的状态,最好执行替换当前条目的导航:

navigation.navigate(location.href, {state: newState, history: 'replace'});

然后,您的 "navigate" 事件监听器可以通过 navigateEvent.destination 接收此更改:

navigation.addEventListener('navigate', navigateEvent => {
  console.log(navigateEvent.destination.getState());
});

同步更新状态

一般来说,最好通过 navigation.reload({state: newState}) 异步更新状态,然后 "navigate" 监听器可以应用该状态。不过,有时在您的代码得知状态变化时,状态变化已完全应用,例如当用户切换 <details> 元素或更改表单输入的状态时。在这些情况下,您可能需要更新状态,以便在重新加载和遍历时保留这些更改。您可以使用 updateCurrentEntry() 完成此操作:

navigation.updateCurrentEntry({state: newState});

我们还举办了一场活动,以便您了解此变更:

navigation.addEventListener('currententrychange', () => {
  console.log(navigation.currentEntry.getState());
});

不过,如果您发现自己正在 "currententrychange" 中对状态变化做出反应,那么您可能正在 "navigate" 事件和 "currententrychange" 事件之间拆分甚至复制状态处理代码,而 navigation.reload({state: newState}) 可让您在一个位置处理该代码。

状态与网址参数

由于状态可以是结构化对象,因此您可能会想将其用于所有应用状态。不过,在许多情况下,最好将该状态存储在网址中。

如果您希望在用户与其他用户分享网址时保留状态,请将状态存储在网址中。 否则,状态对象是更好的选择。

访问所有条目

不过,“当前条目”并非全部。 该 API 还提供了一种方法,可通过其 navigation.entries() 调用来访问用户在使用您的网站时浏览过的整个条目列表,该调用会返回一个条目快照数组。例如,这可用于根据用户导航到特定网页的方式显示不同的界面,或者仅用于回顾之前的网址或其状态。 使用当前的 History API 无法实现这一点。

您还可以监听各个 NavigationHistoryEntry 上的 "dispose" 事件,当相应条目不再是浏览器历史记录的一部分时,系统会触发该事件。这种情况可能会在常规清理过程中发生,也可能会在导航时发生。例如,如果您向后遍历 10 个位置,然后向前导航,系统会处置这 10 个历史记录条目。

示例

如上所述,"navigate" 事件会针对所有类型的导航触发。 (实际上,规范中有一个很长的附录,列出了所有可能的类型。)

虽然对于许多网站来说,最常见的情况是用户点击 <a href="...">,但还有两种值得介绍的更复杂的导航类型。

程序化导航

第一种是程序化导航,即导航是由客户端代码中的方法调用引起的。

您可以从代码中的任何位置调用 navigation.navigate('/another_page') 以触发导航。 这会由在 "navigate" 监听器上注册的集中式事件监听器处理,并且您的集中式监听器会同步调用。

此方法旨在改进对旧方法(如 location.assign() 及相关方法)以及 History API 的方法 pushState()replaceState() 的汇总。

navigation.navigate() 方法会返回一个对象,该对象在 { committed, finished } 中包含两个 Promise 实例。这样一来,调用者可以等待,直到过渡“提交”(可见网址已更改,并且有新的 NavigationHistoryEntry 可用)或“完成”(intercept({ handler }) 返回的所有 promise 都已完成或因失败或被另一导航抢占而遭拒)。

navigate 方法还有一个 options 对象,您可以在其中设置:

  • state:新历史记录条目的状态,可通过 NavigationHistoryEntry 上的 .getState() 方法获取。
  • history:可以设置为 "replace" 以替换当前历史记录条目。
  • info:要通过 navigateEvent.info 传递给 navigate 事件的对象。

具体而言,info 可用于(例如)表示导致下一页出现的特定动画。(另一种方法可能是设置全局变量或将其作为 #hash 的一部分包含在内。这两种方式都有些别扭。) 值得注意的是,如果用户稍后触发了导航(例如通过“返回”和“前进”按钮),系统不会重新播放此 info。 事实上,在这些情况下,它将始终为 undefined

从左侧或右侧打开的演示

navigation 还有许多其他导航方法,所有这些方法都会返回一个包含 { committed, finished } 的对象。我已提及 traverseTo()(接受表示用户历史记录中特定条目的 key)和 navigate()。 它还包括 back()forward()reload()。 这些方法都由集中式 "navigate" 事件监听器处理,就像 navigate() 一样。

表单提交

其次,通过 POST 提交 HTML <form> 是一种特殊的导航类型,Navigation API 可以拦截它。 虽然它包含额外的载荷,但导航仍由 "navigate" 监听器集中处理。

通过查看 NavigateEvent 上的 formData 属性,可以检测到表单提交。以下示例展示了如何通过 fetch() 将任何表单提交转换为停留在当前网页上的提交:

navigation.addEventListener('navigate', navigateEvent => {
  if (navigateEvent.formData && navigateEvent.canIntercept) {
    // User submitted a POST form to a same-domain URL
    // (If canIntercept is false, the event is just informative:
    // you can't intercept this request, although you could
    // likely still call .preventDefault() to stop it completely).

    navigateEvent.intercept({
      // Since we don't update the DOM in this navigation,
      // don't allow focus or scrolling to reset:
      focusReset: 'manual',
      scroll: 'manual',
      handler() {
        await fetch(navigateEvent.destination.url, {
          method: 'POST',
          body: navigateEvent.formData,
        });
        // You could navigate again with {history: 'replace'} to change the URL here,
        // which might indicate "done"
      },
    });
  }
});

缺少哪些信息?

尽管 "navigate" 事件监听器具有集中式特性,但当前的 Navigation API 规范不会在网页的首次加载时触发 "navigate"。对于所有状态都使用服务器端渲染 (SSR) 的网站,这可能没问题 - 您的服务器可以返回正确的初始状态,这是向用户提供内容的最快方式。 不过,利用客户端代码创建网页的网站可能需要创建额外的函数来初始化网页。

Navigation API 的另一个有意设计选择是,它仅在单个框架内运行,即顶级网页或单个特定 <iframe>。这会带来一些有趣的含义,规范中对此进行了进一步说明,但在实践中,这会减少开发者的困惑。 之前的 History API 存在许多令人困惑的极端情况,例如对框架的支持,而重新设计的 Navigation API 从一开始就处理了这些极端情况。

最后,对于以编程方式修改或重新排列用户浏览过的条目列表,目前尚未达成共识。 我们目前正在讨论这个问题,但一种可能的解决方案是仅允许删除:删除历史条目或“所有未来的条目”。 后者允许临时状态。 例如,作为开发者,我可以:

  • 通过前往新网址或状态向用户提问
  • 允许用户完成工作(或返回)
  • 在任务完成后移除历史记录条目

这非常适合临时模态框或插页式广告:用户可以使用返回手势离开新网址,但之后无法意外地向前滑动以再次打开该网址(因为相应条目已被移除)。 使用当前的 History API 无法实现此目的。

试用 Navigation API

Chrome 102 中提供了 Navigation API,无需使用标志。 您还可以试用 Domenic Denicola 提供的演示。

虽然经典 History API 看起来很简单,但它定义得不太明确,并且在极端情况和不同浏览器中实现方式不同的情况下存在大量问题。我们希望您考虑就新的 Navigation API 提供反馈。

参考

致谢

感谢 Thomas SteinerDomenic Denicola 和 Nate Chapin 审核本文。