最近看到一篇使用vue从零开始手写一个猫咪瀑布流组件(支持ssr),讲了关于如何用 Vue 来实现瀑布流,学习后自己动手写了一个简单的 demo。

源码

瀑布流又称瀑布流式布局,是比较流行的一种网站页面布局方式,每张图片的宽度都设置为一样,但是高度是根据内容变化的,实现一个不规则的排列。

原理

原理其实很简单,简单来说就是把每张图都设置为绝对定位,再根据宽高设置图片的偏移值,则 left 和 top 属性确定位置。

如上图,可以看到,前五张图排在第一行(demo 设置 PC 浏览器显示 5 列,移动端浏览器显示 2 列)。

接下来的第六张图应该放在什么位置?应该放在红色框这个位置而不是黑色框这个位置。

这就是瀑布流最关键的地方,除了第一行是按顺序排列之外,其他的图片应该是按照 放到当前高度最小的列下面(可以理解为见缝插针?hhh)。所以第六张图排列后如下图所示,同理之后的每一张图片都按这个规则排列:

图片预处理

通过上图可以发现,要实现这种效果,每个图片的宽度都是一样的(应该也可以实现宽度不同时的效果,这应该还需要再考虑当前的空隙过小无法插入图片的情况,后期再尝试),即每一列的宽度一致(在 demo 我是用当前窗口的宽度 / 列数 得到每一列的宽度)。

然后再通过图片的宽度来设定图片的高度,其实就是把图片按原始比例缩放。

/**
    imgsArr 为图片数组
    loadedCount 为已预处理好的图个数
    imgWidth 为图片的宽度
*/

// 预加载 设置并保存图片宽高
preLoad() {
    // forEach 无法通过 item 直接修改数组元素,需用数组下标修改
    this.imgsArr.forEach((item, index) => {
        if (index < this.loadedCount)
            return;
        // 无图则把高度设置为0
        if (!item.src) {
            // 图片的高度
            this.imgsArr[index]._height = "0";
            ++this.loadedCount;
            // 当前图片都与处理完,则加载图片
            if (this.imgsArr.length === this.loadedCount) {
                this.preloaded();
            }
        } else {
            let img = new Image();
            img.src = item.src;
            img.onload = img.onerror = (e) => {
                // 若加载失败则设置图片高度与宽度一致,加载成功则动态计算图片高度
                this.imgsArr[index]._height = e.type === "load" ? Math.round(this.imgWidth * (img.height / img.width)) : this.imgWidth
                if (e.type === "error") {
                    this.imgsArr[index]._error = true;
                }
                ++this.loadedCount;
                // 当前图片都与处理完,则加载图片
                if (this.imgsArr.length === this.loadedCount) {
                    this.preloaded();
                }
            }
        }
    })
},

加载图片

设置完每张图的高度后,再开始进行渲染,把 imgsArr_c 为真实的渲染数组。

使用一个 div 包含 img 标签,在 div 标签上动态设置宽高,宽则对应图片的宽度 imgWidth(也就是上面所提及的列宽),高则对应着在预处理阶段为每张图片所设置的高度 item._height

<div class="img-box default-card-animation" v-for="(item, index) in imgsArr_c" :key="index"
      :style="{width: imgWidth + 'px', height: item._height + 'px'}" ref="imgBox">
    <img :data-src="item.src" />
</div>
preloaded() {
    // 开始渲染
    this.imgsArr_c = [].concat(this.imgsArr);
    ....
}

渲染完之后,接下来就是对每张图进行排列。

这里需要注意的是,vue 中数据的变化到 DOM 的重新渲染是一个异步过程。vue 中当 data 改变后,并不是立即渲染到页面上的,而是先放到事件队列上。然后,在下一个的事件循环中,Vue 刷新事件队列并执行实际 (已去重的) 工作。这就导致了,数据改变后挂载在 dom 上可能会存在一定的延迟,所以数据改变后立刻去获取 dom 元素可能拿到的不是最新的值而是改变前的值。

所以,使用 $nextTick 来对解决这个问题,$nextTick 的作用就是在下次 dom 更新循环结束后执行其 callback。在修改 imgsArr_c 之后使用这个方法,才能保证排列的元素是更新后的。

preloaded() {
    // 开始渲染
    this.imgsArr_c = [].concat(this.imgsArr);
    this.$nextTick(() => {.
        // 对每个元素进行排列
        this.waterfall();
    });
}

设置瀑布流(核心)

// waterfall,等到整个视图都渲染完毕再执行
waterfall() {
    // 选择所有图片
    this.imgBoxEls = this.$refs["imgBox"];
    // 若没图片,则返回
    if (!this.imgBoxEls)
        return;
    let top, left, height;
    // 开始排列的坐标,若为0则重头排列,colsHeightArr 数组保存的是当前每一列的高度
    if (this.beginIndex === 0)
        this.colsHeightArr = []
    for (let i = this.beginIndex; i < this.imgBoxEls.length; ++i) {
        if (!this.imgBoxEls[i])
            return;
        // 当前图片的高度
        height = this.imgBoxEls[i].offsetHeight;
        // 第一行,则直接按顺序排列
        if (i < this.colNum) {
            this.colsHeightArr.push(height);
            top = 0;
            // colWidth 为列宽,等于图片宽度加 div 左右的padding,colWidth = imgWdith + 2 * padding
            left = i * this.colWidth;
        } else {
            // 找到当前最低的高度和其索引
            let minHeight = Math.min.apply(null, this.colsHeightArr);
            let minIdx = this.colsHeightArr.indexOf(minHeight);
            // 当前图片的 top,即当前图片应所在的高度
            top = minHeight;
            // 当前图片的 left,即当前图片应该排到目前高度最低那一列下面
            left = minIdx * this.colWidth;
            // 更新第 minIdx 列的高度
            this.colsHeightArr[minIdx] += height;
        }
        // 设置 img-box 位置
        this.imgBoxEls[i].style.top = top + "px";
        this.imgBoxEls[i].style.left = left + "px";
        // 当前图片在窗口内,则加载,这是用于后面的图片懒加载。viewHeight 为窗口高度
        if (top < this.viewHeight) {
            let imgEl = this.imgBoxEls[i].children[0];
            imgEl.src = imgEl.getAttribute("data-src");
            imgEl.style.opacity = 1;
            imgEl.style.transform = "scale(1)";
        }
    }   
    // 排列完之后,之后新增图片从这个索引开始预加载图片和排列,之前排列的图片无需在处理
    this.beginIndex = this.imgBoxEls.length;
}

触底更新图片(这个可以根据自己的需求修改)

demo 中实现了触底则在 imgsArr 中添加图片后,自动渲染并排列。

这里为浏览器的 scroll 时间绑定了 scrollFn 时间,并使用节流进行了优化。

// 节流函数
throttle(fn, time) {
    let canRun = true;
    return function () {
        if (!canRun)
            return;
        canRun = false;
        setTimeout(() => {
            fn.apply(this);
            canRun = true;
        }, time)
     }
}

window.onscroll = this.throttle(this.scrollFn, 500);
// 滚动触底事件
scrollFn() {
    let minHeight = Math.min.apply(null, this.colsHeightArr);
    // 滚动条滚动的高度
    let scrollTop = window.pageYOffset || document.documentElement.scrollTop || document.body.scrollTop
    // 到达最底层的高度最低的一列
    if (scrollTop + this.viewHeight > minHeight - this.reachBottomDistance){
        // 修改 imgsArr 数组
        this.imgsArr = this.imgsArr.concat(this.imgsArr);
    }
    ....
}

到达底部后,为 imgsArr 数组添加新图片,要让其自动渲染并排列,在这使用了 watch 来对 imgsArr 进行监听。当 imgsArr 被修改时,调用 preLoad 函数。

watch: {
    imgsArr(newVal, oldVal) {
        this.preLoad();
    }
}

图片懒加载

除了复现了原博主的功能,我在此加入了图片懒加载的功能。

图片懒加载:网页上的所有图片不是默认加载的,而是等到该图片出现在浏览器的可视区域后才加载。懒加载是一种优化网页性能的方式。

懒加载原理:

        图片要加载,即浏览器发出请求,是根据 img 标签是否有 src 属性来决定的,所以实现懒加载就要在 src 属性入手,当图片没进入可视区域时,不给 img 标签设置 src 属性,等到图片进入可是区域是再设置,这时图片才会加载。

// 滚动触底事件
scrollFn() {
    let minHeight = Math.min.apply(null, this.colsHeightArr);
    // 滚动条滚动的高度
    let scrollTop = window.pageYOffset || document.documentElement.scrollTop || document.body.scrollTop

    ....

    // 图片懒加载
    this.imgBoxEls.forEach((imgBoxEl, index) => {
        let imgEl = imgBoxEl.children[0];
        // 若已加载,则跳过
        if (imgEl.src)
            return;
        // 当前图片所处的高度
        let top = imgBoxEl.style.top;
        top = Number.parseFloat(top.slice(0, top.length - 2));
        // 图片已到达可视范围,则加载
        if (scrollTop + this.viewHeight > top) {
            imgEl.src = imgEl.getAttribute("data-src")
            imgEl.style.opacity = 1;
            imgEl.style.transform = "scale(1)";
        }
    })
}

在滚动事件内,遍历 imgBoxEls 数组(页面上的每一个图片元素),通过判断当前滚动条滑动的高度 scrollTop + 浏览器可视高度 viewHeight 是否大于图片所在页面的高度 top(在 waterfall 函数中设置的 top 属性)。当大于,表明该图片已进入可视区域,则设置 src 属性。

Logo

基于 Vue 的企业级 UI 组件库和中后台系统解决方案,为数万开发者服务。

更多推荐