苹果以其产品页面上流畅的动画而闻名。例如,当您向下滚动页面时,产品可能会滑入视野,MacBook 会展开,iPhone 会旋转,同时展示硬件、演示软件并以互动的方式讲述产品的使用故事。
请查看 iPad Pro 移动网页体验的这段视频
您在那里看到的许多效果并非仅用 HTML 和 CSS 创建。那么,您可能会问,是怎么做到的呢?嗯,这可能有点难以弄清楚。即使使用浏览器的 DevTools 也并不总是能揭示答案,因为它通常无法看到 <canvas>
元素后面的内容。
让我们深入了解其中一个效果,看看它是如何制作的,以便您可以在我们自己的项目中重新创建一些这些神奇的效果。具体来说,让我们复制 AirPods Pro 产品页面 中的英雄图片中的光线变化效果。
基本概念
我们的想法是创建动画,就像快速连续播放一系列图像一样。您知道,就像翻书一样!不需要复杂的 WebGL 场景或高级 JavaScript 库。
通过将每个帧与用户的滚动位置同步,我们可以在用户向下(或向上)滚动页面时播放动画。
从标记和样式开始
此效果的 HTML 和 CSS 非常简单,因为魔法发生在 <canvas>
元素内部,我们通过赋予其 ID 来使用 JavaScript 控制它。
在 CSS 中,我们将文档的高度设置为 100vh,并将 <body>
的高度设置为该高度的 5⨉,从而为我们提供必要的滚动长度以使此效果生效。我们还将文档的背景颜色与图像的背景颜色匹配。
最后,我们将定位 <canvas>
,将其居中,并限制 max-width
和 height
,以使其不超过视口尺寸。
html {
height: 100vh;
}
body {
background: #000;
height: 500vh;
}
canvas {
position: fixed;
left: 50%;
top: 50%;
max-height: 100vh;
max-width: 100vw;
transform: translate(-50%, -50%);
}
现在,我们可以向下滚动页面(即使内容没有超出视口高度),并且我们的 <canvas>
始终停留在视口顶部。这就是我们需要的全部 HTML 和 CSS。
让我们继续加载图像。
获取正确的图像
由于我们将使用图像序列(再次,就像翻书一样),因此我们将假设文件名按升序顺序依次编号(即 0001.jpg、0002.jpg、0003.jpg 等),位于同一目录中。
我们将编写一个函数,根据用户的滚动位置返回我们想要的图像文件的路径和编号。
const currentFrame = index => (
`https://www.apple.com/105/media/us/airpods-pro/2019/1299e2f5_9206_4470_b28e_08307a42f19b/anim/sequence/large/01-hero-lightpass/${index.toString().padStart(4, '0')}.jpg`
)
由于图像编号是整数,我们需要将其转换为字符串并使用 padStart(4, '0')
在索引前面添加零,直到达到四位数字以匹配我们的文件名。例如,将 1 传递给此函数将返回 0001。
这样我们就有了处理图像路径的方法。这是在 <canvas>
元素上绘制的序列中的第一张图像
如您所见,第一张图像位于页面上。此时,它只是一个静态文件。我们想要的是根据用户的滚动位置更新它。我们不仅仅想要加载一个图像文件,然后通过加载另一个图像文件来替换它。我们想要在 <canvas>
上绘制图像,并使用序列中的下一张图像更新绘制内容(但我们稍后再讨论这一点)。
我们已经创建了根据我们传递给它的数字生成图像文件路径的函数,因此我们现在需要做的就是跟踪用户的滚动位置并确定该滚动位置对应的图像帧。
将图像连接到用户的滚动进度
要了解我们需要传递哪个数字(以及因此要加载哪个图像)到序列中,我们需要计算用户的滚动进度。我们将创建一个事件侦听器来跟踪该进度并处理一些数学计算以计算要加载的图像。
我们需要知道
- 滚动开始和结束的位置
- 用户的滚动进度(即用户向下滚动页面的百分比)
- 与用户滚动进度相对应的图像
我们将使用 scrollTop
获取元素的垂直滚动位置,在本例中,该元素恰好是文档顶部。这将用作起始点值。我们将通过从文档滚动高度中减去窗口高度来获取结束(或最大)值。然后,我们将 scrollTop
值除以用户可以向下滚动的最大值,这将给我们用户的滚动进度。
然后,我们需要将该滚动进度转换为与图像编号序列相对应的索引号,以便我们为该位置返回正确的图像。我们可以通过将进度数乘以我们拥有的帧数(图像数)来做到这一点。我们将使用 Math.floor()
将该数字向下取整,并将其包装在 Math.min()
中,并使用我们的最大帧数,以确保它永远不会超过总帧数。
window.addEventListener('scroll', () => {
const scrollTop = html.scrollTop;
const maxScrollTop = html.scrollHeight - window.innerHeight;
const scrollFraction = scrollTop / maxScrollTop;
const frameIndex = Math.min(
frameCount - 1,
Math.floor(scrollFraction * frameCount)
);
});
使用正确的图像更新 <canvas>
我们现在知道随着用户滚动进度的变化需要绘制哪个图像。这就是 <canvas> 的魔力发挥作用的地方。<canvas>
具有许多很酷的功能,可以构建从 游戏 和动画到 设计模型生成器 等各种内容!
其中一项功能是一个名为 requestAnimationFrame
的方法,它与浏览器配合使用以更新 <canvas>
,如果我们使用的是普通图像文件而不是 <canvas>
,则无法做到这一点。这就是我选择 <canvas>
方法而不是,比如,<img>
元素或带有背景图像的 <div>
的原因。
requestAnimationFrame
将与浏览器刷新率匹配,并通过使用 WebGL 使用设备的显卡或集成显卡进行渲染来启用硬件加速。换句话说,我们将获得帧之间非常平滑的过渡——没有图像闪烁!
让我们在滚动事件侦听器中调用此函数,以便在用户向上或向下滚动页面时交换图像。requestAnimationFrame
接受一个回调参数,因此我们将传递一个函数,该函数将更新图像源并在 <canvas>
上绘制新图像
requestAnimationFrame(() => updateImage(frameIndex + 1))
我们将 frameIndex
增加 1,因为虽然图像序列从 0001.jpg 开始,但我们的滚动进度计算实际上从 0 开始。这确保了这两个值始终保持一致。
我们传递给更新图像的回调函数如下所示
const updateImage = index => {
img.src = currentFrame(index);
context.drawImage(img, 0, 0);
}
我们将 frameIndex
传递给函数。这将使用序列中的下一张图像设置图像源,该图像绘制在我们的 <canvas>
元素上。
使用图像预加载效果更佳
从技术上讲,我们到此为止已经完成了。但是,说真的,我们可以做得更好!例如,快速滚动会导致图像帧之间出现一点延迟。这是因为每个新图像都会发送一个新的网络请求,需要重新下载。
我们应该尝试预加载图像新的网络请求。这样,每个帧都已下载,从而使过渡速度更快,动画更流畅!
我们只需要循环遍历整个图像序列并加载它们。
const frameCount = 148;
const preloadImages = () => {
for (let i = 1; i < frameCount; i++) {
const img = new Image();
img.src = currentFrame(i);
}
};
preloadImages();
演示!
关于性能的快速说明
虽然这种效果非常酷炫,但它也使用了**大量的图像**。确切地说,是 148 张。
无论我们如何优化图像,或者为它们服务的 CDN 多么快速,加载数百张图像始终会导致页面臃肿。假设我们在同一页面上有多个这样的实例。我们可能会得到这样的性能统计数据

对于高速互联网连接且没有严格数据上限的用户来说,这可能没问题,但对于没有这种便利的用户来说,我们不能这么说。这是一个难以把握的平衡,但我们必须注意每个人的体验——以及我们的决策如何影响他们。
我们可以采取一些措施来帮助找到这种平衡,包括
- 加载单个备用图像,而不是整个图像序列
- 为某些设备创建使用较小图像文件的序列
- 允许用户启用序列,也许可以使用一个按钮来启动和停止序列
Apple 采用了第一个选项。如果您在连接到缓慢的 3G 连接的移动设备上加载AirPods Pro 页面,那么,性能统计数据看起来好多了。

是的,它仍然是一个重量级的页面。但它比完全没有考虑性能的情况下要轻得多。这就是 Apple 能够在单个页面上获得如此多复杂序列的方式。
进一步阅读
如果您对这些图像序列的生成方式感兴趣,一个好的起点是 AirBnB 的Lottie 库。文档将引导您完成使用 After Effects 生成动画的基础知识,同时提供了一种简单的方法将其包含在项目中。
只是在想……是否可以将所有单个图像组合在一个视频文件中?然后将播放位置与滚动同步?这对总文件大小来说是更好还是更差?会导致类似或更差的图像质量吗?动画是否比使用图像序列的解决方案更流畅?
我也这么想!下载一个视频而不是下载 148 张图片会好得多,就像一个带有 148 个 HTTP 连接的巨大的糟糕 GIF。
我尝试使用视频元素并同步视频的 currentTime 与滚动位置来复制此效果,但使用 mp4 进行此操作会导致严重的延迟。WebM 视频效果好得多,但这种格式仍然与 Safari 不兼容。我制作了一个 CodePen,所以您可以自己尝试:https://codepen.io/Maltsbier/pen/dyYmGGq
当然可以使用单个视频文件并将其与用户的滚动位置同步。我怀疑下载性能大致相同。由于我没有 Apple 的源文件,因此无法进行 A/B 测试以查看哪个文件的总文件大小更大,148 张图像或单个视频。Zahar 正确地指出它会减少发出的 HTTP 请求数量,但使用 HTTP/2,所有这些请求都可以通过单个 TCP 连接完成,因此我怀疑在这里无法获得多少性能提升。
视频最重要的缺点,正如 Malte 所证明的,是延迟。video html 标签无法利用
requestAnimationFrame()
,这是使这种效果尽可能流畅的最重要因素。requestAnimationFrame 使用您的显卡进行渲染而不是 CPU,并且受浏览器限制为 60fps,以便您的机器能够跟上。过去,我尝试过很多方法来解决同样的问题,其中一些方法比其他方法更有效。总之,直接的视频帧查找不会像您想象的那样有效和流畅地与滚动同步(会发生帧丢失;浏览器会在内部尝试优化以避免重复工作),尤其是在移动设备上。但是,您仍然可以找到多种巧妙的方法将视频直接解包到浏览器中的帧中,并使用它们来创建滚动动画。视频通常压缩得更好,但不同的图像需要浏览器进行更少的计算工作,并且通过仔细的排序和预加载,您可能能够比视频解包更快地启动动画序列。图像质量和总大小的方面确实取决于您追求的动画保真度(帧数、分辨率)。
我希望您觉得这篇文章很有趣(包含演示和源代码):https://www.ghosh.dev/posts/playing-with-video-scrubbing-animations-on-the-web/
为了测试尺寸差异,我使用 Blender 自己生成了图像和视频,结果非常清楚:90 张图像占用 56MB,而一个 3 秒、30fps 的视频仅占用 1.92MB。
与 Jurn van Wissen 的说法相反,requestAnimationFrame() 并不决定什么会被 GPU 加速,这取决于浏览器。但是,它使应该每帧发生的更新“更流畅”,因为它只在每次重绘之前被调用,从而通过不比需要更频繁地更新来节省处理能力。
当然,视频元素也使用了它,我们不需要每帧都设置它的 currentTime。
我认为导致延迟的原因是视频关键帧之间的插值。如您所见,上面两个视频,以 1 的关键帧间隔渲染,比下面的那个(*)以 20 的关键帧间隔渲染的要流畅得多。
至少在我的电脑上是这样。在我的手机上,所有视频都明显卡顿,其中 VP9/WEBM 视频表现最好。
您可能可以通过组合这些技术获得更好的效果。以视频的形式下载,将帧渲染到离屏画布上,以便提取它们并在滚动时根据图像技术绘制到屏幕上的画布上
@Malte Janßen:正确,我没有写得足够清楚。不是 requestAnimationFrame(),而是浏览器决定什么由 GPU 加速。使用 requestAnimationFrame() 只是浏览器能够通过使用 GPU 加速它的选项之一。创建此动画的方法有很多,其中一些选项严重依赖于单个 CPU 内核,这可能使此动画不太流畅。
@Martin Ansty 有趣的想法。这引发了我的一些疑问。
如果您要获取 3 秒的视频并将其渲染成单个图像,这些图像会大很多吗?
存档所有图像并使用 javascript 解压缩它们怎么样?您可以加载单个文件,解压缩它,然后渲染出各个帧。
是否可以使其在 div 高度而不是整个页面高度上滚动?
演示在 Safari iOS 12 上不起作用
有趣,你能告诉我发生了什么事吗?如果它在 Codepen 演示中不起作用,它在 Apple 自己的网站上对您有用吗?
我在多个 iOS 设备上测试过,并且在所有设备上都能流畅运行。它们运行的是 iOS 13,但由于 Apple 从大约 2014 年开始就在其网站上使用这种效果,因此它也应该在较旧的 iOS 版本上正常运行。
我也在 IOS Safari 中遇到了同样的问题,它在其中不起作用。
当我们从上到下滚动时,任何图像都不会发生变化。
拜托,拜托不要像黑色垃圾桶 Mac Pro 页面那样劫持滚动。
是的,这太糟糕了。我也感觉有人劫持了我的滚轮,阻止我真正滚动!
我可以使用一张大图片(精灵图)代替148张图片,这样只需要1个http请求吗?并且在canvas中移动类似css动画中的“steps”函数的东西?
当然可以!
这是一个在canvas上使用精灵图并动画化位置的示例:https://dev.to/martyhimmel/animating-sprite-sheets-with-javascript-ag3
在这种情况下,使用这种方法可能不是理想的,因为您必须下载整个文件才能向用户显示第一帧。这意味着,您需要下载所有148张图片,而不是下载单个31kb的图片,这会减慢初始加载速度。这将抵消您从减少http请求数量中获得的任何性能优势。
您能否澄清一下
requestAnimationFrame()
和webgl之间的混淆?文章认为使用rAF会导致图像在GPU上使用webgl渲染,但canvas上下文是普通的2d。当然,rAF仍然有用,可以在每个渲染帧期间让浏览器更新canvas;但它与webgl无关。还有一个想法就是简单地使用视频,将其翻转到末端,并进行一些时间索引操作以使其反向和正向播放。我在此处制作了一个演示
https://forums.tumult.com/t/playing-video-in-reverse/17928?u=maxzieb
它并不完美,但可以改进。
非常感谢这篇文章,这确实是一个很棒的技术,并且您已经非常清楚地说明了如何实现。
我发现的一个小但很重要的问题是,在
updateImage
内部设置img.src = currentFrame(index)
每次都会发出新的网络请求,忽略浏览器缓存(至少在Chrome中是这样)。我的解决方案是在预加载时将
img
放入数组中,然后从缓存中绘制图像。我有一个从Adobe Animate制作并导出的Canvas动画。它的体积要小得多,因为它主要是SVG,Animate也对动画进行了性能优化。我唯一想要的是将其与滚动位置同步。我该怎么做?
使用React
https://codepen.io/AliKlein/pen/dyOqrEB
欢迎反馈,尤其是性能改进。
在Firefox中,如果我与上面的codepen交互,然后切换到浏览器中的另一个选项卡一分钟左右,然后再次滚动浏览动画,它会卡顿。其他人可以重现这个问题吗?
因为目前这个问题阻止我在我的网站上使用这种效果。
这是因为Jurn的代码中仍然有一些不必要的网络请求(参见Jonathan Land写的内容)。
我最终使用了这个解决方案,它运行得非常流畅:-)
感谢这篇文章和解释!
我将自己的视频剪辑成图像,并使用此代码创建滚动效果。当我本地开发时,它运行得很好,但是当我部署时,在这种情况下使用Netlify,它变得卡顿。
我对这种级别的网络资产存储了解不多,但是是什么导致部署后出现卡顿?我使用了212张约30kb的图片。我该怎么做才能修复?这仅仅是因为我使用了不支持此数量资产的免费Netlify部署吗?谢谢!!
我们是否可以为图像在canvas上渲染时添加缓动效果,使其看起来动画在滚动时不会停止?
可能需要在CSS中或手动通过使前几帧和后几帧淡入淡出进行操作。
如何编写此javascript代码,使其仅在canvas可见时才开始动画?
这似乎始终在整个页面中不断计算滚动分数并更新图像帧。
没关系,您将canvas放在一个div中并使canvas粘滞,现在它基于div内的滚动进行更新,这要感谢评论中的另一个示例! :)
我无法使其工作。有什么线索——我已经将其放在一个div中并使canvas粘滞,但它仍然根据页面滚动的百分比进行操作:(
如果您想将其与滚动库(例如Locomotive scroll js)结合使用,如何控制具有固定canvas的情况?
我希望滚动效果在滚动到达特定块时开始。我需要在代码中进行哪些更改才能实现这一点?
这个帖子中有没有人可以帮我一下?我已经构建了一个这样的动画,并且运行良好,但是当我滚动时,图像之间会出现闪烁/闪烁。有没有人知道如何解决它?
图像序列正在页面全高处播放,例如,我们能否将其设置为1000vh,所有过渡都已完成,然后在canvas之外添加额外的内容。
我遇到的问题是它在网页的所有高度上都播放,因此我无法在视频结束之后添加内容。
如果我增加或减少网页的高度,帧会根据该高度显示,但它会一直显示到网页的末尾。
你们能帮忙吗,拜托了?
我想在同一页面上的3个部分使用此动画。
我需要进行哪些更改?
请帮忙
实际的Apple AirPod Pro网站使用
<section>标签来适应其几个与滚动相关的动画部分,因此我假设您只需这样做即可。它将改变您如何精确地使用js来操纵每个部分的css,尽管我无法弄清楚您将如何做到这一点。
顺便说一句,Apple在其较新的产品页面中使用了同步视频滚动,但这仍然是一个很好的学习内容。
感谢这篇文章,它非常有帮助。我有一个问题。在此图像序列结束之后,如何移动该部分?
加载下一帧后,如何从屏幕上删除所有其他帧?
嘿,Jurn。这是一个很棒的示例,感谢您提供它。我将要处理类似的事情,我想知道您是否有任何关于每秒视频导出多少张图像的建议?
感谢您的帮助!