新型客户端路由:Navigation API

通过全新的 API 标准化客户端路由,从而彻底改进单页面应用的构建方式。

浏览器支持

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

来源

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

尽管 SPA 能够通过 History API(或在少数情况下,通过调整网站的 #hash 部分)为您提供这项功能,但 SPA 早在 SPA 成为常态之前就已开发出来,这也是一个笨拙的 API,网络迫切需要一种全新的方法。 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 通常感觉可以帮助实现这些可能性。不过,它实际上只有两个界面区域:响应用户在浏览器中按“返回”或“前进”键,以及推送和替换网址。它与 "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 会告知浏览器导航需要多长时间以及导航是否成功。

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 将使用 Event 触发 "navigatesuccess"
  • 如果返回的 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 审核本博文。