那是一个下午,我翻着 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 的 top 和 left 位置:
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() { ... }
}我还用一个图表示了一下项目模块结构和数据流向:

这种结构比起前面直接写逻辑清晰很多,也方便后期维护。
第九步:移动端适配,保持交互顺畅与视觉统一
为了让画廊在移动端也能有良好的体验,我使用了以下几个技巧:
- 使用
viewport来适配缩放比例 - 所有尺寸都用
vw或em相对单位 - 布局列数根据屏幕宽度动态调整(通过 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();
});这些细节虽然不起眼,但决定了移动端用户的观感和操作手感。
第十步:加入主题切换系统(深色 / 浅色)
现代页面几乎都支持暗色模式,我也不例外。思路是定义两个主题类 light 和 dark,挂在 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);
});
















