返回场景指南
场景指南 03

CSS 动效卡顿:从帧到属性逐项排查

卡顿很少只来自一条 transition。布局抖动、昂贵绘制、滚动中的事件和过长时长都会让动作显得迟钝。

CSS 动效卡顿需要回到用户正在完成的任务:找出哪一次输入、哪一帧和哪一段渲染工作打断了反馈链,再用可复现的证据收窄属性、脚本和视觉层的成本。

先做这个判断

先复现最明显的一次卡顿,再区分它发生在开始、运行中,还是结束后的页面更新。

实施路径

  1. 01

    先看帧时间

    用性能面板抓取一次交互,标出长任务和连续掉帧的区间。

  2. 02

    收窄会触发布局的属性

    优先让 transform 和 opacity 承担视觉变化,把尺寸和位置重排留给必要场景。

  3. 03

    把时长调回交互节奏

    短反馈常在 120–240ms 内完成;列表与状态切换按内容量增加,避免拖长每次操作。

从一个真实瞬间建立复现

“页面有点卡”很难直接变成修复任务。先把它缩成一个用户能重复完成的动作:在 300 条结果里输入筛选词、滚动到第六屏后展开详情、拖动时间线上的手柄,或在网络较慢时点击保存。记录起点、可见结果、设备尺寸、数据量、网络状态和连续操作次数。随后让同一位测试者连续做三到五轮,分别标出输入后迟迟没有响应、运动途中突然停顿、还是结束后内容才跳到新位置。每一种现象都对应不同的调查入口,因此这份记录应成为性能讨论的共同起点。

复现还要保护上下文。许多问题只会在字体刚加载、图片尺寸尚未稳定、搜索结果持续变化、后台标签页切回前台或低电量模式出现。把这些条件写进步骤,才能区分偶然抖动和稳定回归。对于高频路径,准备一个固定种子数据集和一个小型录像尤其有价值:录像告诉团队用户在哪一刻失去节奏,数据集让开发者每次都在相同压力下检查。性能工作由此有了可比较的前后状态,修复可通过实际操作验证,桌面机器上的主观感觉只作补充。

输入、脚本、样式与布局、绘制和合成依次连接,布局与绘制以警示色提示需要缩小范围。输入手势与键盘事件脚本状态与计算样式与布局控制读写往返绘制限制大面积效果合成轻反馈优先落点
图解一:一帧中的工作路径输入、脚本、样式与布局、绘制和合成依次连接,布局与绘制以警示色提示需要缩小范围。

用一帧的渲染路径定位成本

浏览器把一帧画出来时,通常要经过事件处理和脚本、样式计算、布局、绘制与合成。性能录制的第一步是把视觉录像和时间线对齐:卡片停止的那一帧,主线程上是否出现很长的脚本块?样式和布局是否连续重复?是否有大面积绘制、图片解码或第三方脚本占住了时间?先读总览里的掉帧区间和长任务,再钻进对应调用栈,效率高于从火焰图开头逐行猜测。团队需要的结论应当具体到“这次滚动在同一帧读了十次尺寸并写了十次位置”,这样下一步才有明确的改动目标。

帧预算需要根据屏幕刷新率和设备余量理解。60Hz 显示器约每 16.7ms 交付一帧,120Hz 的间隔更短;手机的温度、电量、后台活动和 GPU 能力还会压缩可用时间。预算并不要求把每个动作做得完全相同,它要求关键输入始终能得到及时回应。滚动、拖拽、连续输入和媒体拖动属于持续输入,应该优先保护;列表首次进入、装饰性光晕和大面积背景变化可以让出资源。用普通笔记本与常用手机各录一次,再加上较大数据量的场景,能够让“高性能桌面上看起来顺畅”的结论接受现实检验。

让属性选择服务于视觉职责

属性选择决定每一帧需要重新计算到哪里。按钮按下、提示进入、卡片轻微移动和淡入淡出通常只是在表达反馈、方向或层级,`transform` 与 `opacity` 能很好地承担这些任务,并且常常便于浏览器在合成阶段处理。内容展开、真实文本换行、网格列数变化和图片比例变化则表达了真实布局,尺寸与布局的更新具有业务意义。两类任务都值得实现,只需要明确边界:把轻反馈放到合成友好的属性上,把真实结构变化限制在尽可能小的容器里,并避免把每一帧都变成一次全页重算。

布局抖动常来自读写交错。代码先读取元素的 `offsetHeight`,立刻写入 `style.height`,又读取另一个元素的位置,会迫使浏览器提前结算尚未完成的工作。把读取集中在一个阶段,把写入集中到下一帧,或把计算后的值交给 CSS 自定义属性,都能降低这种往返。`will-change` 也应只在即将发生的少量动效上短暂使用;给长列表的每个卡片长期提升图层会增加内存、合成和滚动压力。性能优化的质量取决于取舍是否贴合任务,样式表也应保持简洁、明确的规则。

轻反馈和真实结构变化从视觉职责出发,分别连接到合成友好属性和局部布局更新。短促反馈真实变化轻反馈按下、提示、微移合成属性transform·opacity真实结构展开、文本、网格局部布局限制影响范围
图解二:按视觉职责选择属性轻反馈和真实结构变化从视觉职责出发,分别连接到合成友好属性和局部布局更新。

连续输入优先保持因果关系

用户连续输入筛选词时,每个键盘事件都可能触发状态更新、搜索、列表重排和进入离开动效。如果每次更新都等待上一段 400ms 动画结束,界面会形成视觉队列,输入与结果逐渐脱节。更合适的策略是保留当前最新意图:新查询到来时取消旧的装饰性过渡,立即更新结果数量与活跃条件,再让少量新增项以短促方式出现。对于滚动和拖拽,位置跟随用户手势本身是核心反馈,惯性、阴影、模糊和尾迹属于次级效果,可以按设备能力与偏好收敛。这样用户始终看得出自己的输入正在控制什么。

把昂贵工作移出高频回调同样重要。滚动监听中可以收集可见范围和方向,再用 `requestAnimationFrame` 在下一帧写入视觉状态;搜索可以防抖真正昂贵的远程或本地计算,同时让输入框和结果计数立即回应;拖拽可以把数据提交放在松手后,把显示位置保持在轻量状态里。这里的目标是形成一条稳定的优先级:手势和文字输入先得到确认,关键内容随后更新,装饰性细节在还有余量时才加入。性能和体验在这一点上是同一件事,因为两者都在保护用户的控制感。

用基线和验收守住下一次改动

一次修复只有在下一次功能迭代后仍然成立,才真正提升了产品。为高频组件保留一份轻量基线:固定数据集、复现步骤、设备和屏幕尺寸、十到二十秒的录像,以及一次性能录制中值得关注的区间。基线可以覆盖移动端筛选、桌面拖拽、打开大型详情和网络较慢的保存。它无需成为昂贵的实验室体系,却能让代码评审和回归测试有共同参照。新增动画、图片资源或第三方组件时,团队可以快速问出它影响的是哪条基线、是否占用了连续输入的余量。

验收语言也应描述用户能完成什么。与其只写“保持 60fps”,可以写成:输入筛选词后结果数量立即改变;在指定数量的卡片中滚动时,主操作始终可点击;抽屉打开期间焦点与内容稳定;开启减弱动效后,状态仍完整可读。再为这些目标补充技术证据,例如长任务阈值、录制中的连续掉帧区间、布局读写次数或可接受的视觉层数。产品目标和技术指标并列,能避免为了数字牺牲反馈,也能避免为了效果忽略成本。每次发布前做一次短复查,性能便会从偶发救火变成组件契约的一部分。

复现、录制、定位、修改、回归形成循环;回归发现问题时回到复现步骤。继续验证复现固定真实操作录制对齐录像与时间线定位识别工作与属性修改缩小成本或时长回归检查基线设备
图解三:卡顿排查与回归闭环复现、录制、定位、修改、回归形成循环;回归发现问题时回到复现步骤。

CSS 动效性能发布清单

  • 固定条件下可以稳定复现记录设备、视口、数据量、网络、起点和连续操作次数。
  • 录像与性能时间线对齐将可见停顿对应到长脚本、布局、绘制或资源解码的证据。
  • 轻反馈使用合适的渲染路径将位移和淡化放在 transform 与 opacity,把真实结构变化限制在局部容器。
  • 最新输入始终拥有优先级连续搜索、滚动和拖拽会取消过期装饰性过渡,并及时更新关键状态。
  • 常用设备和减弱动效路径已回归用固定数据集检查普通笔记本、手机、大列表和系统偏好切换。

案例:筛选卡片在连续输入中保持轻量

结果列表会在用户持续输入时更新。卡片只承担短促的进入与离开反馈,真实排序由数据更新完成,旧过渡可以被最新查询中断。

<section class="filter-demo">
  <label>
    Filter projects
    <input type="search" data-filter-input placeholder="Type a project name">
  </label>
  <p role="status" aria-live="polite" data-result-count></p>
  <ul class="result-list" data-result-list></ul>
</section>

<style>
.filter-demo { display: grid; gap: 12px; max-width: 32rem; }
.result-list { display: grid; gap: 8px; padding: 0; list-style: none; }
.result-card { opacity: 1; transform: translateY(0); transition: opacity 160ms ease-out, transform 160ms ease-out; }
.result-card[data-motion="enter"] { opacity: 0; transform: translateY(8px); }
.result-card[data-motion="leave"] { opacity: 0; pointer-events: none; transform: scale(.985); }
@media (prefers-reduced-motion: reduce) {
  .result-card { transition-duration: 1ms; }
}
</style>

<script>
const input = document.querySelector("[data-filter-input]");
const resultList = document.querySelector("[data-result-list]");
const resultCount = document.querySelector("[data-result-count]");
const reducedMotion = window.matchMedia("(prefers-reduced-motion: reduce)");
const projects = ["Alpha dashboard", "Beta settings", "Gamma archive", "Delta reports"];
let renderFrame = 0;
let leaveTimer = 0;

function matchingProjects(query) {
  const normalizedQuery = query.trim().toLowerCase();
  return projects.filter((project) => project.toLowerCase().includes(normalizedQuery));
}

function updateResultCount(results) {
  resultCount.textContent = results.length + " project" + (results.length === 1 ? "" : "s") + " shown.";
}

function createResultCard(project) {
  const item = document.createElement("li");
  item.className = "result-card";
  item.dataset.motion = "enter";
  item.textContent = project;
  return item;
}

function commitResults(results) {
  resultList.replaceChildren(...results.map(createResultCard));
  renderFrame = window.requestAnimationFrame(() => {
    resultList.querySelectorAll("[data-motion='enter']").forEach((card) => {
      card.removeAttribute("data-motion");
    });
  });
}

function applyLatestQuery(query) {
  window.cancelAnimationFrame(renderFrame);
  window.clearTimeout(leaveTimer);
  const results = matchingProjects(query);
  updateResultCount(results);
  const currentCards = Array.from(resultList.children);

  if (reducedMotion.matches || currentCards.length === 0) {
    commitResults(results);
    return;
  }

  currentCards.forEach((card) => {
    card.dataset.motion = "leave";
  });
  leaveTimer = window.setTimeout(() => commitResults(results), 160);
}

input.addEventListener("input", () => applyLatestQuery(input.value));
applyLatestQuery("");
</script>

代码先取消过期的装饰性过渡,再同步更新数量和结果。进入与离开只改动透明度和 transform,列表的真实顺序仍由数据和布局决定;减弱动效路径沿用同一状态,只将过渡收敛到极短时间。