常见的瀑布流实现大部分只适用于子块尺寸固定或内部有图片异步加载的状况。html
而对于子块有图片这种可能引发尺寸变化的状况,一般的作法是写死图片高度,或检测内部的 img
元素从而在 onload
事件中进行重排。vue
因为咱们业务中尺寸变化状况更为复杂,如子块自己异步初始化、内部数据异步获取,且这种尺寸变化时机不可肯定,为知足这种需求因此调研完成了一个通用万能的瀑布流实现。git
如下代码部分以 Vue.js 为例,思路和机制是通用的。github
先不考虑子块尺寸变化的因素,完成基础的瀑布流布局功能。浏览器
瀑布流布局的配置有三个,列数 columnCount
,块水平间距 gutterWidth
、块垂直间距 gutterHeight
。异步
固然也但是使用列宽代替列数,但一般状况下,这样就要求使用方进行列宽计算,有更高的使用成本
props: { columnCount: Number, gutterWidth: Number, gutterHeight: Number, }
对于类列表的结构,在组件开发中一般由两种形式:布局
slot
组件内循环 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
,使用方根据传回的数据进行自定义渲染。优化
这种方式使用起来比较违反视觉直觉,在使用者角度,不能直接的感觉到循环结构,但开发角度,逻辑更封闭,实现复杂逻辑更为简便。this
因为瀑布流组件只提供布局功能,应提供更直观的视觉感觉,同时在咱们的业务需求中,子块部分不尽相同,须要更灵活的自定义子块内容的方式。
因此采起第二种实现方式,拆分设计为 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) } }
$nextTick
等待 DOM 的实际渲染,从而能够得到尺寸。布局思路以下:
0
,那么取块最少的列为目标列,由于可能块高为 0
,块垂直间距为 0
,致使一直向第一列添加块。// 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)
中 newValue
和 oldValue
是否相等,来而避免后续计算,由于若相等是不会触发 totalHeight
的 watch
行为的。
同理,也不须要判断 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% 的容器尺寸时
scrollTop
不变。scrollTop
变小触发滚动。因此咱们可使用:
那么 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__shadow
与 waterfall-item
同高,从而使得用户 DOM 的尺寸变化映射到 waterfall-item__shadow
上。waterfall-item__shadow
滚动到极限位置。waterfall-item__shadow
的 scroll
事件,在事件回调中通知 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 的字体大小,从而触发子块尺寸变化,触发从新布局。
在以上实现以外还能够作一些其余优化,如:
Waterfall.vue
添加的 add
和更新的 update
方法调用有重复(覆盖)触发的状况,能够合并。按需监听尺寸变化,对 WaterfallItem 组件添加新的 props
,如:
scroll
监听,且不渲染 waterfall-item__shadow
。once
,并在后续更新时再也不渲染 waterfall-item__shadow
。visibility: hidden
。activated
和 deactivated
中进行从新布局的中止和激活,避免错误和没必要要的开支。