diff --git a/client/electron/src/App.tsx b/client/electron/src/App.tsx index 09d0149..8013fe1 100644 --- a/client/electron/src/App.tsx +++ b/client/electron/src/App.tsx @@ -56,11 +56,25 @@ export default function App() { useEffect(() => { const root = document.documentElement; root.classList.remove('view-switch'); - // 读一次布局,强制样式失效(否则 remove/add 在同一帧里会被合并,动画不重放) - void root.offsetWidth; - root.classList.add('view-switch'); + /* + * 两次 rAF:让「移除 → 加上」落在两帧里,动画就会重放。 + * + * ★ 2026-09-15 用户:「动画卡顿严重,且绝大部分场景还是没有流畅的动画」。 + * 原先这里写的是 `void root.offsetWidth` —— 强制同步重排。它在**每次**切视图时 + * 都逼浏览器把整个文档布局算一遍(DOM 大、又挂着玻璃面时这笔账很贵), + * 而这笔开销恰好落在动画开始的那一帧上。rAF 同样能让 remove/add 分属两帧, + * 但不需要付重排的钱。 + */ + let second = 0; + const first = requestAnimationFrame(() => { + second = requestAnimationFrame(() => root.classList.add('view-switch')); + }); const t = window.setTimeout(() => root.classList.remove('view-switch'), 400); - return () => window.clearTimeout(t); + return () => { + cancelAnimationFrame(first); + if (second) cancelAnimationFrame(second); + window.clearTimeout(t); + }; }, [viewMode, commTab, narrowPane, composing]); const fetchInbox = useMailStore(s => s.fetchInbox); diff --git a/client/electron/src/components/CalendarView.tsx b/client/electron/src/components/CalendarView.tsx index 0338811..641c676 100644 --- a/client/electron/src/components/CalendarView.tsx +++ b/client/electron/src/components/CalendarView.tsx @@ -747,7 +747,7 @@ function DayAgendaPane({ }) { const isToday = isSameDay(day, new Date()); return ( -
+
diff --git a/client/electron/src/components/MailList.tsx b/client/electron/src/components/MailList.tsx index d47f668..bf7590b 100644 --- a/client/electron/src/components/MailList.tsx +++ b/client/electron/src/components/MailList.tsx @@ -70,7 +70,7 @@ export default function MailList() { }; return ( -
+

{isSent ? '发件箱' : '收件箱'}

diff --git a/client/electron/src/index.css b/client/electron/src/index.css index 4fc7a28..d542fe3 100644 --- a/client/electron/src/index.css +++ b/client/electron/src/index.css @@ -1209,6 +1209,15 @@ html.view-switch .glass-control, html.view-switch .rise-in { animation: rise-in 150ms cubic-bezier(0.22, 0.61, 0.36, 1) both; + /* + * ★ 2026-09-15 用户:「动画卡顿严重」。 + * + * 这一档只动 opacity + transform(合成器可做,不触发 layout/paint), + * 但没提层的时候浏览器不一定把它提到独立图层 —— 一提就不可能在主线程上抖动。 + * `will-change` 写在 view-switch 窗口里(只在切换的 ~400ms 内有效), + * 不是写在元素上:常驻 will-change 会把每个面板都变成常驻图层,内存白吃。 + */ + will-change: transform, opacity; } diff --git a/client/electron/test/narrow-layout.test.mjs b/client/electron/test/narrow-layout.test.mjs index 9f79188..da5cdc7 100644 --- a/client/electron/test/narrow-layout.test.mjs +++ b/client/electron/test/narrow-layout.test.mjs @@ -269,6 +269,22 @@ check( /rise-in/.test(composeSrc) && (mailViewSrc.match(/rise-in/g) || []).length >= 2, '三处目标面没都穿上 rise-in(回复框与转发面板在 MailView 里,必须各有一处)' ); +check( + '动画走合成器(view-switch 窗口内提层)', + /html\.view-switch \.rise-in\s*\{[\s\S]*?will-change:\s*transform,\s*opacity/.test(css), + '缺少 will-change —— 没提层时 opacity/transform 仍可能在主线程上抖' +); +const appMotionSrc = code('src/App.tsx'); +check( + '切视图不再用强制重排触发动画', + !/void\s+root\.offsetWidth/.test(appMotionSrc) && /requestAnimationFrame/.test(appMotionSrc), + '触发动画还在读 offsetWidth(同步强制布局)—— 这笔记账正好落在动画第一帧上,就是卡顿的来源之一' +); +check( + '主要视图也穿 rise-in(列表/日历)', + /rise-in/.test(code('src/components/MailList.tsx')) && /rise-in/.test(code('src/components/CalendarView.tsx')), + '切到列表/日历时没有入场动画 —— "绝大部分场景还是没有流畅的动画"就是这一条' +); check( 'rise-in 也受 prefers-reduced-motion 管', reducedBlocks.some(b => /view-switch \.rise-in/.test(b)),