前言
图片画廊看起来是最简单的页面之一,实际却经常出三类怪毛病。第一类是"排版抽风":四列布局在 1080p 显示器上正好,换到某台高分屏笔记本,第四张图莫名其妙掉到下一行,看起来像少了一列。第二类是"最后一行被拉宽":一行本来放四张,最后一行只剩两张,这两张就被拉成半个屏幕宽的大图。第三类是"移动端挤成一条线":断点没设对,手机上四列每列只剩 80 像素,图片全成了模糊的色块。
这三个问题的根源不在一处:前两个是 Flexbox 的flex-basis与flex-grow语义没吃透,第三个是响应式断点与图片资源尺寸没对齐。而 PHP 在这个组合里的职责被很多人搞错——它不该负责"布局",它负责的是准备数据、生成正确的图片地址与尺寸声明、并且安全地把它们输出成 HTML。
本文先讲清 Flexbox 四列是怎么算出来的,再讲图片本身如何不破坏布局,最后给出一份带缩略图生成、srcset输出和转义处理的完整可运行示例。
一、四列是怎么算出来的
Flex 容器里放n列、列间距为gap时,每个子项的宽度必须满足:
列宽 = (100% - (n - 1) * gap) / n用 CSS 自定义属性把列数抽出来,断点里只改一个变量,这是最省心的写法:
:root { --gap: 16px; } .gallery { display: flex; flex-wrap: wrap; /* 允许换行,否则会被压缩成一坨 */ gap: var(--gap); --cols: 4; } .gallery__item { /* 0 不放大、0 不缩小、固定基础宽度 */ flex: 0 0 calc((100% - (var(--cols) - 1) * var(--gap)) / var(--cols) - 0.01px); margin: 0; } @media (max-width: 1024px) { .gallery { --cols: 3; } } @media (max-width: 768px) { .gallery { --cols: 2; } } @media (max-width: 480px) { .gallery { --cols: 1; } }flex是flex-grow flex-shrink flex-basis的简写,三个值的取舍直接决定布局行为:
| 写法 | 含义 | 后果 |
|---|---|---|
flex: 1 | 放大 1、缩小 1、基础尺寸 auto | 最后一行项数不足时被拉宽 |
flex: 1 1 220px | 弹性放大 + 最小 220px | 列数自适应,但最后一行同样被拉宽 |
flex: 0 0 calc(...) | 不放大、不缩小、宽度精确计算 | 严格 n 列,最后一行保持原宽(推荐) |
flex: 0 1 25% | 不放大、可缩小 | 加上gap后总宽超过 100%,会挤成三列 |
最后一行如果希望"填满",可以改用flex-grow: 1,并用占位元素把空缺补齐——库里面只要有n * 列数个元素,最后一行就不会缺项:
<?php // 需 PHP 8.0+:用占位元素补齐最后一行,避免末行图片被拉伸 function pad_to_full_row(array $items, int $cols): array { $missing = (count($items) % $cols) === 0 ? 0 : $cols - (count($items) % $cols); for ($i = 0; $i < $missing; $i++) { $items[] = ['placeholder' => true]; } return $items; }CSS Grid 也能做同样的事(grid-template-columns: repeat(auto-fit, minmax(220px, 1fr))),但 Flexbox 在需要"最后一行不拉伸""子项高度可不等"这类场景下更好控。两者对比:
| 需求 | Flexbox 首选写法 | Grid 首选写法 |
|---|---|---|
| 严格四列 | flex: 0 0 calc(...)+ 断点变量 | grid-template-columns: repeat(4, 1fr) |
| 列数自适应 | flex: 1 1 220px+ 补齐占位 | repeat(auto-fit, minmax(220px, 1fr)) |
| 最后一行不拉伸 | flex-grow: 0天然满足 | 天然满足 |
| 等高分块 | align-items: stretch | grid-auto-rows: 1fr |
| 卡片高度不齐 | 天然支持瀑布式 | 需要grid-auto-rows配合 |
二、图片本身不能决定布局
画廊之所以"抽风",一半原因是图片在反过来影响容器:图片按自身比例撑高卡片,行与行高度不一致;图片作为行内元素(inline)在基线处留出几像素缝隙;原图尺寸巨大,浏览器解码慢导致布局抖动(CLS)。
正确做法是让容器决定尺寸,让图片适应容器:
.gallery__item { aspect-ratio: 4 / 3; /* 卡片比例固定,与图片原始比例无关 */ overflow: hidden; /* 裁掉超出部分 */ border-radius: 8px; background: #eaeaea; /* 图片加载前先占住位置 */ } .gallery__item img { display: block; /* 消除行内元素基线空隙 */ width: 100%; height: 100%; object-fit: cover; /* 等比裁切填满,不变形 */ }四个属性的分工要记住:aspect-ratio管卡片盒子比例,object-fit管图片怎么填满盒子,display: block管消除缝隙,background管加载前的占位。少任何一个,都会出现一种可复现的错位。
三、PHP 该做的事:数据、尺寸声明、转义
PHP 侧的核心职责有四条,一条都不能省。
第一,过滤出真正的图片。用getimagesize()过滤而不是靠扩展名,因为扩展名可以随便改。
第二,输出转义后的属性值。文件名里带引号、尖括号、单引号是常态(用户上传的图尤其如此),不转义就是存储型 XSS。
第三,声明图片尺寸与响应式资源。让浏览器在图片下载完成之前就知道盒子有多大,同时按屏幕宽度选择合适尺寸的图。
第四,不信任任何来自请求的路径,避免目录穿越。
<?php // gallery.php 的数据层 —— 需 PHP 8.0+ declare(strict_types=1); /** @return list<array{src: string, alt: string, w: int, h: int, srcset: string}> */ function scan_gallery(string $dir, string $baseUrl): array { $items = []; foreach (glob($dir . '/*.{jpg,jpeg,png,webp}', GLOB_BRACE) ?: [] as $path) { $info = @getimagesize($path); if ($info === false) { continue; // 扩展名像图片但内容不是,跳过 } [$w, $h] = [$info[0], $info[1]]; if ($w < 300 || $h < 200) { continue; // 太小的图放上来只会糊 } $name = pathinfo($path, PATHINFO_FILENAME); $file = rawurlencode(basename($path)); // 缩略图按 400 / 800 / 1200 三档预先生成,命名规则为 xxx@400.jpg $variants = []; foreach ([400, 800, 1200] as $size) { $thumb = sprintf('%s/%s@%d.%s', $dir, $name, $size, pathinfo($path, PATHINFO_EXTENSION)); if (is_file($thumb)) { $variants[] = sprintf('%s/%s@%d.%s', $baseUrl, rawurlencode($name), $size, pathinfo($path, PATHINFO_EXTENSION)); } } $items[] = [ 'src' => $baseUrl . '/' . $file, 'alt' => $name, 'w' => $w, 'h' => $h, 'srcset' => build_srcset($variants, [400, 800, 1200]), ]; } usort($items, static fn (array $a, array $b): int => strcmp($a['alt'], $b['alt'])); return $items; } /** @param list<string> $urls @param list<int> $widths */ function build_srcset(array $urls, array $widths): string { $parts = []; foreach ($urls as $i => $url) { if (!isset($widths[$i])) { break; } $parts[] = sprintf('%s %dw', $url, $widths[$i]); } return implode(', ', $parts); }渲染部分用sprintf拼装,所有动态值统一走htmlspecialchars():
<?php // 需 PHP 8.0+ /** @param list<array{src: string, alt: string, w: int, h: int, srcset: string}> $items */ function render_gallery(array $items): string { $sizes = '(max-width: 480px) 100vw, (max-width: 768px) 50vw, ' . '(max-width: 1024px) 33vw, 25vw'; $cards = ''; foreach ($items as $item) { $cards .= sprintf( '<figure class="gallery__item">' . '<img src="%s" alt="%s" width="%d" height="%d" srcset="%s" sizes="%s"' . ' loading="lazy" decoding="async">' . '</figure>', htmlspecialchars($item['src'], ENT_QUOTES), htmlspecialchars($item['alt'], ENT_QUOTES), (int) $item['w'], (int) $item['h'], htmlspecialchars($item['srcset'], ENT_QUOTES), htmlspecialchars($sizes, ENT_QUOTES), ); } return '<div class="gallery">' . $cards . '</div>'; }width和height属性写的是图片的原始像素尺寸,浏览器会用它们算出宽高比,在 CSS 还没生效时先预留出正确比例的空间,这是消除 CLS 的标准手段;CSS 里的aspect-ratio: 4 / 3会在样式加载后接管最终比例,两者并不冲突。
代码实战:完整可运行的两步
第一步,用 GD 生成 12 张测试图(需要 GD 扩展)。没有现成图片也能跑:
<?php // make_samples.php —— 需 PHP 8.0+ 且启用 GD declare(strict_types=1); $dir = __DIR__ . '/images'; if (!is_dir($dir)) { mkdir($dir, 0775, true); } for ($i = 1; $i <= 12; $i++) { $im = imagecreatetruecolor(800, 600); // PHP 8.0 起返回 GdImage 对象 $bg = imagecolorallocate($im, ($i * 37) % 256, ($i * 71) % 256, ($i * 113) % 256); imagefilledrectangle($im, 0, 0, 800, 600, $bg); imagestring($im, 5, 40, 60, "sample {$i}", imagecolorallocate($im, 255, 255, 255)); imagejpeg($im, sprintf('%s/sample%d.jpg', $dir, $i), 85); imagedestroy($im); } echo "已生成 12 张测试图片到 {$dir}\n";第二步,把数据层、渲染函数和页面模板合成gallery.php:
<?php // gallery.php —— 需 PHP 8.0+ declare(strict_types=1); // 这里放 scan_gallery()、build_srcset()、render_gallery() 三个函数 $items = scan_gallery(__DIR__ . '/images', '/images'); if ($items === []) { http_response_code(200); echo '还没有图片,先运行 php make_samples.php'; exit; } ?> <!DOCTYPE html> <html lang="zh-CN"> <head> <meta charset="utf-8"> <meta name="viewport" content="width=device-width, initial-scale=1"> <title>响应式四列图片画廊</title> <style> :root { --gap: 16px; } body { margin: 24px; font-family: system-ui, "Microsoft YaHei", sans-serif; } .gallery { display: flex; flex-wrap: wrap; gap: var(--gap); --cols: 4; } .gallery__item { flex: 0 0 calc((100% - (var(--cols) - 1) * var(--gap)) / var(--cols) - 0.01px); margin: 0; aspect-ratio: 4 / 3; overflow: hidden; border-radius: 8px; background: #eaeaea; } .gallery__item img { display: block; width: 100%; height: 100%; object-fit: cover; transition: transform .25s ease; } .gallery__item:hover img { transform: scale(1.04); } @media (max-width: 1024px) { .gallery { --cols: 3; } } @media (max-width: 768px) { .gallery { --cols: 2; } } @media (max-width: 480px) { .gallery { --cols: 1; } } </style> </head> <body> <?= render_gallery($items) ?> </body> </html>用内置服务器跑起来即可:把两个文件放在同一目录,先执行生成脚本,再启动服务,浏览器打开http://127.0.0.1:8000/gallery.php,拖动窗口宽度就能看到 4 / 3 / 2 / 1 列的切换。
php make_samples.php php -S 127.0.0.1:8000常见坑点
- ❌ 子项只写
flex: 1,最后一行只有两张图时被拉成半屏宽
✅ 用flex: 0 0 <精确宽度>;如果确实想让末行填满,就要用占位元素补齐到整行项数
- ❌
calc((100% - 3 * var(--gap)) / 4)算得"刚刚好"
✅ 浮点误差会让第 4 张图在某些缩放比例下掉到下一行,减去0.01px留出余量即可稳定四列
- ❌ 图片不加
display: block
✅img默认是行内元素,会在基线处留出几像素空隙,行高被撑大;加display: block或给容器line-height: 0
- ❌ 不设
object-fit,指望图片"自己适应"
✅ 竖图与横图混排时卡片会被撑成不同高度。容器用aspect-ratio固定比例,图片用object-fit: cover裁切填充
- ❌ 直接把文件名拼进 HTML 属性
✅ 上传的图片名可能含引号或尖括号,必须htmlspecialchars($v, ENT_QUOTES),URL 部分再过rawurlencode()
- ❌ 用
$_GET['dir']之类的参数直接拼进glob()扫描路径
✅glob('../../' . $userInput . '/*.jpg')可以读到站点外文件。目录必须来自白名单或固定根目录,并校验真实路径
- ❌ 手机拍摄的 JPEG 竖图在画廊里显示成横的
✅ 相机把方向信息写在 EXIF 的 Orientation 里,浏览器不一定遵守。生成缩略图时用exif_read_data()读出 Orientation 并相应旋转、再清掉该标记
- ❌ 直接把几 MB 的原图输出到页面
✅ 预先生成 400 / 800 / 1200 三档缩略图,用srcset+sizes让浏览器按屏幕宽度选择,并加loading="lazy"延迟加载视口外的图
总结
| 环节 | 关键做法 | 少了它会怎样 |
|---|---|---|
| 列宽计算 | flex: 0 0 calc((100% - (n-1)*gap)/n - 0.01px) | 列数不稳定、末行被拉宽 |
| 响应式 | 断点只改 CSS 变量--cols | 每档都要重写一套宽度规则 |
| 卡片盒子 | aspect-ratio+overflow: hidden | 混排图片高度参差,行与行错位 |
| 图片填充 | display: block+object-fit: cover | 基线缝隙、图片变形 |
| 尺寸声明 | 输出width/height与srcset/sizes | 加载时页面抖动 |
| 安全输出 | htmlspecialchars()+rawurlencode() | 存储型 XSS、路径穿越 |
| 资源体积 | 预生成多档缩略图 +loading="lazy" | 移动端首屏加载数 MB |
布局的稳定性和 PHP 的关系其实很间接:PHP 只要把"每张图的路径是安全的、尺寸是准确的、多档资源是齐全的"这三件事做对,剩下的交给flex: 0 0加一个 CSS 变量就够了。那些"第四张图莫名换行"的玄学问题,九成来自calc()里没留那 0.01px 的余量,以及忘了给gap留出计算空间。