useQuery 的工作原理: 多次渲染问题处理

一次工作的渲染问题处理,第1次正常,第2次就缓存了上次的数据

🕐

解决方法1:

目前的改法,可以保持只渲染一次

核心代码 container

'use client';

import { useCreation, useUnmount } from 'ahooks';
import { ScopeProvider } from 'hooks/jotai';
import { notFound } from 'next/navigation';

import PageContainer from '@web/app/(pages)/components/PageContainer';
import { Loading } from '@webCommon/components/atoms/Loading';
import { DefaultWallpaper } from '@webCommon/components/molecules/Komi/DefaultWallpaper';
import { KomiWallpaper } from '@webCommon/components/molecules/Komi/KomiWallpaper';
import { useQuery, useQueryClient } from '@webCommon/hooks/useQuery';
import { getDuplicateEventConfigV2 } from '@webCommon/services/productEvent';

import { createEventDetailAtoms } from '../../../create/hooks/useEventSettings';
import { EventDuplicateForm } from './EventDuplicateForm';

export function EventDuplicatePageContainer({ eventId }: { eventId: string }) {
  const queryClient = useQueryClient();
  const queryKey = useCreation(() => {
    return ['eventDuplicate', eventId];
  }, [eventId]);

  const [data, { loading, state }] = useQuery({
    queryKey,
    queryFn: () => getDuplicateEventConfigV2(eventId),
    refetchOnWindowFocus: false,
    retry: false,
  });

  /**
   * 如果没有这里会有什么问题:
   * 第1次: useQuery 取数据, loading 由 false -> true
   * 第2次: useQuery 缓存里有值,会先将缓存里的值取出,然后 loading 表现为 false -> true -> true -> false
   */
  useUnmount(() => {
    queryClient.removeQueries({ queryKey });
  });

  if (state.isError) {
    return notFound();
  }

  return (
    <ScopeProvider atoms={createEventDetailAtoms}>
      <PageContainer
        disableContentPadding
        pageLevel={1}
        komiProps={{
          startAdornment: (
            <KomiWallpaper
              fallback={<DefaultWallpaper />}
              sx={{ backgroundColor: 'brand.white' }}
            />
          ),
          Inner: { sx: { backgroundColor: 'brand.white' } },
        }}
        hideHeader
        containerProps={{
          className: 'pageContainerPlain',
          sx: {
            overscrollBehaviorX: 'none',
            my: { xs: 0 },
            width: '100%',
          },
        }}
      >
        {loading ? <Loading /> : <EventDuplicateForm event={data} />}
      </PageContainer>
    </ScopeProvider>
  );
}

子组件

'use client';

import { HydrateAtoms } from '@webCommon/atoms/components/HydrateAtoms';
import { type Event } from '@webCommon/services/catalog/event';

import { EventDetailForm } from '../../../create/forms/EventDetailForm';
import { eventDetailAtom, isDuplicateEventAtom } from '../../../create/hooks/useEventSettings';

export function EventDuplicateForm({ event }: { event: Event }) {
  console.log('EventDuplicateForm rendered', {
    eventId: event.id,
    timestamp: Date.now(),
  });

  return (
    <>
      <HydrateAtoms
        initialValues={[
          [eventDetailAtom, event],
          [isDuplicateEventAtom, true],
        ]}
      />
      <EventDetailForm />
    </>
  );
}

解决方法2:

父级继续2次渲染,子级继续更新

'use client';

import { HydrateAtoms } from '@webCommon/atoms/components/HydrateAtoms';
import { type Event } from '@webCommon/services/catalog/event';

import { EventDetailForm } from '../../../create/forms/EventDetailForm';
import { eventDetailAtom, isDuplicateEventAtom } from '../../../create/hooks/useEventSettings';

export function EventDuplicateForm({ event }: { event: Event }) {
  return (
    <>
      <HydrateAtoms
        initialValues={[
          [eventDetailAtom, event],
          [isDuplicateEventAtom, true],
        ]}
        options={{ dangerouslyForceHydrate: true }}
      />
      <EventDetailForm />
    </>
  );
}

实际问题分析

# Duplicate Page 缓存优化方案

## 🚨 问题描述

在 duplicate 页面中,`useQuery` 会先显示缓存的上次数据,然后重新请求,导致:
1. **显示错误的旧数据**
2. **页面"闪烁"**(旧数据 → 新数据)
3. **多次渲染**(loading: false → true → false)

### 观察到的现象

```
第1次进入 duplicate 页面:
└─ 正常请求和显示

第2次进入 duplicate 页面(同一个事件):
├─ loading: false, data: 上次的缓存数据  ← 第1次渲染
├─ loading: true                          ← 第2次渲染(重新请求)
└─ loading: false, data: 新数据           ← 第3次渲染
```

## 🔍 根本原因

### React Query 的缓存机制

```typescript
const [data, { loading, state }] = useQuery({
  queryKey: ['eventDuplicate', eventId],
  queryFn: () => getDuplicateEventConfigV2(eventId),
  refetchOnWindowFocus: false,
  retry: false,
  // 默认配置(隐式):
  // refetchOnMount: true  ← 挂载时重新获取
  // staleTime: 0          ← 数据立即过期
  // gcTime: 5分钟         ← 缓存保留5分钟
});
```

### 执行流程

**第1次 duplicate 事件 A**:
```
1. 检查缓存:没有
2. loading: true
3. 发起请求
4. 请求成功,缓存数据
5. loading: false, data: data_A
6. 用户离开页面
```

**第2次 duplicate 事件 A**:
```
1. 检查缓存:有 data_A
2. loading: false, data: data_A(缓存)  ← 显示旧数据!
3. refetchOnMount: true → 重新请求
4. loading: true
5. 请求成功,更新缓存
6. loading: false, data: data_A_new
```

### 为什么会这样?

这是 React Query 的 **"stale-while-revalidate"** 策略:
1. **快速显示内容**:先显示缓存数据(虽然是旧的)
2. **后台更新**:同时在后台重新请求最新数据
3. **更新显示**:新数据返回后更新界面

**好处**:用户立即看到内容(虽然是旧的)
**坏处**:在 duplicate 场景中,旧数据是错误的(上次的值)

## ✅ 解决方案

### 使用 `useUnmount` 清除缓存

```typescript
import { useUnmount } from 'ahooks';
import { useQueryClient } from '@hooks/jotai';

export function EventDuplicatePageContainer({ eventId }: { eventId: string }) {
  const queryClient = useQueryClient();

  // 离开页面时清除缓存,避免下次进入时显示旧的缓存数据
  useUnmount(() => {
    queryClient.removeQueries({ queryKey: ['eventDuplicate', eventId] });
  });

  const [data, { loading, state }] = useQuery({
    queryKey: ['eventDuplicate', eventId],
    queryFn: () => getDuplicateEventConfigV2(eventId),
    refetchOnWindowFocus: false,
    retry: false,
  });

  // ...
}
```

## 📊 优化效果

### 优化前

| 次数 | 缓存状态 | loading | data | 渲染次数 | 问题 |
|------|---------|---------|------|---------|------|
| 第1次进入 | 无 | false→true→false | undefined→新数据 | 3次 | 正常 |
| 第2次进入 | 有(旧) | false→true→false | 旧数据→新数据 | 3次 | ❌ 显示旧数据 |

### 优化后

| 次数 | 缓存状态 | loading | data | 渲染次数 | 效果 |
|------|---------|---------|------|---------|------|
| 第1次进入 | 无 | true→false | undefined→新数据 | 2次 | ✅ 正常 |
| 离开页面 | - | - | - | - | ✅ 清除缓存 |
| 第2次进入 | 无(已清除) | true→false | undefined→新数据 | 2次 | ✅ 只显示新数据 |

## 🎯 关键点

### 1. 为什么用 `useUnmount` 而不是 `useEffect`?

**useUnmount**(推荐):
```typescript
useUnmount(() => {
  queryClient.removeQueries({ queryKey: ['eventDuplicate', eventId] });
});
```
- ✅ **语义清晰**:专门用于卸载时的清理
- ✅ **简洁**:不需要 return cleanup 函数
- ✅ **可读性好**:一眼看出是卸载时的操作

**useEffect**(也可以):
```typescript
useEffect(() => {
  return () => {
    queryClient.removeQueries({ queryKey: ['eventDuplicate', eventId] });
  };
}, [queryClient, eventId]);
```
- ✅ 功能相同
- ⚠️ 需要依赖数组
- ⚠️ 可读性稍差

### 2. 为什么不直接设置 `refetchOnMount: false`?

```typescript
// ❌ 不推荐
const [data, { loading, state }] = useQuery({
  refetchOnMount: false,  // 禁用挂载时重新获取
});
```

**问题**:
- ⚠️ 如果数据真的更新了,不会获取最新数据
- ⚠️ 用户可能看到过时的数据
- ⚠️ 不符合 duplicate 页面的需求(每次都要最新数据)

### 3. 性能影响

**网络请求**:
- ✅ 每次都重新请求(1次)
- ✅ 不会显示旧数据
- ✅ 用户体验更好

**对比**:
- 方案A(不清除缓存):可能显示旧数据,然后重新请求(2次渲染)
- 方案B(清除缓存):直接请求新数据(1次渲染)✅

## 💡 其他方案对比

| 方案 | 优点 | 缺点 | 推荐度 |
|------|------|------|--------|
| **useUnmount 清除缓存** | ✅ 简洁<br>✅ 语义清晰<br>✅ 只渲染1次 | ⚠️ 每次都请求 | ⭐⭐⭐⭐⭐ |
| refetchOnMount: false | ✅ 不重新请求 | ❌ 可能显示过时数据 | ⭐⭐ |
| staleTime: 0 | ✅ 数据立即过期 | ❌ 还是会先显示缓存 | ⭐⭐ |
| gcTime: 0 | ✅ 不保留缓存 | ❌ 影响其他页面 | ⭐ |

## 📝 完整代码

```typescript
'use client';

import { useUnmount } from 'ahooks';
import { useQueryClient } from '@hooks/jotai';
import { ScopeProvider } from 'hooks/jotai';
import { notFound } from 'next/navigation';

import PageContainer from '@web/app/(pages)/components/PageContainer';
import { Loading } from '@webCommon/components/atoms/Loading';
import { DefaultWallpaper } from '@webCommon/components/molecules/Komi/DefaultWallpaper';
import { KomiWallpaper } from '@webCommon/components/molecules/Komi/KomiWallpaper';
import { useQuery } from '@webCommon/hooks/useQuery';
import { getDuplicateEventConfigV2 } from '@webCommon/services/productEvent';

import { createEventDetailAtoms } from '../../../create/hooks/useEventSettings';
import { EventDuplicateForm } from './EventDuplicateForm';

export function EventDuplicatePageContainer({ eventId }: { eventId: string }) {
  const queryClient = useQueryClient();

  // 离开页面时清除缓存,避免下次进入时显示旧的缓存数据
  useUnmount(() => {
    queryClient.removeQueries({ queryKey: ['eventDuplicate', eventId] });
  });

  const [data, { loading, state }] = useQuery({
    queryKey: ['eventDuplicate', eventId],
    queryFn: () => getDuplicateEventConfigV2(eventId),
    refetchOnWindowFocus: false,
    retry: false,
  });

  if (state.isError) {
    return notFound();
  }

  return (
    <ScopeProvider atoms={createEventDetailAtoms}>
      <PageContainer
        disableContentPadding
        pageLevel={1}
        komiProps={{
          startAdornment: (
            <KomiWallpaper
              fallback={<DefaultWallpaper />}
              sx={{ backgroundColor: 'brand.white' }}
            />
          ),
          Inner: { sx: { backgroundColor: 'brand.white' } },
        }}
        hideHeader
        containerProps={{
          className: 'pageContainerPlain',
          sx: {
            overscrollBehaviorX: 'none',
            my: { xs: 0 },
            width: '100%',
          },
        }}
      >
        {loading ? <Loading /> : <EventDuplicateForm event={data} />}
      </PageContainer>
    </ScopeProvider>
  );
}
```

## 🎯 总结

### 问题
React Query 的缓存机制导致第2次进入时先显示旧数据,然后重新请求。

### 解决方案
使用 `useUnmount` 在离开页面时清除缓存。

### 效果
- ✅ 不会显示旧数据
- ✅ 只渲染1次
- ✅ 用户体验更好
- ✅ 代码简洁清晰

---

**优化时间**: 2026-03-05
**优化者**: Claude Code Analysis Tool