现代 Web 体验优化
太阳作者太阳
原创内容采用 CC-4.0 协议发布,转载请注明出处
WebNext.jsApp RouterRSCnext-themes深色模式用户体验

现代 Web 体验优化

背景

现代网站的明暗模式需要同时处理三个问题:首屏渲染不能明显闪烁,用户可以手动切换,默认状态应能跟随系统偏好。

移动端浏览器还会把浏览器自身的地址栏、标签栏或状态栏作为页面体验的一部分。Chrome for Android 等浏览器不会自动从页面背景推断这些区域的颜色,通常需要页面显式输出 theme-color 元信息。

本项目曾尝试用 Sec-CH-Prefers-Color-Scheme 在服务端判断初始主题,但当前 blog 已经改为 next-themes。主题状态不再由请求头驱动,而是由 ThemeProvider 在客户端管理:默认跟随系统,用户手动选择后由 next-themes 持久化。

明暗模式

blog 的主题 Provider 只包裹客户端交互部分:

// apps/blog/components/providers.tsx
"use client";

import { ThemeProvider } from "next-themes";

export function Providers({ children }: { children: React.ReactNode }) {
  return (
    <ThemeProvider attribute="class" defaultTheme="system" enableSystem disableTransitionOnChange>
      {children}
    </ThemeProvider>
  );
}

这里的关键配置是:

  1. attribute="class":把主题状态落到 &lt;html class="dark"&gt;,和 Tailwind 的 dark mode 约定一致。
  2. defaultTheme="system":没有用户选择时跟随系统主题。
  3. enableSystem:允许系统主题变化继续生效。
  4. disableTransitionOnChange:切换主题时临时禁用过渡,减少颜色切换时的拖影。

root layout 不再读取 headers(),也不再手动解析 sec-ch-prefers-color-scheme。它只保留 suppressHydrationWarning,因为 next-themes 会在客户端根据真实主题修改 &lt;html&gt; class,服务端 HTML 和客户端首帧之间可能存在可接受的属性差异:

<html
  lang="zh-CN"
  className={cn("font-sans", geist.variable)}
  suppressHydrationWarning
>
  <body>
    <Providers>{children}</Providers>
  </body>
</html>

移动浏览器栏颜色

color-scheme 只影响浏览器对表单、滚动条、内置控件等元素的明暗处理,不等于移动浏览器地址栏或标签栏染色。要让手机版 Chrome 等浏览器的 UI chrome 跟随网站主题,需要输出 theme-color

在 Next.js App Router 中,metadata.themeColor 已不再推荐使用,应通过 viewport 配置:

// apps/blog/app/layout.tsx
import type { Metadata, Viewport } from "next";

export const viewport: Viewport = {
  themeColor: [
    { media: "(prefers-color-scheme: light)", color: "#ffffff" },
    { media: "(prefers-color-scheme: dark)", color: "#20242c" },
  ],
};

这会生成类似下面的 HTML:

<meta name="theme-color" media="(prefers-color-scheme: light)" content="#ffffff" />
<meta name="theme-color" media="(prefers-color-scheme: dark)" content="#20242c" />

浅色值应接近页面浅色背景,深色值应接近暗色模式首屏背景。这样移动浏览器栏不会和页面主体形成明显断层。

客户端切换

切换按钮不再自己维护 sessionStoragematchMedia 或自定义 themechange 事件,而是直接使用 next-themesuseTheme()

import { useTheme } from "next-themes";
import { useSyncExternalStore } from "react";

function subscribe() {
  return () => {};
}

export function ThemeToggle() {
  const { resolvedTheme, setTheme } = useTheme();
  const mounted = useSyncExternalStore(
    subscribe,
    () => true,
    () => false,
  );
  const isDark = resolvedTheme === "dark";

  function toggleTheme() {
    setTheme(isDark ? "light" : "dark");
  }
}

resolvedTheme 表示 system 解析后的实际主题。按钮文案和图标应基于它判断当前是深色还是浅色。mounted 用来避免服务端渲染阶段就根据客户端主题输出不稳定 UI;挂载前可以显示一个稳定的默认图标或文案,挂载后再显示真实状态。

分页入口

文章列表不能因为当前文章数量不多就保留一个全量列表入口。几十篇文章时浏览器还能承受,几百篇后,全量卡片列表会同时影响首屏体积、交互流畅度和抓取成本。

分页入口需要同时区分三个概念:

  1. 栏目语义入口:/blog
  2. 分页规范地址:/blog/page/1
  3. 旧路径兼容入口:例如 /content

/blog 是用户和站内导航理解成本最低的栏目入口,可以直接渲染第一页,但不能渲染全量列表。页面内容仍然按分页大小截取:

const PAGE_SIZE = 9;

export default async function BlogIndex() {
  const items = await getBlogItems();
  const { currentPage, totalPages, pageItems } = paginateItems(items, 1, PAGE_SIZE);

  return (
    <ContentBrowser
      items={pageItems}
      labels={dictionary.blog}
      pagination={{
        currentPage,
        totalPages,
        nextHref: "/blog/page/2",
      }}
    />
  );
}

如果 SEO 规范地址希望统一到分页体系,可以让 /blog 的 canonical 指向 /blog/page/1,但不必把 /blog 永久跳转过去。永久跳转会把一个稳定、自然的栏目入口变成分页实现细节,后续再想恢复 /blog 的语义会受到浏览器和搜索引擎缓存影响。

分页链接本身不要省略第一页。也就是说,从第 2 页返回第 1 页时,按钮地址应是 /blog/page/1,而不是 /blog

export function getPaginationLinks(
  currentPage: number,
  totalPages: number,
  hrefs: {
    firstPageHref: string;
    pageHref: (page: number) => string;
  },
) {
  const hrefForPage = (page: number) => (page === 1 ? hrefs.firstPageHref : hrefs.pageHref(page));

  return {
    prevHref: currentPage > 1 ? hrefForPage(currentPage - 1) : undefined,
    nextHref: currentPage < totalPages ? hrefForPage(currentPage + 1) : undefined,
  };
}

旧路径兼容可以继续走服务端跳转,例如 /content -> /blog/page/1。这类路径本来就是历史入口,和 /blog 这种当前栏目入口不同。

不可用控件

分页按钮没有上一页或下一页时,不应该只是不输出链接,或者用普通文本模拟禁用态。控件需要同时满足三个条件:

  1. 语义上不可用:使用原生 button disabled
  2. 交互上不可点:禁用按钮不会触发点击
  3. 视觉上不可误操作:灰态、不可用光标、文字不可选

一个容易忽略的细节是:disabled 不等于文字不可选。如果禁用按钮仍然可以被拖选,高亮效果会让用户误以为按钮处于某种选中状态。应显式加 select-none

<button
  type="button"
  disabled
  aria-disabled="true"
  className="inline-flex h-7 cursor-not-allowed select-none items-center justify-center gap-1 rounded-lg px-2.5 text-sm font-medium text-muted-foreground opacity-50"
>
  下一页
</button>

也不要为了“不可点”随手加 pointer-events: none。禁用按钮本身已经不会触发点击,pointer-events: none 反而可能让鼠标拖选落到内部文字或后面的页面层级上,导致不可用按钮文本被选中。

Link 预取

Next.js App Router 的 &lt;Link&gt; 默认会在生产环境中预取进入视口的目标路由和数据。网络面板里看到带有 next-router-prefetch: 1rsc: 1?_rsc= 请求时,不一定是服务端重复渲染或用户真实点击;它通常只是 App Router 的 RSC payload 预取。

这不是框架异常,但在博客首页这种高密度链接页面里会放大后台请求。一个内容卡片可能同时包含文章链接、分类链接和多个标签链接。默认策略会把这些链接都当成可预热目标,首页加载后就可能产生大量 RSC 请求。

更反直觉的是,同一个目标地址不一定只对应一次网络请求。App Router 的预取不是“请求一份 HTML”,而是预取 RSC Flight/segment 数据。网络面板里可能看到同一路径带着不同 _rsc 参数出现多次,例如:

/about?_rsc=...        next-router-segment-prefetch: /_head
/about?_rsc=...        next-router-segment-prefetch: /about
/about?_rsc=...        next-router-segment-prefetch: /about/__PAGE__

/posts/page/2?_rsc=... next-router-segment-prefetch: /_tree
/posts/page/2?_rsc=... next-router-segment-prefetch: /posts/page/$d$page
/posts/page/2?_rsc=... next-router-segment-prefetch: /posts/page/$d$page/__PAGE__

这不是用户点了多次,也不一定是服务端渲染重复出错,而是一个路由目标被拆成多个可缓存 segment。当前页链接也不能假设安全:如果首页上有 href="/" 的 logo 或“首页”导航,默认 &lt;Link&gt; 仍可能把首页自己加入预取队列。

因此策略不要按“链接价值”局部豁免,而应改成站点级规则:

  1. 页面打开、链接进入视口:不预取。
  2. hover、focus、pointerdown:视为用户意图,可以预取。
  3. 当前路径链接:不预取自己。
  4. 同一个 href:在同一个组件实例里只预取一次。
  5. 外链、锚点和非站内路径:不交给 router.prefetch()

可以把这个规则封装成统一的内部链接组件,而不是在每个页面手写 prefetch={false}

"use client";

import type { ComponentProps, FocusEvent, MouseEvent, PointerEvent } from "react";
import Link from "next/link";
import { useIntentPrefetch } from "@/hooks/use-intent-prefetch";

type IntentLinkProps = Omit<
  ComponentProps<typeof Link>,
  "href" | "prefetch" | "onFocus" | "onMouseEnter" | "onPointerDown"
> & {
  href: string;
  onFocus?: (event: FocusEvent<HTMLAnchorElement>) => void;
  onMouseEnter?: (event: MouseEvent<HTMLAnchorElement>) => void;
  onPointerDown?: (event: PointerEvent<HTMLAnchorElement>) => void;
};

export function IntentLink({
  href,
  onFocus,
  onMouseEnter,
  onPointerDown,
  ...props
}: IntentLinkProps) {
  const { prefetchIntent } = useIntentPrefetch();

  return (
    <Link
      href={href}
      {...props}
      prefetch={false}
      onFocus={(event) => {
        onFocus?.(event);
        if (!event.defaultPrevented) prefetchIntent(href);
      }}
      onMouseEnter={(event) => {
        onMouseEnter?.(event);
        if (!event.defaultPrevented) prefetchIntent(href);
      }}
      onPointerDown={(event) => {
        onPointerDown?.(event);
        if (!event.defaultPrevented) prefetchIntent(href);
      }}
    />
  );
}

对应的预取 hook 只处理站内路径,并跳过当前页:

"use client";

import { useRef } from "react";
import { usePathname, useRouter } from "next/navigation";

export function useIntentPrefetch() {
  const router = useRouter();
  const pathname = usePathname();
  const prefetchedHrefs = useRef(new Set<string>());

  function prefetchIntent(href: string) {
    if (!href.startsWith("/") || href.startsWith("//")) return;

    const hrefPathname = href.split(/[?#]/, 1)[0] || "/";
    if (hrefPathname === pathname) return;
    if (prefetchedHrefs.current.has(href)) return;

    prefetchedHrefs.current.add(href);
    router.prefetch(href);
  }

  return { prefetchIntent };
}

这样文章列表、标签、分类、顶部导航、分页、面包屑、上一篇/下一篇和 404 返回入口都可以使用同一个 IntentLink。站点打开时不会因为链接进入视口就批量发起 RSC 预取;用户移动鼠标、键盘聚焦或触摸按下时,仍能提前预热目标路由。

移动端整卡点击是另一个入口。如果卡片本身不是 <a>,而是通过 router.push() 导航,仍需要在卡片 pointerdown 时手动预取文章详情:

<Card
  onPointerDown={(event) => prefetchItemOnMobile(event, itemHref)}
  onClick={(event) => openItemOnMobile(event, itemHref)}
>
  ...
</Card>

风险

next-themes 会在客户端接管 &lt;html&gt; 的主题 class,因此 root layout 需要保留 suppressHydrationWarning。依赖主题状态的按钮或图标也要等客户端挂载后再展示真实状态,否则容易出现 hydration 前后文案或图标不一致。

theme-color 仍然由浏览器按 prefers-color-scheme 媒体查询选择,不等于用户在站内手动选择的 next-themes 状态。多数场景下这已经足够接近系统体验;如果未来要让移动浏览器栏严格跟随站内手动主题,需要额外用客户端脚本同步更新 meta[name="theme-color"]