新型客户端路由:Navigation API

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

浏览器支持

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

来源

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

虽然 SPA 能够通过 History API(或在某些情况下,通过调整网站的 #hash 部分)为您提供此功能,但它是一个笨拙的 API,在 SPA 成为常态之前就已开发出来,而 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 将使用 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 审核这篇文章。