之前写过一篇文章 瀑布流最佳实现方案 - 掘金 (juejin.cn) 好像大家对这种超级简单的实现方案不太认可;那我就只能来手复杂度和性能拉满的操作,没错!就是熟知的虚拟列表+传统式定位排列节点。
完整代码
老样子,先上代码,后再做拆解
- 预览地址 (需要
js
版的直接右键查看源代码即可)
实现思路
1. 数据来源必要字段
图片的宽高是必要的吗?答案是,因为是对整个页面的节点尺寸进行精确计算,所以宽高的信息必不可少;其次就是宽度在load(next)
方法传入中时,确保这个时候所有的宽度都是想同的,因为内部不做比例计算,而是只做了对高度的比例计算;所以在假数据中我把宽度全部按比例处理成同一个值。
2. 瀑布流数据组装方案
计算瀑布流的两个核心数组变量
/**
* 节点数据列表
* @type {Array<{
* index: number
* columnIndex: number
* width: number
* height: number
* imgHeight: number
* left: number
* top: number
* content: string
* url: string
* }>}
*/
let domDataList = [];
/**
* 瀑布流列数据
* @type {Array<{ index: number, height: number }>}
*/
let columnData = [];
动态列的计算方式和我之前那篇文章一致,给定一个宽度与列数的配置数组,然后监听元素的尺寸的变化进行计算即可;和之前写过的实现方案不同的是:这里数据渲染的列数只有一个,那么怎么去标记每个数据项是属于第几列呢?这里还是参考原来实现的思路,只不过是将列数据columnData
和渲染节点的数据domDataList
分开,通过每次添加时,循环去拿到高度最低的那个列的索引columnData[0].height
,最后设置进渲染列表即可。
3. 滚动监听
核心功能!!!在滚动时,需要遍历指定范围(视口中)的domDataList
,如果超出的节点就将它们移除,这样就达到了页面元素永远只有视口范围的可视数量;至于为什么不全部遍历,是因为在上千条数据时,遍历的性能会明显下降,所以在计算视口范围的条数时,尤其重要。
在开发者模式中可以看到 dom 的处理和市面上做法不同,我这里的移除只是视觉上移除,并不是将节点删除,而是通过设置style="display: none"
去实现,这样有个好处就是不用频繁创建和删除 dom ,因为频繁的删除和创建节点时,在手机上看图片会有闪烁的情况,同时在更新数据的时候也不是直接全部赋值,而是判断当前已有值是不是跟待设置的一样,如果一样则跳过处理,做到了节点的精准更新。所以这种方案才是最优的处理方式;更重要的是,在计算最大遍历次数时,只需要根据当前节点的数量去遍历即可,减少了计算的复杂度。
4. 程序设计
- 这里我将封装成一个函数,可在任意场景下调用;
- 不同的布局场景下,修改的地方有
setElement()
和updateDomDataList()
这两处函数,因为节点布局的不同,所以没办法写成通用的操作; - 文本的计算同理,具体看
textInfo
的实现,计算动态文本的操作不写进waterfallVirtual()
方法里面的原因是有些文字节点高度是固定的,所以这里分开处理;
函数的传参在类型声明文件中已经写得很明细了,这里就不做补充,具体看代码调用部分
/** 瀑布流类型 */
namespace WaterfallVirtual {
/** 函数传参 */
export interface Params {
/** 瀑布流列表节点 */
el: HTMLElement | string
/** 指定监听滚动的节点,不传则默认监听`window`的滚动操作 */
scrollEl?: HTMLElement
/** 容器之间的间距,默认`10` */
gap?: number
/** 是否关闭实时监听元素变动并更新布局,关闭时,由开发者自行决定调用`mutation`来进行更新 */
notMutation?: boolean
/** 容器宽度与列数配置 */
columns: Array<Column>
/** 加载数据回调,返回一个调用函数`next` */
load: (
/**
* 只有调用该函数才会执行输出节点操作
* @param list 由外部添加的列表数据
*/
next: (list: Array<Row>) => void
) => void
/** 元素挂载钩子函数 */
mounted?: (el: HTMLElement) => void
}
interface Column {
/** 最小的匹配宽度 */
minWidth: number
/** `column`的最小值为`2`,小于`2`的将被过滤掉 */
column: number
}
/** 外部传入的基础数据类型 */
export interface Row {
id: number
/** 图片地址 */
url: string
/** 文本信息 */
content: string
/** 图片的宽度 */
width: number
/** 图片的高度 */
height: number
}
/** 节点布局相关数据 */
export interface DomData {
index: number
columnIndex: number
width: number
/**
* 节点的高度
* - 注意需要在`updateDomDataList`方法中,根据布局动态计算
*/
height: number
imgHeight: number
left: number
top: number
content: string
url: string
}
}
function waterfallVirtual(params: WaterfallVirtual.Params) {}
调用片段
const waterfall = waterfallVirtual({
el: ".layout .list",
gap: 12,
columns: [
{ minWidth: 1600, column: 5 },
{ minWidth: 1200, column: 4 },
{ minWidth: 780, column: 3 },
{ minWidth: 500, column: 2 },
],
async load(next) {
if (!state.hasMore) {
const isConfirm = confirm("当前数据已全部加载完成,是否重新开始?");
if (isConfirm) {
waterfall.reset();
onReset();
}
}
if (state.loading) return;
state.loading = true;
const res = await getPicList(state.pageInfo);
state.loading = false;
state.pageInfo.page++;
state.hasMore = state.pageInfo.page * state.pageInfo.size < res.data.total;
next(res.data.list);
},
mounted(el) {
// console.log("元素挂载 >>", el, data);
el.addEventListener("click", function() {
const info = {
index: el.dataset["index"],
column: el.dataset["column"]
}
console.log(el.id, totalData[info.index!]);
});
}
});