现代 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>
);
}这里的关键配置是:
attribute="class":把主题状态落到<html class="dark">,和 Tailwind 的 dark mode 约定一致。defaultTheme="system":没有用户选择时跟随系统主题。enableSystem:允许系统主题变化继续生效。disableTransitionOnChange:切换主题时临时禁用过渡,减少颜色切换时的拖影。
root layout 不再读取 headers(),也不再手动解析 sec-ch-prefers-color-scheme。它只保留 suppressHydrationWarning,因为 next-themes 会在客户端根据真实主题修改 <html> 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" />浅色值应接近页面浅色背景,深色值应接近暗色模式首屏背景。这样移动浏览器栏不会和页面主体形成明显断层。
客户端切换
切换按钮不再自己维护 sessionStorage、matchMedia 或自定义 themechange 事件,而是直接使用 next-themes 的 useTheme():
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;挂载前可以显示一个稳定的默认图标或文案,挂载后再显示真实状态。
分页入口
文章列表不能因为当前文章数量不多就保留一个全量列表入口。几十篇文章时浏览器还能承受,几百篇后,全量卡片列表会同时影响首屏体积、交互流畅度和抓取成本。
分页入口需要同时区分三个概念:
- 栏目语义入口:
/blog - 分页规范地址:
/blog/page/1 - 旧路径兼容入口:例如
/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 这种当前栏目入口不同。
不可用控件
分页按钮没有上一页或下一页时,不应该只是不输出链接,或者用普通文本模拟禁用态。控件需要同时满足三个条件:
- 语义上不可用:使用原生
button disabled - 交互上不可点:禁用按钮不会触发点击
- 视觉上不可误操作:灰态、不可用光标、文字不可选
一个容易忽略的细节是: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 的 <Link> 默认会在生产环境中预取进入视口的目标路由和数据。网络面板里看到带有 next-router-prefetch: 1、rsc: 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 或“首页”导航,默认 <Link> 仍可能把首页自己加入预取队列。
因此策略不要按“链接价值”局部豁免,而应改成站点级规则:
- 页面打开、链接进入视口:不预取。
- hover、focus、pointerdown:视为用户意图,可以预取。
- 当前路径链接:不预取自己。
- 同一个 href:在同一个组件实例里只预取一次。
- 外链、锚点和非站内路径:不交给
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 会在客户端接管 <html> 的主题 class,因此 root layout 需要保留 suppressHydrationWarning。依赖主题状态的按钮或图标也要等客户端挂载后再展示真实状态,否则容易出现 hydration 前后文案或图标不一致。
theme-color 仍然由浏览器按 prefers-color-scheme 媒体查询选择,不等于用户在站内手动选择的 next-themes 状态。多数场景下这已经足够接近系统体验;如果未来要让移动浏览器栏严格跟随站内手动主题,需要额外用客户端脚本同步更新 meta[name="theme-color"]。