ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

原生CSS 3D旋转相册:从几何原理到拖拽惯性完整实现

原生CSS 3D旋转相册:从几何原理到拖拽惯性完整实现

朋友让我给他的个人站做一面照片墙时,他甩过来的参考链接打开就是一个 3D立体旋转相册:十几张照片整齐地绕着一根看不见的中轴转动,鼠标一拖,整圈照片带着透视感甩过来,松手之后还有惯性余量,那个效果第一眼确实唬人。我本来想找个现成插件,翻了一圈发现要么体积太大,要么配置死板不好改,有的还硬塞一堆无关依赖。干脆自己写一套轻量实现,跑通之后算了下核心代码不到两百行。这篇文章就把我完整做下来的过程、原理和踩过的坑都拆开讲清楚,适合有一定 CSS 基础、想亲手实现交互动效的前端新手和刚入行不久的同学参考。

1. 先回答一个关键问题:为什么不用现成插件

市面上做 3D 旋转相册的库和插件真不少。我最早也偷懒,打算直接引一个 star 高的轮播库,装上之后发现几个难以接受的问题。

第一个是包体臃肿。很多 3D 轮播插件底层依赖重量级 3D 引擎,一个相册效果要拉进来的脚本就有几十上百 KB。如果站点本身不大,这个成本完全不划算。第二个是定制受限。客户要的效果往往是"转起来""有一张居中放大""背景带光晕""手机端拖得动",插件提供的配置项有时候根本没有,改源码又容易在二次升级时被覆盖。第三个是依赖黑洞。有的插件为了做效果还会连带引入一堆工具函数,项目后期维护起来很头疼。

而用原生 CSS 3D + JavaScript 自己实现,核心逻辑其实非常集中,概括起来就三件事。

  • 用 CSS 的 3D 变换把照片摆成一个圈或者一面墙。
  • 用一个整体容器绕着 Y 轴旋转。
  • 在自动播放、拖拽、惯性这些交互逻辑里不断更新旋转角度。

这套方案的优点很明显:没有额外依赖,剩下的补丁代码可以完全按需求写;性能可控,因为浏览器对 transform 的渲染本身就做了合成层优化;照片数量、尺寸、排列半径都能通过一个函数算出来,改起来直观。

当然,自研也不是没有成本。最大的成本在调试,CSS 3D 里坐标系和层叠顺序一旦理解不到位,很容易出现"照片叠成一团""转起来穿模""某几张莫名消失"这类问题。所以如果你想自己动手,第一步不是抄代码,而是把 3D 坐标系统吃透。这个基础打牢了,后面几乎是一马平川。

2. 原理拆解:CSS 3D 坐标系统与 transform 链式调用的底层逻辑

很多同学一见到rotateY(60deg) translateZ(300px)就懵,不知道前后顺序到底代表什么。想搞懂 3D 立体旋转相册,必须先理解 CSS 里那个虚拟的三维坐标系。

浏览器里默认有一个三维空间:X 轴向右,Y 轴向屏幕下方,Z 轴指向屏幕外。站在用户的视角看,Z 轴就是"从屏幕指向你眼睛"的那条线。元素的transform属性就是在这个空间里对元素进行位移、旋转和缩放的一组变换指令。

关键点在于transform 的书写顺序不是随便写的。CSS 变换的规则是:先写在左边的变换先作用于元素自身的坐标系。举个例子:

/* 顺序一:先旋转,再沿局部 Z 轴平移 */ transform: rotateY(60deg) translateZ(300px);

这行代码的意思是,先把元素绕它自己的 Y 轴转 60 度,然后沿着旋转后的局部 Z 轴方向往外推 300px。因为方向也跟着旋转了,所以元素最终会落在偏离主轴的一个角度位置上。

/* 顺序二:先平移,再旋转 */ transform: translateZ(300px) rotateY(60deg);

这行则是先沿原始 Z 轴往外推 300px,再绕着平移后的新位置旋转。两者最终的位置和朝向完全不同。这个差异正是做旋转相册时照片能"围成一圈"的核心原理:我们期望每张照片先转到自己那个角度,再沿旋转后的方向推出去,所以顺序必须是rotateY(角度) translateZ(半径),不能反过来。

除此之外,想让子元素真正保持在三维空间里,而不是被压成一个平面,必须给父容器设置一个关键属性:

.carousel { transform-style: preserve-3d; }

这个属性告诉浏览器:子元素的 3D 变换结果要在一个共享的三维场景里渲染。如果不写,浏览器默认会按平面化方式处理,所有子元素只会被投影到父元素的平面上,rotateY和translateZ看着就像一张纸片被压扁之后做二维投影,立体感荡然无存。

还有一个和透视相关的属性,通常写在最外层:

.scene { perspective: 1200px; }

perspective决定观察者离场景的远近,数值越小,透视感越强,视觉上的"近大远小"越夸张;数值越大,画面越接近正投影,立体感越平缓。1200px 是一个比较舒服的起点:既能让照片边缘产生明显的纵深变化,又不会像鱼眼镜头那样变形得让人眩晕。这个值可以理解成镜头焦距,是做 3D 相册时最先需要调的"手感"之一。

这套坐标系和变换顺序搞清楚之后,剩下的就只是数学问题:怎么把 N 张照片均匀地摆成一个圆。

3. 照片围圈算法:正多边形分配与半径推导过程

把照片围成一圈,本质上就是让照片的中心点均匀分布在一个圆的周长上。想象一个正 N 边形,每条边是一张照片,所有照片朝向圆心外侧旋转,就形成了旋转木马式排列。这里的第一件事是算出每张照片的角度增量。

假设一共有 6 张照片,完整一圈是 360 度,那么每张照片相对下一张应该旋转:

const angleStep = 360 / 6; // 60 度

第 i 张照片的绕 Y 轴角度就是i * angleStep。这一步非常直白,不需要任何复杂计算。

第二件事是算出照片离中心轴的距离,也就是圆的半径。半径选不对,照片要么全都叠在中心,要么彼此之间留下巨大空隙。计算半径时有个现成的几何关系可以套用:照片宽度如果看作正多边形的一边,那么正多边形的内切圆半径就决定了每张照片离轴心的距离。

公式是:

radius = (cellWidth / 2) / tan(π / photoCount)

这个公式怎么来的?正 N 边形每个内角对应的中心角是360 / N度,也就是2π / N。取其中一半,得到π / N,这个角的正切值等于"边长的一半"除以"内切圆半径"。所以把边长一半除以tan(π / N),就能反推出半径。对于 6 张宽度为 300px 的照片,计算结果是:

const radius = (300 / 2) / Math.tan(Math.PI / 6); // 约 260px

这就是每张照片沿自身旋转方向往外推 260px 之后,6 张照片刚好首尾相接地围成一个正六边形,远端看起来像光滑的一圈。

实际操作时我不会直接用 HTML 一个个手写照片节点,而是用 JavaScript 根据图片数组自动生成,维护起来省事得多。页面结构非常简单:

<div class="scene" id="scene"> <div class="carousel" id="carousel"></div> </div>

然后在脚本里创建照片单元:

const photos = [ 'photo-1.jpg', 'photo-2.jpg', 'photo-3.jpg', 'photo-4.jpg', 'photo-5.jpg', 'photo-6.jpg' ]; const carousel = document.getElementById('carousel'); const len = photos.length; const angleStep = 360 / len; const cellWidth = 300; const radius = Math.round((cellWidth / 2) / Math.tan(Math.PI / len)); photos.forEach((src, index) => { const cell = document.createElement('div'); cell.className = 'cell'; const img = document.createElement('img'); img.src = src; img.alt = '照片' + (index + 1); cell.appendChild(img); cell.style.transform = 'rotateY(' + (index * angleStep) + 'deg) translateZ(' + radius + 'px)'; carousel.appendChild(cell); });

对应的 CSS 有一个小诀窍。外层.carousel的宽高可以设成 0,因为真正要摆进 3D 空间的是.cell,它们全部通过position: absolute从同一个原点出发,再各自旋转平移。每个.cell的宽高则是照片的实际尺寸:

.scene { width: 100%; height: 60vmin; perspective: 1200px; display: flex; align-items: center; justify-content: center; } .carousel { position: relative; width: 0; height: 0; transform-style: preserve-3d; } .cell { position: absolute; top: -120px; /* 高度的一半,让照片中心落在原点 */ left: -150px; /* 宽度的一半 */ width: 300px; height: 240px; overflow: hidden; border-radius: 12px; } .cell img { width: 100%; height: 100%; object-fit: cover; }

注意top和left设置为负的宽高一半,目的是让照片的几何中心正好位于坐标原点。这样旋转时照片才会以自身中心为轴,否则会绕着一个角点打转,画面飘得没法看。

写完这一步,打开浏览器已经能看到一圈照片悬在空中,只是还没转起来。接下来才是真正让它"活"的部分。

4. 旋转机制:自动播放、缓动曲线与性能取舍

让整个相册旋转,本质上就是不断更新.carousel的transform属性。最直接的想法是用setInterval每隔几十毫秒给角度加一个固定值。这个方案能跑,但不够好。

原因在于浏览器不一定能保证定时器的执行频率和屏幕刷新率同步。如果定时器一秒执行 40 次,而屏幕刷新是 60Hz,就会出现一帧画面被跳过、下一帧又渲染两次的情况,视觉上表现为明显的卡顿抖动。更稳妥的做法是用requestAnimationFrame,它会自动跟随浏览器的刷新节奏,把动画逻辑放在每一帧的开头执行。

为什么用 requestAnimationFrame 而不是 setInterval? 浏览器会在每次重绘前调用 requestAnimationFrame 回调,频率跟屏幕刷新率保持一致, 不会多渲染、不会少渲染。而且页面切换到后台时动画会自动暂停, 不会在看不见的标签页里白白耗电。

自动旋转的核心代码可以这样写:

let rotation = 0; let isAutoPlaying = true; function animate() { if (isAutoPlaying) { rotation += 0.4; updateCarousel(); } requestAnimationFrame(animate); } function updateCarousel() { // 每次都要带上 rotateX(-5deg),这是初始的俯仰角,不然会被覆盖掉 carousel.style.transform = 'rotateX(-5deg) rotateY(' + rotation + 'deg)'; } requestAnimationFrame(animate);

rotation += 0.4的意思是每帧旋转 0.4 度。60Hz 刷新率下每秒大约 24 度,照片转完一整圈需要 15 秒,属于一个温和的观赏速度。如果想快一点,调到 0.6 甚至 0.8 都可以。

这里有一处很容易犯的错:transform是一个整体字符串,每次更新时如果只写rotateY(...),之前设置的rotateX(-5deg)会被冲掉。我第一次做的时候就是没注意这个细节,照片圈一直平躺在屏幕上,少了从上往下看的俯冲视角,立体感差了一大截。所以updateCarousel里必须把rotateX(-5deg)和rotateY(...)一起拼好再赋值。

至于缓动曲线,自动播放阶段用匀速即可,因为用户通常是盯着照片看,匀速转不会产生焦虑感。但如果是点击按钮切换上一张、下一张这种场景,就最好加一段缓动,让旋转从慢到快再回落,观感会自然得多。用 CSS 处理起来反而最简单:

.carousel { transition: transform 0.8s cubic-bezier(0.22, 1, 0.36, 1); }

通过修改一个targetRotation变量,再把carousel.style.transform设成目标角度,CSS 的transition会自动补全中间过程,效果非常丝滑。这一招的背景是:浏览器对transform变化有合成器优化,过渡动画全程不触发布局计算,性能很好。但要注意,一旦加了transition,拖拽时得先把它关掉或者设成transform: none,否则手指每移动 1px 都要等 0.8 秒动画追上来,手感会拖泥带水。

自动播放跑顺之后,交互才是 3D 立体旋转相册的加分项。下一节讲拖拽和惯性,这部分需要一点事件处理上的细心。

5. 交互手感:鼠标拖拽、触屏单点控制与惯性释放

旋转相册的体验好坏,八成取决于拖拽手感。用户按住照片左右拖动,相册应该跟着手指方向转;松手之后,如果拨动速度够快,还要带出一段自然的惯性滑动,最后慢慢停下来。这个交互逻辑拆开之后其实就三块:按下时记录起始位置、移动时计算角度增量、松开时根据速度做衰减动画。

先处理鼠标事件。要注意的是,mousemove和mouseup不能只挂在场景元素上,否则拖到一半光标移出相册区域,事件就断了。稳妥做法是:mousedown挂在场景上,mousemove和mouseup挂在document上,按下之后在文档范围内追踪移动。

let isDragging = false; let startX = 0; let startRotation = 0; let lastX = 0; let velocity = 0; scene.addEventListener('mousedown', (e) => { isDragging = true; isAutoPlaying = false; // 拖动时停掉自动旋转 startX = e.clientX; lastX = e.clientX; startRotation = rotation; carousel.style.transition = 'none'; // 关闭缓动,保证跟手 }); document.addEventListener('mousemove', (e) => { if (!isDragging) return; const deltaX = e.clientX - startX; rotation = startRotation + deltaX * 0.3; // 灵敏度系数 // 简单记录最近一帧的位移,用来估算松手速度 velocity = e.clientX - lastX; lastX = e.clientX; updateCarousel(); }); document.addEventListener('mouseup', () => { if (isDragging) { isDragging = false; startInertia(); // 根据 velocity 启动惯性动画 } });

灵敏度系数 0.3 的意思是:鼠标在屏幕上移动 100px,相册旋转 30 度。这个值用起来比较跟手,如果照片数量少、卡片大,可以降到 0.2,防止手指还没划多少,相册已经飞了好几圈。

触屏设备的处理逻辑基本一致,但有两个细节需要特别注意。第一个是阻止浏览器默认行为:在触屏上拖动页面时,系统默认会尝试滚动页面,这会和横向拖拽相册直接打架。解决方案是在touchmove事件里调用event.preventDefault(),前提是给监听器传入{ passive: false },否则部分浏览器会忽略这个阻止请求。第二个是只跟踪第一根手指,如果用touches[0]会取到所有指尖坐标,多指操作时画面会乱跳,所以统一用changedTouches[0]或者touches[0]并忽略后续的手指变化。

scene.addEventListener('touchstart', (e) => { const touch = e.touches[0]; startX = touch.clientX; lastX = touch.clientX; startRotation = rotation; isDragging = true; isAutoPlaying = false; carousel.style.transition = 'none'; }, { passive: true }); scene.addEventListener('touchmove', (e) => { if (!isDragging) return; e.preventDefault(); const touch = e.touches[0]; const deltaX = touch.clientX - startX; rotation = startRotation + deltaX * 0.3; velocity = touch.clientX - lastX; lastX = touch.clientX; updateCarousel(); }, { passive: false }); scene.addEventListener('touchend', () => { if (isDragging) { isDragging = false; startInertia(); } });

惯性释放的做法是:在松手时拿到刚才记录的velocity(代表最后一帧的像素位移),然后用requestAnimationFrame让旋转角每帧递减一个衰减系数,直到速度低到阈值以下就停住。为了手感自然,速度衰减一般用乘法,比如每帧乘以 0.95,这样会形成先快后慢、逐渐停稳的效果。

function startInertia() { cancelAnimationFrame(inertiaTimer); function step() { if (Math.abs(velocity) < 0.1) { isAutoPlaying = true; // 惯性结束恢复自动播放 return; } rotation += velocity * 0.3; velocity *= 0.92; updateCarousel(); inertiaTimer = requestAnimationFrame(step); } inertiaTimer = requestAnimationFrame(step); }

这里velocity * 0.3是把像素位移换算成旋转角度,和前面灵敏度保持一致。衰减系数 0.92 给一种比较自由的甩动感,如果希望相册更快停下,改成 0.8 就行。

做完交互之后,相册已经能"玩"了。但真正发布之前还有一堆隐藏问题,几乎每个人都会被坑一遍。下一节按时间顺序还原一下我当时的完整排查过程。

6. 踩坑实录:透视层级、加载时序、浏览器压平这些坑是怎么逐个验证的

这一节不讲代码逻辑,讲真真切切排查过的问题。它们不会同时出现,但每一个都能让效果看起来"哪里不太对"。

第一个典型现场是照片全部平贴在屏幕上,完全没有立体感。背景是我给.scene顺手加了border-radius和overflow: hidden,想让照片在圆角容器里显示。刷新页面后,3D 效果当即消失,整个相册像一张扁平的转盘。查了半天才意识到:overflow属性只要不是默认的visible,就会强制把子元素的transform-style: preserve-3d压平成flat。这是浏览器在渲染层面的一个硬性限制,不是 BUG,但很容易被忽略。解决办法是不要在最外层的 3D 容器上直接做圆角裁剪;需要圆角容器时,把overflow: hidden移到另一个只负责视觉裁剪的上层容器上,与真正的 3D 场景拆开。

第二个现场是照片转着转着,相邻两张的边缘出现明显的"撕裂感"或闪烁,有些浏览器里还会看到一条细细的黑线。原因是:照片绕成一个正多边形,每张照片其实是有限大小的平面,相邻平面的边缘在某个视角下会重叠。浏览器对重叠平面的渲染顺序是逐像素深度比较的,精度不足时就会出现 z-fighting,画面像电波一样抖动。试探过几种方式,最终有效的是把半径稍微加大几个像素,让相邻照片之间留出一点点物理间隙,避免边缘贴合。对于 300px 宽的照片和 6 张的配置,半径一般需要额外加 8 到 12px 才不会在慢转时露馅。

第三个现场是打开页面的前两秒,照片全部挤在一个点上,然后突然"炸开"成一圈。根因是radius的计算依赖照片宽度。早期版本我偷懒用 JS 读取图片的实际渲染宽度来计算半径,但图片还没加载完成时读取到的宽度是 0,半径算出来也是 0,于是所有照片都在原点叠成一团。等图片加载完,宽高变化又触发不了重新计算,画面就崩了。后来干脆把照片宽度作为一个固定常量写死,模拟阶段不依赖原始图片的加载状态。如果确实想按实际图片宽度自适应,必须在所有图片全部触发load事件后统一计算一次半径,不能在创建节点时就直接算。

第四个现场发生在移动端:页面上下滑动时,相册区域经常"抢"掉手势,导致整个页面卡住滚不动。这是因为touchmove里的preventDefault()把垂直滚动也一并拦掉了。改进方式很简单:在touchmove里先判断是横滑还是竖滑,只有横向位移明显大于纵向时,才执行preventDefault()并更新旋转角度;否则什么都不做,把事件还给页面。另外给场景容器加上touch-action: pan-y这个 CSS 属性,也可以让浏览器默认把纵向滚动交还页面,省掉一大部分手势判断代码。

第五个现场是相册转到背面时,照片内容变成左右镜像。这不是出 BUG,而是 CSS 3D 的默认行为:当元素旋转超过 90 度,背面以镜像形式显示。相册场景里,背对着用户的照片理应是不可见的,否则满屏都是镜像文字和反着的脸,看起来非常奇怪。解法是给.cell加上:

.cell { backface-visibility: hidden; }

加了之后,转到背面的照片会被浏览器直接跳过渲染,只露出正对视角的那几张,视觉上干净很多。代价是相册会"缺"几页,但旋转木马效果讲究的就是中心聚焦,这个差异通常没人注意,反而更自然。

7. 锦上添花:焦点卡片放大、倒影光效、响应式适配与性能优化清单

基础功能和踩坑排完,相册已经能上线,但离"好看"还有一段距离。我通常会在收尾阶段加四样东西,个个都是低成本高回报。

第一样是焦点卡片放大。相册旋转时,正对屏幕的那张照片应该被强调。前面讲过,第 0 张照片在没有附加旋转时处于正前方位置。所以用当前总旋转角反推,就能找出哪张照片此刻最接近屏幕:

function getFocusIndex() { const normalized = ((Math.round(-rotation / angleStep)) % len + len) % len; return normalized; }

计算出当前焦点照片后,给对应的.cell加一个focus类,不对它做 3D 变换上的修改,只是让内部的img稍微放大一点、加一点阴影。因为img的transform是发生在照片平面内部的,不会干扰整个相册的 3D 排列:

.cell img { transition: transform 0.3s ease, box-shadow 0.3s ease; } .cell.focus img { transform: scale(1.12); box-shadow: 0 24px 48px rgba(0, 0, 0, 0.4); }

第二样是环境光效和地面倒影。纯白背景下的旋转相册会显得很"悬浮",加一层径向渐变背景和轻微倒影,立体感立刻提升一个档次。倒影我试过-webkit-box-reflect,它只对部分浏览器友好,跨平台不可靠;更稳的方案是给每张照片下面放一个翻转的渐变遮罩,模拟镜面。不过如果你的相册照片数量多,倒影的渲染成本会翻倍,移动端上容易掉帧。我的做法是只在焦点照片上保留倒影效果,其余照片全部关闭,视觉上既保留层次又不牺牲流畅度。

第三样是响应式适配。核心思路是不要让照片宽高写死,而是用 CSS 变量定义,再通过 JS 读取变量来做半径计算。常见的处理方式是:

const cellWidth = parseFloat(getComputedStyle(carousel).getPropertyValue('--cell-width'));
.carousel { --cell-width: 300px; --cell-height: 240px; } @media (max-width: 600px) { .carousel { --cell-width: 180px; --cell-height: 144px; } }

这样一来,移动端只需要改 CSS 变量,JS 自动用新宽度重新算半径。注意修改 CSS 变量之后要重新执行一次"摆放照片"的初始化代码,否则照片角度没变,但半径还停留在旧尺寸算出的值。

第四样是性能优化清单。自动播放和拖拽全程只修改外层容器的transform,浏览器能够把这些元素提升到合成层单独处理,不触发重排和重绘。在此基础上,我一般还会给.carousel加一句will-change: transform,明确告诉浏览器提前优化。但不是所有元素都适合开will-change,如果给几十个.cell全部加上,反而会占满移动端的合成层内存,卡顿更严重。

真正拖慢性能的往往是每一帧都在变化的阴影、滤镜和大面积渐变。所以阴影我只放在焦点照片上,滤镜能不用就不用,背景渐变用预先绘制好的图片代替实时渲染。移动端照片数量超过 20 张时,建议用压缩过的图片而不是原图,否则 GPU 显存会被塞满,帧率照样撑不住。

做完这套,3D 立体旋转相册从原理到实战就已经完整闭环了。最后聊点个人体会:我一直觉得这个效果最容易被低估的是"半径留白"。公式算出来的结果是数学上的完美贴合,但真放到屏幕上,相邻照片之间完全贴合会显得非常拥挤,观赏性反而不如把半径增大 15% 到 20%,让照片之间露出一段背景。我的习惯是算完理论值之后手动加一点偏移,再对着真实设备转几圈微调,直到看不出缝隙又不显得空荡。这个小技巧,比你调半天阴影和光效都管用。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进