最近的后台项目里须要添加新鲜事功能,简而言之就是一个带图片的评论回复系统,看了好几个相似的系统后,仍是决定仿照掘金沸点的设计,简洁并且优雅,整个模块界面基本和沸点同样,只是少了一些功能(连接和话题功能没有作)css
整个系统比较复杂,包含图片文字上传组件,emoji表情组件,一级评论,二级回复,以及二级回复要能展现图片,点赞组件,图片展现组件等。其实主要是后台比较复杂,如何有效地设计数据库表结构以及各类增删评论。本文主要介绍下图片展现模块的构思和代码编写逻辑,以下图,下图是新鲜事中的图片展现界面(缩略图),当用户在发布新鲜事时上传了图片时,下面的新鲜事组件就会展现全部图片.html
感受这个图片展现组件逻辑较多且比较复杂,也找不到别人的轮子,所以值得拿出来分析下,下面的分析只会给出关键的代码,不会给出所有代码,主要太多太复杂,理解原理就可以本身作一个数据库
首先接到这个任务第一步是仔细查看掘金沸点的图片展现组件的具体表现和逻辑(能够去沸点模块试一试),这一步须要大量测试来挖掘其中的全部逻辑状况,下面列举出该组件的一些表现
(1)若是只有一张图,则显示为大图,以下图canvas
(2)若是有多张图(最多9张),则显示为缩略图,且图片数量不一样时图片排列不一样数组
(3)当某张图长宽比超过必定阈值时,图片右下角显示长图标签bash
(4)点击任意一张缩略图,切换到详情图展现界面app
(54)点击上面图片中的查看大图按钮进入到全屏的大图查看组件ide
而后点击屏幕,切换到长图原始尺片显示界面(右侧出现滚动条),以下图函数
首先给整个组件命名(新鲜事图片展现组件),只须要一个prop即图片数组,包含每张图片的url
<message-image-viewer :imageList="imageList"></message-image-viewer>
测试
通过上面的功能分析,发现全屏图片查看这个模块能够抽象成一个单独的组件,这个组件是上面组件的子组件
<!--全屏大图组件-->
<full-screen-viewer
:imageList="imageList"
:currentImageIndex="currentImageIndex"
@close="handleFullScreenViewerClose"
v-if="isShowFullScreenViewer">
</full-screen-viewer>
复制代码
须要2个prop,首先是要展现的图片数组,其次是当前展现图片的index,由其父组件<message-image-viewer>
传入
下面是<message-image-viewer>
的整体结构
<template>
<div class="wrapper" ref="wrapper">
<!--缩略图的div-->
<div class="brief-view-wrapper" v-show="!isShowDetail" ref="outerWrapper">
<!--若是是单张图-->
<template v-if="isSingleImage">
<div class="single-img-container"
@click="showDetailImage(imageList[0])"
:style="{backgroundImage:'url('+imageList[0]+')'}">
<!--用div撑开图片-->
<div class="ratio-holder" :style="{paddingTop: calcSingleImgHeight}">
</div>
<span class="long-image" v-if="isLongImage">长图</span>
</div>
</template>
<!--多张图-->
<template v-else>
<!--图片放在backgroundImage属性中-->
<div class="multiple-img-wrapper" :class="[colsOfMultipleImages]">
<div class="multiple-img-container"
v-for="(item,index) in imageList"
@click="showDetailImage(item)"
:style="{backgroundImage:'url('+item+')'}">
<!--控制图片的高度和宽度同样,padding-top基于父元素的宽度-->
<div class="ratio-holder" style="padding-top: 100%">
</div>
<span class="long-image" v-show="isLongImageList[index]">长图</span>
</div>
</div>
</template>
</div>
<!--详情图的div-->
<div class="detail-view-wrapper" v-show="isShowDetail" ref="detailViewWrapper">
<!--顶部操做栏-->
<div class="top-panel">
<div class="panel-item" @click="hideDetailImage">
<i class="iconfont icon-zoomout icon-pos"></i>
<span>收起</span>
</div>
<div class="panel-item" @click="showFullScreenViewer">
<i class="iconfont icon-zoomin icon-pos"></i>
<span>查看大图</span>
</div>
<div class="panel-item" @click="handleImageRotate(-1)">
<!--inline-block才能旋转,inline不行-->
<i class="iconfont icon-reload icon-pos"
style="transform: rotateY(-180deg);display:inline-block;">
</i>
<span>向左旋转</span>
</div>
<div class="panel-item" @click="handleImageRotate(1)">
<i class="iconfont icon-reload icon-pos"></i>
<span>向右旋转</span>
</div>
</div>
<!--中间图片展现栏,注意须要设置高度,由于里面的img是绝对定位-->
<div class="detail-img-wrapper" :style="{height:outerDivHeight+'px'}">
<!--加载的logo-->
<div class="detail-img-loading">
<circle-loading fillColor="#9C9C9C" v-if="!isDetailImageLoaded">
</circle-loading>
</div>
<img src=""
ref="detailImage"
v-show="isDetailImageLoaded"
:style="detailImageStyle"
class="detail-img">
<!--点击隐藏详情图的div-->
<div class="toggle-zoomout" @click="hideDetailImage">
</div>
<!--上一张图片的div-->
<div class="prev-img" @click="switchImage(-1)" v-if="currentImageIndex > 0">
</div>
<div class="next-img" @click="switchImage(1)" v-if="currentImageIndex<imageList.length-1">
</div>
<!--全屏大图组件-->
<full-screen-viewer
:imageList="imageList"
:currentImageIndex="currentImageIndex"
@close="handleFullScreenViewerClose"
v-if="isShowFullScreenViewer">
</full-screen-viewer>
</div>
<!--缩略图展现栏-->
<div class="small-img-wrapper">
<div class="small-img-container"
@click="switchSmallImage(index)"
:class="{'small-img-active':currentImageIndex === index}"
:style="{backgroundImage:'url('+item+')'}"
v-for="(item,index) in imageList">
</div>
</div>
</div>
</div>
</template>
复制代码
是否是感受很复杂,其实一开始的结构也是很简单,慢慢加代码就变成如今这样,主要结构图以下
isShowDetail
进行切换,而后全屏大图组件在详情图内
下面先分析缩略图部分,缩略图就是上面所说的图片的缩略图展现的模样,由前面分析得知单张图时显示大图,多张图时显示小图,最多9张图。总体结构以下
isSingleImage
变量来控制显示单图仍是多图的缩略,它是个计算属性,当prop传入的图片张数小于等于1时为true
//是不是单张图
isSingleImage:function(){
return !(this.imageList.length>1)
},
复制代码
1张图时显示大图,这个逻辑看似简单,其实规则比较复杂,下面是我通过测试得出的单图显示规则:
(1)首先判断应该显示为竖图仍是横图,根据原图的长宽比来决定
(2)全部图片宽度width必定,变的只有高度
(3)高度/宽度超过必定值(1.8)显示长图标签,此时显示的图片的高度为宽度的1.45倍,不然按比例显示
(4)若是高度/宽度小于必定值(0.68),则缩略图外框高度高度为宽度的0.68倍,且居中显示,不然按比例显示
这些规则都是必须的,好比给一张竖直方向很长的图,那么其按上述规则显示为以下
下面是单图的显示逻辑代码
<!--若是是单张图-->
<template v-if="isSingleImage">
<div class="single-img-container"
@click="showDetailImage(imageList[0])"
:style="{backgroundImage:'url('+imageList[0]+')'}">
<!--用div撑开图片-->
<div class="ratio-holder" :style="{paddingTop: calcSingleImgHeight}">
</div>
<span class="long-image" v-if="isLongImage">长图</span>
</div>
</template>
复制代码
图片显示是用的backgroundImage
属性而没有用image标签,感受简单点,须要设置background-size:cover
以及background-position:50%
保证图片居中且div内充满图片不留白,固然图片是会被裁剪,注意这里的single-image-container
类只设置了固定的宽度200px,其高度由里面的div撑开,给ratio-holder
类动态设置padding-top,padding-top的百分比值是取的是基于父元素宽度的百分比,所以宽度必定就能够计算出对应的高度,下面给出由上述规则实现的计算div高度的函数
//单张图的高度计算
calcSingleImgHeight: function(){
let self = this;
let image = new Image();
//获取图片的原始尺寸并计算比例
//图片较大的话必须等图片加载完成才能获取尺寸
image.onload = function(){
self.singleImageNaturalWidth = image.naturalWidth;
self.singleImageNaturalHeight = image.naturalHeight;
};
image.src = this.imageList[0];
let ratio = this.singleImageNaturalWidth?this.singleImageNaturalHeight / this.singleImageNaturalWidth : 1;
if(ratio < this.imageMinHeightRatio){
ratio = this.imageMinHeightRatio
}
if(ratio > this.imageMaxHeightRatio){
// 该图是长图
this.isLongImage = true;
ratio = this.imageMaxHeightRatio
}
return ratio*100+'%';
},
复制代码
这个方法是个计算属性,计算图片的长宽比须要用到naturalWidth和naturalHeight,这2个值是图片的原始宽高,可是必须等到图片加载完成才能获取到,不然就是0,由于是计算属性,因此onload方法触发时会从新计算图片比例。
下面分析多图状况下的缩略图显示
<!--多张图-->
<template v-else>
<!--图片放在backgroundImage属性中-->
<div class="multiple-img-wrapper" :class="[colsOfMultipleImages]">
<div class="multiple-img-container"
v-for="(item,index) in imageList"
@click="showDetailImage(item)"
:style="{backgroundImage:'url('+item+')'}">
<!--控制图片的高度和宽度同样,padding-top基于父元素的宽度-->
<div class="ratio-holder" style="padding-top: 100%">
</div>
<span class="long-image" v-show="isLongImageList[index]">长图</span>
</div>
</div>
</template>
复制代码
多图状况下的显示规则:1-4张为col-4,5,6为col-3, 7,8张为col-4, 9张为col-3,col-n表明n列
下用一个v-for遍历图片数组便可,图片宽度高度相同,所以padding-top为100%,注意图片张数不一样时排列状况不一样的逻辑是经过colsOfMultipleImages
这个计算属性根据图片张数计算出对应的类名
//多图时显示的列数的类
colsOfMultipleImages:function(){
let len = this.imageList.length;
let map = {
1:'col-4',2:'col-4',3:'col-4',4:'col-4',
5:'col-3',6:'col-3',7:'col-4',8:'col-4',
9:'col-3'};
return len===0?'':map[len]
},
复制代码
其实也就2个类,经过控制其宽度保证图片计时换行排列
.col-3{
width:75%
}
.col-4{
width:100%;
}
复制代码
详情图主要涉及到图片旋转,稍微麻烦点
(1)若是图片的宽度超过外层div的宽度,则宽度为div的宽度,高度按图片比例缩放
(2)若是图片的宽度未超过外层div的宽度,则图片按原尺寸显示,图片水平居中
(3)大图加载时的loading图默认宽高是固定的,宽度为外层div的宽度,高度略小
注意若是加载一张很小的图片,仍然按原比例显示,掘金就是这么作的,以下图
下面主要分析下图片旋转功能的实现,略复杂,首先明确一点这里的图片旋转是经过css的transform的rotate进行旋转的,图片只是视觉上旋转了,本质上没有,若是本质上旋转要用canvas从新画图。
css的旋转看似很简单,好比右转90度,只须要给图片的style动态设置transform:rotate(90deg)
便可?其实否则,这样的旋转只会致使图片在原来的位置进行旋转(transform-origin默认为center,图片中心点),且图片的宽高都不变,能够想象下一张很长的图片旋转成水平方向后的状况,明显有问题,所以这里的逻辑须要动态计算图片宽高以及外层div宽高,最终结果以下动态图所示
由上图可见这种旋转其实图片宽高都有变化,不是单纯的css旋转,下面慢慢分析,html结构以下
<!--中间图片展现栏,注意须要设置高度,由于里面的img是绝对定位-->
<div class="detail-img-wrapper" :style="{height:outerDivHeight+'px'}">
<!--加载的logo-->
<div class="detail-img-loading">
<circle-loading fillColor="#9C9C9C" v-if="!isDetailImageLoaded">
</circle-loading>
</div>
<!--要展现的图片-->
<img src=""
ref="detailImage"
v-show="isDetailImageLoaded"
:style="detailImageStyle"
class="detail-img">
<!--点击隐藏详情图的div-->
<div class="toggle-zoomout" @click="hideDetailImage">
</div>
<!--上一张图片的div-->
<div class="prev-img" @click="switchImage(-1)" v-if="currentImageIndex > 0">
</div>
<div class="next-img" @click="switchImage(1)" v-if="currentImageIndex<imageList.length-1">
</div>
<!--全屏大图组件-->
<full-screen-viewer
:imageList="imageList"
:currentImageIndex="currentImageIndex"
@close="handleFullScreenViewerClose"
v-if="isShowFullScreenViewer">
</full-screen-viewer>
</div>
复制代码
上面代码中<img>
标签就是要展现的图片,因为图片须要加载,所以设置一个circle-loading组件用于展现loading效果,当点击缩略图时执行下面的函数。剩下的div都是绝对定位,注意最外层的div动态绑定了高度,这是为了根据图片高度的变化而改变外层div的高度,不然图片会溢出div
//展现详情大图
showDetailImage: function(imgUrl){
let self = this;
//设置index
this.currentImageIndex = this.imageList.indexOf(imgUrl);
//改变状态为大图加载中
this.isDetailImageLoaded = false;
//计算大图的原始尺寸
let image = this.$refs.detailImage;
image.onload = function(){
self.isDetailImageLoaded = true;
self.detailImageNaturalWidth = image.naturalWidth;
self.detailImageNaturalHeight = image.naturalHeight;
};
image.src = imgUrl;
this.isShowDetail = true;
},
复制代码
这里主要作的事就是在图片加载完成后记录当前图片的原始宽高,供后续旋转使用。下面分析下图片旋转的整个过程,首先注意到img标签添加了一个detail-img类,这个类的内容是
.detail-img{
position: absolute;
left:50%;
top:0;
transform-origin: top left;
}
复制代码
上面的css表示该图片绝对定位,不占文档空间,而且向右移动50%,变换的基本点设置在图片左上角,这样作下来的图片显示状况就以下图
此时图片的方向已经旋转正确,但仍然有一部分在div外面,所以只旋转是不行的,每次旋转还必须伴随着transform:translate进行图片平移操做,让图片从新回到div内部,对于上面的图片旋转,旋转后须要translate(0,-50%),即在x方向上不处理,y方向上移动-50%距离,这里容易弄反,该图片看着是须要水平位移,其实这已是旋转后的结果了,所以如今的水平位移就是旋转前的垂直方向上的位移,因此translateY是-50%,继续向右旋转的话translateX和translateY的值会发生变化,根据推理能够得出一个数组detailImageTranslateArray,保存这旋转时图片须要位移的百分比,下面数组中从左到右是顺时针旋转,每一项的第一个值是translateX的值,第二个是translateY的值
detailImageTranslateArray:[['-50%','0'],['0','-50%'],['-50%','-100%'],['-100%','-50%']],
复制代码
图片初始状态是上述数组的第一个值[-50%,0],所以咱们能够根据图片当前的translateX和y的值获得图片旋转后下一个状态的translateX和y的值,而后再将该值绑定到图片的style上便可完成旋转
下面函数就是处理图片旋转的逻辑,点击向左或向右按钮触发下面函数
//处理图片旋转
handleImageRotate: function(dir){
//图片加载完成才能旋转
if(!this.isDetailImageLoaded)return
// 注意旋转中心是图片的左上角(transform-origin:top left)
let angleDelta = dir === 1?90:-90;
//计算旋转后的角度
this.detailRotateAngel = (this.detailRotateAngel + angleDelta)%360;
//修正translate的值
let currentIndex;
this.detailImageTranslateArray.forEach((item,index)=>{
//找到当前的tranlate值
if(item[0]===this.detailImageTranslateX && item[1]===this.detailImageTranslateY){
currentIndex = index;
}
});
//取下一个值
let nextIndex = currentIndex+dir;
if(nextIndex === this.detailImageTranslateArray.length){
nextIndex = 0;
}else if(nextIndex === -1){
nextIndex = this.detailImageTranslateArray.length - 1;
}
//更新tranlate的值
this.detailImageTranslateX = this.detailImageTranslateArray[nextIndex][0];
this.detailImageTranslateY = this.detailImageTranslateArray[nextIndex][1];
//修正外层div的高度
this.processImageScaleInRotate();
},
复制代码
根据传入的dir参数决定旋转方向,而后计算出旋转后的角度,再计算出旋转后须要translate的值,所以,图片的旋转由data中3个值决定: detailRotateAngel,detailImageTranslateX ,detailImageTranslateY 分别表明旋转角度,x方向上的位移,y方向上的位移,最后经过计算属性将其绑定到img标签上便可
//大图的style,注意旋转的时候必须重设宽高和translate值
detailImageStyle:function(){
return {
width:this.detailImageWidth+'px',
height:this.detailImageHeight+'px',
//注意顺序:先旋转再移动
transform:'rotate('+this.detailRotateAngel+'deg)' +' '
+'translate('+this.detailImageTranslateX+','+this.detailImageTranslateY+')'
}
},
复制代码
上面的计算属性返回了一个style对象,里面的transform由旋转和平移组成,这里要注意先rotate再translate,不然会出问题,通过上面的逻辑,图片已经能够正常旋转,可是有个巨大的问题,这里的图片虽然能够旋转,可是其尺寸没有自适应,好比一张很是长的图由竖直方向变为水平方向后,其宽度必须不能超过最外层div的宽度,所以还要给图片动态绑定width和height,见上述代码
图片的宽高是2个计算属性得来的
//详情大图的高度计算
detailImageHeight:function(){
if(!this.isDetailImageLoaded){
//加载时高度固定
return this.loadingDefaultHeight
}else{
return this.processImageScaleInRotate().height
}
},
//详情大图的宽度
detailImageWidth:function(){
if(!this.isDetailImageLoaded){
//外层div的宽度
let outerDiv = this.$refs.wrapper;
let clientWidth = outerDiv?outerDiv.clientWidth:1;
return clientWidth
}else{
return this.processImageScaleInRotate().width
}
},
复制代码
这里又分为加载中和非加载状态的计算,若是图片是处于加载中,则高度固定,宽度为外层div的宽度,这也是为了美观而设置的固定值,若是图片加载完成,调用processImageScaleInRotate方法计算宽高,该方法以下
//图片旋转时从新计算详情图片的宽高
processImageScaleInRotate:function(){
//获取图片原始宽高
let nw = this.detailImageNaturalWidth,
nh = this.detailImageNaturalHeight;
//根据旋转角度来计算该图是初始状态仍是旋转过90度横竖交换的状况
let angel = this.detailRotateAngel;
//图片旋转后的宽高
let imageRotatedWidth,imageRotatedHeight;
let clientWidth = this.$refs.wrapper.clientWidth;
let ratio = nh / nw;
//是不是初始状态
let isInitialState = true;
if(angel === 90 || angel === 270 || angel === -90 || angel === -270){
isInitialState = false;
//由初始状态旋转一次的状况
if(nh > clientWidth){
imageRotatedWidth = clientWidth;
imageRotatedHeight = imageRotatedWidth / ratio;
}else{
imageRotatedWidth = nh;
imageRotatedHeight = imageRotatedWidth / ratio;
}
}else{
//旋转一次变为初始状态的状况
isInitialState = true;
if(nw > clientWidth){
imageRotatedWidth = clientWidth;
imageRotatedHeight = imageRotatedWidth * ratio;
}else{
imageRotatedWidth = nw;
imageRotatedHeight = imageRotatedWidth * ratio;
}
}
//注意这里的判断,width和height在旋转状态下容易弄反
return {
width:isInitialState?imageRotatedWidth:imageRotatedHeight,
height:isInitialState?imageRotatedHeight:imageRotatedWidth
}
},
复制代码
这个方法较为复杂,咱们能够发现不管怎么旋转,图片的[宽,高]这一组数据只可能存在2种状态,初始状态和旋转一次后的状态,这很好理解,拿一张图片操做一下就明白了,这2种状态就是由图片的旋转角度detailRotateAngel决定,当这个角度为90,270,-90,-270度时就是旋转一次后的状态,不然为初始状态,而后分别针对这2种状态计算宽高便可
初始状态的计算:首先获取到图片的原始宽高nw,nh,而后计算其比例ratio,当nw>clientWidth时,代表图片的原始宽度大于外层div的最大宽度,这时图片的宽度就是clientWidth,不然宽度是本身的原始宽度,高度的话根据ratio乘以宽度得出。这样就能保证图片的宽度不会超出div的宽度。
由初始状态旋转一次后的计算:原理同上,只不过这时须要判断的值是nh(原始高度),由于图片宽高置换了
最后return返回width和height便可,上面处理完了图片的宽高计算,还剩一个问题就是外层div的高度计算,由于图片绝对定位,若是外层div的高度不变的话,图片会溢出
<div class="detail-img-wrapper" :style="{height:outerDivHeight+'px'}">
<div>
复制代码
咱们给外层div动态绑定height,outerDivHeight是计算属性
//外层div的高度(随着图片旋转而变化)
outerDivHeight: function(){
//根据旋转角度来计算该图是初始状态仍是旋转过90度横竖交换的状况
let angel = this.detailRotateAngel;
if(angel === 90 || angel === 270 || angel === -90 || angel === -270) {
//由初始状态旋转一次的状况
return this.detailImageWidth
}else{
//初始状态
return this.detailImageHeight
}
}
复制代码
原理很简单,同上,也是根据图片的状态来计算,至此整个旋转的逻辑就结束了。
这个逻辑其实也很简单,代码以下
//计算每张图是不是长图
calcImageIsLongImage: function(){
let self = this;
//计算每张图是不是长图
this.imageList.forEach((item,index)=>{
let image = new Image();
image.onload = function(){
let ratio = image.naturalHeight / image.naturalWidth;
if(ratio > self.longImageLimitRatio){
//经过$set方法修改数组中的值
self.$set(self.isLongImageList,index,true)
}
};
image.src = item;
})
},
复制代码
该方法在mounted中调用,遍历prop传入的图片url数组,而后每张图new一个Image,在onload中获取其宽高比,若是大于阈值则设置长图数组isLongImageList中的那一项为true,最终经过span标签绝对定位于图片div中
<span class="long-image" v-show="isLongImageList[index]">长图</span>
这个组件比较简单,组件fixed定位,外层div的css以下,宽高满屏,z-index尽可能大
position: fixed;
left:0;
top:0;
width:100vw;
height:100vh;
z-index:10000;
复制代码
下图中是一张很长的图,长度有4个屏幕高度,初始状态下要求整个屏幕要可以彻底显示该图片
.img{
max-width: 100vw;
max-height: 100vh;
}
复制代码
最大宽高都是满屏,由于限制了最大高度,因此图片的高度不会超出屏幕高度,不会出现滚动条,那么如今要求点击图片后可以查看原始图片,这时候就只须要设置
.img{
max-height: none;
}
复制代码
图片没有最大高度限制,所以图片显示为原始的高度,此时若是图片高度超过屏幕高度,出现滚动条,图片也变宽为原始的宽度