JS与CSS3实现3D散列画廊:VCD架构与交互逻辑详解
本文详解基于JS与CSS3实现的3D散列画廊特效。通过VCD(视图、控制器、数据)架构分离关注点,利用CSS3的perspective、rotateY及transition属性实现海报的3D旋转与正反面切换。文章深入解析了HTML模板字符串替换、随机位置算法及控制按钮的状态管理,并提供了完整的代码实现与Firefox兼容性问题排查指南。
特效概述与交互功能
本文介绍基于网络视频教程实现的照片墙特效的具体过程。

最终实现的交互效果包含以下核心功能: • 点击任意图片时,该图片移动至屏幕中央并放大显示。同时,图片在点击时触发正反面切换动画。 • 当某张图片被点击时,其余所有图片的位置将重新进行随机排列。 • 点击控制按钮时,对应的图片会移动至正中央显示,且控制按钮的样式随之切换。连续点击同一控制按钮时,图片会配合按钮状态进行正反面翻转切换。
对该特效进行VCD(视图、控制器、数据)分解,结构如下图所示:

按照计算机处理逻辑对案例进行模块化分解。
• View(视图层):由HTML和CSS构成的基础界面模板。 • Controller(控制器层):由JavaScript负责内容处理与事件响应。 • Data(数据层):data.js文件,非必需但有助于理解数据流向。
Data(数据层)是开发中常用的模式。若将内容硬编码至View(即HTML)中,修改内容时需直接改动HTML结构;而采用VCD架构时,仅需修改数据部分即可。此外,通常数据由后台生成,这种替换方式更加便捷。本案例中的海报均由模板结合数据动态生成。
View视图层模块分解与HTML实现
View(视图层)部分 对效果区域进行模块分解,具体分为以下三个部分:
• 当前展现的海报区 水平垂直居中显示 允许被<控制条按钮>控制展现与翻转 • 左右存放区域 以<当前展现的海报区>为基准切分为左右两个区域 用于存放其他海报 每个海报的位置和角度均为随机生成 • 控制条区 负责控制与展现对应的海报 执行翻面切换<当前展现海报>的正反面操作

实现的HTML代码如下:
<body onselectstart = 'return false;'><!--这里防止页面的文字被选中-->
<!-- 2.改写视图为模板字符串 -->
<div class="wrap" id="wrap">
<!-- div.photo负责平移和旋转 -->
<div class="photo photo-front" onclick = "turn(this)" id="photo_{{index}}">
<!-- div.photo-wrap 负责正反面翻转-->
<div class="photo-wrap">
<div class="side side-front">
<p class="image"><img src="photo/{{img}}" alt=""></p>
<p class="caption">{{caption}}</p>
</div>
<div class="side side-back">
<p class="desc">{{desc}}</p>
</div>
</div>
</div>
</div>
其中的{{img}}、{{caption}}、{{desc}}为模板字符串占位符,后续将在数据部分进行相应替换。 View部分的CSS样式如下:
<style type="text/css">
*{
padding:0;
margin:0;
}
body{
background-color:#fff;
color:#555;
font-family:'Avenir Next','Lantinghei SC';
font-size:14px;
-moz-font-smoothing:antialiased;
-webkit-font-smoothing:antialiased;/*字体平滑*/
}
.wrap{ //图片区域在body中垂直居中
width:100%;
height:900px;
position:absolute;
/*-------------------下面两行实现了垂直居中-------------
top:50%;
margin-top:-300px;
/*----------下面这四行也是实现垂直居中的一种方式-----------------*/
top:0;
bottom:0;
margin-top:auto;
margin-bottom:auto;
/*------------------------------------------------------------*/
background-color:#333;
overflow:hidden;
-moz-perspective:800px;
-webkit-perspective:800px;//让子元素获得3D元素支持,这里是设置子元素距离视图的位置
}
/*海报样式*/
.photo{
width:260px;
height:320px;
/*因为每个海报都是利用top和left随机定位的,所以将position设置为absolute*/
position:absolute;
z-index:1;
box-shadow:0 0 1px rgba(0,0,0,.01);
/*transform:rotateY(30deg);*/
-moz-transition:all .6s; /*让海报移动产生动画效果*/
-webkit-transition:all .6s;
}
.photo .side{
width:100%;
height:100%;
background-color:#eee;
position:absolute;
top:0;
right:0;
padding:20px;
box-sizing:border-box;
}
.photo .side-front .image{
width:100%;
height:250px;
line-height:250px;
overflow:hidden;
}
.photo .side-front .image img{
width:100%;
}
.photo .side-front .caption{
text-align:center;
font-size:16px;
line-height:50px;
}
.photo .side-back .desc{
color:#666;
font-size:14px;
line-height:1.5em;
}
/*当前选中的海报样式*/
.photo_center{
/*---实现垂直居中的方式一
left:50%;
top:50%;
margin-left:-130px;
margin-top:-160px;
*/
/*实现垂直居中的方式二*/
top:0;
bottom:0;
left:0;
right:0;
margin:auto;
z-index:999;//让当前选中的海报不被其他的海报覆盖
}
/*负责翻转*/
.photo-wrap{
position:absolute;
width:100%;
height:100%;
-moz-transform-style:perserve-3d;
-webkit-transform-style:preserve-3d;/*让里面的元素支持3d的效果*/
transform-style:preserve-3d;/*因为掉了这一句,firefox中的背面一直显示不出来*/
-webkit-transition:all 1s;
-moz-transition:all 1s;
transition:all 1s;
}
.photo-wrap .side{
-moz-backface-visibility:hidden;//当屏幕不面向屏幕时被隐藏
-webkit-backface-visibility:hidden;
backface-visibility:hidden;
}
.photo-wrap .side-front{
-moz-transform:rotateY(0deg);
-webkit-transform:rotateY(0deg);
transform:rotateY(0deg);
}
.photo-wrap .side-back{
-moz-transform:rotateY(180deg);
-webkit-transform:rotateY(180deg);
transform:rotateY(180deg);
}
.photo-front .photo-wrap{
-moz-transform:rotateY(0deg);
-webkit-transform:rotateY(0deg);
transform:rotateY(0deg);
}
.photo-back .photo-wrap{
-moz-transform:rotateY(180deg);
-webkit-transform:rotateY(180deg);
transform:rotateY(180deg);
}
/*控制按钮的样式*/
.nav{
width:40%;
height:30px;
line-height:30px;
position:absolute;
left:30%;
bottom:20px;
z-index:999;
/*background-color:#fff;*/
text-align:center;
}
/*普通样式*/
.nav .i{
width:30px;
height:30px;
display:inline-block;
cursor:pointer;
background-color:#aaa;
text-align:center;
border-radius:50%;
-moz-transform:scale(.48);
-webkit-transform:scale(.48);
transform:scale(.48);
-webkit-transition:all 1s;
-moz-transition:all 1s;
}
.nav .i:after{
}
/*当前选中样式*/
.nav .i_current{
-moz-transform:scale(1);
-webkit-transform:scale(1);
}
.nav .i_current:after{
opacity:1;
}
/*背面样式*/
.nav .i_back{
-moz-transform:rotateY(-180deg);
-webkit-transform:rotateY(-180deg);
background-color:#555;
}
/*样式优化,*/
.photo{/*定义下面的是为了消除图片突然的闪动*/
left:50%;
top:50%;
margin:-160px 0 0 -130px;
}
.photo-wrap{
-moz-transform-origin:0% 50%;
-webkit-transform-origin:0% 50%;
}
.photo-front .photo-wrap{
-moz-transform:translate(0px,0px) rotateY(0deg);
-webkit-transform:translate(0px,0px) rotateY(0deg);
}
.photo-back .photo-wrap{
-moz-transform:translate(260px,0px) rotateY(180deg);
-webkit-transform:translate(260px,0px) rotateY(180deg);
}
CSS3 3D变换与动画样式详解
以下解释样式中的关键设置。 案例中设置了-webkit-perspective:800px;以定义3D透视深度。

如图所示,设置了该属性后,再应用rotateY(45deg)即可看到3D旋转效果。若不设置-webkit-transform的透视属性,旋转效果将无法正确显示,图片仅会在平面内旋转。如果将该值设置得过小,效果将如下所示:

div.photo负责图片的平移和旋转,而div.photo-wrap负责3D翻转(即正反面切换)。为了让内部元素支持3D效果,需给div.photo-wrap设置-webkit-transform-style:preserve-3d。
在.photo-wrap .side样式中,设置-webkit-backface-visibility:hidden;的作用是当元素背面朝向屏幕时将其隐藏。
为了使图片的移动和翻转具有平滑的动画效果,给.photo-wrap设置了-webkit-transition。具体transition的解释说明如下:

导航条的CSS样式定义。
/*普通样式*/
.nav .i{
width:30px;
height:30px;
display:inline-block;
cursor:pointer;
background-color:#aaa;
text-align:center;
border-radius:50%;
-moz-transform:scale(.48);
-webkit-transform:scale(.48);
transform:scale(.48);
-webkit-transition:all 1s;
-moz-transition:all 1s;
}
/*当前选中样式*/
.nav .i_current{
-moz-transform:scale(1);
-webkit-transform:scale(1);
}
.nav .i_current:after{
opacity:1;
}
/*背面样式*/
.nav .i_back{
-moz-transform:rotateY(-180deg);
-webkit-transform:rotateY(-180deg);
background-color:#555;
}
导航条的结构分析图:

导航条的按钮同样分为正面和反面。为了区分正反面效果,分别设置了不同的背景颜色,并应用了翻转效果。将普通按钮缩小为-transform:scale(.48),当按钮被选中时设置为scale(1)。将按钮先放大再缩小的原因是为了精确判断当前按钮为选中状态时是否会影响整体样式布局。
基本样式设置完成后,对效果进行进一步优化。为了让图片在正反面切换时看起来像一扇门一样略微向右偏移,可增加如下样式:
/*样式优化,*/
.photo{/*定义下面的是为了消除图片突然的闪动*/
left:50%;
top:50%;
margin:-160px 0 0 -130px;
}
.photo-wrap{
-moz-transform-origin:0% 50%;
-webkit-transform-origin:0% 50%;
}
.photo-front .photo-wrap{
-moz-transform:translate(0px,0px) rotateY(0deg);
-webkit-transform:translate(0px,0px) rotateY(0deg);
}
.photo-back .photo-wrap{
-moz-transform:translate(260px,0px) rotateY(180deg);
-webkit-transform:translate(260px,0px) rotateY(180deg);
}
Data数据层与模板字符串替换
Data(数据层)部分

数据主要定义在data.js文件中。data.js中定义了一个data数组,用于存放22张图片的相关信息。data数组输出的结构如下:
data中存放的每个对象包含caption、desc、img这三个属性。data中存放的每个对象的具体内容输出如下。data数据部分主要用于对模板中的字符串进行动态替换。
data.js的具体内容如下:
var data = [];
var dataStr = '1、照片1<br>\
<br>\
绿色蔬菜<br>\
<br>\
<br>\
2、照片2<br>\
<br>\
照片2<br>\
<br>\
<br>\
3、照片3<br>\
<br>\
照片3<br>\
<br>\
<br>\
4、照片4<br>\
<br>\
照片4<br>\
<br>\
<br>\
5、照片5<br>\
<br>\
照片5<br>\
<br>\
<br>\
6、一只超可爱的小熊玩具<br>\
<br>\
一只超可爱的小熊玩具<br>\
<br>\
<br>\
7、照片7<br>\
<br>\
照片7<br>\
<br>\
<br>\
8、照片8<br>\
<br>\
照片8<br>\
<br>\
<br>\
9、照片9<br>\
<br>\
照片9<br>\
<br>\
<br>\
10、照片10<br>\
<br>\
照片10<br>\
<br>\
<br>\
11、照片11<br>\
<br>\
照片11<br>\
<br>\
<br>\
12、照片12<br>\
<br>\
照片12<br>\
<br>\
<br>\
13、照片13<br>\
<br>\
照片13<br>\
<br>\
<br>\
14、照片14<br>\
<br>\
照片14<br>\
<br>\
<br>\
15、照片15<br>\
<br>\
照片15<br>\
<br>\
<br>\
16、照片16<br>\
<br>\
照片16<br>\
<br>\
<br>\
17、照片17<br>\
<br>\
照片17<br>\
<br>\
<br>\
18、照片18<br>\
<br>\
照片18<br>\
<br>\
<br>\
19、照片19<br>\
<br>\
照片19<br>\
<br>\
<br>\
20、照片20<br>\
<br>\
照片20<br>\
<br>\
<br>\
21、照片21<br>\
<br>\
照片21<br>\
<br>\
<br>\
22、照片22<br>\
<br>\
照片22<br>\
';
//下面的代码是将dataStr中的内容拆分存放到data数组中
var d = dataStr.split('<br><br><br>');
for(var i = 0;i < d.length; i++) {
var c = d[i].split('<br><br>');
data.push({
img:'img'+(i+1)+'.jpg',
caption:c[0].split('、')[1],
desc:c[1]
});
}
Controller控制器逻辑与排序算法
VCD分解——Controller(控制器)部分 • 输出所有海报内容(视图模板+数据) • 位置分配控制(中央位置、两侧位置) • 控制条输出与控制(切换、翻面) 输出所有海报,遍历data数组,将内容填充到模板中。
//4.输出所有的海报
var data = data;
function addPhotos(){
var template = $('#wrap').html();
var html = [];
var nav = [];
for(s in data) {
var _html = template.replace('{{index}}',s)
.replace('{{img}}',data[s].img)
.replace('{{caption}}',data[s].caption)
.replace('{{desc}}',data[s].desc);
html.push(_html);
// 每一个海报都有一个对应的按钮
nav.push('<span id="nav_'+s+'" onclick="turn(this)" class="i" > </span>');
}
//遍历完之后进行回写
html.push('<div class="nav">'+nav.join('')+'</div>');
$('#wrap').html(html.join(''));
rsort(random([0,data.length]));
}
海报排序逻辑,分析图如下:

对左右分区的海报进行排序时,首先对左右分区海报的位置进行分析,具体如下:

利用随机数生成当前海报的left和top值。
//随机生成一个值,支持取值范围.random([min,max]);
function random(range){
var max = Math.max(range[0],range[1]);
var min = Math.min(range[0],range[1]);
var diff = max-min;
var number = Math.ceil(Math.random()*diff + min);
return number;
}
//6.计算左右分区的范围{left:{x:[min,max],y[min,max]},right{x:[min,max],y:[min,max] }}
function range() {
var ran = {
left:{
x:[],y:[]
},
right:{
x:[],y:[]
}
};
var wrap = {
w:$('#wrap').width(),
//w:600,
h:$('#wrap').height()
}
var photo = {
w:$('.photo')[0].clientWidth,
h:$('.photo')[0].clientHeight
}
ran.left.x = [0 - photo.w,wrap.w/2 - photo.w/2];
ran.left.y = [0 - photo.h,wrap.h];
ran.right.x = [wrap.w/2 + photo.w/2,wrap.w + photo.w];
ran.right.y = ran.left.y;
return ran;
}
随后即可对海报进行排序处理。
//5.排序海报
function rsort(n) {
var _photo = $('.photo');
var photos = [];
for(var i = 0;i < _photo.length;i++) {
_photo[i].className = _photo[i].className.replace(/\s*photo_center\s*/,'');
_photo[i].className = _photo[i].className.replace(/\s*photo-front\s*/,'');
_photo[i].className = _photo[i].className.replace(/\s*photo-back\s*/,'');
//因为上面把 photo-front和photo-back都替换掉了,
_photo[i].className += ' photo-front';
_photo[i].style.left = '';
_photo[i].style.top = '';
_photo[i].style['-moz-transform'] =_photo[i].style['transform'] = _photo[i].style['-webkit-transform'] = 'rotate(0deg) scale(1.3)';
photos.push(_photo[i]);
}
//var photo_center = $('#photo_'+n)[0];
var photo_center = document.getElementById('photo_'+n);
var newClass = photo_center.className + ' photo_center';
//console.log(photo_center.attr('class'));
photo_center = photos.splice(n,1)[0];
//photo_center.className = newClass;
$('#photo_'+n).attr('class',newClass);
//把剩下的海报分为两个部分
var photos_left = photos.splice(0,Math.ceil(photos.length/2));
var photos_right = photos;
var ranges = range();
//左分区排序
for(s in photos_left) {
var photo = photos_left[s];
photo.style.left = random(ranges.left.x) + 'px';
photo.style.top = random(ranges.left.y) + 'px';
photo.style['-moz-transform'] =photo.style['transform'] = photo.style['-webkit-transform'] = 'rotate('+random([-150,150])+'deg) scale(1)';
}
//右分区排序
for(s in photos_right) {
var photo = photos_right[s];
photo.style.left = random(ranges.right.x) + 'px';
photo.style.top = random(ranges.right.y) + 'px';
photo.style['-moz-transform'] =photo.style['transform'] = photo.style['-webkit-transform'] = 'rotate('+random([-100,100])+'deg) scale(1)';
}
//控制按钮的处理
var navs = $('.i');
for(var i = 0;i<navs.length;i++) {
navs[i].className = navs[i].className.replace(/\s*i_current\s*/,'');
navs[i].className = navs[i].className.replace(/\s*i_back\s*/,'');
}
//给对应当前海报的按钮增加 i_current样式
$('#nav_'+n)[0].className += ' i_current';
}
海报正反面切换及控制按钮正反面切换效果实现:
function turn(elem) {
var cls = elem.className;
var n = elem.id.split('_')[1];
if(!(/photo_center/.test(cls))) {
rsort(n);
}
//上面的if语句执行完后className被改变了,由于我没有意思到这一点
//导致总是缺少photo_center这个class
var cs = $('#photo_'+n)[0];
cls = cs.className;
if(/photo-front/.test(cls)) {
cls = cls.replace(/photo-front/,'photo-back');
$('#nav_'+n)[0].className += ' i_back';
}else {
cls = cls.replace(/photo-back/,'photo-front')
$('#nav_'+n)[0].className = $('#nav_'+n)[0].className.replace(/\s*i_back\s*/,'');
}
cs.className = cls;
}
可以根据rsort(n)中的n参数了解具体哪一张海报是当前展现的海报,也能确定哪一个按钮是当前选中的按钮。 上述即为相应的效果分析。
技术总结与常见问题排查
总结: 分析案例的思路方法: • 模块分析法 • VCD分析法 涉及的新CSS效果 • 3D视图位置设置 & 子元素3D支持 • 翻转不可见时隐藏 • 使用CSS旋转(Y轴)和位移 • CSS切换动画
前端脚本技巧 • 字符串替换的简易模板功能 • 根据范围获取随机数 • 使用脚本切换元素的className以及具体的style属性
在实现过程中,我遇到一个问题:在Firefox浏览器中进行图片翻转时,反面始终无法隐藏。经检查,发现以下代码存在问题:
.photo-wrap .side{
-moz-backface-visibility:hidden;
-webkit-backface-visibility:hidden;
backface-visibility:hidden;
}
原因是漏写了transform-style:preserve-3d。因此,在浏览器兼容性处理时,记得编写不加前缀的标准样式。
.photo-wrap{
position:absolute;
width:100%;
height:100%;
-moz-transform-style:perserve-3d;
-webkit-transform-style:preserve-3d;/*让里面的元素支持3d的效果*/
<span style="color:#cc0000;">transform-style:preserve-3d;/*因为掉了这一句,firefox中的背面一直显示不出来*/</span>
-webkit-transition:all 1s;
-moz-transition:all 1s;
transition:all 1s;
}
最后附上完整的JS代码:
<script type="text/javascript">
//随机生成一个值,支持取值范围.random([min,max]);
function random(range){
var max = Math.max(range[0],range[1]);
var min = Math.min(range[0],range[1]);
var diff = max-min;
var number = Math.ceil(Math.random()*diff + min);
return number;
}
//4.输出所有的海报
var data = data;
function addPhotos(){
var template = $('#wrap').html();
var html = [];
var nav = [];
for(s in data) {
var _html = template.replace('{{index}}',s)
.replace('{{img}}',data[s].img)
.replace('{{caption}}',data[s].caption)
.replace('{{desc}}',data[s].desc);
html.push(_html);
// 每一个海报都有一个对应的按钮
nav.push('<span id="nav_'+s+'" onclick="turn(this)" class="i" > </span>');
}
html.push('<div class="nav">'+nav.join('')+'</div>');
$('#wrap').html(html.join(''));
rsort(random([0,data.length]));
}
addPhotos()
//6.计算左右分区的范围{left:{x:[min,max],y[min,max]},right{x:[min,max],y:[min,max] }}
function range() {
var ran = {
left:{
x:[],y:[]
},
right:{
x:[],y:[]
}
};
var wrap = {
w:$('#wrap').width(),
//w:600,
h:$('#wrap').height()
}
var photo = {
w:$('.photo')[0].clientWidth,
h:$('.photo')[0].clientHeight
}
ran.left.x = [0 - photo.w,wrap.w/2 - photo.w/2];
ran.left.y = [0 - photo.h,wrap.h];
ran.right.x = [wrap.w/2 + photo.w/2,wrap.w + photo.w];
ran.right.y = ran.left.y;
return ran;
}
//5.排序海报
function rsort(n) {
var _photo = $('.photo');
var photos = [];
for(var i = 0;i < _photo.length;i++) {
_photo[i].className = _photo[i].className.replace(/\s*photo_center\s*/,'');
_photo[i].className = _photo[i].className.replace(/\s*photo-front\s*/,'');
_photo[i].className = _photo[i].className.replace(/\s*photo-back\s*/,'');
//因为上面把 photo-front和photo-back都替换掉了,
_photo[i].className += ' photo-front';
_photo[i].style.left = '';
_photo[i].style.top = '';
_photo[i].style['-moz-transform'] =_photo[i].style['transform'] = _photo[i].style['-webkit-transform'] = 'rotate(0deg) scale(1.3)';
photos.push(_photo[i]);
}
//var photo_center = $('#photo_'+n)[0];
var photo_center = document.getElementById('photo_'+n);
var newClass = photo_center.className + ' photo_center';
//console.log(photo_center.attr('class'));
photo_center = photos.splice(n,1)[0];
//photo_center.className = newClass;
$('#photo_'+n).attr('class',newClass);
//把剩下的海报分为两个部分
var photos_left = photos.splice(0,Math.ceil(photos.length/2));
var photos_right = photos;
var ranges = range();
//左分区排序
for(s in photos_left) {
var photo = photos_left[s];
photo.style.left = random(ranges.left.x) + 'px';
photo.style.top = random(ranges.left.y) + 'px';
photo.style['-moz-transform'] =photo.style['transform'] = photo.style['-webkit-transform'] = 'rotate('+random([-150,150])+'deg) scale(1)';
}
//右分区排序
for(s in photos_right) {
var photo = photos_right[s];
photo.style.left = random(ranges.right.x) + 'px';
photo.style.top = random(ranges.right.y) + 'px';
photo.style['-moz-transform'] =photo.style['transform'] = photo.style['-webkit-transform'] = 'rotate('+random([-100,100])+'deg) scale(1)';
}
//控制按钮的处理
var navs = $('.i');
for(var i = 0;i<navs.length;i++) {
navs[i].className = navs[i].className.replace(/\s*i_current\s*/,'');
navs[i].className = navs[i].className.replace(/\s*i_back\s*/,'');
}
//给对应当前海报的按钮增加 i_current样式
$('#nav_'+n)[0].className += ' i_current';
}
function turn(elem) {
var cls = elem.className;
var n = elem.id.split('_')[1];
if(!(/photo_center/.test(cls))) {
rsort(n);
}
//上面的if语句执行完后className被改变了,由于我没有意思到这一点
//导致总是缺少photo_center这个class
var cs = $('#photo_'+n)[0];
cls = cs.className;
if(/photo-front/.test(cls)) {
cls = cls.replace(/photo-front/,'photo-back');
$('#nav_'+n)[0].className += ' i_back';
}else {
cls = cls.replace(/photo-back/,'photo-front')
$('#nav_'+n)[0].className = $('#nav_'+n)[0].className.replace(/\s*i_back\s*/,'');
}
cs.className = cls;
}
</script>
以上就是本文的全部内容,希望对大家的学习有所帮助,也希望大家多多支持本站。
JS动态构建复杂嵌套JSON对象的方法
2026-11-05 15:41:52
JS负数取余为什么是负数?详解余数与模运算区别
2026-11-05 13:34:41
JS 解压出现乱码或报错怎么解决?
2026-11-05 12:32:38
JS 中 startsWith、indexOf 与正则判断字符串前缀的对比
2026-11-04 22:19:43
JS解析Excel后怎么将数据转换为JSON格式
2026-11-04 20:58:28
