perf(webui): 动画卡顿三处治理 + 覆盖列表/日历
用户:「动画卡顿严重,且绝大部分场景还是没有流畅的动画」。 ① 提层:`html.view-switch .rise-in` 里加 will-change: transform, opacity。 这一档只动 opacity+transform,但没提层时浏览器不保证合成器接管。 写在 view-switch 窗口里(只在切换的 ~400ms 内有效),不是写在元素上 —— 常驻 will-change 会把每个面板都变成常驻图层,白吃内存。 ② 去掉触发动画时那次强制同步重排:App.tsx 原来用 `void root.offsetWidth` 让 remove/add 分属两帧,代价是每次切视图都逼浏览器把整个文档布局算一遍, 而这笔账正好落在动画第一帧上。改成两次 rAF,同样分两帧,不付重排的钱。 ③ 覆盖:列表栏与日历根节点也穿 rise-in ⇒ 切 通信/日历/工作列表 都有入场, 仍只动"新出现的那一块",骨架不动(避免 09-14 那次"整屏闪"的老问题)。 判据(narrow-layout +3):will-change 必须在 view-switch 规则里; App.tsx 不得再有 `void root.offsetWidth`(且必须用 requestAnimationFrame); MailList/CalendarView 必须穿 rise-in。变异:抽掉 will-change → 红; 把强制重排放回去 → 红;复原 → 77 通过 0 失败。 ★ 顺带纠错:上一轮我报"整页 146 个元素带 backdrop-filter / 动画子树 76 个全带模糊" 是我探针自己的 bug(`webkitBackdropFilter` 取到 undefined,`undefined !== 'none'` 恒真, 连 <path>/<META> 都被算进去)。修正后实测:真模糊 0 个、同时动画 1 个、 帧间隔中位/最长 17ms(60fps)。所以"卡顿"不是我能在本机复现的形态 —— 需要知道你看的是哪一端/什么状态(见回信)。
This commit is contained in:
@ -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);
|
||||
|
||||
@ -747,7 +747,7 @@ function DayAgendaPane({
|
||||
}) {
|
||||
const isToday = isSameDay(day, new Date());
|
||||
return (
|
||||
<div className="flex flex-col h-full min-h-0 bg-white">
|
||||
<div className="rise-in flex flex-col h-full min-h-0 bg-white">
|
||||
<div className="px-4 py-3 border-b border-gray-200 shrink-0 flex items-start gap-2">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
|
||||
@ -70,7 +70,7 @@ export default function MailList() {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="w-full lg:w-[320px] shrink-0 lg:shadow-panel bg-white flex flex-col min-w-0">
|
||||
<div className="rise-in w-full lg:w-[320px] shrink-0 lg:shadow-panel bg-white flex flex-col min-w-0">
|
||||
<div className="glass-card mx-2.5 mt-2.5 px-3.5 py-2.5 flex items-center gap-1">
|
||||
<h2 className="text-sm font-semibold text-gray-800">{isSent ? '发件箱' : '收件箱'}</h2>
|
||||
<AccountSwitcher />
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
|
||||
|
||||
@ -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)),
|
||||
|
||||
Reference in New Issue
Block a user