粘贴代码就能用了👇🏻:

目标:制作一个多图层的响应式 banner,每层可以是图片或视频,能根据鼠标在 banner 上的横向移动控制 animationProgress
,进而驱动每层的 translate/rotate/scale/blur/opacity
动画。
实现要点:
- 数据结构(
layers.js
)描述每层的资源和动画参数。
- 把资源(img/video)预处理成 DOM 元素并存
.el
,便于后续统一操控。
- 每层放到一个
.layer
容器里,统一用 requestAnimationFrame
渲染变换。
- 鼠标事件只绑定在 banner(或 pointer 事件),并做复位动画。
- 响应式处理:窗口 resize 要重算尺寸。
- 必须在组件卸载时移除监听并取消动画,避免内存泄漏。
分步骤实现(每步:怎么想 → 怎么写 → 为什么)
步骤 0:准备(如果你还没建项目)
怎么想:先有个能跑 Vue 3 的项目(Vite 最简单)。
怎么写(命令,任选其一):
# 推荐:Vite + Vue
npm create vite@latest my-app -- --template vue
cd my-app
npm install
npm run dev
为什么:有了运行环境你才能在浏览器实时调试组件。
步骤 1:设计数据结构(layers.js
)
怎么想:要把每一层需要的参数(资源列表、初始缩放、偏移量、模糊/不透明等)都写成 JSON/JS 对象,方便组件读取并驱动动画。
怎么写(示例):
// src/assets/layers.js
export default [
{
name: '背景',
scale: { initial: 1.0, offset: 0.05 },
translate: { initial: [0, 0], offset: [10, 0] },
rotate: { initial: 0, offset: 2 },
blur: { initial: 0, offset: 2, wrap: 'clamp' },
opacity: { initial: 1, offset: -0.2, wrap: 'clamp' },
resources: [{ src: '/images/bg.jpg' }],
},
{
name: '前景',
scale: { initial: 1.1, offset: -0.02 },
translate: { initial: [0, 0], offset: [-15, 0] },
resources: [{ src: '/images/fg.png' }],
},
];
为什么:把参数和资源分离,便于调试和 A/B 调整,也利于复用/热替换。
步骤 2:静态 DOM 与样式结构(先做最简单的静态展示)
怎么想:先把 HTML/CSS 做好,确保能显示出图片/视频,再加入 JS 控制。
怎么写(template + 最简样式):
<template>
<div class="header-banner">
<div ref="bannerRef" class="animated-banner"></div>
</div>
</template>
<style>
.header-banner { position: relative; min-height: 155px; height: 9.375vw; max-height: 240px; }
.animated-banner { position: absolute; inset: 0; overflow: hidden; }
.layer { position: absolute; inset: 0; display:flex; align-items:center; justify-content:center; }
img, video { max-width:100%; height:auto; }
</style>
为什么:把容器准备好,后面 JS 只需要向 .animated-banner
插入每个 .layer
的子元素即可。
步骤 3:资源预处理(图片和视频分别处理)
怎么想:图片要等 load
获取 naturalWidth/naturalHeight;视频要等 loadedmetadata
获取尺寸。把这些尺寸存到 dataset
里,方便响应式 resize。
怎么写(示例核心):
// 伪代码
if (isImage) {
const img = new Image();
img.src = resource.src;
img.addEventListener('load', () => {
img.dataset.width = img.naturalWidth;
img.dataset.height = img.naturalHeight;
// 根据 banner 高度计算初始缩放尺寸并设置 width/height
});
resource.el = img;
} else { // video
const video = document.createElement('video');
video.src = resource.src; video.muted = true; video.loop = true; video.autoplay = true;
video.addEventListener('loadedmetadata', () => {
video.dataset.width = video.videoWidth;
video.dataset.height = video.videoHeight;
});
resource.el = video;
}
为什么:提前知道原始像素尺寸能精确按比例缩放,避免失真或拉伸,同时视频的元数据有时是异步的,必须等待。
步骤 4:建立每个层的容器和初始状态(layerStates)
怎么想:每层需要一个 DOM 容器和一份「初始状态」来记录最开始的 scale/rotate/translate/opacity/blur。动画时基于这个初始状态叠加偏移量。
怎么写(示例):
const layerContainers = layers.map(() => {
const el = document.createElement('div');
el.classList.add('layer');
bannerElement.appendChild(el);
return el;
});
const layerStates = layers.map(layer => ({
scale: layer.scale?.initial ?? 1,
rotate: layer.rotate?.initial ?? 0,
translate: layer.translate?.initial ?? [0,0],
blur: layer.blur?.initial ?? 0,
opacity: layer.opacity?.initial ?? 1,
}));
为什么:把状态分开保存,方便“基线值 + 动态偏移”这种组合逻辑,并且更易于调试。
步骤 5:渲染/变换逻辑(把 animationProgress 映射到 transform/filter)
怎么想:根据 animationProgress
(一般 -1..1,也可以不限制)计算每层最终的 translate/rotate/scale/blur/opacity
,然后把它们写到 style.transform
/ style.filter
/ style.opacity
。
怎么写(简化版):
const applyTransforms = (index, progress) => {
const base = layerStates[index];
const cfg = layers[index];
// scale
const scale = base.scale + (cfg.scale?.offset ?? 0) * progress;
// rotate
const rotate = base.rotate + (cfg.rotate?.offset ?? 0) * progress;
// translate
const translateOffset = (cfg.translate?.offset ?? [0,0]).map(v => v * progress);
const translate = base.translate.map((v,i) => v + translateOffset[i]);
element.style.transform = `translate(${translate[0]}px, ${translate[1]}px) rotate(${rotate}deg) scale(${scale})`;
};
为什么:拆成很多小步(先算 scale,再算 rotate,再算 translate),逻辑清晰,方便单独调试某个属性。
步骤 6:动画循环(为什么用 requestAnimationFrame)
怎么想:用 requestAnimationFrame
逐帧更新 DOM,浏览器会把动画和渲染周期对齐,保证流畅并节省 CPU。不要直接用 setInterval
。
怎么写(核心):
let rafId = 0;
const frameLoop = () => {
// 对所有层调用 applyTransforms(...)
rafId = requestAnimationFrame(frameLoop);
};
rafId = requestAnimationFrame(frameLoop);
为什么:requestAnimationFrame
在页面不可见时会暂停,从而节省性能;并且帧率与屏幕刷新同步,动画更流畅。
步骤 7:鼠标交互(只绑在 banner 上,做复位动画)
怎么想:不要把 mousemove 绑到 window
上(影响全页面性能)。绑定到 banner 或 使用 pointermove
,并且当鼠标离开时做一个平滑的回退动画(200ms)。
怎么写(关键逻辑):
const pointerMoveHandler = (event) => {
if (!bannerRect) return;
// 记录起始位置 lastMouseX,在后续的移动中根据当前位置 - 起始位置 得到 progress
animationProgress = (event.clientX - lastMouseX) / bannerWidth;
};
const pointerLeaveHandler = () => {
// 用 requestAnimationFrame 做线性插值回到 0
};
bannerElement.addEventListener('pointermove', pointerMoveHandler);
bannerElement.addEventListener('pointerleave', pointerLeaveHandler);
为什么:把事件限制在 banner 区域能显著降低事件触发频率,pointer
系列事件也能同时兼容鼠标与触控。
步骤 8:响应式 resize 与清理(最重要的工程细节)
怎么想:当窗口大小改变,banner 的高度/宽度以及基准缩放 baseRatio
需要重新计算,图片/视频的 width/height
也要重设。同时,组件卸载时必须 removeEventListener & cancelAnimationFrame。
怎么写(示例):
const resizeHandler = () => {
const newHeight = bannerElement.clientHeight;
const newBaseRatio = newHeight / 155;
layers.forEach(layer => {
layer.resources.forEach(res => {
const el = res.el;
const w = Number(el.dataset.width || el.width || 0);
const h = Number(el.dataset.height || el.height || 0);
const newW = w * newBaseRatio * (layer.scale?.initial ?? 1);
const newH = h * newBaseRatio * (layer.scale?.initial ?? 1);
el.style.width = `${newW}px`; el.style.height = `${newH}px`;
});
});
};
window.addEventListener('resize', resizeHandler);
// 清理
onBeforeUnmount(() => {
bannerElement.removeEventListener('pointermove', pointerMoveHandler);
bannerElement.removeEventListener('pointerleave', pointerLeaveHandler);
window.removeEventListener('resize', resizeHandler);
cancelAnimationFrame(rafId);
});
为什么:不清理会导致内存泄漏、在组件再次 mount 时重复绑定监听器与动画。
代码:
<template>
<div class="header-banner">
<div ref="bannerRef" class="animated-banner"></div>
</div>
</template>
<script setup>
import { onMounted, ref } from "vue";
import layers from "@/assets/layers.js";
const bannerRef = ref(null);
onMounted(() => {
const bannerElement = bannerRef.value;
let animationProgress = 0; // 原 k
let animationFrameId = 0; // 原 w
let lastMouseX = 0; // 原 C
// 初始化元素
layers.map((layer) => {
layer.resources.map((resource, resourceKey) => {
if (!/\.(webm|mp4)$/.test(resource.src)) {
const imgElement = document.createElement("img");
imgElement.src = resource.src;
imgElement.addEventListener("load", function () {
imgElement.dataset.height = imgElement.naturalHeight.toString();
imgElement.dataset.width = imgElement.naturalWidth.toString();
const baseRatio = bannerElement.clientHeight / 155;
const scaleInitial = layer.scale?.initial ?? 1;
const scaledHeight =
Number(imgElement.dataset.height) * baseRatio * scaleInitial;
const scaledWidth =
Number(imgElement.dataset.width) * baseRatio * scaleInitial;
imgElement.height = scaledHeight;
imgElement.width = scaledWidth;
imgElement.style.height = scaledHeight + "px";
imgElement.style.width = scaledWidth + "px";
});
layer.resources[resourceKey].el = imgElement;
} else {
const videoElement = document.createElement("video");
videoElement.muted = true;
videoElement.loop = true;
videoElement.autoplay = true;
videoElement.playsInline = true;
videoElement.src = resource.src;
videoElement.style.objectFit = "cover";
layer.resources[resourceKey].el = videoElement;
}
});
});
const bannerHeight = bannerElement.clientHeight;
const bannerWidth = bannerElement.clientWidth;
const baseRatio = bannerHeight / 155;
// 每个图层容器
const layerContainerElements = layers.map(() => {
const divElement = document.createElement("div");
divElement.classList.add("layer");
bannerElement.appendChild(divElement);
return divElement;
});
// 每个图层初始状态
const layerStates = layers.map((layer) => {
return {
scale: 1,
rotate: layer.rotate?.initial || 0,
translate: layer.translate?.initial || [0, 0],
blur: layer.blur?.initial || 0,
opacity: layer.opacity?.initial ?? 1,
};
});
// 动画更新函数
const animationFrameFn = () => {
try {
layerContainerElements.map((layerContainer, index) => {
const currentLayer = layers[index];
const resourceElement = layerContainer.firstChild;
const transformState = {
scale: layerStates[index].scale,
rotate: layerStates[index].rotate,
translate: layerStates[index].translate,
};
// scale
if (currentLayer.scale) {
const offset = currentLayer.scale.offset || 0;
const delta = offset * animationProgress;
transformState.scale = layerStates[index].scale + delta;
}
// rotate
if (currentLayer.rotate) {
const offset = currentLayer.rotate.offset || 0;
const delta = offset * animationProgress;
transformState.rotate = layerStates[index].rotate + delta;
}
// translate
if (currentLayer.translate) {
const offset = currentLayer.translate.offset || [0, 0];
const delta = offset.map((val) => animationProgress * val);
const newTranslate = layerStates[index].translate.map(
(val, subIndex) => {
return (
(val + delta[subIndex]) *
baseRatio *
(currentLayer.scale?.initial || 1)
);
}
);
transformState.translate = newTranslate;
}
resourceElement.style.transform = `translate(${transformState.translate[0]}px, ${transformState.translate[1]}px) rotate(${transformState.rotate}deg) scale(${transformState.scale})`;
// blur
if (currentLayer.blur) {
const offset = currentLayer.blur.offset || 0;
const delta = offset * animationProgress;
let blurValue = 0;
if (!currentLayer.blur.wrap || currentLayer.blur.wrap === "clamp") {
blurValue = Math.max(0, layerStates[index].blur + delta);
} else if (currentLayer.blur.wrap === "alternate") {
blurValue = Math.abs(layerStates[index].blur + delta);
}
resourceElement.style.filter =
blurValue < 1e-4 ? "" : `blur(${blurValue}px)`;
}
// opacity
if (currentLayer.opacity) {
const offset = currentLayer.opacity.offset || 0;
const delta = offset * animationProgress;
const baseOpacity = layerStates[index].opacity;
if (
!currentLayer.opacity.wrap ||
currentLayer.opacity.wrap === "clamp"
) {
resourceElement.style.opacity = Math.max(
0,
Math.min(1, baseOpacity + delta)
).toString();
} else if (currentLayer.opacity.wrap === "alternate") {
const total = baseOpacity + delta;
let finalOpacity = Math.abs(total % 1);
if (Math.abs(total % 2) >= 1) finalOpacity = 1 - finalOpacity;
resourceElement.style.opacity = finalOpacity.toString();
}
}
});
} catch (err) {
console.log("animation error", err);
}
};
// 初始化每个 layer 的 DOM
layers.map((layer, index) => {
const firstResourceElement = layer.resources[0].el;
layerContainerElements[index].appendChild(firstResourceElement);
requestAnimationFrame(animationFrameFn);
});
// 鼠标离开后复位动画
const resetAnimation = () => {
const startTime = performance.now();
const duration = 200;
const startProgress = animationProgress;
cancelAnimationFrame(animationFrameId);
const step = (now) => {
if (now - startTime < duration) {
animationProgress =
startProgress * (1 - (now - startTime) / duration);
animationFrameFn();
requestAnimationFrame(step);
} else {
animationProgress = 0;
animationFrameFn();
}
};
animationFrameId = requestAnimationFrame(step);
};
const mouseActiveState = { value: false };
// 鼠标事件
const mouseLeaveFn = () => {
mouseActiveState.value = false;
resetAnimation();
};
const mouseMoveFn = (event) => {
if (
document.documentElement.scrollTop + event.clientY <
bannerHeight
) {
if (!mouseActiveState.value) {
mouseActiveState.value = true;
lastMouseX = event.clientX;
}
animationProgress = (event.clientX - lastMouseX) / bannerWidth;
cancelAnimationFrame(animationFrameId);
animationFrameId = requestAnimationFrame(animationFrameFn);
} else if (mouseActiveState.value) {
mouseActiveState.value = false;
resetAnimation();
}
};
// 窗口缩放时重新计算尺寸
const resizeFn = () => {
const newHeight = bannerElement.clientHeight;
const newWidth = bannerElement.clientWidth;
const newRatio = newHeight / 155;
layers.forEach((layer) => {
layer.resources.forEach((resource) => {
const resourceElement = resource.el;
const newWidthScaled =
Number(resourceElement.dataset.width) *
newRatio *
(layer.scale?.initial || 1);
const newHeightScaled =
Number(resourceElement.dataset.height) *
newRatio *
(layer.scale?.initial || 1);
resourceElement.height = newHeightScaled;
resourceElement.width = newWidthScaled;
resourceElement.style.height = `${newHeightScaled}px`;
resourceElement.style.width = `${newWidthScaled}px`;
});
});
cancelAnimationFrame(animationFrameId);
animationFrameId = requestAnimationFrame(animationFrameFn);
};
document.addEventListener("mouseleave", mouseLeaveFn);
window.addEventListener("mousemove", mouseMoveFn);
window.addEventListener("resize", resizeFn);
});
</script>
<style>
body {
margin: 0;
padding: 0;
}
.header-banner {
position: relative;
z-index: 0;
min-height: 155px;
height: 9.375vw;
max-height: 240px;
background-color: #e3e5e7;
}
.animated-banner {
position: absolute;
top: 0;
bottom: 0;
left: 0;
right: 0;
overflow: hidden;
}
.layer {
position: absolute;
left: 0;
top: 0;
height: 100%;
width: 100%;
display: flex;
align-items: center;
justify-content: center;
}
img {
width: auto;
height: auto;
}
</style>
拿B站的数据和素材,如下:
const layers = [
{
resources: [
{
src: "./static_13/90240f707cb4a015bbf8bbd13e018b3f664087ce.png",
id: 0,
},
],
scale: {
initial: 0.47,
offset: 0.02,
},
rotate: {},
translate: {
offset: [40, 10],
},
blur: {},
opacity: {
wrap: "clamp",
},
id: 0,
name: "21天空",
},
{
resources: [
{
src: "./static_13/cd4194a7be89655450147d2384a162b966cf2ec2.png",
id: 0,
},
],
scale: {
initial: 0.47,
},
rotate: {},
translate: {
offset: [5, 0],
},
blur: {},
opacity: {
wrap: "clamp",
},
id: 1,
name: "20远景色",
},
{
resources: [
{
src: "./static_13/ab2379f7c80b225020ee42289db11b84b57c2766.png",
id: 0,
},
],
scale: {
initial: 0.47,
offset: -0.02,
},
rotate: {},
translate: {
offset: [5, 10],
},
blur: {},
opacity: {
wrap: "clamp",
},
id: 2,
name: "19月亮",
},
{
resources: [
{
src: "./static_13/938b58321d184fde31c783f5b321621ffb0c190e.png",
id: 0,
},
],
scale: {
initial: 0.47,
},
rotate: {},
translate: {
offset: [8, 0],
},
blur: {},
opacity: {
wrap: "clamp",
},
id: 3,
name: "18沙滩1",
},
{
resources: [
{
src: "./static_13/af840eae2cc555b0757d406e252f7a7dd6542eed.png",
id: 0,
},
],
scale: {
initial: 0.47,
},
rotate: {},
translate: {
offset: [10, 0],
},
blur: {},
opacity: {
wrap: "clamp",
},
id: 4,
name: "17投影桥",
},
{
resources: [
{
src: "./static_13/1271b110ca83ef84bf8d2c664537c8644a273216.png",
id: 0,
},
],
scale: {
initial: 0.47,
},
rotate: {},
translate: {
offset: [15, 0],
},
blur: {},
opacity: {
wrap: "clamp",
},
id: 5,
name: "16投影碎石",
},
{
resources: [
{
src: "./static_13/c8b49dc1aa86b2573ce2736de6692acfe0182481.png",
id: 0,
},
],
scale: {
initial: 0.47,
},
rotate: {},
translate: {
offset: [10, 0],
},
blur: {},
opacity: {
wrap: "clamp",
},
id: 6,
name: "15星星投影+反光",
},
{
resources: [
{
src: "./static_13/0881f755a4857a3b9bc12c3bbe41c00382ac6fc6.png",
id: 0,
},
],
scale: {
initial: 0.47,
},
rotate: {},
translate: {
offset: [30, 0],
},
blur: {},
opacity: {
wrap: "clamp",
},
id: 7,
name: "14投影33+狗",
},
{
resources: [
{
src: "./static_13/065fff3eb5ce38fd2d3a658ff825d5aa3a744e73.png",
id: 0,
},
],
scale: {
initial: 0.47,
},
rotate: {},
translate: {
offset: [9, 0],
},
blur: {},
opacity: {
wrap: "clamp",
},
id: 8,
name: "13沙滩2",
},
{
resources: [
{
src: "./static_13/7e61b8ea5efd98ade40b7ee386dd1bbc2b5898f5.png",
id: 0,
},
],
scale: {
initial: 0.47,
offset: 0.01,
},
rotate: {},
translate: {
offset: [6, 0],
},
blur: {},
opacity: {
wrap: "clamp",
},
id: 9,
name: "12海水",
},
{
resources: [
{
src: "./static_13/27724404973bbe4573bfabab6fed6767d6b06815.png",
id: 0,
},
],
scale: {
initial: 0.47,
offset: 0.02,
},
rotate: {},
translate: {
offset: [15, 0],
},
blur: {},
opacity: {
wrap: "clamp",
},
id: 10,
name: "11海浪",
},
{
resources: [
{
src: "./static_13/29d11ebfdd1b9d0840cc528c95ede27fe3d0a8e2.png",
id: 0,
},
],
scale: {
initial: 0.47,
},
rotate: {},
translate: {
offset: [10, 0],
},
blur: {},
opacity: {
wrap: "clamp",
},
id: 11,
name: "10左侧垃圾",
},
{
resources: [
{
src: "./static_13/3af7aa17868b8e5bc26950ac4a399bec62b83e50.png",
id: 0,
},
],
scale: {
initial: 0.47,
},
rotate: {},
translate: {},
blur: {},
opacity: {
wrap: "clamp",
},
id: 12,
name: "09月光",
},
{
resources: [
{
src: "./static_13/27ec840f903725d7ad7ad8356d37ead40ece4b31.png",
id: 0,
},
],
scale: {
initial: 0.47,
offset: 0.01,
},
rotate: {},
translate: {},
blur: {},
opacity: {
wrap: "clamp",
},
id: 13,
name: "08桥",
},
{
resources: [
{
src: "./static_13/230bdd9372f4d1362d0d9cd75d3b233b2db29d43.png",
id: 0,
},
],
scale: {
initial: 0.47,
},
rotate: {},
translate: {
offset: [15, 0],
},
blur: {},
opacity: {
wrap: "clamp",
},
id: 14,
name: "07沙滩上的石头",
},
{
resources: [
{
src: "./static_13/56f088b30dadc2c99fed255fcf4cc34c4f37f313.png",
id: 0,
},
],
scale: {
initial: 0.47,
},
rotate: {},
translate: {
offset: [30, 0],
},
blur: {},
opacity: {
wrap: "clamp",
},
id: 15,
name: "0633与狗",
},
{
resources: [
{
src: "./static_13/c5a4a63c098b81d89c73d359de35fcea9bb1c7a3.png",
id: 0,
},
],
scale: {
initial: 0.47,
},
rotate: {},
translate: {
offset: [10, 0],
},
blur: {},
opacity: {
wrap: "clamp",
},
id: 21,
name: "00沙滩碎星星",
},
{
resources: [
{
src: "./static_13/d195a834c55a24c1f599e7c15e3b59e5795c5c0f.webm",
id: 0,
},
],
scale: {
initial: 0.5,
},
rotate: {},
translate: {
initial: [-205, 65],
offset: [10, 0],
},
blur: {},
opacity: {
wrap: "clamp",
},
id: 23,
name: "动态小星星",
},
{
resources: [
{
src: "./static_13/59b1c59d919469fc0a4632acc5a6eecd9260d099.png",
id: 0,
},
],
scale: {
initial: 0.45,
},
rotate: {},
translate: {
offset: [35, 10],
},
blur: {},
opacity: {
wrap: "clamp",
},
id: 17,
name: "0422",
},
{
resources: [
{
src: "./static_13/7b7dd8a92bf8036be6502898e9804c65ee0f6284.png",
id: 0,
},
],
scale: {
initial: 0.45,
},
rotate: {},
translate: {
initial: [-5, -5],
offset: [38, 13],
},
blur: {},
opacity: {
wrap: "clamp",
},
id: 18,
name: "0322手里星星",
},
{
resources: [
{
src: "./static_13/aabd831214cdae044414980e3c06787c0f3073ff.png",
id: 0,
},
],
scale: {
initial: 0.47,
},
rotate: {},
translate: {
offset: [100, 0],
},
blur: {},
opacity: {
wrap: "clamp",
},
id: 19,
name: "02最前景石头",
},
{
resources: [
{
src: "./static_13/b5b5336124932336e39a99d64014fc0154d53912.webm",
id: 0,
},
],
scale: {
initial: 0.47,
},
rotate: {},
translate: {
initial: [-1140, 130],
offset: [130, 0],
},
blur: {},
opacity: {
wrap: "clamp",
},
id: 22,
name: "05前景星星",
},
{
resources: [
{
src: "./static_13/928547de5ced33ce2b28b7924e1a470a110eed26.png",
id: 0,
},
],
scale: {
initial: 0.47,
},
rotate: {},
translate: {
offset: [-5, 0],
},
blur: {},
opacity: {
wrap: "clamp",
},
id: 20,
name: "01流星",
},
];
export default layers;