万能瀑布流

常见的瀑布流实现大部分只适用于子块尺寸固定或内部有图片异步加载的情况。

而对于子块有图片这种可能引起尺寸变化的情况,通常的做法是写死图片高度,或检测内部的 img 元素从而在 onload 事件中进行重排。

由于我们业务中尺寸变化情况更为复杂,如子块本身异步初始化、内部数据异步获取,且这种尺寸变化时机不可确定,为满足这种需求所以调研完成了一个通用万能的瀑布流实现。

以下代码部分以 Vue.js 为例,思路和机制是通用的。

基础瀑布流

先不考虑子块尺寸变化的因素,完成基础的瀑布流布局功能。

基本属性

瀑布流布局的配置有三个,列数 columnCount,块水平间距 gutterWidth、块垂直间距 gutterHeight

当然也可是使用列宽代替列数,但通常情况下,这样就要求使用方进行列宽计算,有更高的使用成本
props: {
  columnCount: Number,
  gutterWidth: Number,
  gutterHeight: Number,
}

基本结构

对于类列表的结构,在组件开发中通常由两种形式:

  1. 组件内循环 slot
  2. 拆分为容器组件和子块组件,组件间做关联逻辑

组件内循环 slot 的方式如下:

// Waterfall.vue
<template>
  <div>
    <slot v-for="data in list" v-bind="data">
  </div>
</template>
// 使用方--父级组件
<waterfall :list="list">
  <template v-slot="data">
    <ecology-card :ecology-info="data" />
  </template>
</waterfall>

其实现思路是,使用者将列表数据传入组件,组件内部循环出对应个数的 slot,并将每一项数据传入 slot,使用方根据传回的数据进行自定义渲染。

这种方式使用起来比较违反视觉直觉,在使用者角度,不能直接的感受到循环结构,但开发角度,逻辑更封闭,实现复杂逻辑更为简便。

由于瀑布流组件只提供布局功能,应提供更直观的视觉感受,同时在我们的业务需求中,子块部分不尽相同,需要更灵活的自定义子块内容的方式。

所以采取第二种实现方式,拆分设计为 Waterfall.vue 瀑布流容器和 WaterfallItem.vue 瀑布流子块两个组件。

// 使用方
<waterfall>
  <waterfall-item>
    <a-widget /> // 业务组件
  </waterfall-item>
  <waterfall-item>
    <b-image /> // 业务组件
  </waterfall-item>
</waterfall>
// Waterfall.vue
<script>
  render (h) {
    return h('div', this.$slots.default)
  }
<script>

<style>
.waterfall {
  position: relative;
  width: 100%;
  min-height: 100%;
  overflow-x: hidden;
}
</style>

Waterfall.vue 组件只需要与父组件同宽高,并且将插入内部的元素原样渲染。

增删

为了保证在新增或删除子块时使重新布局的成本最小化,我选择由 WaterfallItem.vue 告知 Waterfall.vue 自己的新增和移除。

// Waterfall.vue
data () {
  return {
    children: []
  }
},
methods: {
  add (child) {
    const index = this.$children.indexOf(child)
    this.children[index] = child
    this.resize(index, true)
  },
  delete (child) {
    const index = this.$children.indexOf(child)
    this.children[index].splice(index, 1)
    this.resize(index, false)
  }
}
// WaterfallItem.vue
created () {
  this.$parent.add(this)
},
destoryed () {
  this.$parent.delete(this)
}

那么下面就要开始进行布局逻辑方法的编写。

瀑布流布局受两个因素影响,每个子块的宽和高,我们需要在适当的时候重新获取这两个维度的数据,其中块宽即列宽。

布局要素:列宽

列宽受两个因素的影响,容器宽度和期望的列数,那么列宽明显就是一个计算属性,而容器宽度需要在初始化和窗口变化时重新获取。

// Waterfall.vue
data () {
  return {
    // ...
    containerWidth: 0
  }
},
computed: {
  colWidth () {
    return (this.containerWidth - this.gutterWidth * (cols -1))/this.cols
  }
},
methods: {
  //...
  getContainerWidth () {
    this.containerWidth = this.$el.clientWidth
  }
},
mounted () {
  this.getContainerWidth()
  window.addEventListener('resize', this.getContainerWidth)
},
destory () {
  window.removeEventListener('resize', this.getContainerWidth)
}

也不要忘记在组件销毁时移除监听。

布局要素:块高

子块高的获取时机有两个:获取新增的块的高度和列宽变化时重新获取所有。

data () {
  return {
    //...
    childrenHeights: []
  }
},
resize (index, update) {
  this.$nextTick(() => {
    if (!update) {
      this.childrenHeights.splice(index, 1)
    } else {
      const childrenHeights = this.childrenHeights.slice(0, index)
      for (let i = index; i < this.children.length; i++) {
        childrenHeights.push(this.$children[i].$el.getBoundingClientRect().height)
      }
      this.childrenHeights = childrenHeights
    }
  })
},
watch: {
  colWidth () {
    this.resize(0, true)
  }
}
  • 在删除块时只需要删除对应块 DOM 的尺寸,不需要更新其他块的高度。
  • 新增块或列宽变化时,子块 DOM 未必实际渲染完成,所以需要添加 $nextTick 等待 DOM 的实际渲染,从而可以获得尺寸。
  • 列宽变化时,重新获取所有块的高度。

布局计算

布局思路如下:

  1. 记录每列的高度,取最短的列放入下一个块,并更新此列高度。
  2. 如果最短的列高度为 0,那么取块最少的列为目标列,因为可能块高为 0,块垂直间距为 0,导致一直向第一列添加块。
  3. 在此过程中根据列数和列宽获取每个块的布局位置。
// Waterfall.vue
computed: {
  //...
  layouts () {
    const colHeights = new Array(this.columnCount).fill(0)
    const colItemCounts = new Array(this.columnCount).fill(0)
    const positions = []
    this.childrenHeights.forEach(height => {
      let col, left, top
      const minHeightCol = colHeights.indexOf(min(colHeights))
      const minCountCol = colItemCounts.indexOf(min(colItemCounts))
      if (colHeights[minHeightCol] === 0) {
        col = minCountCol
        top = 0
      } else {
        col = minHeightCol
        top = colHeights[col] + this.gutterHeight
      }
      colHeights[col] = top + height
      colItemCounts[col] += 1
      left = (this.colWidth + this.gutterWidth) * col
      positions.push({ left, top })
    })
    const totalHeight = max(colHeights)
    return {
      positions,
      totalHeight
    }
  },
  positions () {
    return this.layouts.positions || []
  },
  totalHeight () {
    return this.layouts.totalHeight || 0
  }
}

同时需要注意的一点是,在整个布局的高度发生改变的时候,可能会伴随着滚动条的出现和消失,这会引起布局区域宽度变化,所以需要对 totalHeight 增加监听。

watch: {
  totalHeight () {
    this.$nextTick(() => {
      this.getContainerWidth()
    })
  }
}

totalHeight 发生变化时,重新获取容器宽度,这也是为什么 getContainerWidth 方法中使用 clientWidth 值的原因,因为 clientWidth 不包含滚动条的宽度。

同时在 totalHeight 发生改变后要使用 $nextTick 后获取宽度,因为 totalHeight 是我们的计算值,此刻,布局数据变化引发的视图渲染还未发生,在 $nextTick 回调等待视图渲染更新完成,再获取 clientWidth

同时我们也不需要关注 totalHeight(newValue, oldValue)newValueoldValue 是否相等,来而避免后续计算,因为若相等是不会触发 totalHeightwatch 行为的。

同理,也不需要判断 totalHeight 变化前后 clientWidth 是否一致来决定是否要对 containerWidth 重新赋值,从而避免引发后续的列宽、布局计算,因为 Vue.js 内都做了优化,只需重新获取并赋值,避免无用的“优化”代码。

排列

计算完成的位置和列宽需要应用到 WaterfallItem.vue

<template>
  <div class="waterfall-item" :style="itemStyle">
    <slot />
  </div>
</template>

<script>
export default {
  created () {
    this.$parent.add(this)
  },
  computed: {
    itemStyle () {
      const index = this.$parent.$children.indexOf(this)
      const { left, top } = this.$parent.positions[index] || {}
      const width = this.$parent.colWidth
      return {
        transform: `translate3d(${left}px,${top}px,0)`,
        width: `${width}px`
      }
    }
  },
  destoryed () {
    this.$parent.delete(this)
  }
}
</script>

<style>
.waterfall-item {
  box-sizing: border-box;
  border: 1px solid black;
  position: absolute;
  left: 0;
  right: 0;
}
</style>

结果

至此,基础瀑布流逻辑也就结束了,使用现代浏览器点此预览

预览中定时向 Waterfall 中插入高度随机的 WaterfallItem。

完成限定子块高度在初始渲染时就固定的瀑布流后,怎么能做一个无论什么时候子块尺寸变化,都能进行感知并重新布局的瀑布流呢?

万能瀑布流

如何感知尺寸变化

根据这篇文章知,可以利用滚动事件去探知元素的尺寸变化。

简要来说:

scrollTop 为例,在滚动方向为向右和向下,已经滚动到 scrollTop 最大值前提下

  • 当内容(子元素)高度固定且大于容器时

    • 容器高度变大时,已滚动到最下方,容器只能上边界向上扩展,上边界到内容区上边界距离变小,scrollTop 变小触发滚动。
    • 容器高度变小时,容器底边向上缩小,容器上边界到内容区上边界距离不变,scrollTop 不变,不触发滚动。
  • 当内容为 200% 的容器尺寸时

    • 容器高度变大时,内容区 200% 同步变化,容器向下扩展空间充足,所以下边界向下扩展,上边界不动,上边界到内容区上边界距离不变,scrollTop 不变。
    • 当容高度变小时,内容区下边界二倍于容器收缩,容器下边界收缩空间不足,导致上边界相对内容区上移,scrollTop 变小触发滚动。

所以我们可以使用:

  • 内容区尺寸固定且远大于容器尺寸,检测容器的尺寸增大。
  • 内容区尺寸为容器尺寸的 200%,检测容器的尺寸减小。

改动

那么 WaterfallItem.vue 需要调整如下

<template>
  <div class="waterfall-item" :style="itemStyle">
    <div class="waterfall-item__shadow" ref="bigger" @scroll="sizeChange">
      <div class="waterfall-item__holder--bigger">
      </div>
    </div>
    <div class="waterfall-item__shadow" ref="smaller" @scroll="sizeChange">
      <div class="waterfall-item__holder--smaller">
      </div>
    </div>
    <slot />
  </div>
</template>

<script>
  mounted () {
    this.$nextTick(() => {
      this.$refs.bigger.scrollTop = '200000'
      this.$refs.smaller.scrollTop = '200000'
    })
  },
  methods: {
    sizeChange () {
      this.$parent.update(this)
    }
  }
</script>

<style>
  .waterfall-item {
    position: absolute;
    left: 0;
    right: 0;
    overflow: hidden;
    box-sizing: border-box;
    border: 1px solid black ;
  }
  .waterfall-item__shadow {
    height: 100%;
    left: 0;
    overflow: auto;
    position: absolute;
    top: 0;
    transform: translateX(200%);
    width: 100%;
  }
  .waterfall-item__holder--bigger {
    height: 200000px;
  }
  .waterfall-item__holder--smaller {
    height: 200%;
  }
</style>
  • slot 为用户的真实 DOM,其撑开 waterfall-item 的高度。
  • 两个分别检测尺寸增加和减小的 waterfall-item__shadowwaterfall-item 同高,从而使得用户 DOM 的尺寸变化映射到 waterfall-item__shadow 上。
  • 渲染完成后使 waterfall-item__shadow 滚动到极限位置。
  • 用户 DOM 的尺寸变化触发 waterfall-item__shadowscroll 事件,在事件回调中通知 Waterfall.vue 组件更新对应子块高度。
// Waterfall.vue
methods: {
  // ...
  update (child) {
    const index = this.$children.indexOf(child)
    this.childrenHeights.splice(index, 1, this.$children[index].$el.getBoundingClientRect().height)
  }
}

在父组件中只需要更新此元素的高度即可,自会触发后续布局计算。

结果

至此,可动态感知尺寸变化的万能瀑布流也就完成了,使用现代浏览器点此预览

预览中定时修改部分 WaterfallItem 的字体大小,从而触发子块尺寸变化,触发重新布局。

优化

在以上实现之外还可以做一些其他优化,如:

  1. 通知 Waterfall.vue 添加的 add 和更新的 update 方法调用有重复(覆盖)触发的情况,可以合并。
  2. 按需监听尺寸变化,对 WaterfallItem 组件添加新的 props,如:

    • 固定大小的就可以不绑定 scroll 监听,且不渲染 waterfall-item__shadow
    • 只会变化一次的可以对监听使用 once,并在后续更新时不再渲染 waterfall-item__shadow
  3. 在布局计算完成前对 WaterfallItem 添加不可见 visibility: hidden
  4. 在元素过多时,使用虚拟渲染的方式,只渲染在视图范围内的 WaterfallItem。
  5. 对应 keep-alive 的路由渲染,在非激活状态是拿不到容器尺寸的,所以需要在 activateddeactivated 中进行重新布局的停止和激活,避免错误和不必要的开支。
阅读 3.1k

推荐阅读
智联大前端
用户专栏

作为智联招聘的前端架构团队,我们开创了细粒度的前端研发和发布模式,统一了移动端和桌面端的技术栈,...

66 人关注
8 篇文章
专栏主页