实现会挖坑的瀑布流组件

什么是瀑布流组件?

区别于每一列等高的多栏布局,等宽不等高的多栏布局就是瀑布流,看起来参差不齐,给用户一种错落感,往下滑动加载内容,后面的元素会不断追加到前面高度最小的一栏下面,把空间利用率做到最大化。瀑布流常应用于移动端场景,一般是一行两图的形式使用最多。

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

相关推荐
鑫~阳1 小时前
html + css 淘宝网实战
前端·css·html
Catherinemin1 小时前
CSS|14 z-index
前端·css
漫天转悠1 小时前
Vue3项目中引入TailwindCSS(图文详情)
vue.js
qq_589568102 小时前
Echarts+vue电商平台数据可视化——后台实现笔记
vue.js·信息可视化·echarts
2401_882727573 小时前
低代码配置式组态软件-BY组态
前端·后端·物联网·低代码·前端框架
NoneCoder3 小时前
CSS系列(36)-- Containment详解
前端·css
anyup_前端梦工厂3 小时前
初始 ShellJS:一个 Node.js 命令行工具集合
前端·javascript·node.js
5hand3 小时前
Element-ui的使用教程 基于HBuilder X
前端·javascript·vue.js·elementui
GDAL3 小时前
vue3入门教程:ref能否完全替代reactive?
前端·javascript·vue.js
六卿3 小时前
react防止页面崩溃
前端·react.js·前端框架