Skip to content

密钥管理页面性能优化

背景

ServiceProvidersPage.tsx 中,当有几十个 provider、几百个 API Key 账号时,页面打开慢、点击切换 provider 慢、保存操作也慢。

瓶颈分析

瓶颈 0(打开慢):fetchProviders 依赖 selectedName 导致重复请求

fetchProvidersuseCallback 依赖了 selectedName(第 255 行):

typescript
const fetchProviders = useCallback(async () => {
  // ... GET /openai-compatibility
  if (data.length > 0 && !selectedName) {
    setSelectedName(data[0].name);
  }
}, [selectedName, showNotification]);

useEffect(() => {
  void fetchProviders();
}, [fetchProviders]);

问题链路

首次加载 → fetchProviders() → GET /openai-compatibility(几百条数据)
         → setProviders(data)
         → setSelectedName(data[0].name)  ← selectedName 变了!
         → fetchProviders 重建(依赖 selectedName)
         → useEffect 再次触发 → 又 GET /openai-compatibility  ← 重复请求!

每次切换侧边栏 provider,selectedName 变化 → fetchProviders 重建 → 又全量请求。每次请求都触发 setLoading(true),页面闪烁。

修复:移除 selectedName 依赖,只在首次加载时请求一次:

typescript
const fetchProviders = useCallback(async () => {
  try {
    setLoading(true);
    const data = await providersApi.getOpenAIProviders();
    setProviders(data);
    setSelectedName((prev) => prev ?? (data.length > 0 ? data[0].name : null));
  } catch {
    showNotification('Failed to load providers', 'error');
  } finally {
    setLoading(false);
  }
}, [showNotification]);

瓶颈 1(核心):每次操作都是全量保存

当前页面所有写操作(编辑、删除、移动、批量操作)都调用 saveOpenAIProviders(all),即 PUT /openai-compatibility

这个函数的内部流程:

1. GET /config              ← 读取完整后端配置(可能几 MB)
2. 遍历全部 providers       ← 序列化每一个 provider(几十个)
3. 对每个 provider 做 merge ← 和后端原始数据合并
4. PUT /openai-compatibility ← 发送全部序列化数据到后端

举例:删除 1 条 API Key(只需修改 1 个 provider 中的 1 条 entry),但实际执行:

  • 额外发起 1 次 GET 请求读取完整配置
  • 序列化 50 个 provider × 每个 provider 10 条 entry = 500 条 entry 的数据
  • PUT 全部数据到后端

涉及的所有函数及行号:

函数行号调用 saveOpenAIProviders
handleDelete399
handleBatchDelete428
handleMove466, 500
handleBatchMove~560
handleMoveFromDrawer605, 640
handleFormSubmit794

瓶颈 2:React 全量 re-render

每个 handler 的 useCallback 依赖项都包含 providers,导致:

  • 任何一次保存后 setProviders(all) 触发整个组件 re-render
  • 所有 handler 依赖 providersproviders 变化后 handler 全部重建
  • 侧边栏 + 表格 + 工具栏全部重新渲染

瓶颈 3:滚动约束缺失(次要)

侧边栏和表格没有高度约束,当数据多时 DOM 节点全部渲染且页面被撑得过长,滚动体验差。但这不是卡顿的主要原因。

优化方案

方案 A:增量更新 API(核心优化,解决卡顿)

已有的 updateOpenAIProviderdeleteOpenAIProvider API 可以做到单个 provider 级别的增量更新,不需要额外的 GET 请求,不需要序列化其他 provider

现有可用 API

typescript
// PATCH 单个 provider(按数组索引)
updateOpenAIProvider(index: number, value: OpenAIProviderConfig)
  → apiClient.patch('/openai-compatibility', { index, value: serializeOpenAIProvider(value) })

// DELETE 单个 provider(按 name)
deleteOpenAIProvider(name: string)
  → apiClient.delete(`/openai-compatibility?name=${name}`)

改造方案

1. 封装 entry 级别的增量保存工具函数

providers.ts 中新增(或在页面中封装)一个辅助函数,用于仅保存单个 provider:

typescript
/**
 * 保存单个 provider 的修改(使用 PATCH 而非 PUT 全量替换)
 */
async function saveSingleProvider(
  providerIdx: number,
  updatedProvider: OpenAIProviderConfig,
  currentProviders: OpenAIProviderConfig[]
): Promise<OpenAIProviderConfig[]> {
  await providersApi.updateOpenAIProvider(providerIdx, updatedProvider);
  // 前端乐观更新
  return currentProviders.map((p, i) => (i === providerIdx ? updatedProvider : p));
}
2. 改造 handleDelete(第 387~409 行)
typescript
// 修改前
const handleDelete = useCallback(
  async (provider: OpenAIProviderConfig, entryIdx: number) => {
    showConfirmation({
      message: 'Delete this API key entry?',
      onConfirm: async () => {
        const next = { ...provider, apiKeyEntries: [...provider.apiKeyEntries] };
        next.apiKeyEntries.splice(entryIdx, 1);
        const all = providers.map((p) => (p.name === provider.name ? next : p));
        await providersApi.saveOpenAIProviders(all);  // ← 全量 PUT
        setProviders(all);
      },
    });
  },
  [providers, ...]
);

// 修改后
const handleDelete = useCallback(
  async (provider: OpenAIProviderConfig, entryIdx: number) => {
    showConfirmation({
      message: 'Delete this API key entry?',
      onConfirm: async () => {
        const next = { ...provider, apiKeyEntries: [...provider.apiKeyEntries] };
        next.apiKeyEntries.splice(entryIdx, 1);
        const providerIdx = providers.indexOf(provider);
        await providersApi.updateOpenAIProvider(providerIdx, next);  // ← PATCH 单个
        setProviders((prev) => prev.map((p, i) => (i === providerIdx ? next : p)));
      },
    });
  },
  [providers, showConfirmation, showNotification, t]
);
3. 改造 handleMove(第 451~508 行)

移动操作涉及两个 provider(源 + 目标),分别 PATCH:

typescript
// 修改前
await providersApi.saveOpenAIProviders(all);  // 全量 PUT

// 修改后
const srcIdx = providers.indexOf(provider);
const tgtIdx = providers.indexOf(target);
await Promise.all([
  providersApi.updateOpenAIProvider(srcIdx, srcNext),
  providersApi.updateOpenAIProvider(tgtIdx, tgtNext),
]);
setProviders((prev) => prev.map((p, i) => {
  if (i === srcIdx) return srcNext;
  if (i === tgtIdx) return tgtNext;
  return p;
}));
4. 改造 handleFormSubmit(第 751~804 行)

编辑保存同理:

typescript
// 修改前
await providersApi.saveOpenAIProviders(all);  // 全量 PUT

// 修改后
await providersApi.updateOpenAIProvider(providerIdx, next);  // PATCH 单个
setProviders((prev) => prev.map((p, i) => (i === providerIdx ? next : p)));
5. handleBatchDelete、handleBatchMove、handleMoveFromDrawer

同理改造,将 saveOpenAIProviders(all) 替换为 updateOpenAIProvider(providerIdx, next)

性能提升预估

操作优化前优化后
删除 1 条 entryGET 全量配置 + PUT 全部 provider(~500 entries)PATCH 1 个 provider(~10 entries)
移动 1 条 entryGET + PUT 全部PATCH 2 个 provider
编辑保存GET + PUT 全部PATCH 1 个 provider
网络请求数2 次(GET + PUT)1 次(PATCH)
数据传输量数百 KB ~ 几 MB几 KB

方案 A 风险评估

saveOpenAIProviders(PUT)内部调用 buildPreservedList,会先 GET /config 读取后端原始配置,对每个 provider 做 merge(合并后端有但前端不知道的字段)。

updateOpenAIProvider(PATCH)直接发送 { index, value: serializedProvider }不经过 merge 流程

潜在风险:如果后端 config 中存在前端 OpenAIProviderConfig 类型里没有的字段,PATCH 发送的 value 里这些字段会是 undefined,可能导致这些字段被覆盖丢失。

验证方法:用 PATCH 接口修改一个 provider 的 apiKey,然后检查后端 config 文件中该 provider 的其他字段(如 models、headers 等)是否完整保留。如果后端 PATCH handler 本身支持只更新传入字段的语义(而非全量替换),则方案 A 完全安全。

其他方案风险

方案风险
瓶颈 0 修复无风险,只是消除一个 bug 性的重复请求
方案 B1(函数式 setProviders)无风险,纯前端状态更新方式变更
方案 B2(React.memo)无风险,纯渲染优化
方案 C(滚动约束)无风险,纯 CSS

方案 B:减少 React re-render(辅助优化)

B1. setProviders 使用函数式更新

当前代码:

typescript
const all = providers.map((p) => (p.name === provider.name ? next : p));
setProviders(all);

providers 出现在 handler 的闭包中,导致 useCallback 依赖 providers。改为函数式更新后可消除此依赖:

typescript
setProviders((prev) => prev.map((p) => (p.name === provider.name ? next : p)));

这样 useCallback 就不需要依赖 providers,handler 不会因 providers 变化而重建。

B2. 给侧边栏按钮和表格行加 React.memo

侧边栏的每个 sidebarItem 和表格的每一行都是内联组件,父组件 re-render 时会全部重建。可以抽成独立组件并用 React.memo 包裹:

tsx
const SidebarItem = React.memo(function SidebarItem({ provider, isActive, isError, onSelect }) {
  return (
    <button ...>
      <div ...>
        <span>{provider.name}</span>
        <span>{provider.baseUrl}</span>
      </div>
      <span>{(provider.apiKeyEntries ?? []).length}</span>
    </button>
  );
});

方案 C:滚动约束(体验优化,非性能核心)

见下方 附录:滚动约束

实施优先级

优先级方案效果工作量
P00. 修复 fetchProviders 重复请求解决"打开慢"和"切换 provider 卡顿"小(改 1 行依赖 + 1 行状态更新)
P0A. 增量更新 API解决"保存慢"卡顿根因中(改造 6 个 handler)
P1B1. 函数式 setProviders减少 re-render 和 handler 重建小(改 useCallback 依赖)
P2C. 滚动约束改善滚动体验小(纯 CSS)
P3B2. React.memo进一步减少 re-render中(抽组件)

建议先做两个 P0 + P1 + P2,这四个改动组合起来能显著改善卡顿问题。P3 是锦上添花。

变更清单

文件变更
ServiceProvidersPage.tsxfetchProviders 移除 selectedName 依赖,避免重复请求;6 个 handler 改为 PATCH 增量更新;setProviders 改为函数式更新;减少 useCallback 依赖
ServiceProvidersPage.module.scss.sidebarmax-height + flex;.sidebarListoverflow-y: auto.mainPanelmax-height;新增 .tableScrollWrap

附录:滚动约束

侧边栏

.sidebar 上加高度约束和内部滚动:

scss
.sidebar {
  /* 现有样式保持不变,新增: */
  max-height: calc(100vh - 160px);
  display: flex;
  flex-direction: column;
}

.sidebarList {
  /* 现有样式保持不变,新增: */
  overflow-y: auto;
  flex: 1;
  min-height: 0;
}

表格

scss
.mainPanel {
  /* 现有样式保持不变,新增: */
  max-height: calc(100vh - 160px);
  overflow: hidden;
}

.tableScrollWrap {
  /* 新增样式 */
  overflow-y: auto;
  flex: 1;
  min-height: 0;
}

在 JSX 中给 <Table> 包一层:

tsx
<div className={styles.tableScrollWrap}>
  <Table cols={...}>...</Table>
</div>

响应式适配

scss
@media (max-width: 768px) {
  .sidebar { max-height: 300px; }
  .mainPanel { max-height: none; overflow: visible; }
  .tableScrollWrap { overflow: visible; }
}