摄影档案页的画廊不是 CSS Grid,不是 Masonry,也不是任何现成库。它是一个自研的 Justified Gallery 引擎:每行等高,图片按宽高比弹性填满整行,类似 Flickr 和 Google Photos 的布局方式。
gallery-layout.js 用 259 行原生 JavaScript 实现了完整的布局、渲染和交互。
算法:贪心分行
核心思路是逐行构建。从左到右遍历图片列表,把图片逐张加入当前行,每加一张就计算"如果到这里结束,行高是多少",找到最接近目标行高的切分点。
for (var j = i; j < images.length; j++) {
var newSum = sumAR + ar(images[j]);
var gapTotal = (j - i) * GAP;
var scaledH = (availWidth - gapTotal) / newSum;
var diff = Math.abs(scaledH - targetRowHeight);
if (diff < bestDiff) {
bestDiff = diff;
bestEnd = j;
}
if (scaledH < targetRowHeight * 0.5 && j > i + 1) break;
sumAR = newSum;
}
scaledH 是当前行如果在这里结束时的高度。bestDiff 记录最小的偏差。当行高已经低于目标的一半时提前终止——继续加图只会让行更矮。
这是一个贪心算法,不是全局最优解。但它产生的布局在视觉上足够好,而且速度快、结果稳定。
目标行高
目标行高按容器宽度分四档:
| 容器宽度 | 目标行高 | 最小 | 最大 |
|---|---|---|---|
| ≥ 1440px | 360px | 300px | 430px |
| ≥ 1024px | 330px | 280px | 400px |
| ≥ 640px | 260px | 220px | 320px |
| < 640px | 180px | 150px | 230px |
行高被钳制在最小和最大值之间,避免极端情况(比如只有 1 张超宽图片时行高变成 800px)。
桌面端行高更大,因为屏幕宽、图片多,较高的行能让每张图有足够展示面积。移动端行高更小,保证每行至少能放下 2-3 张图,不至于需要大量纵向滚动。
竖图分散
摄影集里横图和竖图混合排列。如果两张竖图(宽高比 < 0.85)出现在同一行的相邻位置,它们会占据过多纵向空间,让整行看起来不均衡。
引擎用两层策略处理这个问题。
第一层:行内惩罚
var adjPenalty = (prevIsPortrait && imgIsPortrait) ? 0.3 : 0;
var diff = rawDiff * (1 + adjPenalty);
当连续两张竖图出现在同一行时,这个切分点的偏差分数增加 30%。算法会倾向于在这里之前切行,把第二张竖图推到下一行。
第二层:跨行交换
function breakAdjacentPortraits(rows) {
for (var r = 0; r < rows.length; r++) {
for (var k = 1; k < imgs.length; k++) {
if (isPortrait(imgs[k-1]) && isPortrait(imgs[k])) {
// 尝试与下一行的横图交换
// 如果下一行没有横图,尝试与上一行交换
}
}
}
}
分行完成后做一次后处理:扫描每一行,如果仍有相邻竖图,尝试从相邻行找一张横图来交换。先试下一行,再试上一行。
这两层策略让竖图在画廊中均匀分散,避免视觉上的"竖图扎堆"。
末行保护
最后一行只有 1-2 张图时,如果强制填满整行,图片会被拉伸到不合理的高度:
if (isLastRow && imgCount <= 2 && rowHeight > targetRowHeight * 1.5) {
rowHeight = targetRowHeight;
forceFill = false;
}
末行图片少于 3 张且行高超过目标的 1.5 倍时,行高被限制在目标值,行尾留白。这是 Flickr 和 Google Photos 的共同行为——末行不强求铺满。
非末行的 justify-content 保持默认(stretch),图片按比例填满整行。末行使用 flex-start,图片保持自然宽度靠左排列。
渲染:Flex 比例填充
每张图片的 CSS:
flex: {aspectRatio} 1 0px;
min-width: 0;
overflow: hidden;
flex-grow 值等于图片的宽高比。浏览器自动按比例分配行内空间:宽图(ar=1.5)获得窄图(ar=0.7)约两倍的宽度。行高统一,图片高度等于行高,宽度由 flex 引擎自动计算。
这个方案的好处是不需要 JavaScript 计算每张图片的精确像素宽度——浏览器原生 flexbox 引擎处理分配和对齐。
渲染:Fancybox 集成
每张图片包裹在 <a data-fancybox="gallery"> 中,href 指向 large/ 版本(2160px WebP),data-caption 携带 caption 文本。渲染完成后调用 Fancybox.bind('[data-fancybox]') 绑定灯箱。
缩略图使用 thumbs/ 版本(720px WebP),loading="lazy" 延迟加载,object-fit: cover 防止变形。
重绘与防抖
画廊在以下时机重新布局:
- 页面加载
- 窗口 resize(250ms 防抖)
- 筛选条件变化(通过
window.__rerenderGallery())
window.addEventListener('resize', function () {
clearTimeout(resizeTimer);
resizeTimer = setTimeout(rerender, 250);
});
__rerenderGallery 是一个全局函数,由筛选脚本在更新数据后调用。它从 <script id="gallery-data"> 标签中重新读取 JSON,重新计算布局,重新渲染 DOM。
入场动画
渲染完成后,每张图片以 50ms 间隔依次淡入:
for (var i = 0; i < items.length; i++) {
(function (el, delay) {
setTimeout(function () {
el.style.transition = 'opacity 500ms ease';
el.style.opacity = '1';
}, delay);
})(items[i], i * 50);
}
效果是从左到右、从上到下的波浪式显现。200 张图片的画廊约需 10 秒完成全部淡入,但用户可以在淡入过程中就开始浏览和点击。
与筛选系统的联动
摄影页的筛选系统(题材 / 地点 / 年份)在客户端运行。页面嵌入两份 JSON 数据:
<script id="gallery-data" type="application/json">
[...当前筛选结果...]
</script>
<script id="gallery-data-full" type="application/json">
[...全量数据...]
</script>
筛选时,JavaScript 从全量数据中过滤出匹配项,写入 gallery-data 标签,然后调用 __rerenderGallery()。URL 同步更新(?category=城市风光&city=南京&year=2025),刷新页面保持筛选状态。
这个设计让筛选完全在客户端完成,不需要重新请求服务器或重新加载页面。
为什么不用现有库
Justified Gallery 的开源实现不少(justifiedGallery、flickr-justified-gallery、react-photo-album),但自研有几个理由:
第一,可控性。竖图惩罚、末行保护、入场动画这些细节需要精确控制,改别人的库不如自己写。
第二,零依赖。259 行原生 JavaScript,不依赖 jQuery(justifiedGallery 依赖)、不依赖 React(react-photo-album 依赖)。这座站是静态生成的,运行时依赖越少越好。
第三,集成度。引擎直接读取页面内嵌的 JSON 数据,直接绑定 Fancybox,直接暴露 __rerenderGallery 全局函数给筛选系统。不需要适配层。
第四,体积。259 行代码约 6KB(未压缩),比任何第三方库都小。