Next.js 切换动态路由后,为什么页面还保留着上一个分类的数据?
最近在开发一个文章分类页时遇到了这样的问题:
用户在 /articles/deal-news/fashion 页面底部点击 Related topics,进入另一个分类后,页面标题已经变成了新分类,但文章卡片的内容和数量仍然属于上一个分类。
手动刷新页面后,数据又恢复正常。
这是一个典型的 React 状态与 Next.js 动态路由组件复用问题。
问题代码
分类页面接收服务端返回的数据,并将其保存到本地状态中:
javascript
const CategoryPage = ({
category,
initialPosts,
totalPages,
}: CategoryPageProps) => {
const [page, setPage] = useState(1);
const [posts, setPosts] = useState(initialPosts);
return (
<main>
<h1>{category.name}</h1>
{posts.map((post) => (
<ArticleCard key={post.slug} post={post} />
))}
</main>
);
};
Related topics 使用 Next.js 的 Link 进行跳转:
ini
<Link href="/articles/deal-news/beauty">
Beauty
</Link>
从表面上看没有问题:新路由会获取新的分类数据,然后将新的 category 和 initialPosts 传给页面。
但实际结果却是:
category.name更新了initialPosts更新了posts没有更新page仍然保留旧值
为什么会发生?
假设用户进行了下面的跳转:
bash
/articles/deal-news/fashion
↓
/articles/deal-news/beauty
虽然 URL 中的分类参数发生了变化,但这两个地址使用的是同一个动态路由文件:
css
pages/articles/[articleType]/[identifier].tsx
对 React 来说,跳转前后渲染的组件结构仍然是:
xml
<CategoryPage />
组件类型没有变化,组件所处的位置也没有变化。因此 React 会复用当前的 CategoryPage 实例,而不是先卸载再重新创建。
于是,新的 props 会传进来,但组件内部已有的 state 会被保留。
useState 的初始值只使用一次
问题的关键在这一行:
scss
const [posts, setPosts] = useState(initialPosts);
很多人会下意识地认为:
当
initialPosts更新时,posts也会跟着更新。
实际上不会。
useState(initialPosts) 中的 initialPosts 只在组件第一次挂载时使用。之后即使 props 发生变化,React 也不会重新初始化 state。
可以用一个更小的例子说明:
typescript
const Counter = ({ initialCount }: { initialCount: number }) => {
const [count] = useState(initialCount);
return (
<div>
<p>属性:{initialCount}</p>
<p>状态:{count}</p>
</div>
);
};
第一次渲染:
ini
<Counter initialCount={10} />
页面显示:
属性:10
状态:10
随后父组件传入新的值:
ini
<Counter initialCount={20} />
页面会变成:
属性:20
状态:10
initialCount 已经更新,但 count 依然保留第一次初始化时的值。
文章分类页遇到的就是同一个问题。
为什么刷新页面后正常?
刷新页面时,React 应用会重新启动,CategoryPage 也会重新挂载。
此时:
scss
useState(initialPosts);
接收到的是当前分类的文章数据,所以页面显示正常。
这也是判断此类问题的一个常见线索:
客户端跳转后数据错误,但刷新后恢复正常,通常需要检查组件状态是否在路由之间被复用。
最小修复:使用 key 标识不同分类
这个页面中的文章列表和页码都完全属于当前分类。因此分类改变时,让整个 CategoryPage 重新挂载是合理的。
ini
const ArticleIdentifierPage = (props: PageProps) =>
props.pageType === "category" ? (
<CategoryPage
key={props.category.tagId}
{...props}
/>
) : (
<BlogDetailPage {...props} />
);
当分类从 fashion 变成 beauty 时,组件的 key 也会发生变化:
ini
key="fashion"
↓
key="beauty"
React 会把它们识别为两个不同的组件实例:
- 卸载旧的
CategoryPage - 创建新的
CategoryPage - 使用新分类数据初始化
posts - 将
page重新初始化为 1 - 将加载状态等其他本地状态一起重置
这样,新分类页面就不会继续使用上一个分类的文章数据。
也可以使用 useEffect 吗?
可以,例如:
scss
useEffect(() => {
setPage(1);
setPosts(initialPosts);
}, [category.tagId, initialPosts]);
但是这种方案需要手动列出所有应该重置的状态。
当前组件除了 posts 和 page,还有加载状态、引用或未来可能新增的分类相关状态。如果漏掉其中一个,切换分类时仍然可能残留旧数据。
另外,useEffect 是在一次渲染之后执行的,页面可能短暂出现"新标题配旧卡片"的状态。
如果需求就是"分类改变后,这是一个全新的分类页面",使用 key 更直接:
ini
<CategoryPage key={category.tagId} />
如果需求是"只同步某一个状态,同时保留用户在页面上的其他操作",则更适合使用 useEffect。
不要把所有 props 都复制到 state
这类 Bug 背后还有一个常见设计问题:把 props 无条件复制到 state。
例如,如果文章列表不需要在客户端修改,可以直接使用 props:
javascript
const CategoryPage = ({ initialPosts }: CategoryPageProps) => (
<div>
{initialPosts.map((post) => (
<ArticleCard key={post.slug} post={post} />
))}
</div>
);
这样 props 更新时,页面自然会重新渲染,也就不存在同步问题。
但如果页面支持分页、筛选或客户端请求,就需要本地状态:
scss
const [posts, setPosts] = useState(initialPosts);
一旦选择将 props 作为 state 的初始值,就需要明确回答一个问题:
当数据所属的业务对象发生变化时,这些 state 应该保留,还是重新初始化?
在这个案例中,"业务对象"就是分类。分类改变,文章列表和页码都应该重置,因此使用分类 ID 作为 key 是合适的。
总结
这个 Bug 不是接口返回错误,也不是 Next.js 缓存导致的,而是下面三个条件共同造成的:
- 不同分类使用同一个动态路由组件
- Next.js 客户端跳转时复用了组件实例
useState(initialPosts)只在首次挂载时读取初始数据
最终修复只有一行:
ini
<CategoryPage key={props.category.tagId} {...props} />
理解这行代码的重点,不是"加一个 key 可以刷新页面",而是:
key定义了 React 眼中的组件身份。身份改变时,旧状态就不应该继续属于新页面。