效果预览
vue2.0瀑布流+虚拟列表效果预览
目录
一、什么是瀑布流布局
瀑布流布局是现代浏览器常见布局之一,是比较流行的一种网站页面布局,视觉表现为参差不齐的多栏布局,随着页面滚动条向下滚动,这种布局还会不断加载数据块并附加至当前尾部。
瀑布流布局通常用于电商、视频、图片网站等,例如抖音、花瓣、小红书等。其优点本文不做介绍。
二、什么是虚拟列表
虚拟列表是一种优化长列表性能的手段。
它能够做到节省内存、提升页面流畅性、提升用户体验等,通俗来讲就是只在你能看见的地方渲染元素,你看不见的地方部分渲染或者不渲染。
三、需求前提
实现瀑布流布局+虚拟列表,首先我的这个案例有一个大前提,即拿到的图片数据均为已知宽高!
1. 纯css还是js?
纯css实现瀑布流有点麻烦,不管是用column-count也好grid也好flex也好反正都行,但是它无法实现虚拟列表啊,所以毙掉这种方式,另外每一个元素都有自己的位置,那就需要绝对定位,因此肯定是选择js+绝对定位的方式。
还有,用原生js实现不会受到框架的限制,方便进行拓展。
2. translate还是left top?
translate不会引起重排而left top会,还能开启硬件加速,性能肯定强于left top,translate赢麻了。
3.什么时候才渲染真实dom?
当不存在虚拟列表时,dom元素排列如下图所示:
上刻线与下刻线, 是决定元素是否被渲染的重要参照,根据元素与参照的位置,我们得出以下结论:
- 情况①元素处于上刻线之上,见图索引0 1 2 3 4 6
- 情况②元素与上刻线交叉,见图索引7 5
- 情况③元素处于上刻线与下刻线之间,见图索引8 9 10 11 12 13
- 情况④元素与下刻线交叉,见图索引14 15 16
- 情况⑤元素处于下刻线之下,见图索引17 18 19
使用虚拟列表后,上述所列情况中的①⑤不会进行渲染,其余情况均为渲染。
4. 需要对虚拟列表设置startIdx和endIdx?
需要,生成位置表(下文均有介绍)后,比起直接循环整个位置表,当位置表中记录了1000条甚至更多条记录时,startIdx和endIdx的存在明确了循环区间,极大的缩短循环次数,减少页面留白时间,提升性能。
5. 上下滚动时,如何进行添加、删除dom?
不管怎么滚动,都要做两件事情。
第一件事:
根据不同的滚动方向,添加dom元素
- 滚动方向向下时,从endIdx + 1 处开始循环位置表到位置表尾,不断的添加dom,直到找到一个元素的位置属于情况⑤时停止添加。
- 滚动方向向上时,从startIdx - 1 处倒序循环到索引0,不断的添加dom,直到找到一个元素的位置属于情况①时停止添加。
第二件事:
循环位置表,从startIdx至endIdx(如图6-1中的5与16),将对应索引元素的位置与上述第6点中提到的情况①⑤进行比较, 此时会出现:
- 属于情况①⑤,检查已渲染表中是否 含有 该项,有即删除。
- 不属于情况①⑤,检查已渲染表中是否 没有 该项,无则添加。
循环结束后更新startIdx、endIdx、已渲染表,下次发生滚动事件时继续重复这套逻辑。
解释下为何不直接循环已渲染表进行元素的删除?如果是这样,那就会出现一个bug:如图8-2所示,虚线框为上次视口位置,实线框为当前视口位置,如果按照直接循环已渲染表的方式,那么就只有删除dom这一种情况,于是图中索引6的dom应被删除,结束后更新startIdx、endIdx、已渲染表,那么如果此刻向上滚动,回到上次视口位置,startIdx将从5开始0结束进行寻找,索引为6的dom明明满足,却没有执行添加dom,造成了该位置缺失dom,从而形成了bug。
流程图:
四、源码
页面vue文件代码如下:
<template>
<div class="container">
<div class="water-fall-container">
<div class="box">
<div class="loading">加载中...</div>
</div>
</div>
<div class="to-top" @click="onclick">↑</div>
</div>
</template>
<script>
import WHList from './data'
import './debounce'
import'./throttle'
export default {
name:'WaterfallVirtual',
data(){
return{
waterfallContainerDom:'',
containerDom:'',
loadingDom:'',
canvas:'',
getTextLineHeightCtx:'',
list:[],
page:1,
pageSize:50,
hasNextPage:true,
gap:16,
columnWidth:0,
containerTop:0,
domDataList:[],
positionList:[],
renderMap:{},
startIdx:0,
endIdx:0,
screenOffset:'',// 偏移量
isLoadNextPage:false,// 是否加载下一页数据
testList:[
'《蜡笔小新》是一部于1992年出品的日本家庭搞笑动画片,该片主要由本乡满、原惠一、武藤裕治导演,日本朝日电视台于1992年4月13日播映了第一集。至今仍在播出。',
'看过蜡笔小新的人,都知道,他有一个很逗的老爸——野原广志。 这位胡须浓密、面条脸的野原广志先生是一名普通的上班族,在车上享受着和周围女子相互挤攘的感觉(偶尔旁边是大叔也很囧)。',
'脚臭的广志,小气的美伢,淘气的小新……',
],
imgList:[
"https://img2.baidu.com/it/u=3600821550,221281285&fm=253&fmt=auto&app=120&f=JPEG?w=889&h=500",
"https://img0.baidu.com/it/u=2506471502,1373494428&fm=253&fmt=auto&app=120&f=JPEG?w=530&h=500",
"https://img2.baidu.com/it/u=824566914,3863846826&fm=253&fmt=auto&app=120&f=JPEG?w=800&h=500"
],
resizeCallback:null,
lastOffsetWidth:'',
lastScrollNumY:0, // 上次滚动距离Y
lastScrollNumX :0,// 上次滚动距离X
scrollDirection:1,// 上次滚动方向 向下 为 1,向上为 -1
}
},
methods:{
getList(){// 获取数据
return new Promise(resolve => {
const start = (this.page - 1) * this.pageSize
const nextList = WHList.slice(start, start + this.pageSize)
this.hasNextPage = !!nextList.length
this.list = this.page === 1 ? nextList : this.list.concat(nextList)
setTimeout(() => {
resolve(nextList)
}, this.page === 1 ? 0 : 2000) // 模拟发送请求
})
},
computeDomData(list, startRenderIdx = 0){// 计算数据形成 排序表
const tempDomDataList = []
for (let i = 0; i < list.length; i++) {
const param = {
idx: startRenderIdx + i,
img:this.imgList[Math.trunc(Math.random() * 3)],
columnIdx: 0,
width: this.columnWidth,
height: list[i].h * this.columnWidth / list[i].w,
left: 0,
top: 0,
text: this.testList[Math.trunc(Math.random() * 3)],
lineHeight: 74,// 根据css设置的值计算得到
}
// 排序,第一项必定是长度最短的一列
this.positionList.sort((a, b) => a.columnHeight - b.columnHeight)
param.columnIdx = this.positionList[0].columnIdx
param.left = (param.columnIdx - 1) * (this.gap + this.columnWidth)
param.top = this.positionList[0].columnHeight
const canvas = document.createElement('canvas')
this.getTextLineHeightCtx = canvas.getContext('2d')
this.getTextLineHeightCtx.font = '16px sans-serif'
// css 样式表设置了 纵坐标的12px内边距,要加上
param.lineHeight = this.getTextLineHeightCtx.measureText(param.text).width + 24 > this.columnWidth ? 98 : 78
param.height += param.lineHeight
this.positionList[0].columnHeight += param.height + this.gap
tempDomDataList.push(param)
}
this.domDataList = this.domDataList.concat(tempDomDataList)
// 设置容器高度
this.positionList.sort((a, b) => a.columnHeight - b.columnHeight)
this.containerDom.style.height = this.positionList[this.positionList.length - 1].columnHeight + 32 + 'px'
},
renderDomByDomDataList(startRenderIdx = 0){// 根据元素列表进行渲染
if (!this.domDataList.length) return
const tempRenderMap = {}
let topIdx = startRenderIdx
let bottomIdx = startRenderIdx
// 处于这两条线之间的元素将被渲染进容器
for (let i = startRenderIdx; i < this.domDataList.length; i++) {
const { idx } = this.domDataList[i]
const { overTopLine, underBottomLine } = this.checkIsRender(this.domDataList[i])
const dom = this.containerDom.querySelector(`#item_${idx}`)
if (overTopLine || underBottomLine) {
dom?.remove()
continue
}
topIdx = topIdx < idx ? topIdx : idx
bottomIdx = bottomIdx < idx ? idx : bottomIdx
if (dom) {
tempRenderMap[idx] = this.createDom(dom, this.domDataList[i])
} else {
tempRenderMap[idx] = this.createDom(document.createElement('div'), this.domDataList[i])
this.containerDom.append(tempRenderMap[idx])
}
}
const keys = Object.keys(Object.assign(this.renderMap, tempRenderMap))
this.startIdx = +keys[0]
this.endIdx = +keys[keys.length - 1]
},
checkIsRender(params){// 计算元素是否符合渲染条件
const { top, height } = params
const y = top + height + this.containerTop
// 1个视口的数据再快速滚动滚动条时大概率会有加载项,不妨扩大到上下各0.5个视口,共2个视口内的数据,这样就比较丝滑了,这里也是自由发挥
const topLine = this.waterfallContainerDom.scrollTop - this.screenOffset
const bottomLine = this.waterfallContainerDom.scrollTop + this.waterfallContainerDom.offsetHeight + this.screenOffset
// 是否在上线之上
const overTopLine = topLine > y
// 是否在下线之下
const underBottomLine = top > bottomLine
return{
overTopLine,
underBottomLine,
}
},
createDom(dom, param){// 创建瀑布流每一项 dom元素
dom.classList.add('waterfall-item')
dom.style.width = param.width + 'px'
dom.style.height = param.height + 'px'
dom.style.transform = `translate(${param.left}px, ${param.top}px)`
dom.id = `item_${param.idx}`
// <div class="main">${param.idx}</div>
// <div class="main">${param.idx}</div>
dom.innerHTML = `
<image class="main" src="${param.img}" alt=""/>
<div class="footer" style="height: ${param.lineHeight}px">
<div class="text">${param.idx}--${param.text}</div>
<div class="info">@脆脆土豆条 -《蜡笔小新》</div>
</div>`
return dom
},
getColumnNum(boxWidth){// 根据容器宽度获取显示列数(自由发挥)
if (boxWidth >= 1600) return 5
else if (boxWidth >= 1200) return 4
else if (boxWidth >= 768 && boxWidth < 1200) return 3
else return 2
},
computeColumnWidth(){// 计算瀑布流每一列列宽
// 首先计算应呈现的列数
const columnNum = this.getColumnNum(window.innerWidth)
const allGapLength = this.gap * (columnNum - 1)
this.columnWidth = (this.containerDom.offsetWidth - allGapLength) / columnNum
},
initPositionList(){// 重置瀑布流每一列数据
this.positionList = []
// 首先计算应呈现的列数
for (let i = 0; i < this.getColumnNum(window.innerWidth); i++) {
this.positionList.push({
columnIdx: i + 1,
columnHeight: 0
})
}
},
updateDomPosition(direction = 1){// 当滚动条滚动时,更新容器内的 每一项 元素是 插入 还是 删除
const tempRenderMap = {}
console.log(this,'updateDomPosition',this.endIdx)
for (let i = this.startIdx; i <= this.endIdx; i++) {// 检查已渲染列表中的元素,不符合条件删除元素,反之插入元素
if(!this.domDataList[i]) return
const { overTopLine, underBottomLine } = this.checkIsRender(this.domDataList[i])
if (overTopLine || underBottomLine) {
this.renderMap[i]?.remove()
} else if (this.renderMap[i]) {
tempRenderMap[i] = this.renderMap[i]
} else {
tempRenderMap[i] = this.createDom(document.createElement('div'), this.domDataList[i])
this.containerDom.append(tempRenderMap[i])
}
}
// 向上
if (direction < 0) {
for (let i = this.startIdx - 1; i >= 0; i--) {
const { overTopLine } = this.checkIsRender(this.domDataList[i])
if (overTopLine) break
tempRenderMap[i] = this.createDom(document.createElement('div'), this.domDataList[i])
this.containerDom.append(tempRenderMap[i])
}
} else { // 向下
for(let i = this.endIdx + 1; i < this.domDataList.length; i++) {
const { underBottomLine } = this.checkIsRender(this.domDataList[i])
// 只要找到Bottom在下线之下的立即停止
if (underBottomLine) break
tempRenderMap[i] = this.createDom(document.createElement('div'), this.domDataList[i])
this.containerDom.append(tempRenderMap[i])
}
}
this.renderMap = tempRenderMap
const keys = Object.keys(this.renderMap)
this.startIdx = +keys[0]
this.endIdx = +keys[keys.length - 1]
},
resizeFn(){
this.computeColumnWidth()
// 如果宽度发生变化时,若列宽是一致的不用处理
if (this.lastOffsetWidth !== window.innerWidth && this.columnWidth === this.domDataList[0]?.width) return
this.lastOffsetWidth = window.innerWidth
this.initPositionList()
this.domDataList = []
this.renderMap = {}
this.computeDomData(this.list, 0)
this.renderDomByDomDataList(0)
},
resize:window.debounce(function(){// 窗口变化事件
console.log('resize')
if (this.isLoadNextPage) {// 加载数据时发生了视口变化,保存回调
this.resizeCallback = this.resizeFn()
return
}
this.resizeFn()
}, 150),
handleScroll:window.throttle(async function(){// 窗口滚动事件
this.waterfallContainerDom.scrollTop >= window.innerHeight ? this.gotoTopDom.classList.add('active') : this.gotoTopDom.classList.remove('active')
this.scrollDirection = this.waterfallContainerDom.scrollTop - this.lastScrollNumY >= 0 ? 1 : -1
this.lastScrollNumY = this.waterfallContainerDom.scrollTop
this.updateDomPosition(this.scrollDirection)
if (this.isLoadNextPage || !this.hasNextPage) return false
if (this.waterfallContainerDom.scrollTop + this.waterfallContainerDom.offsetHeight >= this.waterfallContainerDom.scrollHeight * 0.85) {
this.isLoadNextPage = true
this.loadingDom.classList.add('active')
this.page += 1
const list = await this.getList()
this.isLoadNextPage = false
this.loadingDom.classList.remove('active')
// 加载数据期间发生了视口变化时,执行一次回调
if (this.resizeCallback) {
this.resizeCallback()
this.resizeCallback = null
} else {
// 节点信息排列完毕后进行渲染
const startIdx = (this.page - 1) * this.pageSize
this.computeDomData(list, startIdx)
this.renderDomByDomDataList(startIdx)
}
}
}, 150),
onclick(){// 渠道顶部
this.waterfallContainerDom.scrollTo({
left: 0,
top: 0,
behavior: "smooth"
})
},
async getData(){
this.computeDomData(await this.getList(), 0)
this.renderDomByDomDataList(0)// 节点信息排列完毕后进行渲染
}
},
mounted() {
this.waterfallContainerDom = document.querySelector('.water-fall-container')
this.screenOffset =this.waterfallContainerDom.offsetHeight / 2
this.containerDom = document.querySelector('.box')
this.loadingDom = document.querySelector('.loading')
this.gotoTopDom = document.querySelector('.to-top')
this.lastOffsetWidth = window.innerWidth
this.waterfallContainerDom.addEventListener('scroll', ()=>{// 添加滚动事件监听器
console.log('滚动事件触发');
this.handleScroll()
});
window.addEventListener('resize', ()=>{// 添加滚动事件监听器
console.log('视窗大小变化');
this.resize()
});
this.computeColumnWidth()
this.initPositionList()
this.getData()
},
created(){
this.$bus.emit('title', '虚拟列表+瀑布流');
},
}
</script>
<style lang="less">
#app{
width: 100%;
height: 100vh;
display: flex;
}
html{
overflow: hidden;
}
.container{
height: 100%;
flex-grow: 1;
flex-shrink: 0;
padding-top: 0px;
}
.to-top{
position: fixed;
right: 40px;
bottom: 40px;
cursor: pointer;
transform: scale(0);
transition: transform .15s;
width: 60px;
height: 60px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
background-color: #f8f8f8;
color: tomato;
font-size: 32px;
}
.to-top.active{
transform: scale(1);
}
.loading{
height: 32px;
position: absolute;
bottom: 0;
left: 0;
width: 100%;
display: flex;
align-items: center;
justify-content: center;
font-size: 20px;
opacity: 0;
transition: all .15s;
}
.loading.active{
opacity: 1;
}
.header{
height: 80px;
background-color: #aaa;
}
.water-fall-container::-webkit-scrollbar{
width: 8px;
background-color: #eee;
}
.water-fall-container::-webkit-scrollbar-thumb{
background-color: #bbb;
border-radius: 4px;
}
.water-fall-container::-webkit-scrollbar-thumb:hover{
background-color: #aaa;
}
.water-fall-container{
padding: 20px;
height: calc(100% - 130px);
overflow-y: scroll;
overflow-x: hidden;
}
.box{
position: relative;
width: 100%;
}
.waterfall-item{
position: absolute;
transition: all .12s;
font-family: sans-serif;
display: flex;
flex-direction: column;
}
.main{
flex-grow: 1;
flex-shrink: 0;
background-color: pink;
border-top-left-radius: 8px;
border-top-right-radius: 8px;
object-fit: contain;
}
.footer{
box-sizing: border-box;
padding: 12px;
background-color: darksalmon;
border-bottom-left-radius: 8px;
border-bottom-right-radius: 8px;
}
.info{
font-size: 14px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.text{
overflow: hidden;
text-overflow: ellipsis;
display: -webkit-box;
-webkit-line-clamp: 2;
line-clamp: 2;
-webkit-box-orient: vertical;
font-size: 16px;
line-height: 24px;
margin-bottom: 10px;
letter-spacing: 0;
}
</style>
debounce文件内容如下
var FUNC_ERROR_TEXT = 'Expected a function';
var NAN = 0 / 0;
var symbolTag = '[object Symbol]';
var reTrim = /^\s+|\s+$/g;
var reIsBadHex = /^[-+]0x[0-9a-f]+$/i;
var reIsBinary = /^0b[01]+$/i;
var reIsOctal = /^0o[0-7]+$/i;
var freeParseInt = parseInt;
var freeGlobal = typeof global == 'object' && global && global.Object === Object && global;
var freeSelf = typeof self == 'object' && self && self.Object === Object && self;
var root = freeGlobal || freeSelf || Function('return this')();
var objectProto = Object.prototype;
var objectToString = objectProto.toString;
var nativeMax = Math.max,
nativeMin = Math.min;
var now = function() {
return root.Date.now();
};
function debounce(func, wait, options) {
var lastArgs,
lastThis,
maxWait,
result,
timerId,
lastCallTime,
lastInvokeTime = 0,
leading = false,
maxing = false,
trailing = true;
if (typeof func != 'function') {
throw new TypeError(FUNC_ERROR_TEXT);
}
wait = toNumber(wait) || 0;
if (isObject(options)) {
leading = !!options.leading;
maxing = 'maxWait' in options;
maxWait = maxing ? nativeMax(toNumber(options.maxWait) || 0, wait) : maxWait;
trailing = 'trailing' in options ? !!options.trailing : trailing;
}
function invokeFunc(time) {
var args = lastArgs,
thisArg = lastThis;
lastArgs = lastThis = undefined;
lastInvokeTime = time;
result = func.apply(thisArg, args);
return result;
}
function leadingEdge(time) {
// Reset any `maxWait` timer.
lastInvokeTime = time;
// Start the timer for the trailing edge.
timerId = setTimeout(timerExpired, wait);
// Invoke the leading edge.
return leading ? invokeFunc(time) : result;
}
function remainingWait(time) {
var timeSinceLastCall = time - lastCallTime,
timeSinceLastInvoke = time - lastInvokeTime,
result = wait - timeSinceLastCall;
return maxing ? nativeMin(result, maxWait - timeSinceLastInvoke) : result;
}
function shouldInvoke(time) {
var timeSinceLastCall = time - lastCallTime,
timeSinceLastInvoke = time - lastInvokeTime;
// Either this is the first call, activity has stopped and we're at the
// trailing edge, the system time has gone backwards and we're treating
// it as the trailing edge, or we've hit the `maxWait` limit.
return (lastCallTime === undefined || (timeSinceLastCall >= wait) ||
(timeSinceLastCall < 0) || (maxing && timeSinceLastInvoke >= maxWait));
}
function timerExpired() {
var time = now();
if (shouldInvoke(time)) {
return trailingEdge(time);
}
// Restart the timer.
timerId = setTimeout(timerExpired, remainingWait(time));
}
function trailingEdge(time) {
timerId = undefined;
// Only invoke if we have `lastArgs` which means `func` has been
// debounced at least once.
if (trailing && lastArgs) {
return invokeFunc(time);
}
lastArgs = lastThis = undefined;
return result;
}
function cancel() {
if (timerId !== undefined) {
clearTimeout(timerId);
}
lastInvokeTime = 0;
lastArgs = lastCallTime = lastThis = timerId = undefined;
}
function flush() {
return timerId === undefined ? result : trailingEdge(now());
}
function debounced() {
var time = now(),
isInvoking = shouldInvoke(time);
lastArgs = arguments;
lastThis = this;
lastCallTime = time;
if (isInvoking) {
if (timerId === undefined) {
return leadingEdge(lastCallTime);
}
if (maxing) {
// Handle invocations in a tight loop.
timerId = setTimeout(timerExpired, wait);
return invokeFunc(lastCallTime);
}
}
if (timerId === undefined) {
timerId = setTimeout(timerExpired, wait);
}
return result;
}
debounced.cancel = cancel;
debounced.flush = flush;
return debounced;
}
function isObject(value) {
var type = typeof value;
return !!value && (type == 'object' || type == 'function');
}
function isObjectLike(value) {
return !!value && typeof value == 'object';
}
function isSymbol(value) {
return typeof value == 'symbol' ||
(isObjectLike(value) && objectToString.call(value) == symbolTag);
}
function toNumber(value) {
if (typeof value == 'number') {
return value;
}
if (isSymbol(value)) {
return NAN;
}
if (isObject(value)) {
var other = typeof value.valueOf == 'function' ? value.valueOf() : value;
value = isObject(other) ? (other + '') : other;
}
if (typeof value != 'string') {
return value === 0 ? value : +value;
}
value = value.replace(reTrim, '');
var isBinary = reIsBinary.test(value);
return (isBinary || reIsOctal.test(value))
? freeParseInt(value.slice(2), isBinary ? 2 : 8)
: (reIsBadHex.test(value) ? NAN : +value);
}
window.debounce = debounce;
thorttle文件代码如下
var FUNC_ERROR_TEXT = 'Expected a function';
function throttle(func, wait, options) {
var leading = true,
trailing = true;
if (typeof func != 'function') {
throw new TypeError(FUNC_ERROR_TEXT);
}
if (isObject(options)) {
leading = 'leading' in options ? !!options.leading : leading;
trailing = 'trailing' in options ? !!options.trailing : trailing;
}
return window.debounce(func, wait, {
'leading': leading,
'maxWait': wait,
'trailing': trailing
});
}
function isObject(value) {
return typeof value === 'object' && value!== null;
}
window.throttle = throttle;
data文件代码如下(这里我的是随机生成的,宽高可自行修改)
const WHList1 = [
{
"w": 600,
"h": 600
},
{
"w": 600,
"h": 1067
},
{
"w": 600,
"h": 600
},
{
"w": 600,
"h": 1067
},
{
"w": 600,
"h": 800
},
{
"w": 600,
"h": 1067
},
{
"w": 600,
"h": 800
},
{
"w": 600,
"h": 600
},
{
"w": 600,
"h": 700
},
{
"w": 600,
"h": 600
},
{
"w": 600,
"h": 1067
},
{
"w": 600,
"h": 700
},
{
"w": 600,
"h": 700
},
],
let WHList = WHList1.concat(WHList2)
export default WHList