为 Web 应用添加即时导航和无缝网页转换功能

1. 开始前须知

此 Codelab 介绍如何使用 Google Chrome 原生支持的最新 API 为示例 Web 应用添加即时导航和无缝网页转换功能。

此模块中的示例 Web 应用可查询热门水果和蔬菜的营养价值。水果清单页面和水果详情页面构建为单页应用 (SPA),蔬菜清单页面和蔬菜详情页面构建为传统的多页应用 (MPA)。

移动设备上的示例应用屏幕截图 移动设备上的示例应用屏幕截图

具体而言,您将实现预渲染往返缓存 (bfcache) 和专用预提取代理(用于添加即时导航)以及根/共享元素转换(用于添加无缝网页转换)。您要为 MPA 页面实现预渲染和 bfcache,为 SPA 页面实现共享元素转换。

网站速度一直是用户体验的一个重要方面,因此 Google 推出了核心网页指标。这组指标用于衡量网页的加载性能、互动性和视觉稳定性,从而评估真实的用户体验。最新 API 可帮助您提高网站的实际核心网页指标得分,尤其是加载性能方面的得分。

此图片演示了 bfcache 在缩短加载时间方面的效果

Mindvalley 中的演示

此外,用户也已习惯于移动设备原生应用中使用转换来极其直观地进行导航和更改状态。遗憾的是,想要在网页中实现同样的用户体验并非易事。虽然使用当前的网络平台 API 也许能够实现类似的效果,但开发起来可能会非常困难或复杂,尤其是与 Android 或 iOS 应用中的等效功能相比而言。因此,我们开发了无缝 API,旨在弥补应用和网页之间的这种用户和开发者体验差距。

pixiv 中的 Shared Element Transitions API 演示 Tokopedia 中的 Shared Element Transitions API 演示

pixivTokopedia 中的演示

前提条件

具备以下方面的知识:

学习内容:

如何实现:

  • 预渲染
  • bfcache
  • 专用预提取代理
  • 根/共享元素转换

构建内容

一个使用 Next.js 构建的示例 Web 应用,具备以下最新的即时、无缝浏览器功能:

  • 通过预渲染实现近乎即时的导航
  • 通过 bfcache 在使用浏览器的后退和前进按钮时实现即时加载
  • 通过专用预提取代理或 Signed Exchange (SXG) 实现跨源导航,给用户留下良好的第一印象
  • 通过根/共享元素转换实现网页间无缝转换

所需工具

  • Chrome 101 或更高版本

2. 开始

启用 Chrome flag

  1. 前往 about://flags,然后启用 Prerender2documentTransition API 运行时 flag。
  2. 重新启动浏览器。

获取代码

  1. 在您惯用的开发环境中,打开此 GitHub 代码库中的以下代码:
git clone -b codelab git@github.com:googlechromelabs/instant-seamless-demo.git
  1. 安装运行服务器所需的依赖项:
npm install
  1. 在端口 3000 上启动服务器:
npm run dev
  1. 在浏览器中,前往 http://localhost:3000

现在,您可以修改和改进您的应用。每当您做出更改时,该应用都会重新加载,并直观地反映您所做的更改。

3. 集成预渲染

在此演示中,示例应用中蔬菜详情页面的加载时间会因服务器端的任意延迟而变得非常缓慢。您可通过预渲染去除这种等待时间。

如需将预渲染按钮添加到蔬菜清单页面,并让其在用户点击后触发预渲染,请执行以下操作:

  1. 创建一个按钮组件,用于动态插入推测规则脚本代码:

components/prerender-button.js

import { useContext } from 'react'
import ResourceContext from './resource-context'

// You use resource context to manage global states.
// In the PrerenderButton component, you update the prerenderURL parameter when the button is clicked.
export default function PrerenderButton() {
  const { dispatch } = useContext(ResourceContext)
  const handleClick = (e) => {
    e.preventDefault()
    e.stopPropagation()
    const parent = e.target.closest('a')
    if (!parent) {
      return
    }
    const href = parent.getAttribute('href')
    dispatch({ type: 'update', prerenderURL: href })
  }

  return (
    <button className='ml-auto bg-gray-200 hover:bg-gray-300 px-4 rounded' onClick={handleClick}>
      Prerender
    </button>
  )
}
  1. PrerenderButton 组件导入到 list-item.js 文件中。

components/list-item.js

// Codelab: Add a PrerenderButton component.
import PrerenderButton from './prerender-button'

...
function ListItemForMPA({ item, href }) {
  return (
    <a href={href} className='block flex items-center'>
      <Icon src={item.image} />
      <div className='text-xl'>{item.name}</div>
      {/* Codelab: Add PrerenderButton component. */}
      <PrerenderButton />
    </a>
  )
}
  1. 创建一个组件,用于添加 Speculation Rules API。

当应用更新 prerenderURL 状态时,SpeculationRules 组件会将一段脚本代码动态插入到网页中。

components/speculationrules.js

import Script from 'next/script'
import { useContext, useMemo } from 'react'
import ResourceContext from './resource-context'

export default function SpeculationRules() {
  const { state } = useContext(ResourceContext)
  const { prerenderURL } = state

  return useMemo(() => {
    return (
      <>
        {prerenderURL && (
          <Script id='speculationrules' type='speculationrules'>
            {`
            {
              "prerender":[
                {
                  "source": "list",
                  "urls": ["${prerenderURL}"]
                }
              ]
            }
          `}
          </Script>
        )}
      </>
    )
  }, [prerenderURL])
}
  1. 将组件与应用集成。

pages/_app.js

// Codelab: Add the SpeculationRules component.
import SpeculationRules from '../components/speculationrules'

function MyApp({ Component, pageProps }) {
  useAnalyticsForSPA()

  return (
    <ResourceContextProvider>
      <Layout>
        <Component {...pageProps} />
      </Layout>
      {/* Codelab: Add SpeculationRules component */}
      <SpeculationRules />
      <Script id='analytics-for-mpa' strategy='beforeInteractive' src='/analytics.js' />
    </ResourceContextProvider>
  )
}

export default MyApp
  1. 点击 Prerender(预渲染)。

现在,可以看到加载性能得到了显著改善。在实际的使用场景中,系统会通过一些启发法,针对用户接下来可能会访问的网页触发预渲染。

示例应用的预渲染演示视频

analytics.js 文件

默认情况下,示例 Web 应用中的 analytics.js 文件会在 DOMContentLoaded 事件发生时发送网页浏览事件。遗憾的是,这种默认行为并不可取,因为此事件会在预渲染阶段触发。

要引入 document.prerenderingprerenderingchange 事件来解决此问题,请执行以下操作:

  • 重写 analytics.js 文件:

public/analytics.js

  const sendEvent = (type = 'pageview') => {
    // Codelab: Make analytics prerendering compatible.
    // The pageshow event could happen in the prerendered page before activation.
    // The prerendered page should be handled by the prerenderingchange event.
    if (document.prerendering) {
      return
    }
    console.log(`Send ${type} event for MPA navigation.`)
    fetch(`/api/analytics?from=${encodeURIComponent(location.pathname)}&type=${type}`)
  }
  ...

  // Codelab: Make analytics prerendering compatible.
  // The prerenderingchange event is triggered when the page is activated.
  document.addEventListener('prerenderingchange', () => {
    console.log('The prerendered page was activated.')
    sendEvent()
  })
  ...

太棒了,您已成功修改 analytics.js,使其可与预渲染共存。现在,您可以在浏览器控制台中看到包含正确时间戳的网页浏览日志。

4. 为 bfcache 消除障碍

移除 unload 事件处理脚本

添加不必要的 unload 事件是一种很常见的错误,建议不要再这样做。该事件不仅会导致 bfcache 无法正常工作,而且本身也不可靠。例如,在移动设备Safari 上,该事件不一定会触发。

您不应使用 unload 事件,而应改用 pagehide 事件,因为只要触发 unload 事件或将网页放入 bfcache 时,都会触发 pagehide 事件。

如需移除 unload 事件处理脚本,请执行以下操作:

  • analytics.js 文件中,将 unload 事件处理脚本的代码替换为 pagehide 事件处理脚本的代码:

public/analytics.js

// Codelab: Remove the unload event handler for bfcache.
// The unload event handler prevents the content from being stored in bfcache. Use the pagehide event instead.
window.addEventListener('pagehide', () => {
  sendEvent('leave')
})

更新 cache-control 标头

使用 Cache-control: no-store HTTP 标头提供的网页无法受益于浏览器的 bfcache 功能,因此最好少用此标头。特别是,如果网页不含个性化信息或重要信息(例如已登录状态),那么可能就无需使用 Cache-control: no-store HTTP 标头来提供网页。

如需更新示例应用的 cache-control 标头,请执行以下操作:

  • 修改 getServerSideProps 代码:

pages/vegetables/index.js

export const getServerSideProps = middleware(async (ctx) => {
  const { req, res } = ctx
  // Codelab: Modify the cache-control header.
  res.setHeader('Cache-Control', 'public, s-maxage=10, stale-while-revalidate=59')
  ...

pages/vegetables/[name].js

export const getServerSideProps = middleware(async (ctx) => {
  const { req, res, query } = ctx
  // Codelab: Modify the cache-control header.
  res.setHeader('Cache-Control', 'public, s-maxage=10, stale-while-revalidate=59')
  ...

确定某个网页是否从 bfcache 恢复

网页最初加载时以及每次从 bfcache 恢复网页时,pageshow 事件都会在 load 事件之后立即触发。pageshow 事件具有 persisted 属性,如果网页是从 bfcache 恢复的,此属性为 true,否则为 false。您可以使用 persisted 属性来区分常规网页加载和 bfcache 恢复。主要的分析服务应该都知道 bfcache,不过您也可以手动检查网页是否从 bfcache 恢复并发送事件。

如需确定某个网页是否从 bfcache 恢复,请执行以下操作:

  • 将以下代码添加到 analytics.js 文件中。

public/analytics.js

  // Codelab: Use the pageshow event handler for bfcache.
  window.addEventListener('pageshow', (e) => {
    // If the persisted flag exists, the page was restored from bfcache.
    if (e.persisted) {
      console.log('The page was restored from bfcache.')
      sendEvent()
    }
  })

调试网页

Chrome 开发者工具可以帮助您测试网页,确保网页已针对 bfcache 进行优化并找出可能导致其不符合资格条件的问题。

如需测试特定网页,请执行以下操作:

  1. 在 Chrome 中,转到相应网页。
  2. 在 Chrome 开发者工具中,依次点击应用 > 往返缓存 > 运行测试

Chrome 开发者工具会尝试离开网页再返回,从而确定该网页能否从 bfcache 恢复。

49bf965af35d5324.png

如果成功,面板会说明该网页是从往返缓存中恢复的:

47015a0de45f0b0f.png

如果不成功,面板会说明网页不是恢复的以及原因为何。如果原因是开发者可以解决的问题,面板也会相应地予以说明。

dcf0312c3fc378ce.png

5. 启用跨网站预提取

预提取会提前开始提取数据,这样,当用户导航时,要提取的数据已位于浏览器中,从而加快导航速度。想要改进核心网页指标并在导航前提前完成一些网络活动,这是一种很简便的方式。这会直接加快 Largest Contentful Paint (LCP),并为 First Input Delay (FID) 和 Cumulative Layout Shift (CLS) 留出更多余地。

专用预提取代理可以实现跨网站预提取,但不会向目标服务器透露有关用户的私密信息。

专用预提取代理的工作原理

通过专用预提取代理实现跨网站预提取

网站所有者仍然可以通过一个 well-known traffic-advice 资源来控制预提取,该资源类似于网页抓取工具的 /robots.txt,可让 HTTP 服务器声明实现代理应采纳相应的建议。目前,网站所有者可以建议代理禁止网络连接或对其节流。今后可能会增加其他建议。

如需托管 traffic-advice 资源,请执行以下操作:

  1. 添加类似 JSON 的以下文件:

public/.well-known/traffic-advice

[
  {
    "user_agent": "prefetch-proxy",
    "google_prefetch_proxy_eap": {
      "fraction": 1
    }
  }
]

google_prefetch_proxy_eap 字段是供抢先体验计划使用的特殊字段,fraction 字段是用于控制专用预提取代理发送的请求预提取比例的字段。

应当以 application/trafficadvice+json MIME 类型返回流量建议。

  1. next.config.js 文件中,配置响应标头:

next.config.js

const nextConfig = {
  // Codelab: Modify content-type for traffic advice file.
  async headers() {
    return [
      {
        source: '/.well-known/traffic-advice',
        headers: [
          {
            key: 'Content-Type',
            value: 'application/trafficadvice+json',
          },
        ],
      },
    ]
  },
}

module.exports = nextConfig

6. 集成 Shared Element Transitions API

当用户在网络上从一个网页转到另一个网页时,他们看到的内容会随着第一个网页的消失和新网页的出现而突兀地改变。这种依序出现但不连贯的用户体验会让人感到迷惑并产生较高的认知负担,因为转到新网页的过程必须由用户自己拼凑。此外,在等待目标网页加载期间,这种体验还会延长用户感知到的网页加载时间。

流畅的加载动画能减轻用户的认知负担(因为用户在页面间导航时始终处于同一个上下文环境中),并减少用户感知到的加载延迟时间(因为用户在此期间会看到一些引人入胜、赏心悦目的内容)。出于这些原因,大多数平台(例如 Android、iOS、MacOS 和 Windows 等)都提供了易于使用的基元供开发者构建无缝转换。

Shared Element Transitions API 可在网页上为开发者提供相同的功能,不管是跨文档转换 (MPA) 还是文档内转换 (SPA) 都适用。

pixiv 中的 Shared Element Transitions API 演示 Tokopedia 中的 Shared Element Transitions API 演示

pixivTokopedia 中的演示

如需为示例应用的 SPA 部分集成 Shared Element Transitions API,请执行以下操作:

  1. use-page-transition.js 文件中创建用于管理转换的自定义钩子:

utils/use-page-transition.js

import { useEffect, useContext, useRef, useCallback } from 'react'
import ResourceContext from '../components/resource-context'

// Call this hook on this first page before you start the page transition. For Shared Element Transitions, you need to call the transition.start() method before the next page begins to render, and you need to do the Document Object Model (DOM) modification or setting of new shared elements inside the callback so that this hook returns the promise and defers to the callback resolve.
export const usePageTransitionPrep = () => {
  const { dispatch } = useContext(ResourceContext)

  return (elm) => {
    const sharedElements = elm.querySelectorAll('.shared-element')
    // Feature detection
    if (!document.createDocumentTransition) {
      return null
    }

    return new Promise((resolve) => {
      const transition = document.createDocumentTransition()
      Array.from(sharedElements).forEach((elm, idx) => {
        transition.setElement(elm, `target-${idx}`)
      })
      transition.start(async () => {
        resolve()
        await new Promise((resolver) => {
          dispatch({ type: 'update', transition: { transition, resolver } })
        })
      })
    })
  }
}

// Call this hook on the second page. Inside the useEffect hook, you can refer to the actual DOM element and set them as shared elements with the transition.setElement() method. When the resolver function is called, the transition is initiated between the captured images and newly set shared elements.
export const usePageTransition = () => {
  const { state, dispatch } = useContext(ResourceContext)
  const ref = useRef(null)
  const setRef = useCallback((node) => {
    ref.current = node
  }, [])

  useEffect(() => {
    if (!state.transition || !ref.current) {
      return
    }
    const { transition, resolver } = state.transition
    const sharedElements = ref.current.querySelectorAll('.shared-element')
    Array.from(sharedElements).forEach((elm, idx) => {
      transition.setElement(elm, `target-${idx}`)
    })
    resolver()
    return () => {
      dispatch({ type: 'update', transition: null })
    }
  })

  return setRef
}
  1. 在清单页面中调用 usePageTransitionPrep() 自定义钩子,然后在 click 事件内调用异步函数来触发 transition.start() 方法。

在该函数内,系统会收集 shared-element 类元素并将其注册为共享元素。

components/list-item.js

// Codelab: Add the Shared Element Transitions API.
import { usePageTransitionPrep } from '../utils/use-page-transition'
...

function ListItemForSPA({ item, href }) {
  // Codelab: Add Shared Element Transitions.
  const transitionNextState = usePageTransitionPrep()
  const handleClick = async (e) => {
    const elm = e.target.closest('a')
    await transitionNextState(elm)
  }
  return (
    <Link href={href}>
      <a className='block flex items-center' onClick={handleClick}>
        <Icon src={item.image} name={item.name} className='shared-element' />
        <div className='text-xl'>{item.name}</div>
      </a>
    </Link>
  )
}
  1. 在详情页面上,调用 usePageTransition() 钩子来完成 transition.start() 回调函数。

在此回调中,也要注册详情页面中的共享元素。

pages/fruits/[name].js

// Codelab: Add the Shared Element Transitions API.
import { usePageTransition } from '../../utils/use-page-transition'

const Item = ({ data }) => {
  const { name, image, amountPer, nutrition } = data
  // Codelab: Add the Shared Element Transitions API.
  const ref = usePageTransition()

  return (
    <div className={'flex flex-col items-center justify-center py-4 px-4 sm:flex-row'} ref={ref}>
      <div className='flex flex-col items-center sm:w-2/4'>
        <Image
          className='object-cover border-gray-100 border-2 rounded-full shared-element'
          src={image}
          width='240'
          height='240'
          alt={`picture of ${name}`}
        />
        <h1 className='text-4xl font-bold mt-4'>{name}</h1>
      </div>

      <div className='sm:w-2/4 w-full'>
        <Nutrition amountPer={amountPer} nutrition={nutrition} />
      </div>
    </div>
  )
...
}

现在,可以看到图片元素是清单页面和详情页面共用的元素,并在网页转换时无缝连接。您甚至可以使用 CSS 伪元素自定义动画,使其呈现更精美的效果。

未采用共享元素转换的示例应用演示视频 采用共享元素转换的示例应用演示视频

7. 恭喜

恭喜!您制作了一款即时导航、无缝转换的 Web 应用,它可以提供顺畅、直观、具有吸引力的用户体验。

了解详情

预渲染

bfcache

跨网站预提取

Signed Exchange

根/共享元素转换

这些 API 仍处于早期开发阶段,欢迎您在 crbug.com 上提供反馈,或在相关 API 的 GitHub 代码库中提出问题。