ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

用requestAnimationFrame实现图片缓慢放大缩小:从补间动画到性能优化

用requestAnimationFrame实现图片缓慢放大缩小:从补间动画到性能优化 做前端这些年图片放大缩小这种效果我写过不下几十次但每次看到有人用CSS的transition硬怼或者干脆让图片transform: scale(1.2)瞬间跳变我都想按着对方的肩膀晃一晃你听说过“补间”两个字吗这期JS实例我想好好聊聊图片的缓慢放大和缩小。注意关键词是“缓慢”。不是hover上去scale(1.2)就完事而是那种“像镜头推近又拉远”的丝滑感鼠标移进去慢慢变大移出来慢慢缩回去节奏、加速度、手感都在你的掌控里。这篇文章我会从需求拆解、动画原理、完整代码到性能边界把整个过程讲透保证你读完能直接抄走还能在面试时把“为什么用requestAnimationFrame而不用transition”这件事说清楚。1. 从需求到方案为什么不直接用CSS非要写JS1.1 拆解“图片的缓慢放大和缩小”到底在说什么先把需求掰开揉碎。标题里的几个词每一个都不是无意义的修饰。“图片”——主体是img元素或者带背景的容器通常被一个固定大小的父盒子包住盒子设了overflow: hidden。为什么要裁切因为缩放图片时我们希望视觉效果是“镜头在图片内部推近”而不是图片把布局撑大、把旁边的文字挤得四处乱跑。这是所有图片缩放效果的地基外层容器尺寸固定内层图片随心缩放。“缓慢”——这个词最容易被低估。缓慢意味着动画有时间长度、有速度变化不能是瞬时的。瞬时切换是状态改变缓慢切换才叫动画。而要做出“缓慢”我们本质上需要在一段时间内连续生成很多个中间状态让图片的缩放值从1.0一点点逼近1.2再从1.2一点点回落到1.0。这就是“补间动画”的思路英文叫tween。“放大和缩小”——是一个双向的动作。鼠标进入时放大离开时缩小。这要求动画可以被随时打断、反方向执行。比如用户鼠标刚移入0.2秒就移出这时候放大动画还在半路缩小动画却要立刻从当前值开始往回走。这个能力纯CSS的transition虽然也能做到一部分但如果你想控制动画进度、改变速度曲线、叠加其他逻辑就会有点力不从心。所以这个需求的核心就是用JavaScript按帧驱动图片的transform: scale值让它在一段时间内平滑变化并且随时可以反向。1.2 CSS transition 与 JS 动画的取舍有朋友可能会问既然CSS的transition也能实现“缓慢过渡”我直接写transition: transform 0.6s ease-in-outhover时改scale(1.2)离开时改scale(1)不也一样吗是的在“最简单的情况下”确实一样。但如果你写过几个真实项目就会发现transition能轻松搞定的事一旦遇到下面这些场景就开始难受对比维度CSS transitionJS requestAnimationFrame实现成本很低一行声明需要几十行代码缓动曲线只能用预设或简单贝塞尔曲线任意数学函数自定义自由度极高动画中间态无法直接读取和干预每一帧你都知道当前进度和当前值动态控制只能靠切换类名或样式打断后行为固定可随时暂停、反向、变速、跳跃多实例管理每个元素独立配置全局统一困难可封装成类或函数统一生命周期与业务逻辑集成弱结束时靠transitionend回调每帧都能做检测、触发回调、联动其他元素我举一个真实场景图片轮播里当前大图需要缓慢放大到1.1倍作为背景等动画结束后再切换下一张。用CSStransition虽然能用transitionend监听结束但如果你还想在过程中加一个“进度条”或者在放大到50%时让旁边的文字开始淡出CSS就比较绕了。而JS方案因为每一帧你都知道当前进度是0.47还是0.83你可以在任意进度点触发任意逻辑。更重要的是JS动画里的缓动函数是“数学级”的自由。CSS提供的ease-in-out只是一个固定曲线而JS里我可以写easeOutBack、easeInElastic甚至自定义“先加速后减速再回弹”的诡异曲线。这个自由度在追求交互细节的项目里就是刚需。1.3 为什么最终选择 requestAnimationFrame 而不是 setInterval这是每次写JS动画都绕不开的问题。setInterval的思路是“我每16毫秒执行一次函数自己模拟出一秒60帧”。听起来合理但实际上有两个致命问题第一setInterval不保证间隔准确浏览器标签页切到后台后会降频甚至挂起第二它不管浏览器当前能不能绘制60帧如果你的函数执行超过16ms就会掉帧或者造成任务堆积。requestAnimationFrame简称rAF的思路完全不同它告诉浏览器“下一帧绘制前请调用我这个函数”。浏览器会按照屏幕的实际刷新率来安排调用通常是60Hz高刷屏上可能是120Hz。也就是说rAF的调用节奏和屏幕绘制是完全同步的该执行的时候执行不该执行的时候绝不多跑一次。而且当页面切到后台浏览器会自动暂停rAF调用不会白白消耗CPU。在实际手感上的差别我做个很直观的对比用setInterval(16.7)在普通笔记本上做图片缩放快速移动鼠标时偶尔会感觉画面“抖”了一下换成rAF之后同样的代码几乎感觉不到顿挫。这个差距在静止时看不出来一旦动画频繁打断、反向就非常明显。2. 动画核心插值、缓动函数与渲染帧2.1 动画的本质是“每一帧修改一个数字”不管多花哨的动画剥开外壳内核都是一个非常朴素的动作在一个时间段里把一个数字从A平滑地改到B。对于图片缩放来说A是当前缩放值B是目标缩放值。举个例子图片要从scale(1)变到scale(1.2)时长600ms。如果我们用rAF驱动浏览器大约会调用我们36次600ms / 16.7ms。我们需要做的就是在每一次回调里计算出当前应该处于“从1到1.2的哪个位置”然后把这个数字应用到元素上。这个“位置”通常用进度progress表示范围是0到1。0代表动画刚开始1代表动画结束。每一帧我们拿到的timestamp毫秒时间戳减去动画开始的时间再除以总时长就能得到当前进度const elapsed timestamp - startTime; const progress Math.min(elapsed / duration, 1); // 防止超出1拿到progress之后最关键的一步就是“线性插值”也叫lerplinear interpolation。公式极其简单const currentValue startValue (endValue - startValue) * progress;这行代码的意思是当前值 起点 总距离 × 已走比例。如果进度是0.5那么当前值就是起点和终点的正中间如果进度是0.25那么就走了四分之一的路程。图片的缩放值就是这样算出来的初始值是1.0目标值是1.2progress从0走到1缩放值就从1.0匀速走到1.2。如果始终用这个公式做出来的动画是“匀速”的也就是全程速度一模一样像电梯一样无趣而且开始和结束都显得很突兀。真正好看的动画需要变速这就是缓动函数登场的时机。2.2 缓动函数让缩放不再是“匀速直线运动”我用走路来类比。匀速运动就像机器人走路每一步间隔绝对相同看起来很僵硬。而真人走路起步时速度慢中间加速快到终点时又慢下来所以才觉得自然。缓动函数就是给动画加上这种“真人感”的数学函数。最简单也是最常用的缓动是easeInOutQuad。它的特点是动画前半段加速后半段减速起点和终点速度都为0视觉效果非常柔和。它的数学实现长这样function easeInOutQuad(t) { if (t 0.5) { return 2 * t * t; } else { return 1 - Math.pow(-2 * t 2, 2) / 2; } }这里t就是上一节的progress0到1返回值是修正后的进度。比如t0.5时返回值也是0.5t0.25时返回值是2 * 0.25 * 0.25 0.125也就是说动画在时间过去了25%时实际上只走了12.5%的路程明显是起步阶段在“蓄力”。而t0.75时返回值是1 - (0.5*0.5)/2 0.875说明时间过了75%路程已经走了87.5%说明后半程明显在减速收尾。把缓动函数应用进线性插值公式我们的核心计算就变成const easedProgress easeInOutQuad(progress); const currentScale startScale (targetScale - startScale) * easedProgress;这个写法你现在就可以记到脑子里因为以后写任何补间动画——移动位置、旋转角度、透明度变化、滚动进度——都是同一个套路改一下属性名和数值范围就行。除了easeInOutQuad我还常用另外两种缓动函数特点适用场景easeOutCubic起步快结尾拖尾很长图片弹入、卡片飞入适合“来了就不想走”的感觉easeInOutBack冲过头再弹回来需要有弹性的交互比如点赞图标easeInOutQuad温和加速减速图片缓慢缩放的首选几乎不会出错我建议你在项目里维护一个easing.js文件把这几个函数都存起来因为它们是纯函数输入0-1输出0-1非常好测试和复用。2.3 requestAnimationFrame 的基础循环框架有了插值公式和缓动函数我们再看rAF的完整用法。rAF的核心思路是在每一帧回调里更新数值、应用到DOM、然后再请求下一帧。如此循环直到动画结束。一个标准的动画循环框架是这样的let startTime null; let startScale 1; let targetScale 1.2; const duration 600; function animate(timestamp) { if (startTime null) { startTime timestamp; } const elapsed timestamp - startTime; const progress Math.min(elapsed / duration, 1); const eased easeInOutQuad(progress); const currentScale startScale (targetScale - startScale) * eased; img.style.transform scale(${currentScale}); if (progress 1) { requestAnimationFrame(animate); } } requestAnimationFrame(animate);注意两个细节第一startScale不是写死的1而是动画开始那一刻图片的当前缩放值。第二为什么startTime初始为null因为rAF回调传入的timestamp是页面加载以来的总毫秒数不是相对时间所以必须记录“第一帧的时间”作为起点。这段代码已经能跑通“图片从1缓慢放大到1.2”。接下来我要在这个基础上加入鼠标事件、打断逻辑和反向控制把它变成一个真正可以直接用的完整交互组件。3. 完整实操一步步实现图片缓慢缩放3.1 先搭好页面骨架HTML结构和基础CSS先准备一个最普通的图片卡片。外层容器负责裁切内层图片负责缩放。结构非常简单就一个class为card的div包着一只img。div classcard img srchttps://example.com/photo.jpg alt风景图 /div接下来是CSS这一步看似简单但对最终效果影响极大。有三个关键点要知道.card { width: 320px; height: 200px; overflow: hidden; cursor: zoom-in; } .card img { width: 100%; height: 100%; object-fit: cover; transform-origin: center center; backface-visibility: hidden; -webkit-backface-visibility: hidden; }第一个关键点是overflow: hidden它保证图片放大时不会溢出卡片边界。第二个是object-fit: cover让图片无论什么比例都能完美填满卡片而不变形。第三个是transform-origin: center center它控制缩放的“锚点”在正中间图片从中心向外放大视觉上最稳定。这里我还加了backface-visibility: hidden它对缩放动画本身没有直接影响但可以触发浏览器的GPU合成层优化减少一些设备上图片缩放时的闪烁问题算是一个老前辈传下来的经验写法。3.2 第一个版本用 rAF 实现最基本的放大与缩小现在开始写JS。第一个版本先不考虑缓动和打断就是用最直白的方式让图片在鼠标移入时放大、移出时缩小。这一步的意义是建立代码骨架后面的优化都基于这个骨架展开。const card document.querySelector(.card); const img card.querySelector(img); const MIN_SCALE 1; const MAX_SCALE 1.2; const DURATION 600; let startTime null; let startScale MIN_SCALE; let targetScale MAX_SCALE; let animationId null; // 缓动函数easeInOutQuad function easeInOutQuad(t) { if (t 0.5) return 2 * t * t; return 1 - Math.pow(-2 * t 2, 2) / 2; } function updateScale(timestamp) { if (startTime null) startTime timestamp; const elapsed timestamp - startTime; const progress Math.min(elapsed / DURATION, 1); const eased easeInOutQuad(progress); const currentScale startScale (targetScale - startScale) * eased; img.style.transform scale(${currentScale}); if (progress 1) { animationId requestAnimationFrame(updateScale); } else { animationId null; } } function startAnimation(nextTarget) { // 如果当前已有动画先取消避免多个循环互相打架 if (animationId) { cancelAnimationFrame(animationId); } startTime null; // 重点startScale 取当前实时缩放值而不是固定值 startScale getCurrentScale(); targetScale nextTarget; animationId requestAnimationFrame(updateScale); } function getCurrentScale() { // 从 transform 字符串里解析当前缩放值 const transform img.style.transform; const match transform.match(/scale\(([\d.])\)/); return match ? parseFloat(match[1]) : MIN_SCALE; } card.addEventListener(mouseenter, () startAnimation(MAX_SCALE)); card.addEventListener(mouseleave, () startAnimation(MIN_SCALE));这段代码里有个细节值得划线getCurrentScale函数。它的作用是从DOM元素的style.transform里把当前的缩放值抠出来作为下一次动画的起点。为什么不能直接用startScale MIN_SCALE因为你第一次放大到一半就移出鼠标了这时候图片实际缩放值是1.1左右缩小动画必须从1.1开始往回走而不是从1.0开始跳一下。把当前值作为起点动画才能无缝反向。这里用正则解析scale(...)是简单的取巧做法。如果项目里transform还包含位移和旋转解析会变得复杂更严谨的方案是用new DOMMatrix(getComputedStyle(img).transform)来获取矩阵值然后读取matrix.a作为缩放值。对于当前场景正则已经足够。3.3 第二个版本让动画“可打断”并且从根本上避免闪烁第一个版本已经能实现缓慢放大和缩小但有一个隐蔽的问题如果你快速在卡片上反复移入移出动画虽然能反向但每次都会从头开始算时间而且起始值是通过解析字符串拿到的如果解析失败就会退回1.0出现“闪跳”的bug。为了更健壮我把状态管理改成一个简单的对象每帧都记录当前值而不是依赖DOM解析。这个版本的核心思路是动画循环里维护一个state对象它保存当前缩放值current、开始缩放值start、目标缩放值end、开始时间startTime。每次触发新的动画时直接读取state.current作为起点这样不需要从DOM解析。const state { current: MIN_SCALE, start: MIN_SCALE, end: MIN_SCALE, startTime: null, }; function updateScale(timestamp) { if (state.startTime null) state.startTime timestamp; const elapsed timestamp - state.startTime; const progress Math.min(elapsed / DURATION, 1); const eased easeInOutQuad(progress); state.current state.start (state.end - state.start) * eased; img.style.transform scale(${state.current}); if (progress 1) { animationId requestAnimationFrame(updateScale); } else { animationId null; state.current state.end; // 保证精确落在目标值上 } } function startAnimation(nextTarget) { if (animationId) cancelAnimationFrame(animationId); state.start state.current; state.end nextTarget; state.startTime null; animationId requestAnimationFrame(updateScale); }这个版本看上去和上一版差不多但两个改动很关键第一state.current会持续被每帧更新它就是你时刻都能读取的“实时缩放值”后续如果要同步进度条、触发其他动画直接读它就行。第二动画结束时强制state.current state.end。为什么需要这一行因为缓动函数和progress计算都是浮点运算最终值可能是1.1999999999虽然视觉上看不出来但如果你以后要判断“图片是否已缩放到最大”用比较就会踩坑。强制赋值能保证状态精确。3.4 第三个版本从单个图片扩展到自动轮播和手动暂停到这里图片缩放的基础能力已经齐了。但真实项目中很少会只有一个孤零零的图片卡片。更常见的场景是一个图片轮播组件当前展示的图片自动缓慢放大鼠标移入时暂停放大移出时继续切换下一张时先缩小再放大。这个扩展其实非常自然因为我们已经有state.current这个实时值了只需要再维护一个currentIndex和一个“是否自动播放”的状态。const images document.querySelectorAll(.card img); let currentIndex 0; let autoPlay true; let autoPlayId null; function playNext() { if (!autoPlay) return; // 先把当前图片缩回原形 setActiveImage(currentIndex); startAnimation(MIN_SCALE, () { // 缩回后切换图片 currentIndex (currentIndex 1) % images.length; setActiveImage(currentIndex); startAnimation(MAX_SCALE); }); autoPlayId setTimeout(playNext, 3000); } function setActiveImage(index) { images.forEach((img, i) { img.style.opacity i index ? 1 : 0; img.style.transform scale(1); }); } images.forEach((img) { img.addEventListener(mouseenter, () { autoPlay false; clearTimeout(autoPlayId); // 当前图片继续放大到最大 startAnimation(MAX_SCALE); }); img.addEventListener(mouseleave, () { autoPlay true; playNext(); }); });注意这里startAnimation我增加了一个可选的回调参数在动画结束后调用。这是补间动画里非常常用的模式——动画链A动画结束了自然启动B动画。你可以把上一节的核心函数稍微改造一下在if (progress 1)分支里调用传入的回调函数。这种做法的价值在于你把“动画什么时候结束”这件事从人肉估算变成了系统通知代码逻辑更可靠。实际项目里图片切换、页面滚动到某个锚点、表单校验失败后的抖动提示都可以用这种动画链串联起来。4. 性能与边界情况为什么有的机器上会卡怎么解4.1 特效优先让GPU干活远离重排和重绘做前端动画这些年我最常跟团队里新人强调的一件事就是能用transform和opacity实现的绝对不要用left、top、width、height去实现。原因很简单前者走的是GPU合成层后者会触发浏览器的重排reflow和重绘repaint性能差距在移动端尤其明显。我们这期写的图片缩放全程只改transform: scale()这就是一个非常理想的“GPU友好”属性。浏览器会把图片提升到一个独立的合成层之后每一帧的缩放只是把这个层做矩阵变换不再重新计算布局也不影响其他元素的排版。这也是为什么用transform做缩放几乎不会出现“把兄弟元素挤跑”的问题。但有一个容易踩的坑transform本身不会触发重排可如果你在动画的每一帧里都去读取offsetWidth、offsetHeight这些强制同步布局的属性浏览器就只能打断优化当场重新计算布局性能瞬间崩盘。这种问题在缩放的场景里特别隐蔽因为你可能为了计算“缩放后的宽高”而去读offsetWidth。记住一句话动画帧里只写不读或者只读不写千万别又读又写。4.2 大图场景下的内存与加载优化图片缩放的效果很多人第一反应是用一张高分辨率大图这样放大1.2倍后画面依然清晰。这个思路没错但要注意内存占用。一张4000x3000的图片RGBA格式下约占48MB内存如果页面上有五六张这样的图光图片数据就轻松超过200MB在低端手机上很容易被浏览器强杀。我建议的做法是图片的原始资源可以是大图但要加上loadinglazy让浏览器按需加载同时缩放的容器不要做得太大320px宽的卡片用1200px宽度的图片就足够清晰了。如果确实需要在放大后展示细节可以改成“点击后打开大图遮罩”这类交互而不是无限放大细节。另外提一句缩放过程中图片出现模糊尤其是放大超过1.5倍之后几乎是无法完全避免的。这是因为浏览器图像插值算法在实时缩放下做不到完美。业界比较常见的折中方案是在动画结束后用image-rendering: -webkit-optimize-contrast或者临时把图片换成更高清晰的资源。但一般1.2倍左右的缩放肉眼几乎看不出区别无需担心。4.3 触摸设备与系统偏好别让鼠标事件成为唯一入口现在很多“鼠标悬停放大”的效果在手机上会直接失效。原因很简单——触摸屏没有真正的hover状态。所以如果这个组件要适配移动端我建议监听pointerenter和pointerleave这两个事件能同时覆盖鼠标和触控笔还能在触摸设备上模拟悬停效果。但真正严谨的做法是区分设备类型if (window.matchMedia((hover: hover)).matches) { card.addEventListener(pointerenter, () startAnimation(MAX_SCALE)); card.addEventListener(pointerleave, () startAnimation(MIN_SCALE)); } else { card.addEventListener(click, () { if (state.current MAX_SCALE - 0.01) { startAnimation(MAX_SCALE); } else { startAnimation(MIN_SCALE); } }); }matchMedia((hover: hover))能判断当前设备是否支持悬停。支持的就用悬停交互不支持的就改成点击切换放大/缩小。这个细节在你开发响应式项目时会帮你省掉好几个“为什么手机上没效果”的bug。还有一个容易被忽视的细节系统级的prefers-reduced-motion。有的用户会在操作系统里开启“减少动态效果”这是为了缓解晕动症。代码层面我们完全可以尊重这个偏好const prefersReducedMotion window.matchMedia((prefers-reduced-motion: reduce)).matches; if (prefersReducedMotion) { // 直接切换缩放不做动画 card.addEventListener(mouseenter, () { img.style.transform scale(${MAX_SCALE}); }); }这套处理加进去项目在可访问性这条加分项上会很好看而且代码量并不大。5. 常见问题与排查技巧实录5.1 高频Bug速查表写这个效果的过程中我收集了几个出现频率极高、排查起来又有点绕的问题整理成一张速查表方便你遇到了直接查现象可能原因解决方案图片放大时卡片外出现滚动条外层容器没设overflow: hidden给卡片加overflow: hidden同时把border-radius也加上裁切会更自然鼠标快速进出后图片卡在奇怪的位置动画被打断后起点被解析错改用state.current记录实时值不要每次从DOM解析transform缩放到一半突然闪回1.0正则解析scale(...)失败返回默认值用CSS变量存缩放值每帧只更新变量避免解析动画完成后图片边缘有细微锯齿缓动结束值不是精确的目标值在动画结束时强制state.current state.end动画看起来一顿一顿的有可能是缩略图分辨率过低缩放后模糊被误认为卡顿先确认图片分辨率再看代码里有没有读取offsetWidth这类强制同步布局的操作切到后台标签页再切回来动画位置不对Date.now()计算时间没有被正确校准统一使用rAF回调里的timestamp不要混用Date.now()这些坑我几乎都踩过尤其是第一个。很多人做缩放效果只关注图片和transform忘了给父容器加overflow: hidden结果一放大整个页面都跟着撑开直接把布局搞崩。这个错误在面试现场出现率很高准备面试的朋友要格外留意。5.2 排查动画性能问题的一个实用方法如果你觉得动画不流畅但又不确定瓶颈在哪我推荐一个非常简单的方法打开Chrome DevTools的Rendering面板快捷键CtrlShiftP输入Rendering勾选Frame Rendering Stats然后触发你的动画盯着左上角的帧率曲线看。如果你发现帧率频繁掉到50以下那就要怀疑是不是代码里存在重排或者大图解码导致的卡顿。这时候按照上一节的排查思路先检查是否有offsetWidth读取再检查图片尺寸。还有一个小技巧把所有动画相关的日志在开发阶段用console.time包起来大概估算出每帧的执行耗时。如果单帧逻辑超过8ms就说明代码性能告急需要优化。图片缩放这个效果的正常耗时应该在2ms以内超过这个值多半是代码里写了不该写的操作。5.3 经验心得三个我反复用的小窍门文章最后分享几个我在这个效果上沉淀下来的经验不一定是大道理但在实战中非常管用。第一把动画时长和缩放系数做成常量甚至暴露到配置里。我在好几个项目里都吃过亏起初写死DURATION 600后来设计说改成800ms我得在代码里到处搜索替换。现在我会把这类参数集中放在文件头部或者用一个config对象管理改起来一行搞定。第二缩放的缓动强度要克制。很多新手喜欢用特别夸张的缓动函数比如easeInOutElastic做出来的效果看起来很不“商务”反而像玩具。图片缩放这种偏“质感”的交互用easeInOutQuad或者easeOutCubic就够了在0.5到0.8秒之间完成视觉上既明显又不浮夸。第三先想清楚“什么时候不需要动画”。有的场景下图片缩放功能只是一个无关紧要的装饰比如按钮上的小图标悬停放大。这时候杀鸡用牛刀去写一整套rAF框架反而是过度设计。我自己的判断标准是如果只是hover时放大1.1倍、300ms完成transition完全够用但如果你是做轮播图背景、产品展示大图、需要动画链和动态控制的组件才值得用JS方案。工具没有优劣匹配场景才是关键。这套图片缓慢放大缩小的JS实现我从第一行代码写到现在反复优化了好几轮。希望文章的每一段都能帮你省掉一点踩坑时间让你的图片交互既顺滑又可靠。
返回列表