什么是瀑布流组件?
区别于每一列等高的多栏布局,等宽不等高的多栏布局就是瀑布流,看起来参差不齐,给用户一种错落感,往下滑动加载内容,后面的元素会不断追加到前面高度最小的一栏下面,把空间利用率做到最大化。瀑布流常应用于移动端场景,一般是一行两图的形式使用最多。
lazada
的商品列表截图:
截图来源:www.lazada.com.ph/tag/dress/?...
什么叫在瀑布流中插坑?
瀑布流中存在的可以不只是商品,还可以是插入各种形式的广告推荐信息,比如看下面的截图
这些广告坑与商品服务不属于一个服务,一般有两种做法,一种是在服务端聚合广告坑和商品数据,把聚合好的结果返回给前端。另一种是广告坑作为异步数据,在前端发异步请求,请求到结果后,再把数据插入到瀑布流中。本文讲下第二种方案的实现与可能遇到的问题。
瀑布流实现
网上有很多文章讲如何用css实现瀑布流,用css实现比用js实现会具备更好的性能表现,但是css实现瀑布流的缺点在于无法精准计算高度,导致出现空隙过大,而且css在各种手机浏览器中、各种版本的app-webview中容易出现兼容性问题。css实现也没有js实现来得灵活,实现异步插坑有技术难度。
用js实现的计算代价比css实现大,但是足够精准、足够灵活,兼容性好。
用js计算绝对定位偏移量来实现是一种可行的方案(很多公司都采用了这种方案),不过我想了下,假如卡片元素是可折叠的(卡片高度可变),这种方案在折叠操作下,绝对定位偏移量需要重新计算,引起很多重绘重排。有些广告坑是根据已展示次数来确定是否展示的,而已展示次数是保存在客户端的,假如第一页的绝对定位偏移量在服务端ssr算好了,而在客户端需要展示广告坑,这时候就会在客户端触发重新渲染。
我讲一下flex布局结合js计算的实现方案:
如下图dom结构,布局有几栏就会有几个waterfall-column
的div,waterfall-column
样式设置flex-direction: column;
,其中的waterfall-item
就是这一栏中有多少个组件项。
html
<div class="waterfall-container">
<div
v-for="(column, columnIndex) in columns"
:key="'column' + columnIndex"
ref="$columns"
class="waterfall-column"
>
<div
v-for="itemIndex in column"
:key="'item' + itemIndex"
class="waterfall-item"
>
<slot :index="itemIndex"></slot>
</div>
</div>
</div>
css
.waterfall-container {
display: flex;
gap: 6px;
align-items: flex-start;
}
.waterfall-column {
display: flex;
flex-direction: column;
}
展示结构确定下来,接下来我们要实现计算每一栏的高度,然后把数据依次添加到每轮计算出高度最低的那一栏下面。
由于第一页的数据在ssr的时候就准备好了,因此我们要对ssr场景做一下初始化处理,第一页的数据不计算高度,依次添加到每一栏下面。
js
const initColumns = (props) => {
const result = Array.from({ length: props.columnNum }, () => [])
const len = Math.min(props.prepareNum, props.total)
for (let i = 0; i < len; i++) {
result[i % props.columnNum].push(i)
}
return result
}
上面的代码注意下,要用Array.from({ length: 3 }).map(() => [])
,使用new Array(3).fill([])
的话就会踩进js填充数组的坑。
初始化完成之后,我们要做一下判断,如果是服务端ssr,就不要往下走了。
js
const checkDom = async ($columns) => {
if (typeof window === 'undefined') {
return false
}
if (!$columns.value?.length) {
await nextTick()
}
return true
}
添加组件项的时候,我们要有一个游标记录每轮加进去的最后一个组件项索引,这样子在翻页的时候从游标记录开始添加,不需要每次都从第一个添加。
js
const addItem = async ({ cursor, props, $columns }) => {
if (cursor.value >= props.total) {
return
}
const targetIndex = findShortestColumnIndex($columns)
$columns.value[targetIndex].push(cursor.value)
cursor.value++
await nextTick()
await addItem({ cursor, props, $columns })
}
找出高度最小的栏,我们要从右往左找,因为假设两栏高度相同,这时应该添加到左栏。
js
const findShortestColumnIndex = ($columns) => {
let min = $columns.value[0].getBoundingClientRect().height
let result = 0;
for (let i = $columns.value.length - 1; i > 0; i--) {
const current = $columns.value[0].getBoundingClientRect().height
if (current <= min) {
min = current
result = i
}
}
return result
}
完整的代码实现可以翻到最下面查看
插坑的实现
js
<Waterfall :total="items.length">
<template v-slot="{ index }">
<AsyncPit v-if="items[index].isPit" />
<Item v-else />
</template>
</Waterfall>
优化:广告坑只会在特定的场景下才会展示,这里我们要把广告坑组件做成异步导入的,但是瀑布流计算高度时会统计不到异步组件的高度,导致瀑布流计算高度有误差问题,我们还需要在广告坑组件中实现一个高度计算占位
js
<template>
<component :is="Pit" v-bind="$attrs" v-on="$listeners" />
</template>
<script>
import { defineComponent, ref } from 'vue'
const Block = defineComponent({ render(h) { return h('div', { style: 'width: 100%; height: 360px;' }) } })
export default defineComponent({
name: 'AsyncPit',
setup () {
const Pit = ref(Block)
import('./Pit.vue').then((component) => {
Pit.value = component.default
})
return { Pit }
},
})
</script>
最后,这种flex+js方案的缺点,就是比较难实现下面截图形式的插坑效果:
不过,这种跨列插坑效果应该不会应用在瀑布流中,只会用在等宽等高的布局中,每种方案都有自己的优缺点,需要自行研究找到最适合自己业务场景的方案。
其它
瀑布流完整代码的实现如下:
js
<template>
<div class="waterfall-container">
<div
v-for="(column, columnIndex) in columns"
:key="'column' + columnIndex"
ref="$columns"
class="waterfall-column"
>
<div
v-for="itemIndex in column"
:key="'item' + itemIndex"
class="waterfall-item"
>
<slot :index="itemIndex"></slot>
</div>
</div>
</div>
</template>
<script>
import { defineComponent, ref, watch, nextTick } from 'vue'
export default defineComponent({
name: 'Waterfall',
props: {
total: {
type: Number,
default: 0,
},
prepareNum: {
type: Number,
default: 6,
},
columnNum: {
type: Number,
default: 2,
},
},
setup(props) {
const $columns = ref(null)
const columns = ref([])
const cursor = ref(0)
watch(() => props.total, async () => {
if (columns.value.length === 0) {
columns.value = initColumns(props)
}
const valid = await checkDom($columns)
if (!valid) {
return
}
addItem({ cursor, props, $columns })
}, { immediate: true })
return {
$columns,
columns,
}
},
})
const initColumns = (props) => {
const result = Array.from({ length: props.columnNum }, () => [])
const len = Math.min(props.prepareNum, props.total)
for (let i = 0; i < len; i++) {
result[i % props.columnNum].push(i)
}
return result
}
const checkDom = async ($columns) => {
if (typeof window === 'undefined') {
return false
}
if (!$columns.value?.length) {
await nextTick()
}
return true
}
const addItem = async ({ cursor, props, $columns }) => {
if (cursor.value >= props.total) {
return
}
const targetIndex = findShortestColumnIndex($columns)
$columns.value[targetIndex].push(cursor.value)
cursor.value++
await nextTick()
await addItem({ cursor, props, $columns })
}
const findShortestColumnIndex = ($columns) => {
let min = $columns.value[0].getBoundingClientRect().height
let result = 0;
for (let i = $columns.value.length - 1; i > 0; i--) {
const current = $columns.value[0].getBoundingClientRect().height
if (current < min) {
min = current
result = i
}
}
return result
}
</script>
<style lang="less" scoped>
.waterfall-container {
display: flex;
gap: 6px;
align-items: flex-start;
}
.waterfall-column {
display: flex;
flex-direction: column;
}
</style>
魅族pc端效果图来源:www.meizu.cn/now
管你看没看懂,关注我:github.com/brenner8023