perf: skip redundant composer measurement, cache i18next language codes and follow streaming once per frame
This commit is contained in:
@@ -605,6 +605,38 @@ Renderer 主线程降速 4 倍(Main 不降速,每次 reload 后重新应用
|
||||
低成本热点:Composer 高度改为只在需要时测量、`t()` 结果或 i18next 语言码规范化做缓存、
|
||||
滚动跟随按帧合并且只在贴底时执行。
|
||||
|
||||
#### 2026-10-02 第二步:三处低成本热点
|
||||
|
||||
- **Composer 高度(`App.tsx` `resizeComposerTextarea`):** 记住上次的值与高度。值未变不测量;
|
||||
在原文本末尾追加时不再先设 `height: auto`(只会变高),已到最大高度直接跳过测量;删除、
|
||||
替换、清空仍按原方式重置并测量。
|
||||
- **i18next 语言码(`i18n/index.ts`):** i18next 25 每次 `t()` 都经
|
||||
`toResolveHierarchy`→`formatLanguageCode` 调用 `Intl.getCanonicalLocales`。初始化后对
|
||||
`languageUtils.formatLanguageCode` 加按输入缓存(结果只取决于代码与静态初始化选项,上限
|
||||
64 项),翻译结果不变。
|
||||
- **滚动跟随(`ChatHistoryPane.tsx`):** 激活面板时仍同步滚到底;之后内容更新时改为每帧最多
|
||||
一次 `requestAnimationFrame` 中读取 `scrollHeight` 并 `scrollTo`,回调里再次确认仍贴底;
|
||||
卸载时取消。读者离开底部时不滚动,恢复阅读位置逻辑不变。
|
||||
- **测试:** 新增“流式三次更新只滚动一帧”(旧实现 3 次,失败)、Composer 追加与删除的测量
|
||||
次数、`t()` 不再调用 `Intl.getCanonicalLocales`。全量测试 5,621 通过,2 失败与本改动
|
||||
无关(真实 OpenCode 集成测试因免费额度返回 403;Supervisor 超时设置用例此前已失败)。
|
||||
- **`npm run perf:app` A/B(同机,改动前为临时 worktree 中的 `983cdd4` 构建,交替运行):**
|
||||
|
||||
| 场景(4× 降速,3 轮) | 改动前 按键/点击 p50 / p95 | 改动后 |
|
||||
| --- | --- | --- |
|
||||
| 输入 120 字(新 profile) | 28–80 / 33–361 ms | 19–36 / 24–57 ms |
|
||||
| 输入 120 字(有 300 个会话) | 265–2,372 / 1,486–4,266 ms | 27–161 / 32–346 ms |
|
||||
| 首次切换会话 | 442–761 / 1,063–1,384 ms | 360–398 / 820–1,334 ms |
|
||||
| 再次切换(缓存命中) | 472–749 / 501–1,143 ms | 390–420 / 456–951 ms |
|
||||
| 流式 40 KB:Long Tasks 总时长 | 15.9–20.6 s | 15.1–17.4 s |
|
||||
| 流式期间输入 | 432–6,778 / 1,146–9,634 ms | 96–466 / 297–1,567 ms |
|
||||
|
||||
不降速时(2 轮)两版各场景持平,输入 p95 均约 9 ms,流式 1 个 Long Task。4× 降速下
|
||||
方差很大(同版本轮间可差数倍),应看量级:有历史时输入从秒级回到百毫秒内,切换略有改善,
|
||||
流式本身仍是每帧卡顿——剩余成本是 App 级重渲染与 Markdown/React 提交,需 `PERF-13`。
|
||||
- **剩余:** 流式 delta 按帧合并后以 transition 提交、大面板 memo 仍待实施;之后进入
|
||||
`PERF-13`。
|
||||
|
||||
### PERF-13 Renderer 领域 Store 迁移
|
||||
|
||||
- **优先级 / 状态:** P1 / 待开始
|
||||
|
||||
@@ -3660,7 +3660,7 @@ describe("App", () => {
|
||||
it("measures composer height once per input and resizes after sending", async () => {
|
||||
render(<App />);
|
||||
const input = await screen.findByLabelText("向 GoodBuddy 提问");
|
||||
const measureHeight = vi.fn(() =>
|
||||
const measureHeight = vi.fn((): number =>
|
||||
(input as HTMLTextAreaElement).value ? 300 : 0,
|
||||
);
|
||||
Object.defineProperty(input, "scrollHeight", {
|
||||
@@ -3672,6 +3672,21 @@ describe("App", () => {
|
||||
expect(measureHeight).toHaveBeenCalledTimes(1);
|
||||
expect(input).toHaveStyle({ height: "220px" });
|
||||
|
||||
// Appending at the maximum height cannot change the size.
|
||||
measureHeight.mockClear();
|
||||
fireEvent.input(input, { target: { value: "First line\nSecond line!" } });
|
||||
expect(measureHeight).not.toHaveBeenCalled();
|
||||
expect(input).toHaveStyle({ height: "220px" });
|
||||
|
||||
// Deleting text may shrink the box, so it is measured again.
|
||||
measureHeight.mockImplementation(() => 100);
|
||||
fireEvent.input(input, { target: { value: "First" } });
|
||||
expect(measureHeight).toHaveBeenCalledTimes(1);
|
||||
expect(input).toHaveStyle({ height: "100px" });
|
||||
|
||||
measureHeight.mockImplementation(() =>
|
||||
(input as HTMLTextAreaElement).value ? 300 : 0,
|
||||
);
|
||||
measureHeight.mockClear();
|
||||
fireEvent.click(screen.getByLabelText("发送"));
|
||||
await waitFor(() => expect(input).toHaveValue(""));
|
||||
@@ -4477,6 +4492,51 @@ describe("App", () => {
|
||||
expect(screen.getByRole("button", { name: "到底部" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("follows streaming output at the bottom once per frame", async () => {
|
||||
render(<App />);
|
||||
fireEvent.change(await screen.findByLabelText("向 GoodBuddy 提问"), {
|
||||
target: { value: "生成流式回复" },
|
||||
});
|
||||
fireEvent.click(screen.getByLabelText("发送"));
|
||||
await waitFor(() => expect(run).toHaveBeenCalledOnce());
|
||||
const request = run.mock.calls[0]?.[0];
|
||||
if (!request) {
|
||||
throw new Error("Missing request");
|
||||
}
|
||||
await act(
|
||||
() =>
|
||||
new Promise<void>((resolve) => requestAnimationFrame(() => resolve())),
|
||||
);
|
||||
const chat = document.querySelector<HTMLElement>(".chat");
|
||||
if (!chat) {
|
||||
throw new Error("Missing chat scroll container");
|
||||
}
|
||||
let scrollHeight = 1_200;
|
||||
Object.defineProperties(chat, {
|
||||
clientHeight: { configurable: true, value: 400 },
|
||||
scrollHeight: { configurable: true, get: () => scrollHeight },
|
||||
scrollTop: { configurable: true, writable: true, value: 800 },
|
||||
});
|
||||
const scrollTo = vi.fn();
|
||||
chat.scrollTo = scrollTo;
|
||||
|
||||
for (const delta of ["第一段", "第二段", "第三段"]) {
|
||||
scrollHeight += 100;
|
||||
act(() => {
|
||||
agentListener?.({ requestId: request.requestId, type: "text", delta });
|
||||
});
|
||||
}
|
||||
expect(await screen.findByText(/第三段/u)).toBeInTheDocument();
|
||||
await act(
|
||||
() =>
|
||||
new Promise<void>((resolve) => requestAnimationFrame(() => resolve())),
|
||||
);
|
||||
|
||||
expect(scrollTo).toHaveBeenCalled();
|
||||
expect(scrollTo.mock.calls.length).toBeLessThanOrEqual(2);
|
||||
expect(scrollTo).toHaveBeenLastCalledWith({ top: 1_500, behavior: "auto" });
|
||||
});
|
||||
|
||||
it("restores the reader position after activity continues on another page", async () => {
|
||||
const { container } = render(<App />);
|
||||
fireEvent.change(await screen.findByLabelText("向 GoodBuddy 提问"), {
|
||||
|
||||
@@ -1354,15 +1354,51 @@ function formatAttachmentSize(size: number): string {
|
||||
const composerTextareaMinHeight = 72;
|
||||
const composerTextareaMaxHeight = 220;
|
||||
|
||||
const composerTextareaSizes = new WeakMap<
|
||||
HTMLTextAreaElement,
|
||||
{ height: number; value: string }
|
||||
>();
|
||||
|
||||
function clampComposerTextareaHeight(height: number): number {
|
||||
return Math.max(
|
||||
composerTextareaMinHeight,
|
||||
Math.min(height, composerTextareaMaxHeight),
|
||||
);
|
||||
}
|
||||
|
||||
// Sizing reads scrollHeight, which forces a synchronous layout of the whole
|
||||
// window. Skip the read when the result is known, and avoid the extra
|
||||
// "auto" reset layout when text was only appended (it can only grow).
|
||||
function resizeComposerTextarea(textarea: HTMLTextAreaElement | null): void {
|
||||
if (!textarea) {
|
||||
return;
|
||||
}
|
||||
textarea.style.height = "auto";
|
||||
textarea.style.height = `${Math.max(
|
||||
composerTextareaMinHeight,
|
||||
Math.min(textarea.scrollHeight, composerTextareaMaxHeight),
|
||||
)}px`;
|
||||
const value = textarea.value;
|
||||
const previous = composerTextareaSizes.get(textarea);
|
||||
const previousHeightApplied =
|
||||
previous !== undefined &&
|
||||
textarea.style.height === `${previous.height}px`;
|
||||
let height: number;
|
||||
if (previousHeightApplied && value === previous.value) {
|
||||
height = previous.height;
|
||||
} else if (
|
||||
previousHeightApplied &&
|
||||
previous.value !== "" &&
|
||||
value.startsWith(previous.value)
|
||||
) {
|
||||
height =
|
||||
previous.height >= composerTextareaMaxHeight
|
||||
? composerTextareaMaxHeight
|
||||
: clampComposerTextareaHeight(textarea.scrollHeight);
|
||||
} else {
|
||||
textarea.style.height = "auto";
|
||||
height = clampComposerTextareaHeight(textarea.scrollHeight);
|
||||
}
|
||||
const nextHeight = `${height}px`;
|
||||
if (textarea.style.height !== nextHeight) {
|
||||
textarea.style.height = nextHeight;
|
||||
}
|
||||
composerTextareaSizes.set(textarea, { height, value });
|
||||
}
|
||||
|
||||
const imageDataUrlPattern = /^data:image\/(png|jpeg|webp);base64,/u;
|
||||
|
||||
@@ -109,6 +109,7 @@ export const ChatHistoryPane = memo(function ChatHistoryPane({
|
||||
const latestScrollSnapshotRef = useRef(scrollSnapshot);
|
||||
const restorePendingRef = useRef(true);
|
||||
const wasActiveRef = useRef(false);
|
||||
const followFrameRef = useRef<number | undefined>(undefined);
|
||||
const prependScrollPositionRef = useRef<
|
||||
| {
|
||||
scrollHeight: number;
|
||||
@@ -260,14 +261,42 @@ export const ChatHistoryPane = memo(function ChatHistoryPane({
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (pinnedToBottomRef.current) {
|
||||
if (!pinnedToBottomRef.current) {
|
||||
return;
|
||||
}
|
||||
if (activated) {
|
||||
scrollContainer.scrollTo({
|
||||
top: scrollContainer.scrollHeight,
|
||||
behavior: "auto",
|
||||
});
|
||||
return;
|
||||
}
|
||||
// Streaming updates commit many times per frame. Reading scrollHeight here
|
||||
// would force a synchronous layout per commit, so follow the bottom once
|
||||
// per frame instead; rAF still runs before the frame is painted.
|
||||
if (followFrameRef.current !== undefined) {
|
||||
return;
|
||||
}
|
||||
followFrameRef.current = requestAnimationFrame(() => {
|
||||
followFrameRef.current = undefined;
|
||||
const container = scrollRef.current;
|
||||
if (!container || !pinnedToBottomRef.current) {
|
||||
return;
|
||||
}
|
||||
container.scrollTo({ top: container.scrollHeight, behavior: "auto" });
|
||||
});
|
||||
}, [active, conversation.messages, scrollSnapshot, visibleMessageCount]);
|
||||
|
||||
useEffect(
|
||||
() => () => {
|
||||
if (followFrameRef.current !== undefined) {
|
||||
cancelAnimationFrame(followFrameRef.current);
|
||||
followFrameRef.current = undefined;
|
||||
}
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const previous = prependScrollPositionRef.current;
|
||||
if (!previous) {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import i18n, {
|
||||
changeUiLocale,
|
||||
i18nResources
|
||||
@@ -41,4 +41,20 @@ describe('renderer i18n resources', () => {
|
||||
await changeUiLocale('zh-CN')
|
||||
expect(i18n.t('navigation.chat')).toBe('对话')
|
||||
})
|
||||
|
||||
it('canonicalizes language codes once instead of on every translation', async () => {
|
||||
const getCanonicalLocales = vi.spyOn(Intl, 'getCanonicalLocales')
|
||||
try {
|
||||
await changeUiLocale('en-US')
|
||||
expect(i18n.t('navigation.chat')).toBe('Chat')
|
||||
getCanonicalLocales.mockClear()
|
||||
for (let index = 0; index < 50; index += 1) {
|
||||
i18n.t('navigation.chat')
|
||||
}
|
||||
expect(getCanonicalLocales).not.toHaveBeenCalled()
|
||||
expect(i18n.services.languageUtils.formatLanguageCode('zh-cn')).toBe('zh-CN')
|
||||
} finally {
|
||||
getCanonicalLocales.mockRestore()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -68,6 +68,37 @@ void i18n
|
||||
returnNull: false
|
||||
})
|
||||
|
||||
memoizeLanguageCodeFormatting(i18n)
|
||||
|
||||
/**
|
||||
* i18next canonicalizes the current language with `Intl.getCanonicalLocales`
|
||||
* on every `t()` call. The result depends only on the code and the static init
|
||||
* options, so cache it; large lists call `t()` hundreds of times per render.
|
||||
*/
|
||||
export function memoizeLanguageCodeFormatting(instance: typeof i18n): void {
|
||||
const languageUtils = instance.services?.languageUtils as
|
||||
| { formatLanguageCode?: (code: string) => string }
|
||||
| undefined
|
||||
const format = languageUtils?.formatLanguageCode
|
||||
if (!languageUtils || typeof format !== 'function') {
|
||||
return
|
||||
}
|
||||
const cache = new Map<string, string>()
|
||||
languageUtils.formatLanguageCode = (code: string): string => {
|
||||
if (typeof code !== 'string') {
|
||||
return format.call(languageUtils, code)
|
||||
}
|
||||
let formatted = cache.get(code)
|
||||
if (formatted === undefined) {
|
||||
formatted = format.call(languageUtils, code)
|
||||
if (cache.size < 64) {
|
||||
cache.set(code, formatted)
|
||||
}
|
||||
}
|
||||
return formatted
|
||||
}
|
||||
}
|
||||
|
||||
export async function changeUiLocale(locale: UiLocale): Promise<void> {
|
||||
await i18n.changeLanguage(locale)
|
||||
document.documentElement.lang = locale
|
||||
|
||||
Reference in New Issue
Block a user