perf: GSAP 统一交互动画 + 砍掉流式热路径的逐帧开销

This commit is contained in:
Rick Ye
2026-09-08 00:08:49 +08:00
parent 55f9838bcb
commit d1cee518fa
6 changed files with 128 additions and 42 deletions
@@ -118,9 +118,13 @@ export function ChatView({
useEffect(() => { useEffect(() => {
const el = scrollRef.current const el = scrollRef.current
if (el && pinnedRef.current) { if (!el || !pinnedRef.current) return
// rAF 合帧:流式 delta 一帧内多次触发时只吸底一次,
// 且读 scrollHeight 的强制布局每帧至多一次
const raf = requestAnimationFrame(() => {
el.scrollTop = el.scrollHeight el.scrollTop = el.scrollHeight
} })
return () => cancelAnimationFrame(raf)
}, [messages, tools, streaming]) }, [messages, tools, streaming])
const hasContent = messages.length > 0 const hasContent = messages.length > 0
@@ -1,4 +1,4 @@
import { useEffect, useState, type ReactNode } from 'react' import { memo, useEffect, useState, type ReactNode } from 'react'
import ReactMarkdown, { type Components } from 'react-markdown' import ReactMarkdown, { type Components } from 'react-markdown'
import remarkGfm from 'remark-gfm' import remarkGfm from 'remark-gfm'
import { import {
@@ -261,7 +261,7 @@ function BlockView({ blockMatch }: { blockMatch: BlockMatch }): ReactNode {
return <Component blockMatch={blockMatch} /> return <Component blockMatch={blockMatch} />
} }
export function Markdown({ text, streaming = false }: { text: string; streaming?: boolean }): ReactNode { function MarkdownBase({ text, streaming = false }: { text: string; streaming?: boolean }): ReactNode {
// 流式开启时 llm-ui 逐块渐进解析(未闭合围栏 → partial code block,未完段落 → partial markdown), // 流式开启时 llm-ui 逐块渐进解析(未闭合围栏 → partial code block,未完段落 → partial markdown),
// 已完成的块会被 lookBack 冻结不再重渲染;流式结束后整体收敛为最终形态 // 已完成的块会被 lookBack 冻结不再重渲染;流式结束后整体收敛为最终形态
const { blockMatches } = useLLMOutput({ const { blockMatches } = useLLMOutput({
@@ -279,3 +279,7 @@ export function Markdown({ text, streaming = false }: { text: string; streaming?
</> </>
) )
} }
/** memo:流式 delta 会让整份消息列表重渲染,历史消息的 text/streaming 不变,
* 直接跳过 react-markdown 的整棵 vdom diff —— 这是流式卡顿的大头。 */
export const Markdown = memo(MarkdownBase)
@@ -1,5 +1,6 @@
import { useEffect, useRef, useState, type ReactNode } from 'react' import { useEffect, useLayoutEffect, useRef, useState, type ReactNode } from 'react'
import type { ApprovalRequest, ToolState, UiMessage } from '../hooks/useAgent' import type { ApprovalRequest, ToolState, UiMessage } from '../hooks/useAgent'
import { animateCollapse, animateExpand } from '../lib/anim'
import { Markdown } from './Markdown' import { Markdown } from './Markdown'
import { Loading } from './Loading' import { Loading } from './Loading'
import { StreamingText } from './StreamingText' import { StreamingText } from './StreamingText'
@@ -169,6 +170,30 @@ export function AssistantTurn({
prevTurnActive.current = turnActive prevTurnActive.current = turnActive
}, [turnActive]) }, [turnActive])
// 过程区折叠/展开:GSAP 高度 + 透明度补间(可中断续接)。内联高度完全由
// 动画层持有,React 不渲染 style,避免重渲染时清掉补间中的值。
// 首帧直接落位(历史回合默认收起),不播动画。
const processRef = useRef<HTMLDivElement | null>(null)
const prevCollapsedRef = useRef<boolean | null>(null)
useLayoutEffect(() => {
const el = processRef.current
if (!el) return
const effective = collapsed && hasProcess
const prev = prevCollapsedRef.current
prevCollapsedRef.current = effective
if (prev === null) {
if (effective) {
el.style.height = '0px'
el.style.opacity = '0'
el.style.pointerEvents = 'none'
}
return
}
if (prev === effective) return
if (effective) animateCollapse(el)
else animateExpand(el)
}, [collapsed, hasProcess])
const workedFor = useWorkingSeconds(turnActive) const workedFor = useWorkingSeconds(turnActive)
const headerLabel = workedFor || `${calls} 次工具调用${thinking ? `,${thinking} 条消息` : ''}` const headerLabel = workedFor || `${calls} 次工具调用${thinking ? `,${thinking} 条消息` : ''}`
@@ -239,22 +264,10 @@ export function AssistantTurn({
</div> </div>
)} )}
{/* 过程区:grid-rows 0fr/1fr 高度动画 + 透明度渐隐,收起/展开更从容 */} {/* 过程区:GSAP 高度 + 透明度补间(见上方 useLayoutEffect) */}
<div <div ref={processRef} aria-hidden={collapsed && hasProcess} className="overflow-hidden">
aria-hidden={collapsed && hasProcess}
className={`grid transition-[grid-template-rows] duration-500 ease-in-out ${
collapsed && hasProcess ? 'pointer-events-none' : ''
}`}
style={{ gridTemplateRows: collapsed && hasProcess ? '0fr' : '1fr' }}
>
<div
className={`min-h-0 overflow-hidden transition-opacity duration-300 ${
collapsed && hasProcess ? 'opacity-0' : 'opacity-100'
}`}
>
{processNodes} {processNodes}
</div> </div>
</div>
{/* 答案区:最终正文 / 流式中的答案雏形,始终可见 */} {/* 答案区:最终正文 / 流式中的答案雏形,始终可见 */}
{answerNodes} {answerNodes}
@@ -30,13 +30,7 @@ export function Thinking({
}, [rows, expanded]) }, [rows, expanded])
return ( return (
<div <div className="flex w-full max-w-95 flex-col" style={{ minHeight: expanded ? 60 : undefined }}>
className="flex w-full max-w-95 flex-col"
style={{
minHeight: expanded ? Math.min(60 + rows.length * 28, 220) : undefined,
transition: 'min-height 400ms ease'
}}
>
{/* 表头:状态词 + 旋转箭头 */} {/* 表头:状态词 + 旋转箭头 */}
<button <button
type="button" type="button"
@@ -1,26 +1,13 @@
import { useEffect, useRef } from 'react' import { useEffect, useRef } from 'react'
import type { RefObject } from 'react' import type { RefObject } from 'react'
import { animateEntrance } from '../lib/anim'
/** 流式消息的入场动画(GSAP),仅挂载时执行一次 */ /** 流式消息的入场动画(GSAP),仅挂载时执行一次 */
export function useEntrance<T extends HTMLElement>(): RefObject<T | null> { export function useEntrance<T extends HTMLElement>(): RefObject<T | null> {
const ref = useRef<T | null>(null) const ref = useRef<T | null>(null)
useEffect(() => { useEffect(() => {
const el = ref.current const el = ref.current
if (!el) return if (el) animateEntrance(el)
let tween: { kill(): void } | null = null
let cancelled = false
void import('gsap').then((gsapModule) => {
if (cancelled) return
tween = gsapModule.gsap.fromTo(
el,
{ opacity: 0, y: 10 },
{ opacity: 1, y: 0, duration: 0.4, ease: 'power2.out', clearProps: 'transform' }
)
})
return () => {
cancelled = true
tween?.kill()
}
}, []) }, [])
return ref return ref
} }
+84
View File
@@ -0,0 +1,84 @@
/** 共享动画模块(GSAP 懒加载单例)。
* 原则:transform/opacity 小动画交给 CSS 合成器;GSAP 负责需要中断衔接、
* 按内容测量的补间(消息入场、回合过程区折叠/展开)。
* 全部遵循系统「减弱动态效果」(prefers-reduced-motion)。 */
type Gsap = (typeof import('gsap'))['gsap']
let gsapPromise: Promise<Gsap> | null = null
function loadGsap(): Promise<Gsap> {
gsapPromise ??= import('gsap').then((m) => {
m.gsap.defaults({ overwrite: 'auto' })
return m.gsap
})
return gsapPromise
}
function reducedMotion(): boolean {
return window.matchMedia('(prefers-reduced-motion: reduce)').matches
}
/** 消息入场:上移淡入,仅挂载时一次 */
export function animateEntrance(el: HTMLElement): void {
if (reducedMotion()) return
void loadGsap().then((gsap) => {
gsap.fromTo(
el,
{ opacity: 0, y: 10 },
{ opacity: 1, y: 0, duration: 0.4, ease: 'power2.out', clearProps: 'transform' }
)
})
}
/** 收起:高度 → 0、透明度 → 0。先同步落起始帧再异步补间,中间不闪帧;
* 从当前高度续接(可中断重入,快速反复点击也顺滑)。 */
export function animateCollapse(el: HTMLElement): void {
el.style.pointerEvents = ''
if (reducedMotion()) {
el.style.height = '0px'
el.style.opacity = '0'
el.style.pointerEvents = 'none'
return
}
const from = el.offsetHeight
el.style.height = `${from}px`
void loadGsap().then((gsap) => {
gsap.killTweensOf(el)
gsap.to(el, {
height: 0,
opacity: 0,
duration: 0.5,
ease: 'power2.inOut',
onComplete: () => {
el.style.pointerEvents = 'none'
}
})
})
}
/** 展开:高度 0 → 内容实际高度,完成后复位 auto,后续内容增长不被裁剪 */
export function animateExpand(el: HTMLElement): void {
el.style.height = 'auto'
el.style.pointerEvents = ''
const to = el.offsetHeight
if (reducedMotion()) {
el.style.height = ''
el.style.opacity = ''
return
}
el.style.height = '0px'
void loadGsap().then((gsap) => {
gsap.killTweensOf(el)
gsap.to(el, {
height: to,
opacity: 1,
duration: 0.5,
ease: 'power2.inOut',
onComplete: () => {
el.style.height = ''
el.style.opacity = ''
}
})
})
}