那是一个下午,我翻着 Behance 看设计灵感的时候,脑海里突然蹦出一个念头:要不自己做一个现代化、交互流畅又视觉惊艳的图片画廊吧?我并不打算用什么现成的库,比如 Pinterest 或者 Masonry.js,这次我想尝试纯手写,挑战自己。于是,我关掉网页,打开编辑器,开始了这个不小的挑战。

从想法到动手:我要做个怎样的画廊?

我的目标很清晰:画廊要响应式,能适应不同设备;布局是瀑布流样式,但不依赖 JavaScript 插件;点击图片能放大预览,最好有 Lightbox 效果,还能键盘翻图;浏览到底部还能自动加载更多图片;还要加上颜色或主题的筛选功能。所有这些,我想尽可能用现代 Web 技术完成,不求极致炫技,但要简洁优雅、体验顺滑。

脑中构思了这个画廊的功能流程,于是画了个示意图来理清逻辑:

打造一个响应式图片画廊_前端

第一步:响应式布局,用 CSS Grid 构建骨架

我先从基础结构写起。HTML 很简单,一个外层容器 .gallery,里面是若干 .item,每个代表一张图片。我没有使用 <img> 标签,而是用背景图 + 容器撑开,方便后续控制大小和动画效果。

<div class="gallery">
  <div class="item" style="background-image: url(images/photo1.jpg)"></div>
  <div class="item" style="background-image: url(images/photo2.jpg)"></div>
  ...
</div>

CSS 我用 Grid 布局来做第一版尝试,目标是让图片自动填满行列,并在宽度变化时自动重排。代码如下:

.gallery {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
  grid-auto-rows: 10px;
  gap: 16px;
}

.item {
  background-size: cover;
  background-position: center;
  border-radius: 12px;
  transition: transform 0.3s ease;
}

我用了 grid-auto-rows: 10px 的技巧,并给每个 .item 设置一个 grid-row-end,用来模拟瀑布流效果,这是纯 CSS 实现 Masonry 的关键。JavaScript 中这样写:

const items = document.querySelectorAll('.item');

items.forEach(item => {
  const contentHeight = item.getBoundingClientRect().height;
  const rowSpan = Math.ceil(contentHeight / 10);
  item.style.gridRowEnd = `span ${rowSpan}`;
});

这段逻辑在图片加载后执行,把每个格子根据高度手动对齐成不等高布局。

第二步:让图片点击弹出 Lightbox,添加键盘翻页功能

布局有了,接着是交互部分。点击图片弹出大图预览我用的是原生方式实现 Lightbox。我为此写了一个模块化的预览组件。

HTML 结构大概如下:

<div class="lightbox hidden">
  <img class="lightbox-image" />
  <button class="prev">←</button>
  <button class="next">→</button>
  <span class="close">×</span>
</div>

CSS 用了 fixed 定位和半透明背景遮罩,再加上简单的淡入动画:

.lightbox {
  position: fixed;
  top: 0; left: 0; right: 0; bottom: 0;
  background: rgba(0,0,0,0.85);
  display: flex;
  justify-content: center;
  align-items: center;
  transition: opacity 0.3s;
  z-index: 1000;
}
.lightbox img {
  max-width: 90vw;
  max-height: 90vh;
  border-radius: 8px;
}

JavaScript 的逻辑是这样的,维护一个当前 index,支持键盘左右切换:

let currentIndex = 0;
const lightbox = document.querySelector('.lightbox');
const image = document.querySelector('.lightbox-image');
const items = document.querySelectorAll('.item');

function openLightbox(index) {
  currentIndex = index;
  const bg = items[index].style.backgroundImage;
  const url = bg.slice(5, -2); // 提取 url("...")
  image.src = url;
  lightbox.classList.remove('hidden');
}

items.forEach((item, index) => {
  item.addEventListener('click', () => openLightbox(index));
});

document.addEventListener('keydown', e => {
  if (e.key === 'ArrowRight') openLightbox((currentIndex + 1) % items.length);
  if (e.key === 'ArrowLeft') openLightbox((currentIndex - 1 + items.length) % items.length);
  if (e.key === 'Escape') lightbox.classList.add('hidden');
});

第三步:无限滚动加载,滚到哪加载到哪

这部分我最开始用的是监听 scroll,但很快发现性能不理想。后来改用 IntersectionObserver 监听最后一项是否出现在视口内,更高效也更现代。

const observer = new IntersectionObserver(entries => {
  if (entries[0].isIntersecting) {
    loadMoreImages();
  }
}, {
  rootMargin: '100px'
});

observer.observe(document.querySelector('.item:last-child'));

我写了一个假的 API 来模拟加载数据:

function loadMoreImages() {
  const newItems = Array.from({ length: 10 }).map(() => {
    const div = document.createElement('div');
    div.className = 'item';
    div.style.backgroundImage = `url(images/photo${Math.floor(Math.random() * 20)}.jpg)`;
    return div;
  });

  newItems.forEach(item => {
    gallery.appendChild(item);
  });

  // 重新计算 grid-row-end
  setTimeout(() => {
    newItems.forEach(item => {
      const h = item.getBoundingClientRect().height;
      const span = Math.ceil(h / 10);
      item.style.gridRowEnd = `span ${span}`;
    });
    observer.observe(document.querySelector('.item:last-child'));
  }, 100);
}

这样一来,用户滚动到底部后就会自动加载新图片,并触发布局更新。

第四步:加入滤镜筛选系统,让用户按颜色或主题筛选图片

在我完成基本的画廊后,体验虽然流畅,但如果图片一多就显得杂乱,用户很难快速找到自己感兴趣的图片。于是我决定加入一个“筛选”系统,允许用户按颜色、主题或标签来过滤图片展示。

我为每张图片数据加了一个 metadata,结构大致如下:

{
  "url": "images/photo12.jpg",
  "tags": ["nature", "blue", "mountain"]
}

我写了一个过滤面板,HTML 很简单:

<div class="filter-bar">
  <button data-filter="all">全部</button>
  <button data-filter="nature">自然</button>
  <button data-filter="urban">城市</button>
  <button data-filter="blue">蓝色</button>
  <button data-filter="green">绿色</button>
</div>

接着绑定按钮事件,点击按钮后更新画廊:

const filterButtons = document.querySelectorAll('.filter-bar button');

filterButtons.forEach(btn => {
  btn.addEventListener('click', () => {
    const filter = btn.dataset.filter;
    document.querySelectorAll('.item').forEach(item => {
      const tags = item.dataset.tags.split(',');
      if (filter === 'all' || tags.includes(filter)) {
        item.style.display = 'block';
      } else {
        item.style.display = 'none';
      }
    });
  });
});

这里 data-tags 是我在生成 item 时附加上的,例如:

div.dataset.tags = 'nature,blue';

这一机制非常灵活,如果后期图片来源换成后端接口,也可以在 JSON 数据中直接管理 tags。

为了让筛选后布局依然整齐,我还需要重新触发布局计算,尤其是 grid-row-end 的更新,所以在每次筛选后我加了一个 reflowItems() 函数:

function reflowItems() {
  document.querySelectorAll('.item').forEach(item => {
    if (item.style.display !== 'none') {
      const h = item.getBoundingClientRect().height;
      const span = Math.ceil(h / 10);
      item.style.gridRowEnd = `span ${span}`;
    }
  });
}

第五步:完善 Packery 式布局,模拟更真实的自由排列感

尽管我们用 CSS Grid + grid-auto-rows 模拟了瀑布流,但从视觉上看,它还是有些“机械”。真正自由的 Packery 布局看起来更随性,更像拼贴画。我研究了一下 Packery 的布局原理,它是基于块块碰撞后落地的方式进行排列,类似物理掉落过程。

虽然我们不使用 Packery.js,但我决定模拟这种“碰撞式定位”,我改用了 position: absolute 布局,每张图片根据高度动态定位:

我为画廊容器加了以下样式:

.gallery {
  position: relative;
}
.item {
  position: absolute;
  transition: top 0.4s, left 0.4s;
}

然后写了一个 layout() 函数,负责在图片加载或筛选后重新计算所有 item 的 topleft 位置:

function layout() {
  const containerWidth = gallery.clientWidth;
  const columnWidth = 250;
  const gap = 16;
  const columns = Math.floor(containerWidth / (columnWidth + gap));
  const columnHeights = Array(columns).fill(0);

  const items = [...document.querySelectorAll('.item')].filter(item => item.style.display !== 'none');

  items.forEach(item => {
    const minCol = columnHeights.indexOf(Math.min(...columnHeights));
    const x = minCol * (columnWidth + gap);
    const y = columnHeights[minCol];

    item.style.width = `${columnWidth}px`;
    item.style.left = `${x}px`;
    item.style.top = `${y}px`;

    columnHeights[minCol] += item.offsetHeight + gap;
  });

  const maxHeight = Math.max(...columnHeights);
  gallery.style.height = `${maxHeight}px`;
}

每次加载或筛选图片之后,我都执行这个 layout() 函数,这样实现了不依赖任何库的 Packery 风格布局。

为了让它更平滑,我还添加了过渡动画,使图片“滑动”到位而不是“瞬移”。

第六步:添加加载动画和懒加载优化,让体验更加顺滑

在加载新图片时,我不想用户盯着空白卡片等待图片加载完成,于是我为每张 .item 添加了骨架屏动画,加载成功后再替换背景图。

.item {
  background-color: #eee;
  animation: shimmer 1.5s infinite linear;
}
@keyframes shimmer {
  0% { background-position: -100px 0; }
  100% { background-position: 200px 0; }
}

加载图片后替换背景:

function createItem(url, tags) {
  const div = document.createElement('div');
  div.className = 'item';
  div.dataset.tags = tags.join(',');
  const img = new Image();
  img.src = url;
  img.onload = () => {
    div.style.backgroundImage = `url(${url})`;
    div.classList.remove('loading');
    layout(); // 重新布局
  };
  return div;
}

另外,为了性能我加入了懒加载,利用 IntersectionObserver 判断图片是否在视口内,再加载真实图片地址:

const observer = new IntersectionObserver(entries => {
  entries.forEach(entry => {
    if (entry.isIntersecting) {
      const div = entry.target;
      const url = div.dataset.src;
      const img = new Image();
      img.src = url;
      img.onload = () => {
        div.style.backgroundImage = `url(${url})`;
        div.classList.remove('loading');
      };
      observer.unobserve(div);
    }
  });
});

第七步:美化界面,追求极简却不简单的现代感

我希望这个画廊页面不仅功能强大,还得“好看”。为此我参考了一些 UI 设计规范,加入了一些小细节:

  • 所有按钮用了柔和阴影和渐变
  • 图片 hover 时略微放大,配合淡入淡出的光影动画
  • 滤镜按钮激活状态有动态 underline 滑动
  • Lightbox 背景模糊而不是单纯透明
.filter-bar button.active {
  border-bottom: 2px solid #3fa9f5;
}
.item:hover {
  transform: scale(1.02);
  box-shadow: 0 8px 24px rgba(0,0,0,0.2);
}

我还为整个页面加入了深色模式支持,只需切换 body.dark 类:

body.dark {
  background: #111;
  color: #eee;
}
body.dark .item {
  background-color: #222;
}

一个精致的画廊页面,往往就是靠这些细节打动用户的。

第八步:模块化封装,构建一个可维护的图片画廊组件

功能实现得差不多以后,我意识到此时的代码已经越来越长,功能之间也出现了一定的耦合。比如布局和懒加载、筛选和重新渲染高度,都是通过一些全局变量来完成的。虽然在 Demo 阶段没问题,但要想拓展或复用,必须得做模块化封装。

我决定采用原生 JavaScript 的 class 结构来组织这整个画廊的逻辑,把画廊行为打包成一个 Gallery 类,并暴露一些方法供外部调用,比如 addImage(), filter(), loadMore()

封装结构如下:

class Gallery {
  constructor(container, options = {}) {
    this.container = container;
    this.images = [];
    this.columns = 4;
    this.gap = 16;
    this.filterTag = 'all';
    this.layoutMode = options.layout || 'masonry';
    this.lazyObserver = null;
    this.lightbox = new Lightbox();

    this.init();
  }

  init() {
    this.createLazyObserver();
    this.listenScroll();
    window.addEventListener('resize', () => this.layout());
  }

  addImage(data) {
    const item = document.createElement('div');
    item.className = 'item loading';
    item.dataset.tags = data.tags.join(',');
    item.dataset.src = data.url;
    this.container.appendChild(item);
    this.lazyObserver.observe(item);
    this.images.push(item);
  }

  filter(tag) {
    this.filterTag = tag;
    this.images.forEach(item => {
      const tags = item.dataset.tags.split(',');
      if (tag === 'all' || tags.includes(tag)) {
        item.style.display = 'block';
      } else {
        item.style.display = 'none';
      }
    });
    setTimeout(() => this.layout(), 100);
  }

  layout() {
    if (this.layoutMode === 'packery') {
      this.packeryLayout();
    } else {
      this.gridLayout();
    }
  }

  // 省略 packeryLayout, gridLayout, lazy loading 等具体函数...
}

每一个职责都明确拆分成独立方法,这样以后不管是切换布局模式,还是换图片源、支持多种尺寸设备,只需重写对应方法即可。

Lightbox 也封装为单独类,控制图片弹出与切换逻辑,结构如下:

class Lightbox {
  constructor() {
    // 初始化 DOM 和事件绑定
  }

  show(index, imageList) {
    // 展示并设置 src
  }

  next() { ... }
  prev() { ... }
  hide() { ... }
}

我还用一个图表示了一下项目模块结构和数据流向:

打造一个响应式图片画廊_加载_02

这种结构比起前面直接写逻辑清晰很多,也方便后期维护。


第九步:移动端适配,保持交互顺畅与视觉统一

为了让画廊在移动端也能有良好的体验,我使用了以下几个技巧:

  • 使用 viewport 来适配缩放比例
  • 所有尺寸都用 vwem 相对单位
  • 布局列数根据屏幕宽度动态调整(通过 JS 或 CSS 媒体查询)

CSS 中媒体查询示例:

@media (max-width: 768px) {
  .gallery {
    grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
  }
}

在 JS 中动态计算列数:

this.columns = window.innerWidth < 768 ? 2 : 4;

Lightbox 弹窗在移动端默认占满整个屏幕,并添加 touch 事件实现左右滑动切换图片,我加上了一个简单的 touch 滑动识别逻辑:

let startX = 0;
image.addEventListener('touchstart', e => startX = e.touches[0].clientX);
image.addEventListener('touchend', e => {
  const endX = e.changedTouches[0].clientX;
  if (startX - endX > 50) this.next();
  if (endX - startX > 50) this.prev();
});

这些细节虽然不起眼,但决定了移动端用户的观感和操作手感。


第十步:加入主题切换系统(深色 / 浅色)

现代页面几乎都支持暗色模式,我也不例外。思路是定义两个主题类 lightdark,挂在 body 上控制全局颜色变量。

CSS:

body.light {
  --bg: #ffffff;
  --text: #333;
}
body.dark {
  --bg: #121212;
  --text: #eee;
}

body {
  background-color: var(--bg);
  color: var(--text);
}

用户点击一个切换按钮即可改变主题:

document.querySelector('#theme-toggle').addEventListener('click', () => {
  document.body.classList.toggle('dark');
});

localStorage 记录用户上次选择的主题,自动恢复:

const saved = localStorage.getItem('theme');
if (saved) document.body.classList.add(saved);

document.querySelector('#theme-toggle').addEventListener('click', () => {
  const theme = document.body.classList.toggle('dark') ? 'dark' : 'light';
  localStorage.setItem('theme', theme);
});