效果预览
一、什么是瀑布流布局
瀑布流布局是现代浏览器常见布局之一,是比较流行的一种网站页面布局,视觉表现为参差不齐的多栏布局,随着页面滚动条向下滚动,这种布局还会不断加载数据块并附加至当前尾部。
瀑布流布局通常用于电商、视频、图片网站等,例如抖音、花瓣、小红书等。其优点本文不做介绍。
二、什么是虚拟列表
虚拟列表是一种优化长列表性能的手段。
它能够做到节省内存、提升页面流畅性、提升用户体验等,通俗来讲就是只在你能看见的地方渲染元素,你看不见的地方部分渲染或者不渲染。
三、分析实现方式
实现瀑布流布局+虚拟列表,首先有一个大前提,即拿到的图片数据均为已知宽高,下面我们来分析一下实现:
1. 纯css还是js?
纯css实现瀑布流有点麻烦,不管是用column-count也好grid也好flex也好反正都行,但是它无法实现虚拟列表啊,所以毙掉这种方式,另外每一个元素都有自己的位置,那就需要绝对定位,因此肯定是选择js+绝对定位的方式。
2. translate还是left top?
translate不会引起重排而left top会,还能开启硬件加速,性能肯定强于left top,translate赢麻了。
3. 纯图片还是上图下文?
举例:
本文选择后者上图下文的方式实现,因为喜欢刷抖音哈哈。
当然,实现了后者,那前者易如反掌。
4. 图片必须知宽高吗?
必须,因为瀑布流列宽是动态的,事先知道图片宽高直接省去图片load步骤,直接进行实际大小计算,直接弄好盒子让图片自己慢慢去load,当然,可以顺便加上占位或者骨架屏。
另外,这也是很合理的,用户在后台进行投稿时,在上传这一步就可以读取到宽高比例等属性,并保存在数据库中。
5. 文本是1行还是2行或者n行?
这是一个麻烦的点,如图所示:
上图下文底部的盒子中,除了几乎都带有一段长短不一的文本描述外,其余部分大多都能在UI稿上就能确定高度,而长短不一文本描述在瀑布流列宽是动态计算的情况下,会出现1行或者2行或者n行的情况,如何能在渲染该元素时事先知道在该列宽下文本描述呈现的行数,就能确定最终盒子高度。
首先,换行的条件是,文本描述的渲染像素 > 列宽 即换行。例:
- 若列宽 100px,文本描述渲染像素 101px,那就必定换1行共2行。
- 若列宽 100px,文本描述渲染像素 201px,那就必定换2行共3行,以此类推。
通常我们能够想到,事先在body或者某个角落内丢一个行内块dom,通过 innerHTML = text 赋值,随后拿offsetWidth值来与列宽比较即得到换行结果。这种方式需要频繁操作dom,必定带来性能损耗。
这里介绍另一种方式,熟悉canvas的朋友都知道,它有一个方法专门用来测量文本宽度,即measureText,几乎完美的浏览器兼容性,只要设置canvas的字体样式属性 和dom中上下使用的文字体字号一致,不操作dom,而是在内存中计算完成,就能得到相同的字体宽度(下文会可见具体使用)。
6. 什么时候才渲染真实dom?
当不存在虚拟列表时,dom元素排列如下图6-1所示:
(图6-1)
上刻线与下刻线, 是决定元素是否被渲染的重要参照,根据元素与参照的位置,我们得出以下结论:
- 情况①元素处于上刻线之上,见图索引0 1 2 3 4 6
- 情况②元素与上刻线交叉,见图索引7 5
- 情况③元素处于上刻线与下刻线之间,见图索引8 9 10 11 12 13
- 情况④元素与下刻线交叉,见图索引14 15 16
- 情况⑤元素处于下刻线之下,见图索引17 18 19
使用虚拟列表后,上述所列情况中的①⑤不会进行渲染,其余情况均为渲染。
7. 需要对虚拟列表设置startIdx和endIdx?
需要,生成位置表(下文均有介绍)后,比起直接循环整个位置表,当位置表中记录了1000条甚至更多条记录时,startIdx和endIdx的存在明确了循环区间,极大的缩短循环次数,减少页面留白时间,提升性能。
8. 上下滚动时,如何进行添加、删除dom?
不管怎么滚动,都要做两件事情。
第一件事:
根据不同的滚动方向,添加dom元素
- 滚动方向向下时,从endIdx + 1 处开始循环循环位置表到位置表尾,不断的添加dom,直到找到一个元素的位置属于情况⑤时停止添加。
- 滚动方向向上时,从startIdx - 1 处倒序循环到索引0,不断的添加dom,直到找到一个元素的位置属于情况①时停止添加。
解释一下为什么设定这样的停下的条件,结合图8-1,以滚动方向向下为例,由于添加dom是追加到最短高度的列内,由于停下追加元素的位置属于情况⑤,那么其他列此刻最后一个元素则必然位于下刻线之上或者与下刻线相交。同理滚动方向向上时可得停下调教为元素的位置属于情况①。
(图8-1)
第二件事:
循环位置表,从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。
(图8-2)
四、开始搞事情
简略的画了一下流程图:
dom结构,css部分就不贴出来了
xml
<body>
<div class="root">
<main>
<div class="water-fall-container">
<div class="container">
<div class="loading">加载中...</div>
</div>
</div>
</main>
</div>
<div class="to-top">↑</div>
</body>
第一步,生成位置表,用一张表记录所有元素对应的渲染位置,这张表是最先提供的,下面所有步骤都需要依据该表。
获取数据伪代码:
ini
const getList = () => {
return new Promise(resolve => {
const start = (page - 1) * pageSize
const nextList = data.slice(start, start + pageSize)
hasNextPage = !!nextList.length
list = page === 1 ? nextList : list.concat(nextList)
resolve(nextList)
})
}
获取数据后,根据视口宽度(或屏幕宽window.innerWidth)确定列数列宽,这里随便发挥了。
kotlin
const computeColumnWidth = () => {
// 首先计算应呈现的列数
const columnNum = getColumnNum(containerDom.offsetWidth)
const allGapLength = gap * (columnNum - 1)
columnWidth = (containerDom.offsetWidth - allGapLength) / columnNum
}
const getColumnNum = (boxWidth) => {
if (boxWidth >= 1600) return 5
else if (boxWidth >= 1200) return 4
else if (boxWidth >= 768 && boxWidth < 1200) return 3
else return 2
}
开始生成位置表,同时记录每列的总高度,注意,元素的排列是追加在最短列内的
ini
const computeDomData = (list, startRenderIdx = 0) => {
const tempDomDataList = []
for (let i = 0; i < list.length; i++) {
const param = {
idx: startRenderIdx + i,
columnIdx: 0,
width: columnWidth,
height: list[i].h * columnWidth / list[i].w,
left: 0,
top: 0,
text: testList[Math.trunc(Math.random() * 3)],
lineHeight: 78,// 根据css设置的值计算得到
}
// 排序,第一项必定是长度最短的一列
// positionList结构为 array<{columnIdx: number, columnHeight: number}>
positionList.sort((a, b) => a.columnHeight - b.columnHeight)
param.columnIdx = positionList[0].columnIdx
param.left = (param.columnIdx - 1) * (gap + columnWidth)
param.top = positionList[0].columnHeight
// css 样式表这里设置了padding: 12px,要加上
param.lineHeight = getTextLineHeightCtx.measureText(param.text).width + 24 > columnWidth ? 98 : 78
param.height += param.lineHeight
positionList[0].columnHeight += param.height + gap
tempDomDataList.push(param)
}
domDataList = domDataList.concat(tempDomDataList)
setContainerHeight()
}
这样我们就拥有了位置表,随后根据positionList中记录的最长一列高度作为容器总高
javascript
const setContainerHeight = () => {
positionList.sort((a, b) => a.columnHeight - b.columnHeight)
// 32px 是底部loading 盒子的高度
containerDom.style.height = positionList[positionList.length - 1].columnHeight + 32 + 'px'
}
接下来就要根据位置表进行渲染dom了,第一页时直接从下标0开始,同时用对象记录下已渲染dom,形成已渲染表,利用js对象key的特性,js对象是无序的,当key为数值类型时自动排序的特点,js对象就变成了"有序"的,取keys数组时最小最大值就是数组首项和末项。
ini
const renderDomByDomDataList = (startBy = 0) => {
if (!domDataList.length) return
const tempRenderMap = {}
let topIdx = startBy
let bottomIdx = startBy
// 处于这两条线之间的元素将被渲染进容器
for (let i = startBy; i < domDataList.length; i++) {
const { idx } = domDataList[i]
// 这overTopLine 为上刻线之上, underBottomLine 为下刻线之下
const { overTopLine, underBottomLine } = checkIsRender(domDataList[i])
const dom = containerDom.querySelector(`#item_${idx}`)
if (overTopLine || underBottomLine) {
dom?.remove()
continue
}
topIdx = topIdx < idx ? topIdx : idx
bottomIdx = bottomIdx < idx ? idx : bottomIdx
if (dom) {
tempRenderMap[idx] = createDom(dom, domDataList[i])
} else {
tempRenderMap[idx] = createDom(document.createElement('div'), domDataList[i])
containerDom.append(tempRenderMap[idx])
}
}
const keys = Object.keys(Object.assign(renderMap, tempRenderMap))
startIdx = +keys[0]
endIdx = +keys[keys.length - 1]
}
做到这里,这个瀑布流就初具雏形了:
然后解决一下视口宽高变化的问题,那就监听resize事件呗:
scss
// 用作回调句柄,用来解决加载下一页 和 视口宽高变化的冲突
let resizeCallback = null
// 窗口变化事件
const resizeFn = () => {
computeColumnWidth()
// 如果宽度发生变化时,若列宽是一致的不用处理
if (lastOffsetWidth !== window.innerWidth && columnWidth === domDataList[0]?.width) return
lastOffsetWidth = window.innerWidth
initPositionList()
domDataList = []
renderMap = {}
computeDomData(list, 0)
renderDomByDomDataList(0)
}
// 窗口变化事件
const resize = window.debounce(() => {
// 加载数据时发生了视口变化,保存回调
if (isLoadNextPage) {
resizeCallback = resizeFn
return
}
resizeFn()
}, 150)
window.addEventListener('resize', resize)
看下成果:
还剩一件重要的事,滚动监听,那就来呗:
ini
const handleScroll = window.throttle(async () => {
scrollDirection = waterfallcontainerDom.scrollTop - lastScrollNumY >= 0 ? 1 : -1
lastScrollNumY = waterfallcontainerDom.scrollTop
updateDomPosition(scrollDirection)
if (isLoadNextPage || !hasNextPage) return false
// 一般我喜欢滚动条滚动到距离底部还剩15%距离处时触发分页,这个距离自由发挥了
if (waterfallcontainerDom.scrollTop + waterfallcontainerDom.offsetHeight >= waterfallcontainerDom.scrollHeight * 0.85) {
isLoadNextPage = true
page += 1
const list = await getList()
isLoadNextPage = false
// 加载数据期间发生了视口变化时,执行一次回调
if (resizeCallback) {
resizeCallback()
resizeCallback = null
} else {
// 节点信息排列完毕后进行渲染
const startIdx = (page - 1) * pageSize
computeDomData(list, startIdx)
renderDomByDomDataList(startIdx)
}
}
}, 150)
waterfallcontainerDom.addEventListener('scroll', handleScroll)
看下效果:
(加载下一页)
(下一页时宽度变化)
这些效果组合在一起,就是本文开头预览中的样子了。
结尾
源码地址奉上 戳这里
首先感谢各位大佬的阅读,其实本文重点部分在于讲述思想设计上,中国有句古话:读书破万卷下笔如有神,代码都是那些代码,可思想设计却千变万化,写代码前有一个好的思想设计才能事半功倍,不然在工位上敲了半天,到头来搞得晕头转向的,还不能按时下班哈哈,祝愿大家写代码前都能文思泉涌下笔如有神。
本文的观点可能有所错漏,还请各位大佬海涵,若能给予指正那就感激不尽啦,另外,本文中的是样例使用原生js实现的,如果真的有需要,后续会补充vue和react版本到仓库中。