新型客户端路由:Navigation API

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

山姆·索罗古德
Sam Thorogood
杰克·阿奇博尔德
Jake Archibald

浏览器支持

  • 102
  • 102
  • x
  • x

来源

单页应用或 SPA 由一项核心功能定义:在用户与网站交互时动态重写其内容,而不是从服务器加载全新页面的默认方法。

虽然 SPA 能够通过 History API (或者,在少数情况下,通过调整网站的 #hash 部分)为您提供此功能,但这是一个笨拙的 API,在 SPA 成为常态之前开发出来,网络正在期盼一种全新的方法。 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" 事件仍会触发。它信息丰富,因此您的代码可以实现某些目的,例如记录一个 Google 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 经常有一种感觉,就像它可以帮助实现这些可能性。 不过,它实际上只有两个 surface:用户在浏览器中按“后退”或“前进”按钮时进行响应,以及推送和替换网址。它与 "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 functions会自动发生),该 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 会随着此新条目创建新广告位而发生变化。

对于开发者来说,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 方法还有一个选项对象,您可以在其中进行以下设置:

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

特别需要指出的是,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 查看这篇博文。 Unsplash 的主打图片,作者:Jeremy Zero