简介:这份源码是基于JavaScript实现的抖音同款旋转3D相册网页项目,主要面向前端初学者、网页特效爱好者以及需要快速搭建个性相册展示页的开发者,帮助他们用轻量代码实现炫酷的3D轮播浏览效果。资源包内共14个文件,包含1个HTML入口页面、1个CSS样式文件、1个JS交互脚本及11张JPG示例图片,压缩包整体仅375KB,结构十分精简,无需复杂配置即可在浏览器直接预览运行。版本已特意修改尺寸适配逻辑,增强了不同屏幕比例下的显示稳定性,同时保留旋转、缩放、鼠标拖拽等标志性交互玩法。资源内还保留了清晰目录结构,图片素材与样式表分离,方便按需替换照片与主题。目前已有1755人学习浏览,热度较高。下载后可直接获得完整可运行的网页源码与配套图片素材,既适合部署为个人3D相册,也适合作为分析CSS 3D transform、JavaScript动画控制与事件监听机制的实战案例。
1. 抖音很火的JavaScript旋转3D相册源码到底在做什么
你在抖音上刷到的那种“自己照片环成一圈,在暗色背景里慢慢旋转”的作品,并不是什么高大上的3D应用。它的核心是一个纯前端的 CSS3D 小场景:几张照片先按角度均匀分布成一个环形,再让这个环围绕中轴持续旋转。整个过程中,负责布局的是 transform 的 rotateY 与 translateZ,负责动画的是 requestAnimationFrame,不涉及任何后端请求。
对于开发者来说,这份源码的价值在于把三个知识点串在一起:三维坐标变换的数学基础、CSS3D三维渲染能力、Pointer Event交互控制。看完这篇的内容你可以自己从零写一个,拿到别人打包的zip也知道改哪里、缺什么,遇到 JavaScript 运行时报错也知道去哪儿定位,这比直接套模板实用得多。下面先从布局原理说起,再给能直接跑的最小实现。
2. 旋转3D相册的环形排列原理与CSS3D选型要点
2.1 环形排列的数学基础:角度与半径参数
把N张照片排成一个圆环,需要两个参数:每张照片的偏转角,以及视点到环心的距离,也就是半径。偏转角只与照片数量有关:
angle = 360° / N * i
在CSS里把照片排成环形,核心是一行变形语句:
const radiusUnoverlap = photoWidth / (2 * Math.sin(Math.PI / count)); const radiusSeamless = (count * photoWidth) / (2 * Math.PI);前者是“照片互不重叠”时的最小半径,后者是“照片首尾相接连成连续圆柱面”时的半径。这两个公式在使用 transform: rotateY(angle) translateZ(R) 时最容易混淆。不重叠公式的推导逻辑是:相邻照片中心点在圆上的弦长为 2 * R * sin(180° / N),弦长不能小于单张照片宽度;无缝圆柱公式则直接让圆周长等于照片总宽度,也就是 N * photoWidth = 2πR。
第二种是抖音视频里最常见的观感,连续圆柱面看起来更饱满。下表给出照片宽度为200px时两种方案的数值:
| 照片数量 N | 不重叠最小半径(px) | 无缝圆柱半径(px) |
|---|---|---|
| 8 | 约261 | 约255 |
| 10 | 约324 | 约318 |
| 12 | 约386 | 约382 |
| 14 | 约451 | 约446 |
两种数值相差不大,因为在照片数量多时,正多边形内切圆与外接圆非常接近。实际写代码直接取无缝圆柱那一列即可,12张照片能刚好围一圈不留缝,看起来更像一个实体相册柱。
提示:照片数量少于6张时,无缝圆柱公式算出来的半径会很大,导致左右照片离得过远。少于6张建议改用不重叠公式,或者把照片宽度调大以缩短半径。
2.2 CSS3D、Three.js与纯WebGL如何选型
这是拿到“旋转3D相册源码”后第一个要判断的问题。源码里到底用 CSS3D 还是 Three.js,直接影响你能改动的范围。下面从相册场景出发做对比:
| 对比项 | CSS3D | Three.js | 纯WebGL |
|---|---|---|---|
| 素材形态 | DOM + CSS + 图片 | Canvas 贴图 | 原始缓冲与着色器 |
| 上手成本 | 低 | 中 | 高 |
| 额外依赖 | 无 | 约150KB以上库文件 | 无 |
| 交互方式 | 原生DOM事件 | 射线拾取 | 手动射线计算 |
| 适合场景 | 几十张以内照片、轮播 | 复杂光效、粒子、3D建模模型 | 自定义渲染管线 |
CSS3D 方案体积小,能跑在所有现代浏览器上,不需要引入额外依赖。缺点是做不了复杂光照、不支持加载 3D 建模软件导出的 OBJ/GLTF 模型,也没有真正的深度材质。Three.js 可以做到更多视觉效果,但播放器体积大,对于相册这个需求来说属于杀鸡用牛刀。
我收到过不少所谓“旋转3D相册源码”,拆开看十有八九是 CSS3D + 原生 JavaScript,整个压缩包不到 100KB,剩下全是照片。做这个需求的正确路线就是先 CSS3D,等需要阴影、粒子、模型嵌入时再迁移到 Three.js,而不是一开始就上 WebGL。
2.3 CSS3D渲染方式的性能边界
CSS3D 渲染时,每个带 transform-style: preserve-3d 的图片节点都会被浏览器当成独立的合成层来处理。合成层数量越多,每一帧的矩阵计算与合成开销越大。相册照片数量在 6 到 16 张之间时没有任何压力,16 张以上就要注意了。
限制性能的主要是两处:一是图片本身的分辨率,二是每层上叠加的样式效果。一张 4000px 宽的照片放进 220px 的相框里,浏览器仍然要处理 4000px 的纹理,旋转时每帧都要对这块超大纹理做变换,帧率自然掉下来。常见做法是把参与3D布局的图片处理成宽度不超过 720px 的缩略图,原图留给点击放大时再加载。给 photo 元素补一行 backface-visibility: hidden 也值得做,它让背对相机的照片不参与绘制,等于直接减掉一半合成层,性能收益明显。
3. JavaScript旋转3D相册最小实现:HTML与动画循环
3.1 项目目录与照片资源准备
源码包不管怎么打包,落地到网页运行时一定包含几样基础内容:
album-demo/ index.html css/style.css js/album.js img/photo01.jpg img/photo02.jpg ...在 HBuilder 里新建 HTML 项目就会生成类似目录。HTML 负责页面骨架,CSS 负责 3D 舞台,JS 负责生成节点和动画。拿到别人的源码包时,第一件事是检查 index.html 里的相对路径与实际目录结构是否一致,尤其要确认 img 目录的大小写,否则控制台会报 404,但页面本身不报错。
3.2 用HTML与CSS搭出3D透视舞台
先写最小的页面结构。舞台负责提供透视距离,相册容器负责承载所有照片:
<!DOCTYPE html> <html lang="zh-CN"> <head> <meta charset="UTF-8" /> <meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no" /> <title>JavaScript 旋转3D相册</title> <link rel="stylesheet" href="css/style.css" /> </head> <body> <div class="stage"> <div class="album" id="album"></div> </div> <script src="js/album.js"></script> </body> </html>CSS 部分最关键的是三个属性,缺一个效果都出不来:
.stage { width: 100vw; height: 100vh; perspective: 1200px; display: flex; align-items: center; justify-content: center; background: radial-gradient(ellipse at center, #2b2b3f 0%, #111111 100%); } .album { position: relative; width: 0; height: 0; transform-style: preserve-3d; } .photo { position: absolute; width: 220px; height: 150px; left: -110px; top: -75px; }参数说明:perspective: 1200px 表示视点到舞台的距离是1200px,视距越小,近大远小效果越强,旋转时边缘照片的透视形变越夸张。手机屏建议 1200 到 1600 之间,太小容易导致边缘照片拉伸变形。transform-style: preserve-3d 是核心,没有它子元素的3D变换会被拍平到父元素平面,环形成不了。photo 类的 left: -110px 和 top: -75px 是为了把照片中心对齐到坐标原点,因为宽度220px、高度150px,后续 rotateY 和 translateZ 都是相对中心计算的。
提示:用 HBuilder 内置浏览器预览时页面空白,优先检查 js 路径是否写错、img 目录大小写是否匹配。按 F12 打开开发者工具看 Network 面板里的红色请求就能定位。
3.3 用JavaScript生成环形并驱动自动旋转
第一版 JS 可以只做两件事:生成 12 张照片,让整个环持续旋转。
const COUNT = 12; const PHOTO_WIDTH = 220; const RADIUS = (COUNT * PHOTO_WIDTH) / (2 * Math.PI); const album = document.getElementById('album'); const fragment = document.createDocumentFragment(); for (let i = 0; i < COUNT; i++) { const angle = (360 / COUNT) * i; const photo = document.createElement('div'); photo.className = 'photo'; const img = document.createElement('img'); img.src = `img/photo${String(i + 1).padStart(2, '0')}.jpg`; img.alt = `相册图片${i + 1}`; img.width = 220; img.height = 150; photo.appendChild(img); photo.style.transform = `rotateY(${angle}deg) translateZ(${RADIUS}px)`; fragment.appendChild(photo); } album.appendChild(fragment); let currentAngle = 0; const SPEED = 0.3; function animate() { currentAngle = (currentAngle + SPEED) % 360; album.style.transform = `rotateY(${currentAngle}deg)`; requestAnimationFrame(animate); } animate();代码逻辑说明:String(i + 1).padStart(2, '0')把序号补成两位,也就是 photo01.jpg;如果你的图片命名为 photo_1,改成photo_${i + 1}.jpg即可。transform 字符串里 rotateY 在前、translateZ 在后,顺序不能互换,否则每张照片会围绕自己所在点原地打转,整个环塌缩成一个点。目标容器用 fragment 批量插入,减少一次回流,虽然12张照片影响不大,但这属于写源码包时的好习惯。
动画部分的速度常量 SPEED 是每帧0.3度。在60Hz显示器下每秒约18度,旋转一圈约20秒,观感适中。速度需要根据照片数量和屏幕尺寸调整,给出参考表:
| 照片数量 | 建议SPEED | 整圈耗时 | 使用场景 |
|---|---|---|---|
| 8 | 0.4 | 约15秒 | 产品展示,节奏略快 |
| 12 | 0.3 | 约20秒 | 默认 |
| 16 | 0.2 | 约30秒 | 张数多,避免眩晕 |
按帧步进的方式有一个问题:复制过程中掉帧会导致前后速度不一致。更稳的做法是基于时间戳计算:
const DEG_PER_SECOND = 18; let lastTime = performance.now(); function animate(now) { const delta = now - lastTime; lastTime = now; currentAngle = (currentAngle + (delta / 1000) * DEG_PER_SECOND) % 360; album.style.transform = `rotateY(${currentAngle}deg)`; requestAnimationFrame(animate); } requestAnimationFrame(animate);delta 单位是毫秒,除以1000换成秒后再乘以每秒度数。这样无论显示器是60Hz还是144Hz,每秒转过的角度都恒定,动画不会随着设备刷新率变化而快慢不一。
4. 给旋转3D相册加Pointer Event拖拽与触摸滑动交互
4.1 Pointer Event统一处理鼠标与触摸
现代浏览器可以用 Pointer Event 一举兼容鼠标、触摸笔和手指,不再需要分别监听 mousedown 和 touchstart,也避免了移动端同时触发两个事件的重复问题。拖拽核心逻辑如下:
let isDragging = false; let lastX = 0; const album = document.getElementById('album'); album.addEventListener('pointerdown', (e) => { isDragging = true; lastX = e.clientX; album.setPointerCapture(e.pointerId); }); album.addEventListener('pointermove', (e) => { if (!isDragging) return; const dx = e.clientX - lastX; lastX = e.clientX; currentAngle += dx * 0.3; }); album.addEventListener('pointerup', () => { isDragging = false; });参数说明:dx 是本次移动相对上一帧的横向像素差,乘 0.3 代表每移动1px 让相册转 0.3 度,这是“拖拽灵敏度”常量。灵敏度调小则手感滞重,调大则轻轻一甩相册飞转。手机上建议 0.3 到 0.5 之间。setPointerCapture 的作用是让后续 pointermove 事件在指针离开元素后仍然派发给当前元素,避免拖到边缘时丢失事件导致转角抖一下。
但当前这个版本有一个问题:手指松开相册立刻停住,没有惯性滑动。抖音上这类相册通常有“甩出去还能继续转一会”的效果,需要额外维护一个速度和衰减量:
let velocity = 0; album.addEventListener('pointermove', (e) => { const dx = e.clientX - lastX; lastX = e.clientX; velocity = dx; currentAngle += dx * 0.3; }); function tick(now) { const delta = (now - lastTime) / 1000; lastTime = now; if (!isDragging) { currentAngle += velocity * delta * 8; velocity *= Math.pow(0.92, delta * 60); if (Math.abs(velocity) < 0.1) velocity = 0; } requestAnimationFrame(tick); } requestAnimationFrame(tick);velocity 保存的是最近一帧的指针位移,拖拽结束后每帧把它换算成角度增量,再用 0.92 的衰减值逐步收敛到0。delta 乘以60是为了让衰减速度以60帧为基准,不管帧率高低,惯性持续时长接近一致。拖拽相关参数整理如下:
| 参数 | 常用值 | 说明 |
|---|---|---|
| 拖拽灵敏度 dx*0.3 | 0.3 ~ 0.5 | 越大,相同位移相册转得越快 |
| 惯性速度换算系数 | 8 | 影响甩动的力度感 |
| 惯性衰减底数 | 0.92 | 越大惯性持续越久,约0.5秒内停下 |
4.2 touch-action与移动端滚动冲突处理
这一节是移动端最容易踩的坑。如果 CSS 里没有设置 touch-action,手指在相册上横向滑动时,浏览器会把手势当成页面滚动,相册旋转动画会和页面滚动抢事件,最终表现为拖一下相册不动、页面却在橡皮筋回弹。
解决办法是在相册容器上禁用默认手势:
.album { touch-action: none; }touch-action: none 表示禁止在当前元素上触发浏览器的滚动和双指缩放。代价是原本的页面滑动也没了,对全屏相册来说没有副作用。但如果你把相册嵌入一个长页面,这个属性会让相册区域无法参与页面滚动,用户手指划过相册时会滚不动页面,解决方法是把相册限制在固定高度容器内,只在相册一层加这个属性。
4.3 单击放大与拖拽误触的区分逻辑
在3D旋转状态下给照片加点击事件,最大的麻烦是拖拽结束也会触发 click。需要一个位移阈值来判断到底是点击还是拖拽:
let movedDistance = 0; album.addEventListener('pointermove', (e) => { const dx = e.clientX - lastX; lastX = e.clientX; movedDistance += Math.abs(dx); currentAngle += dx * 0.3; }); function bindPhotoClick(photo, src) { photo.addEventListener('click', () => { if (movedDistance > 8) return; showLightbox(src); }); } function showLightbox(src) { const overlay = document.createElement('div'); overlay.className = 'lightbox'; const img = document.createElement('img'); img.src = src; overlay.appendChild(img); document.body.appendChild(overlay); overlay.addEventListener('click', () => overlay.remove()); }movedDistance 累积的是指针总位移。正常单击几乎为0,拖拽时三百像素的运行累计很容易超过8px,所以 8px 是一个安全的阈值。超过阈值就判定为拖拽,跳过 click。轻量弹层的样式可用一行固定定位覆盖全屏:
.lightbox { position: fixed; inset: 0; background: rgba(0, 0, 0, 0.85); display: flex; align-items: center; justify-content: center; z-index: 99; } .lightbox img { max-width: 90vw; max-height: 90vh; border-radius: 8px; }5. 用浏览器工具验证3D相册的帧率与内存占用
5.1 帧率检测脚本与渲染瓶颈判断
性能问题依靠肉眼判断不准,把检测器写进页面里最直观。在 album.js 末尾追加:
let frames = 0; let lastSecond = performance.now(); function checkFPS(now) { frames++; if (now - lastSecond >= 1000) { console.log('FPS:', frames, '单帧耗时:', Math.round(1000 / frames) + 'ms'); frames = 0; lastSecond = now; } requestAnimationFrame(checkFPS); } requestAnimationFrame(checkFPS);FPS 稳定在55以上说明渲染负载在安全范围。降到40以下时优先检查图片分辨率:把相册里参与3D变换的图片宽度控制在720px内,再观察一次帧率。如果仍然偏低,减少照片数量或去掉背景上的径向渐变,因为大面积渐变和模糊滤镜在移动端会拉高合成成本。
5.2 内存占用趋势与排查方法
Chrome 任务管理器可以看到每个页签的内存占用量,相册页面打开5分钟后内存涨幅应该在几十兆范围。如果持续上涨,重点查源码里是否在动画循环中反复创建 DOM 节点,比如在 requestAnimationFrame 里调用 document.createElement,每次给轮播追加节点却不清除。移动端 iOS Safari 的长时间运行还会触发内存警告,最直接的验证方式是滚轮循环看是否有白屏或页面突然刷新。
5.3 真机上线前的三点检查清单
最后在手机上跑一遍,按这个顺序确认:
- 用浏览器直接打开 index.html,确认自动旋转、拖拽和惯性滑动都正常,拖拽时页面本身不能上下滚动。
- 打开开发者工具的控制台,确认没有 JavaScript 运行时报错,也没有 imgs 和 js 文件的 404。
- 切换几台不同屏幕宽度的设备,确认照片环没有错位,点击放大后原图能正常加载,关闭弹层后相册自动旋转继续运行。
三点都通过,这份 JavaScript 旋转3D相册源码就可以放到线上,或者继续套壳打包成 App 分发。
本文还有配套的精品资源,点击获取