【JavaScript】瀑布流布局

7 篇文章 0 订阅
6 篇文章 0 订阅

瀑布流(waterfull)布局,也称作masonry。是一种被广泛应用的多图布局,尤其在移动端。对于一个网页,人类是习惯于横向定宽,垂直滚动来进行阅读的。在展示大量图片时,为避免杂乱无章,采用定宽定列的布局方式,可以促使浏览更加高效。

最终效果

准备工作

开始之前,我们需要准备一些图片方便测试。本节使用canvasAPI 来程序化的生成测试图片。

random

封装生成随机数和随机颜色的方法

const random = Math.random;
const floor = Math.floor;

/**
 * 随机整数
 * @param {number} n
 * @returns [0, n)
 */
function intRand(n) {
  return floor(random() * n);
}

/**
 * 从范围[a, b)内随机
 * @param {number} a start
 * @param {number} b end
 * @returns [a, b)
 */
function rand(a, b) {
  if (b) {
    const diff = b - a;
    return a + intRand(diff);
  }
  return intRand(a);
}

/**
 * 随机HEX颜色
 */
function hexColor() {
  return (
    "#" +
    Array(6)
      .fill(0)
      .map(() => rand(16).toString(16))
      .join("")
  );
}

canvas

// 创建一个canvas
function createCanvas(width = 300, height = 150) {
  const canvas = document.createElement("canvas");
  canvas.width = width;
  canvas.height = height;
  return canvas;
}

利用canvasAPI的toDataURL()方法,得到编码,用的时候赋值给<image>src属性就好了。

/**
 * 生成随机彩色图片URL
 */
function randImage(
  text,
  width = rand(50, 150),
  height = rand(50, 150),
  bgColor = hexColor(),
  fontColor = "white"
) {
  const canvas = createCanvas(width, height);
  const ctx = canvas.getContext("2d");
  ctx.fillStyle = bgColor;
  ctx.fillRect(0, 0, width, height);

  ctx.fillStyle = fontColor;
  ctx.textAlign = "center";
  ctx.textBaseline = "middle";
  ctx.font = Math.min(width, height) / 2 + "px bold";
  ctx.fillText(text, width / 2, height / 2);

  return canvas.toDataURL();
}

测试randImage方法:

<img id="testImage"></img>
<script>
  document.getElementById("testImage").src = randImage('测试')
</script>

测试

map

/**
 * 生成一个length大小的数组
 * @param {Number} length
 * @param {Function} func
 * @returns {Array}
 */
function map(length, func = i => i) {
  return Array.from({ length }, (_, i) => func(i));
};

生成50张图片直接调用const images = map(50, randImage);

CSS 方案

CSS 的实现五花八门,主要说两种,column-countflex布局。方便演示起见,html部分用petite-vue来写。

img通用样式如下,宽度撑满,块级布局避免img间隔问题

<style>
  img {
    width: 100%;
    border: 4px solid #ccc;
    display: block;
  }
</style>

column-count

HTML结构无需分列 div.container>div.item*n>img

<div class="container">
  <div class="item"><img src="x" alt="x"></div>
  <div class="item"><img src="y" alt="y"></div>
  <!-- ... -->
</div>

petite-vue实现:

<script type="module">
import { createApp } from 'https://unpkg.com/petite-vue?module'
const images = map(16, randImage);
function Container() {
  return {
    images,
  }
}
createApp({
  Container
}).mount();
</script>

<div class="container" v-scope="Container()">
  <div class="item" v-for="image in images">
    <img :src="image" :alt="image">
  </div>
</div>

<style>
  .container {
    column-count: 3;
    margin-right: 8px;
    break-inside: avoid;
  }
</style>

这样实现的瀑布流由上至下布局,不符合从左到右的习惯,也不支持滚动加载,并不推荐。

column-count

flex

HTML结构需要认为分列 div.container>div.column*n>div.item*m>img

<div class="container">
  <div class="column">
    <div class="item"><img src="x" alt="x"></div>
    <div class="item"><img src="y" alt="y"></div>
    <!-- ... -->
  </div>
  <div class="column">
    <div class="item"><img src="z" alt="z"></div>
  </div>
  <!-- ... -->
</div>

petite-vue实现:

<script type="module">
import { createApp } from 'https://unpkg.com/petite-vue?module'
const images = map(50, randImage);
const columns = [[], [], []];
let cur = 0;
async function loadNext() {
  if (cur >= images.length) {
    cur = 0;
    return;
  };
  const minHeightIndex = cur % 3;
  columns[minHeightIndex].push(images[cur]);
  cur++;
  await loadNext();
}
await loadNext();
function Container() {
  return {
    columns,
  }
}

createApp({
  Container
}).mount();
</script>

<div class="container" v-scope="Container()">
  <div class="column" v-for="column in columns">
    <div class="item" v-for="image in column">
      <img :src="src" :alt="src" />
    </div>
  </div>
</div>

<style>
  .column .item {
    margin: 4px 8px;
    flex: auto;
    width: 100%;
  }

  .column {
    display: flex;
    flex-wrap: wrap;
    flex-direction: row;
    align-content: flex-start;
    flex: 1;
  }

  .container {
    display: flex;
  }
</style>

我们根据图片默认的顺序,从左到右按序填坑。基本的瀑布流就完成了。
flex-basic-sequence

基于flex布局的改进

基本的瀑布流实现,在极端条件,比如某一列的高度异常大时,就会特别不合理。修改images生成逻辑,使第一列高度变大。

const images = map(50, (i) => {
  return randImage(
    i,
    i % 3 === 0 ? 50 : undefined,
    i % 3 === 0 ? 150 : undefined,
  )
})

flex-basic-shorts

此时第一列的[0,3,6,9,12]中只有0,3在视野中,6,9,12都看不见,但是7,8,10,11,13,14却都已经在视野里面了,如果图片顺序是比较重要的信息,那显然这样的布局方案是需要改进的。改进的依据就是每张图片的大小信息。

getImageHeight

一般来说,请求拿到的图片只有一个url地址,没有图片大小信息,如果我们基于大小进行布局,就需要一个获取图片大小的辅助函数。

/**
 * 根据 src 获取图片高度
 * @param {string} src dataURL
 * @returns {Promise<[width, height]>}
 */
function getImageHeight(src) {
  return new Promise((res, rej) => {
    const img = new Image();
    img.src = src;
    img.onload = function () {
      res([this.width, this.height]);
    }
    img.onerror = function (e) {
      rej(e);
    }
  })
}

贪心

假定我们分3列,并记录每列现有的图片总高度,每来一张图片,我们去找3列中高度最小的那一列来放置图片,即可。

修改分列部分的核心代码:

const heights = [0, 0, 0]; // 增加高度数组,记录每一列的高度

const minHeightIndex = heights.reduce((pi, c, ci, a) => pi = (a[pi] > c) ? ci : pi, 0); // 找到高度最小的一列

const [w, h] = await getImageHeight(images[cur]); // 记录新加入图片的高度到高度数组
heights[minHeightIndex] += h / w; // 由于我们每列同宽,所以 高宽比 足以描述高度差异

完整代码:

<script type="module">
import { createApp } from 'https://unpkg.com/petite-vue?module'
const images = map(50, randImage);
const columns = [[], [], []];
const heights = [0, 0, 0];
let cur = 0;
async function loadNext() {
  if (cur >= images.length) {
    cur = 0;
    return;
  };
  const minHeightIndex = heights.reduce((pi, c, ci, a) => pi = (a[pi] > c) ? ci : pi, 0);
  columns[minHeightIndex].push(images[cur]);
  const [w, h] = await getImageHeight(images[cur]);
  heights[minHeightIndex] += h / w;

  cur++;
  await loadNext();
}
await loadNext();
function Container() {
  return {
    columns,
  }
}

createApp({
  Container
}).mount();
</script>

<div class="container" v-scope="Container()">
  <div class="column" v-for="column in columns">
    <div class="item" v-for="image in column">
      <img :src="image" :alt="image" />
    </div>
  </div>
</div>

<style>
  .column .item {
    margin: 4px 8px;
    flex: auto;
  }

  .column {
    display: flex;
    flex-wrap: wrap;
    flex-direction: row;
    align-content: flex-start;
    flex: 1;
  }

  .container {
    display: flex;
  }
</style>

flex-greedy-contrast
现在视野之内的图片都是按照顺序的,不存在看不到8 却能看到9的问题。

懒加载

参考图片的懒加载

瀑布流做懒加载的话,基本上就要服务端提前返回宽高信息了。因为一旦需要前端拿到图片再计算宽高,自然就是提前加载(不是懒加载)了。

我们这里用IntersectionObserver实现不用贪心的基本瀑布流,核心代码如下,加在mounted钩子里。同时懒加载的一个小技巧是使用background: url('loading.png') 来在图片未更新之前显示一个占位信息。

mounted() {
    const images = document.querySelectorAll('img');
    const loadImage = (img) => {
      img.src ||= img.dataset.src;
      img.onload = () => {
        img.style.height = 'auto'; // 给未加载的图片一个默认初始高度,真正加载后再置为auto
      }
    }
    const observer = new IntersectionObserver((entries) => {
      entries.forEach(entry => {
        if (entry.intersectionRatio > 0) {
          loadImage(entry.target);
        }
      })
    });
    images.forEach(img => {
      observer.observe(img);
    })
}

动态规划

这是一个有趣的问题,假如有10张图片,分两列,怎么布局能使的最后两列的高度差最小?
这里我们假设服务端会返回图片的高度,即我们的randImage函数改写如下:

function randImage(
  text,
  width = rand(50, 150),
  height = rand(50, 150),
  bgColor = hexColor(),
  fontColor = "white"
) {
  const canvas = createCanvas(width, height);
  const ctx = canvas.getContext("2d");
  ctx.fillStyle = bgColor;
  ctx.fillRect(0, 0, width, height);

  ctx.fillStyle = fontColor;
  ctx.textAlign = "center";
  ctx.textBaseline = "middle";
  ctx.font = Math.min(width, height) / 2 + "px bold";
  ctx.fillText(text, width / 2, height / 2);

  return {
    url: canvas.toDataURL(),
    width,
    height
  }
}

未完待续…

References

  • 0
    点赞
  • 6
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值