密钥管理页面性能优化
背景
在 ServiceProvidersPage.tsx 中,当有几十个 provider、几百个 API Key 账号时,页面打开慢、点击切换 provider 慢、保存操作也慢。
瓶颈分析
瓶颈 0(打开慢):fetchProviders 依赖 selectedName 导致重复请求
fetchProviders 的 useCallback 依赖了 selectedName(第 255 行):
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 依赖,只在首次加载时请求一次:
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 |
|---|---|---|
handleDelete | 399 | ✅ |
handleBatchDelete | 428 | ✅ |
handleMove | 466, 500 | ✅ |
handleBatchMove | ~560 | ✅ |
handleMoveFromDrawer | 605, 640 | ✅ |
handleFormSubmit | 794 | ✅ |
瓶颈 2:React 全量 re-render
每个 handler 的 useCallback 依赖项都包含 providers,导致:
- 任何一次保存后
setProviders(all)触发整个组件 re-render - 所有 handler 依赖
providers→providers变化后 handler 全部重建 - 侧边栏 + 表格 + 工具栏全部重新渲染
瓶颈 3:滚动约束缺失(次要)
侧边栏和表格没有高度约束,当数据多时 DOM 节点全部渲染且页面被撑得过长,滚动体验差。但这不是卡顿的主要原因。
优化方案
方案 A:增量更新 API(核心优化,解决卡顿)
已有的 updateOpenAIProvider 和 deleteOpenAIProvider API 可以做到单个 provider 级别的增量更新,不需要额外的 GET 请求,不需要序列化其他 provider。
现有可用 API
// 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:
/**
* 保存单个 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 行)
// 修改前
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:
// 修改前
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 行)
编辑保存同理:
// 修改前
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 条 entry | GET 全量配置 + PUT 全部 provider(~500 entries) | PATCH 1 个 provider(~10 entries) |
| 移动 1 条 entry | GET + 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 使用函数式更新
当前代码:
const all = providers.map((p) => (p.name === provider.name ? next : p));
setProviders(all);providers 出现在 handler 的闭包中,导致 useCallback 依赖 providers。改为函数式更新后可消除此依赖:
setProviders((prev) => prev.map((p) => (p.name === provider.name ? next : p)));这样 useCallback 就不需要依赖 providers,handler 不会因 providers 变化而重建。
B2. 给侧边栏按钮和表格行加 React.memo
侧边栏的每个 sidebarItem 和表格的每一行都是内联组件,父组件 re-render 时会全部重建。可以抽成独立组件并用 React.memo 包裹:
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:滚动约束(体验优化,非性能核心)
见下方 附录:滚动约束。
实施优先级
| 优先级 | 方案 | 效果 | 工作量 |
|---|---|---|---|
| P0 | 0. 修复 fetchProviders 重复请求 | 解决"打开慢"和"切换 provider 卡顿" | 小(改 1 行依赖 + 1 行状态更新) |
| P0 | A. 增量更新 API | 解决"保存慢"卡顿根因 | 中(改造 6 个 handler) |
| P1 | B1. 函数式 setProviders | 减少 re-render 和 handler 重建 | 小(改 useCallback 依赖) |
| P2 | C. 滚动约束 | 改善滚动体验 | 小(纯 CSS) |
| P3 | B2. React.memo | 进一步减少 re-render | 中(抽组件) |
建议先做两个 P0 + P1 + P2,这四个改动组合起来能显著改善卡顿问题。P3 是锦上添花。
变更清单
| 文件 | 变更 |
|---|---|
ServiceProvidersPage.tsx | fetchProviders 移除 selectedName 依赖,避免重复请求;6 个 handler 改为 PATCH 增量更新;setProviders 改为函数式更新;减少 useCallback 依赖 |
ServiceProvidersPage.module.scss | .sidebar 加 max-height + flex;.sidebarList 加 overflow-y: auto;.mainPanel 加 max-height;新增 .tableScrollWrap |
附录:滚动约束
侧边栏
在 .sidebar 上加高度约束和内部滚动:
.sidebar {
/* 现有样式保持不变,新增: */
max-height: calc(100vh - 160px);
display: flex;
flex-direction: column;
}
.sidebarList {
/* 现有样式保持不变,新增: */
overflow-y: auto;
flex: 1;
min-height: 0;
}表格
.mainPanel {
/* 现有样式保持不变,新增: */
max-height: calc(100vh - 160px);
overflow: hidden;
}
.tableScrollWrap {
/* 新增样式 */
overflow-y: auto;
flex: 1;
min-height: 0;
}在 JSX 中给 <Table> 包一层:
<div className={styles.tableScrollWrap}>
<Table cols={...}>...</Table>
</div>响应式适配
@media (max-width: 768px) {
.sidebar { max-height: 300px; }
.mainPanel { max-height: none; overflow: visible; }
.tableScrollWrap { overflow: visible; }
}