返回首页

Next.js 标签与分类系统问题

2026年2月13日·前端开发
开发博客系统的过程中,我遇到的几个问题

在开发博客系统的过程中,我遇到了几个问题:

  • 点击标签没有任何反应
  • 分类页无法根据 URL 参数自动筛选
  • 控制台报错:Cannot read properties of undefined
  • 标签字段既可能是字符串,也可能是数组

本文将完整拆解问题成因,并结合关键代码,深入讲解技术细节与设计思路。

一、问题一:点击标签没反应

原因分析

最初的实现是:

const [selectedTag, setSelectedTag] = useState("全部");

<TagItem
  onClick={() => setSelectedTag(tag)}
/>

问题在于:

  • 只是更新了 React 本地状态
  • 没有做页面跳转
  • 没有筛选逻辑

所以点击后 UI 没变化,看起来“没有反应”。

正确做法:使用 useRouter 进行跳转

在 Next.js App Router 中,客户端跳转需要使用:

import { useRouter } from "next/navigation";

核心修改:

const router = useRouter();

<TagItem
  onClick={() =>
    router.push(`/category?category=${encodeURIComponent(tag)}`)
  }
/>

技术细节说明

  1. useRouter 只能在 "use client" 组件中使用。

  2. encodeURIComponent 必须使用。

如果标签包含:

  • 空格
  • 中文
  • 特殊字符

不编码会导致 URL 解析错误。

二、问题二:分类页无法读取 URL 参数

点击标签后跳转到:

/category?category=React

但页面没有自动筛选。

原因

selectedCategory 默认写死为:

useState("全部")

没有读取 searchParams。

正确实现:useSearchParams

import { useSearchParams } from "next/navigation";

const searchParams = useSearchParams();
const categoryFromUrl = searchParams.get("category");

const [selectedCategory, setSelectedCategory] = useState(
  categoryFromUrl ?? "全部"
);

技术原理

App Router 中:

  • useSearchParams 是客户端 Hook
  • 每次 URL 变化会触发重新渲染

这样分类页就可以支持:

  • 可分享链接
  • 刷新后状态保留
  • SEO 友好结构

三、问题三:Cannot read properties of undefined

报错位置:

articles.forEach(...)

原因

articles 在某些渲染周期可能是 undefined。

比如:

  • 异步数据还未加载
  • 父组件传值错误

防御式编程

const tagMap = useMemo(() => {
  if (!articles) return [];

同时在 JSX 中:

count={articles?.length || 0}

技术原则

在 React 中永远不要假设 props 一定存在。

防御式编程可以避免:

  • 页面崩溃
  • 白屏
  • hydration mismatch

四、核心逻辑解析:TagCard 标签统计算法

下面是核心代码:

export default function TagCard({ articles }: TagCardProps) {
  const router = useRouter();

  const tagMap = useMemo(() => {
    if (!articles) return [];

    const map = new Map<string, number>();

    articles.forEach((article) => {
      const rawTags = article.tags;
      if (!rawTags) return;

      const tagArray =
        typeof rawTags === "string"
          ? rawTags.split(",")
          : Array.isArray(rawTags)
          ? rawTags
          : [];

      tagArray.forEach((tag) => {
        const trimmed = tag.trim();
        if (!trimmed) return;
        map.set(trimmed, (map.get(trimmed) || 0) + 1);
      });
    });

    return Array.from(map.entries()).sort((a, b) =>
      a[0].localeCompare(b[0])
    );
  }, [articles]);

技术细节拆解

1. useMemo 的意义

useMemo(() => {}, [articles])

作用:

  • 只在 articles 变化时重新计算
  • 避免每次渲染都重新统计标签
  • 优化性能

如果文章数量很多,性能差距会明显。

2. Map 作为统计结构

const map = new Map<string, number>();

优势:

  • 键可以是任意字符串
  • 不会有原型污染问题
  • API 更语义化

统计逻辑:

map.set(tag, (map.get(tag) || 0) + 1);

这是一个典型的计数器模式。

3. 兼容 tags 字段多种类型

typeof rawTags === "string"
Array.isArray(rawTags)

说明后端数据结构可能不稳定。

可能是:

"React,Next.js,TypeScript"

或者:

["React", "Next.js"]

这段代码完成了数据归一化处理。

这是重要的工程实践:在前端做数据兜底。

4. trim 的作用

const trimmed = tag.trim();

避免出现:

" React"
"React "

否则统计会出现两个不同的标签。

5. 排序

.sort((a, b) => a[0].localeCompare(b[0]))

使用 localeCompare 是为了支持:

  • 中文排序
  • 多语言排序

比直接使用 > 更安全。

五、最终系统结构

现在博客结构已经升级为:

首页 → 点击标签 → /category?category=xxx → 自动筛选 → 可分享链接

它具备:

  • URL 状态同步
  • 客户端跳转
  • 性能优化
  • 防御式编程
  • 数据结构兼容

这已经是一个接近成熟博客系统的实现方式。

六、工程经验总结

  1. 状态更新不等于页面跳转。
  2. URL 是状态的一部分。
  3. 所有 props 都应进行防御处理。
  4. 后端数据需要做前端兜底。
  5. useMemo 是性能优化的重要工具。
#前端开发