Next.js App Router 中实现 React Router 风格 activeClassName 导航高亮:以 active-class-name 示例为例
本文基于 Next.js 官方仓库中的 active-class-name 示例,讲解如何在 App Router 下用 next/link 与 usePathname 复刻 React Router Link 组件的 activeClassName 能力——即在当前路由对应的导航链接上自动附加一个高亮类名。读完本文,你将掌握:完整的 ActiveLink 组件实现与逐行原理、动态路由 as 与静态路由 href 的匹配差异、usePathname 在 Next.js 客户端组件中的底层实现,以及该示例的工程结构与启动方式。
背景:为什么需要自己实现 activeClassName
React Router 的 Link 组件提供了一个便捷的 activeClassName 属性:当用户停留在某个链接指向的路由时,该链接自动获得指定的类名,便于用 CSS 高亮"当前页"。而 Next.js 自带的 Link 组件(无论是 Pages Router 还是 App Router)并没有内建这个能力。
本示例的 README 对此的定位很明确:
ReactRouter has a convenience property on the
Linkelement to allow an author to set the active className on a link. This example replicates that functionality using Next's ownLinkcomponent with the new app router.
也就是说,示例的核心目标是:在不脱离 Next.js 原生 Link 的前提下,用 App Router 时代推荐的 usePathname 客户端 Hook 来等价实现"当前路由高亮"。整条技术路线只依赖两个 Next.js 原生 API:
next/navigation的usePathname:读取当前 URL 的 pathname;next/link的Link:负责客户端路由跳转与预取。
项目结构与启动方式
示例采用 App Router 目录结构(app/ 下为路由),完整文件布局如下:
examples/active-class-name/
├── app/
│ ├── [slug]/page.tsx # 动态路由页,用于验证 as 匹配
│ ├── about/page.tsx
│ ├── news/page.tsx
│ ├── layout.tsx # 根布局
│ └── page.tsx # 首页
├── components/
│ ├── ActiveLink.tsx # 核心:可高亮当前路由的链接组件
│ └── Nav.tsx # 演示用的导航栏
├── next.config.js
├── package.json
└── tsconfig.json
根据 README 给出的方式,可以用 create-next-app 引导一个包含该示例的独立项目(分别对应 npm、Yarn、pnpm 三种包管理器):
npx create-next-app --example active-class-name active-class-name-app
yarn create next-app --example active-class-name active-class-name-app
pnpm create next-app --example active-class-name active-class-name-app
生成项目后,按 package.json 中定义的脚本运行:
npm run dev # next dev
npm run build # next build
npm run start # next start
依赖上,示例锁定 react / react-dom 为 18.3.1,next 使用 latest 渠道(见 package.json),因此整套实现针对的是 App Router(React 18)环境。
核心实现:ActiveLink 组件逐行剖析
整个示例的灵魂是 ActiveLink.tsx,不到 60 行代码,值得逐段拆解。
1. 客户端组件声明与类型定义
"use client";
import Link, { LinkProps } from "next/link";
import React, { PropsWithChildren, useEffect, useState } from "react";
import { usePathname } from "next/navigation";
文件顶部的 "use client" 是关键:usePathname 是客户端 Hook,因此 ActiveLink 必须被标记为 Client Component。它的 Props 在 LinkProps 基础上额外要求一个 activeClassName:
type ActiveLinkProps = LinkProps & {
className?: string;
activeClassName: string;
};
activeClassName 是必填的,其余属性(href、as、className 以及 Link 支持的其他透传属性)全部沿用 next/link 的原始类型。
2. 区分动态路由与静态路由的 URL 提取
const getLinkUrl = (href: LinkProps["href"], as?: LinkProps["as"]): string => {
// Dynamic route will be matched via props.as
// Static route will be matched via props.href
if (as) return as.toString();
return href.toString();
};
这段函数处理了 Link 中一个容易踩坑的差异:
- 静态路由(如
href="/about"):实际地址由href决定; - 动态路由(如
href="/dynamic-route"配合as="/posts/1"):用户看到的真实地址是as,若拿href去和当前 pathname 比较就永远不会命中。
因此只要传了 as,就用 as 参与匹配。示例中的 Nav.tsx 同时演示了这两种形态:/about、/blog 是静态链接,/dynamic-route 则对应 app/[slug]/page.tsx 动态段。
3. pathname 归一化与类名拼接
const ActiveLink = ({
children,
activeClassName,
className,
...props
}: PropsWithChildren<ActiveLinkProps>) => {
const pathname = usePathname();
const [computedClassName, setComputedClassName] = useState(className);
useEffect(() => {
if (pathname) {
const linkUrl = getLinkUrl(props.href, props.as);
const linkPathname = new URL(linkUrl, location.href).pathname;
const activePathname = new URL(pathname, location.href).pathname;
const newClassName =
linkPathname === activePathname
? `${className} ${activeClassName}`.trim()
: className;
if (newClassName !== computedClassName) {
setComputedClassName(newClassName);
}
}
}, [
pathname,
props.as,
props.href,
activeClassName,
className,
computedClassName,
]);
return (
<Link className={computedClassName} {...props}>
{children}
</Link>
);
};
几个实现细节值得注意:
new URL(x, location.href).pathname做归一化:usePathname()返回的是当前 URL 的 pathname 部分(不含 query 与 hash),而href/as可能写成相对路径、带前导/等不同形式。借助URL构造器以当前页面地址为基准解析,只取.pathname,可以把两侧统一为可直接比较的绝对路径字符串。- 类名拼接与去重:命中时输出
`${className} ${activeClassName}`.trim(),否则只保留className。拼接前先trim,保证className缺省(undefined)时不会出现多余空格。 useEffect+ 状态守卫:匹配逻辑放在useEffect中、依赖数组覆盖pathname、as、href、activeClassName、className与上一次计算结果;且只有newClassName !== computedClassName时才setState,避免在路由未变化时触发多余渲染。这也是"仅在必要时更新"的防御式写法。{...props}全量透传:activeClassName与className被显式解构出来后,剩余 props 原样交给Link,所以replace、shallow、事件回调等Link能力全部保留,ActiveLink是对Link的严格超集,可以无缝替换。
使用演示:Nav 组件
Nav.tsx 给出了标准用法,并顺带展示如何用 styled-jsx 全局样式让"当前页"可视化:
"use client";
import ActiveLink from "./ActiveLink";
const Nav = () => (
<nav>
<style jsx global>{`
.nav-link {
text-decoration: none;
}
.active:after {
content: " (current page)";
}
`}</style>
<ul className="nav">
<li>
<ActiveLink activeClassName="active" className="nav-link" href="/">
Home
</ActiveLink>
</li>
<li>
<ActiveLink activeClassName="active" className="className=" className="nav-link" href="/about">
About
</ActiveLink>
</li>
...
</ul>
</nav>
);
要点:
- 每个导航项都同时传
className="nav-link"(常驻样式)与activeClassName="active"(高亮样式),两者职责分离,高亮类名可以随项目任意自定义; - 高亮效果通过
.active:after { content: " (current page)"; }直接渲染出文字后缀,方便肉眼验证匹配是否生效。在实际项目中,.active通常对应颜色/字重等视觉差异; - 从示例的 app 目录结构看,静态页面有
/(page.tsx)、/about(about/page.tsx)、/news(news/page.tsx),以及兜底的单段动态路由[slug]。导航中的/dynamic-route链接会被[slug]段捕获,正好用来验证"链接 pathname 与动态段解析后 pathname 相等"的匹配路径。
各页面本身都很简单——服务端/客户端组件中直接渲染 <Nav /> 加一行文案,例如 app/page.tsx:
import Nav from "../components/Nav";
const IndexPage = () => (
<>
<Nav />
<p>Hello, I'm the index page</p>
</>
);
export default IndexPage;
而动态页 app/[slug]/page.tsx 特意声明为客户端组件并再次调用 usePathname(),把实际访问的路径打印出来:
"use client";
import { usePathname } from "next/navigation";
import Nav from "../../components/Nav";
const SlugPage = () => {
const pathname = usePathname();
return (
<>
<Nav />
<p>Hello, I'm the {pathname} page</p>
</>
);
};
这为"访问任意单段路径都会命中 [slug]、且 usePathname 返回真实 pathname"提供了可观测的验证手段。
底层原理:usePathname 在 Next.js 中如何实现
ActiveLink 的全部魔法来自一个 Hook,看它的源码就能理解匹配为何可靠。usePathname 定义在 packages/next/src/client/components/navigation.ts:
export function usePathname(): string {
useDynamicRouteParams?.('usePathname()')
// In the case where this is `null`, the compat types added in `next-env.d.ts`
// will add a new overload that changes the return type to include `null`.
const pathname = useContext(PathnameContext) as string
// During build-time instant validation, error if fallback params exist
// because usePathname() can't return a sensible value without all params.
if (
typeof window === 'undefined' &&
process.env.__NEXT_CACHE_COMPONENTS &&
pathname
) {
expectCompleteParamsInClientValidation!('usePathname()')
return pathname
}
// Instrument with Suspense DevTools (dev-only)
if (process.env.NODE_ENV !== 'production' && 'use' in React) {
const navigationPromises = use(NavigationPromisesContext)
if (navigationPromises) {
return use(navigationPromises.pathname)
}
}
return pathname
}
从源码结构看有三个关键事实:
- 数据来源是 React Context:
usePathname()本质是useContext(PathnameContext),pathname 由 Next.js 客户端路由运行时在导航时注入 Context。因此它是"响应式"的——每次客户端路由切换后 Context 更新,依赖它的useEffect会重新执行,这正是ActiveLink不需要监听popstate或pushState就能跟随路由变化的原因。 - 返回值语义:官方 JSDoc 给出的例子是
/dashboard?foo=bar下返回/dashboard,即不含 query 与 hash 的路径部分。这也解释了为什么ActiveLink中只需比较两个pathname字符串:带 query 的同一页面天然归一为同一路径。 - 动态路由约束:
useDynamicRouteParams?.('usePathname()')表明该 Hook 与动态段参数体系联动;构建期校验场景下若动态参数不完整会直接报错,避免返回"半吊子"路径。
类型层面,compat 类型声明 中 usePathname 的返回类型是 string | null(兼容 Pages Router 等场景),而 App Router 客户端组件的实际实现返回 string。示例中 if (pathname) 的判断正是对这一类型差异的稳健处理。
适用边界与可扩展方向
把实现放回 README 的语境下,有几条边界值得明确:
- 精确 pathname 匹配,不做前缀匹配:
linkPathname === activePathname是严格相等。若你的导航结构是"分类页 + 列表页"(如/blog与/blog/1),当前实现在/blog/1上不会高亮/blog链接。可以将其扩展为activePathname.startsWith(linkPathname)(对非根路径加/边界判断),这是社区常见的改进形态。 - 仅客户端生效:
usePathname是 Client Component API,首次服务端渲染阶段ActiveLink只会输出不带activeClassName的基类,高亮在客户端水合/路由切换后的useEffect中才生效。对导航样式这类"客户端增强"而言这是合理代价,但不应依赖activeClassName参与首屏 SSR 输出。 as与href的优先级:只要as存在就以as为准,这与next/link的实际跳转地址一致;若自定义封装ActiveLink时改动匹配逻辑,务必保持这一语义,否则动态路由下的导航高亮会全部失效。
小结
这个不到百行的示例完整演示了 App Router 下"当前路由高亮"的标准解法:用 usePathname 读取响应式 pathname、用 URL 归一化后做严格比较、把计算结果通过受控的 useState 回灌给 next/link 的 className,同时保持对 Link 全部 props 的透传。它既是 Next.js 官方 examples 目录中一个可直接通过 create-next-app --example active-class-name 拉取的独立应用,也是一份关于客户端导航状态如何与路由上下文联动的最小可读参考实现。
atomcodeClaude Code 的开源替代方案。连接任意大模型,编辑代码,运行命令,自动验证 — 全自动执行。用 Rust 构建,极致性能。 | An open-source alternative to Claude Code. Connect any LLM, edit code, run commands, and verify changes — autonomously. Built in Rust for speed. Get StartedRust0629
Hy4-previewHy4 preview 是由腾讯混元团队研发的新一代混合专家(MoE)旗舰模型。模型总参数量 770B,每个 token 激活 49B,主干共包含78层,第一层采用标准 FFN,其余 77 层均为 MoE 结构,每层包含 256 个路由专家与 1 个共享专家,每个 token 激活 top-8 路由专家及共享专家。主干之外原生内置 1 层 MTP(总参数量 10B,激活 0.7B)以支持投机解码。Python00
GLM-5.3GLM-5.3 与 GLM-5.2 使用相同的基座模型——所有提升均来自后训练。与 GLM-5.2 相比,它在复杂编程和长程任务上的表现显著提升。Jinja00
GLM-5.3-FlashGLM-5.3-Flash (320B-A18B),是GLM-5系列的首个原生多模态模型。320B总参数,能力超过GLM-5.2Jinja00
Spark-X2.5-4BSpark-X2.5-4B 旨在让强大的 AI 更实用、更高效、更易获得。在广泛日常任务中表现强劲,涵盖对话、写作、翻译、推理、编码、工具调用以及智能体工作流,并在同等规模的开源模型中取得领先成绩。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00