首页
/ Next.js App Router 中实现 React Router 风格 activeClassName 导航高亮:以 active-class-name 示例为例

Next.js App Router 中实现 React Router 风格 activeClassName 导航高亮:以 active-class-name 示例为例

2026-09-06 13:11:48作者:谭伦延

本文基于 Next.js 官方仓库中的 active-class-name 示例,讲解如何在 App Router 下用 next/linkusePathname 复刻 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 Link element to allow an author to set the active className on a link. This example replicates that functionality using Next's own Link component with the new app router.

也就是说,示例的核心目标是:在不脱离 Next.js 原生 Link 的前提下,用 App Router 时代推荐的 usePathname 客户端 Hook 来等价实现"当前路由高亮"。整条技术路线只依赖两个 Next.js 原生 API:

项目结构与启动方式

示例采用 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必填的,其余属性(hrefasclassName 以及 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 中、依赖数组覆盖 pathnameashrefactiveClassNameclassName 与上一次计算结果;且只有 newClassName !== computedClassName 时才 setState,避免在路由未变化时触发多余渲染。这也是"仅在必要时更新"的防御式写法。
  • {...props} 全量透传activeClassNameclassName 被显式解构出来后,剩余 props 原样交给 Link,所以 replaceshallow、事件回调等 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)、/aboutabout/page.tsx)、/newsnews/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
}

从源码结构看有三个关键事实:

  1. 数据来源是 React ContextusePathname() 本质是 useContext(PathnameContext),pathname 由 Next.js 客户端路由运行时在导航时注入 Context。因此它是"响应式"的——每次客户端路由切换后 Context 更新,依赖它的 useEffect 会重新执行,这正是 ActiveLink 不需要监听 popstatepushState 就能跟随路由变化的原因。
  2. 返回值语义:官方 JSDoc 给出的例子是 /dashboard?foo=bar 下返回 /dashboard,即不含 query 与 hash 的路径部分。这也解释了为什么 ActiveLink 中只需比较两个 pathname 字符串:带 query 的同一页面天然归一为同一路径。
  3. 动态路由约束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 输出。
  • ashref 的优先级:只要 as 存在就以 as 为准,这与 next/link 的实际跳转地址一致;若自定义封装 ActiveLink 时改动匹配逻辑,务必保持这一语义,否则动态路由下的导航高亮会全部失效。

小结

这个不到百行的示例完整演示了 App Router 下"当前路由高亮"的标准解法:用 usePathname 读取响应式 pathname、用 URL 归一化后做严格比较、把计算结果通过受控的 useState 回灌给 next/linkclassName,同时保持对 Link 全部 props 的透传。它既是 Next.js 官方 examples 目录中一个可直接通过 create-next-app --example active-class-name 拉取的独立应用,也是一份关于客户端导航状态如何与路由上下文联动的最小可读参考实现。

登录后查看全文
热门项目推荐
相关项目推荐

项目优选

收起
kernelkernel
deepin linux kernel
C
33
18
ops-transformerops-transformer
本项目是CANN提供的transformer类大模型算子库,实现网络在NPU上加速计算。
C++
1.13 K
2.75 K
pytorchpytorch
作为 Ascend for PyTorch 社区的核心组件,TorchNPU 是昇腾专为 PyTorch 打造的深度学习适配插件,使 PyTorch 框架能够直接调用昇腾 NPU,为开发者提供昇腾 AI 处理器的超强算力。
Python
857
1.35 K
docsdocs
暂无描述
Markdown
897
5.8 K
kernelkernel
openEuler内核是openEuler操作系统的核心,既是系统性能与稳定性的基石,也是连接处理器、设备与服务的桥梁。
C
529
593
ops-nnops-nn
本项目是CANN提供的神经网络类计算算子库,实现网络在NPU上加速计算。
C++
916
1.83 K
jiuwenswarmjiuwenswarm
JiuwenSwarm 是一款基于openJiuwen开发的智能AI Agent,它能够将大语言模型的强大能力,通过你日常使用的各类通讯应用,直接延伸至你的指尖。
Python
3.58 K
1.01 K
ops-mathops-math
本项目是CANN提供的数学类基础计算算子库,实现网络在NPU上加速计算。
C++
1.35 K
1.46 K
cann-learning-hubcann-learning-hub
CANN 学习中心仓,支持在线互动运行、边学边练,提供教程、示例与优化方案,一站式助力昇腾开发者快速上手。
Jupyter Notebook
1.01 K
515
AscendNPU-IRAscendNPU-IR
AscendNPU-IR是基于MLIR(Multi-Level Intermediate Representation)构建的,面向昇腾亲和算子编译时使用的中间表示,提供昇腾完备表达能力,通过编译优化提升昇腾AI处理器计算效率,支持通过生态框架使能昇腾AI处理器与深度调优
C++
547
388