新型客户端路由:Navigation API

通过全新的 API 对客户端路由进行标准化,该 API 彻底改变了单页应用的构建流程。

Sam Thorogood
Sam Thorogood
Jake Archibald
Jake Archibald

浏览器支持

  • Chrome:102.
  • Edge:102。
  • Firefox:不受支持。
  • Safari:不受支持。

来源

单页应用 (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(使用异步函数时会自动返回 promise),该 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 会触发带有 ErrorEvent"navigateerror"

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

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

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

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

"navigateerror" 事件监听器(用于接收 ErrorEvent)特别方便,因为它保证会收到设置新网页的代码中的任何错误。您可以直接 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 方法还有一个 options 对象,您可以在其中设置:

  • 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

Navigation API 在 Chrome 102 中无需标志即可使用。 您还可以尝试观看 Domenic Denicola 的演示。

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

参考

致谢

感谢 Thomas SteinerDomenic Denicola 和 Nate Chapin 审核这篇文章。