新型客户端路由:Navigation API

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

浏览器支持

  • Chrome:102。 <ph type="x-smartling-placeholder">
  • Edge:102。 <ph type="x-smartling-placeholder">
  • Firefox:不支持。 <ph type="x-smartling-placeholder">
  • Safari:不支持。 <ph type="x-smartling-placeholder">

来源

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

尽管 SPA 能够通过 History API(或在少数情况下,通过调整网站的 #hash 部分)为您实现这一功能,但它在 SPA 成为常态之前,就属于笨拙的 API,而网络迫切需要一种全新的方法。 Navigation API 是一种提议的 API,它彻底颠覆了这一领域,而不是试图简单地修补 History API 的粗糙边缘。 (例如,滚动恢复功能修补了 History API,而不是尝试重新创建该 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 将触发 "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}`);
});

用于接收 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 方法还有一个 options 对象,您可以在其中设置以下内容:

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

举例来说,info 特别适用于表示导致下一页显示的特定动画。 (另一种方法是设置全局变量或将其包含在 #hash 中。这两个选项都有点尴尬。) 值得注意的是,如果用户之后(例如通过“后退”和“前进”按钮)导致导航,此 info 将不会重放。 实际上,在这种情况下,它将始终为 undefined

<ph type="x-smartling-placeholder">
</ph>
从左侧或右侧打开的演示

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 看似简单,但定义并不明确,并且存在大量问题。此外,该 API 在不同浏览器中的实现方式也不同。 我们希望您考虑提供有关新 Navigation API 的反馈。

参考

致谢

感谢 Thomas SteinerDomenic Denicola 和 Nate Chapin 审核本博文。 来自 Jeremy ZeroUnsplash 网站主打图片。