From 285509a3e34868580290f89523c1f05ab1581712 Mon Sep 17 00:00:00 2001 From: alicesainta Date: Mon, 9 Mar 2026 19:53:01 +0800 Subject: [PATCH] =?UTF-8?q?feat(perf):=20=E5=A2=9E=E5=8A=A0=E6=80=A7?= =?UTF-8?q?=E8=83=BD=E5=9F=BA=E7=BA=BF=E9=9D=A2=E6=9D=BF=E5=B9=B6=E9=85=8D?= =?UTF-8?q?=E7=BD=AE=20allowedDevOrigins?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 10 ++ docs/performance-baseline.md | 42 ++++++ next.config.ts | 15 +- src/app/components/MessageList.tsx | 3 + src/app/perf/page.tsx | 220 +++++++++++++++++++++++++++++ src/lib/perf/chatPerfDataset.ts | 41 ++++++ 6 files changed, 330 insertions(+), 1 deletion(-) create mode 100644 docs/performance-baseline.md create mode 100644 src/app/perf/page.tsx create mode 100644 src/lib/perf/chatPerfDataset.ts diff --git a/README.md b/README.md index ce4aa26..800863f 100644 --- a/README.md +++ b/README.md @@ -9,6 +9,7 @@ - [CI 检查](#ci-检查) - [项目结构](#项目结构) - [按目标阅读文档](#按目标阅读文档) +- [性能基线复现](#性能基线复现) - [许可证](#许可证) ## 展示快照 @@ -34,6 +35,15 @@ pnpm lint pnpm type-check ``` +## 性能基线复现 + +```bash +pnpm dev +``` + +打开 `http://localhost:3000/perf`,使用固定轮次执行基线并记录结果。 +详细说明见 [docs/performance-baseline.md](docs/performance-baseline.md)。 + ## CI 检查 - Workflow 文件:[`.github/workflows/ci.yml`](.github/workflows/ci.yml) diff --git a/docs/performance-baseline.md b/docs/performance-baseline.md new file mode 100644 index 0000000..5a25b92 --- /dev/null +++ b/docs/performance-baseline.md @@ -0,0 +1,42 @@ +# 性能基线复现说明 + +本文提供可复现的前端基线测量流程,用于比较优化前后差异。 + +## 1. 目标指标 + +- 历史会话加载耗时(`historyLoadMs`) +- 长会话首次渲染耗时(`renderMs`) +- 滚动帧率(`scrollFps`) +- 内存增量(`memoryDeltaMb`,浏览器支持时) + +## 2. 运行前准备 + +1. 启动开发环境: + +```bash +pnpm dev +``` + +2. 打开性能面板: + +- `http://localhost:3000/perf` + +## 3. 复现步骤 + +1. 在“轮次”输入固定值(建议 `300`,对应约 `600` 条消息)。 +2. 点击“运行基线”。 +3. 记录输出的 4 项核心指标。 +4. 每个版本重复 3 次,取平均值。 + +## 4. 对比建议 + +- 同一台机器、同一浏览器、同一轮次数进行对比。 +- 若要做 PR 评估,建议附上: + - 基线版本 commit + - 优化版本 commit + - 三次平均结果 + +## 5. 限制说明 + +- `memoryDeltaMb` 依赖浏览器的 `performance.memory`,部分浏览器会显示 `N/A`。 +- 当前脚本聚焦前端渲染链路,不覆盖后端网络延迟。 diff --git a/next.config.ts b/next.config.ts index e9ffa30..bc3cbd8 100644 --- a/next.config.ts +++ b/next.config.ts @@ -1,7 +1,20 @@ import type { NextConfig } from "next"; +const parseAllowedDevOrigins = () => { + const raw = process.env.NEXT_ALLOWED_DEV_ORIGINS?.trim(); + if (!raw) { + // 默认放行本地局域网调试来源,避免 Next 未来版本升级后被跨域策略拦截。 + return ["192.168.96.167"]; + } + + return raw + .split(",") + .map((item) => item.trim()) + .filter(Boolean); +}; + const nextConfig: NextConfig = { - /* config options here */ + allowedDevOrigins: parseAllowedDevOrigins(), }; export default nextConfig; diff --git a/src/app/components/MessageList.tsx b/src/app/components/MessageList.tsx index a7aa519..3b1d575 100644 --- a/src/app/components/MessageList.tsx +++ b/src/app/components/MessageList.tsx @@ -13,6 +13,7 @@ type MessageListProps = { onRegenerate?: (messageId: string) => void; canRegenerate?: boolean; isStreaming?: boolean; + scrollerRef?: (element: HTMLElement | Window | null) => void; }; /** @@ -233,6 +234,7 @@ export default function MessageList({ onRegenerate, canRegenerate = false, isStreaming = false, + scrollerRef, }: MessageListProps) { return (
@@ -240,6 +242,7 @@ export default function MessageList({ data={messages} followOutput={isStreaming ? "smooth" : false} increaseViewportBy={480} + scrollerRef={scrollerRef} itemContent={(_, message) => (
{ + if (typeof performance === "undefined") return null; + const withMemory = performance as Performance & { + memory?: { + usedJSHeapSize?: number; + }; + }; + + return typeof withMemory.memory?.usedJSHeapSize === "number" + ? withMemory.memory.usedJSHeapSize + : null; +}; + +/** + * 用两帧作为“渲染稳定”采样点,减少单帧偶然抖动影响。 + */ +const waitForStableFrame = async () => { + await new Promise((resolve) => { + requestAnimationFrame(() => { + requestAnimationFrame(() => { + resolve(); + }); + }); + }); +}; + +/** + * 通过固定时长自动滚动来估算滚动帧率。 + */ +const measureScrollFps = async (element: HTMLElement) => { + const durationMs = 1_500; + const start = performance.now(); + let frameCount = 0; + const maxScrollTop = Math.max(1, element.scrollHeight - element.clientHeight); + + await new Promise((resolve) => { + const tick = (now: number) => { + frameCount += 1; + const progress = Math.min(1, (now - start) / durationMs); + element.scrollTop = maxScrollTop * progress; + if (progress >= 1) { + resolve(); + return; + } + requestAnimationFrame(tick); + }; + requestAnimationFrame(tick); + }); + + return (frameCount / durationMs) * 1_000; +}; + +export default function PerfPage() { + const [turnCount, setTurnCount] = useState(300); + const [messages, setMessages] = useState([]); + const [running, setRunning] = useState(false); + const [result, setResult] = useState(null); + const [error, setError] = useState(""); + const scrollerElementRef = useRef(null); + const datasetPreviewCount = useMemo(() => Math.max(1, Math.floor(turnCount)) * 2, [turnCount]); + + const handleRunBaseline = async () => { + setRunning(true); + setError(""); + setResult(null); + + try { + const dataset = buildSyntheticMessages(turnCount); + const heapBefore = getUsedHeapSize(); + + writeStoredMessages(PERF_SESSION_ID, dataset); + const loadStartedAt = performance.now(); + const loaded = readStoredMessages(PERF_SESSION_ID); + const historyLoadMs = performance.now() - loadStartedAt; + + const renderStartedAt = performance.now(); + setMessages(loaded); + await waitForStableFrame(); + const renderMs = performance.now() - renderStartedAt; + + const scrollerElement = scrollerElementRef.current; + if (!scrollerElement) { + throw new Error("未找到消息滚动容器,无法测量滚动 FPS"); + } + const scrollFps = await measureScrollFps(scrollerElement); + const heapAfter = getUsedHeapSize(); + const memoryDeltaMb = + heapBefore !== null && heapAfter !== null + ? (heapAfter - heapBefore) / (1024 * 1024) + : null; + + setResult({ + datasetMessages: loaded.length, + historyLoadMs, + renderMs, + scrollFps, + memoryDeltaMb, + }); + } catch (runError) { + setError(runError instanceof Error ? runError.message : "性能基线执行失败"); + } finally { + setRunning(false); + } + }; + + return ( +
+
+
+

+ 性能基线面板 +

+

+ 固定数据集 + 固定流程,复现历史加载、长会话渲染与滚动 FPS。 +

+ +
+ + + + 预计消息数:{datasetPreviewCount} + +
+
+ + {error ? ( +
+ {error} +
+ ) : null} + + {result ? ( +
+
+

消息总数

+

+ {result.datasetMessages} +

+
+
+

历史加载

+

+ {result.historyLoadMs.toFixed(1)} ms +

+
+
+

首次渲染

+

+ {result.renderMs.toFixed(1)} ms +

+
+
+

滚动 FPS

+

+ {result.scrollFps.toFixed(1)} +

+
+
+

内存增量

+

+ {result.memoryDeltaMb === null ? "N/A" : `${result.memoryDeltaMb.toFixed(2)} MB`} +

+
+
+ ) : null} + +
+
+ { + scrollerElementRef.current = + element instanceof HTMLElement ? element : null; + }} + /> +
+
+
+
+ ); +} diff --git a/src/lib/perf/chatPerfDataset.ts b/src/lib/perf/chatPerfDataset.ts new file mode 100644 index 0000000..9d04f44 --- /dev/null +++ b/src/lib/perf/chatPerfDataset.ts @@ -0,0 +1,41 @@ +import type { UIMessage } from "ai"; + +const SAMPLE_CODE = [ + "```ts", + "export const sum = (a: number, b: number) => a + b;", + "```", +].join("\n"); + +/** + * 生成可复现的长会话数据集,便于在不同版本间做稳定对比。 + */ +export const buildSyntheticMessages = (turnCount: number): UIMessage[] => { + const normalizedTurns = Math.max(1, Math.floor(turnCount)); + const messages: UIMessage[] = []; + + for (let i = 1; i <= normalizedTurns; i += 1) { + messages.push({ + id: `u_${i}`, + role: "user", + parts: [ + { + type: "text", + text: `请解释第 ${i} 轮问题,并给出可执行建议。`, + }, + ], + } as UIMessage); + + messages.push({ + id: `a_${i}`, + role: "assistant", + parts: [ + { + type: "text", + text: `第 ${i} 轮回答:\n- 结论\n- 方案\n- 风险\n\n代码示例:\n${SAMPLE_CODE}`, + }, + ], + } as UIMessage); + } + + return messages; +};