不用框架也能做出 Apple 风的网页动画!
写在前面
在 Apple 官网浏览产品时,是否注意到那种随着滚动页面而动态播放的产品展示动画?这种顺滑的滚动驱动动画不仅提升了用户体验,也极具视觉吸引力。今天,我们就用 原生 HTML + JavaScript + Canvas 实现类似的滚动帧动画,零依赖、效果惊艳,非常适合前端练手与项目展示。
找了一个最具冲击感的 airPods 效果!废话不多说,直接上效果图!
实现思路
我们要实现的是一个随着网页滚动而播放的动画效果,我们通过控制台可以看出官网中这种动画其实本质上是利用一组连续的静态图像,模拟出动态变化的感觉。大致可以分为以下几个步骤:
1. 图片序列就是动画的每一帧
首先我们准备好一组连续编号的图片,比如从 0001.png 到 0065.png,共 65 张。这些图片每张略有不同,连续播放就形成了动画效果。这种方式和传统的帧动画原理一致。你可以下载到本地会更加丝滑,我这里选择用线上的地址,省区下载到本地的步骤;
2. 使用 canvas 来显示图片
在 HTML 页面中放置一个 canvas 元素,我们会使用 JavaScript 将图片绘制到 canvas 上。这块画布在页面中是固定的,不随滚动移动。
3. 页面滚动控制帧数
用户滚动页面时,JavaScript 监听滚动事件,计算当前滚动位置占页面总高度的百分比(即滚动进度),再将这个比例映射到图片索引上,从而确定当前要显示哪一帧。
4. 预加载图片
因为使用线上图片地址,这里为了避免滚动时图片来不及加载造成卡顿,我们会在页面加载初期就提前把所有图片加载到内存中,存储在一个数组中,这样每次切换帧时直接从内存中读取即可。
5. 绘制逻辑(核心)
每次滚动时,我们会执行如下操作:
- 清空 canvas 的内容
- 根据当前滚动位置确定应显示的帧索引
- 从预加载的数组中取出对应图片
- 使用 drawImage 方法将其绘制到 canvas 上
6. 使用 requestAnimationFrame 提高性能
我们用 requestAnimationFrame 来执行绘图操作,它是浏览器提供的优化方法,能保证动画在屏幕刷新时才运行,从而提高性能并避免画面撕裂或卡顿。
总而言之一句话:将滚动进度与图片帧绑定,通过 canvas 绘制每一帧,就能实现一个丝滑、响应用户滚动的动画效果。
代码实现
新建html文件,命名为 airPods.html
通过英文 ! 生成基础的HTML框架
HTML
html中的代码比较简单,我们仅仅在body中添加一个元素即可,这是我们动画绘制的“画布”,一切图像都会被画在这个 <canvas>
元素上。
它就像一个透明板子,我们用 JavaScript 把图片一张一张地画上去。我们用 id=“airPods” 是为了在 JavaScript 中可以精准获取它。
<!DOCTYPE html>
<html lang="en">
<head><meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1.0"><title>Document</title>
</head>
<body><canvas id="airPods"></canvas>
</body>
</html>
CSS
设置基础样式,我们设置 body 高度为 500vh 是为了有足够的滚动空间(vh 是相对于视口高度的单位)。
canvas 设置为 fixed 表示它始终固定在屏幕上,不随页面滚动而移动。
z-index: -1 保证它不会遮挡其他页面内容(比如官网上的文字说明,这里设置与否都不影响我们的效果)。
body {height: 500vh;margin: 0;background-color: #000;
}canvas {position: fixed;top: 0;left: 0;width: 100%;height: 100vh;object-fit: cover;z-index: -1;
}
当然目前的效果就是一片黑
JavaScript 实现逻辑
首先我们获取 Canvas 元素,getContext("2d")
是 Canvas 绘图的必备方法,它会返回一个2D绘图环境,我们可以用它来画图。
const canvas = document.getElementById("airPods");
const context = canvas.getContext("2d");
设置总帧数 以及 图片路径拼接函数,我们上面观察了官网是65张图片,并且我们这里使用的线上的图片地址;padStart(4, '0')
是关键,它让我们把数字变成四位数的格式如:‘0001’, ‘0002’(Apple 的图片命名方式)。
const frameCount = 65;const getImageUrl = (index) => {const paddedIndex = String(index).padStart(4, '0'); // 变成官网图片命名 '0001', '0002' ...return `https://www.apple.com.cn/105/media/us/airpods-pro/2022/d2deeb8e-83eb-48ea-9721-f567cf0fffa8/anim/hero/large/${paddedIndex}.png`;
};
预加载图片,先把所有帧图片提前加载,避免滚动时卡顿。所有图片都保存在 images[]
数组中,对应下标为帧编号。
const images = [];const preloadImages = () => {for (let i = 1; i < frameCount; i++) {const img = new Image();img.src = getImageUrl(i);images[i] = img;}
};
初始绘制第一帧,设置 Canvas 画布尺寸(可根据图片尺寸调整)。加载第一帧图片并绘制,避免页面初始是空白。
canvas.width = 1440;
canvas.height = 810;const img = new Image();
img.src = getImageUrl(1);
img.onload = () => {context.drawImage(img, 0, 0);
};
根据滚动切换图片帧,render()
函数用来在 canvas 上绘制第 index 帧。clearRect()
是清除画布,防止旧帧残留。
const render = index => {const image = images[index];if (image && image.complete) {context.clearRect(0, 0, canvas.width, canvas.height);context.drawImage(image, 0, 0);}
};
核心逻辑来了!滚动事件绑定,页面滚动越多,我们就显示更靠后的图片帧。scrollTop / maxScrollTop 得到滚动的比例(0~1)。用这个比例去换算应该显示第几帧。requestAnimationFrame()
让动画更加流畅高效。
const onScroll = () => {const scrollTop = document.documentElement.scrollTop;const maxScrollTop = document.documentElement.scrollHeight - window.innerHeight;const scrollFraction = scrollTop / maxScrollTop;const frameIndex = Math.min(frameCount - 1,Math.floor(scrollFraction * frameCount));requestAnimationFrame(() => render(frameIndex + 1));
};window.addEventListener('scroll', onScroll);
最后一行启动图片预加载,启动预加载,让用户滚动时不卡顿。
preloadImages();
js部分的逻辑可以总结一句话:我们监听滚动事件 → 根据滚动百分比确定动画帧 → 在 canvas 上绘制相应的图像 → 实现丝滑滚动动画!
完整代码
<!DOCTYPE html>
<html lang="en"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1.0"><title>Document</title><style>body {height: 500vh;margin: 0;background-color: #000;}canvas {position: fixed;top: 0;left: 0;width: 100%;height: 100vh;object-fit: cover;z-index: -1;}</style>
</head><body><canvas id="airPods"></canvas><script>const html = document.documentElement;const canvas = document.getElementById("airPods");const context = canvas.getContext("2d");const frameCount = 65;const getImageUrl = (index) => {const paddedIndex = String(index).padStart(4, '0');return `https://www.apple.com.cn/105/media/us/airpods-pro/2022/d2deeb8e-83eb-48ea-9721-f567cf0fffa8/anim/hero/large/${paddedIndex}.png`;};const images = [];const preloadImages = () => {for (let i = 1; i < frameCount; i++) {const img = new Image();img.src = getImageUrl(i);images[i] = img;}};canvas.width = 1440;canvas.height = 810;const img = new Image();img.src = getImageUrl(1);img.onload = () => {context.drawImage(img, 0, 0);};const render = index => {const image = images[index];if (image && image.complete) {context.clearRect(0, 0, canvas.width, canvas.height);context.drawImage(image, 0, 0);}};const onScroll = () => {const scrollTop = html.scrollTop;const maxScrollTop = html.scrollHeight - window.innerHeight;const scrollFraction = scrollTop / maxScrollTop;const frameIndex = Math.min(frameCount - 1,Math.floor(scrollFraction * frameCount));requestAnimationFrame(() => render(frameIndex + 1));};window.addEventListener('scroll', onScroll);preloadImages();</script></body></html>