去年做旅行产品运营后台时,我接了个看似不起眼的需求:运营每周都要更新目的地推荐,然后把新目的地做成页面上的卡片。一开始他们是用复制粘贴改HTML的办法,改一次要动十几个地方,图片路径、价格、标签稍不留神就漏掉一个。后来我直接基于最普通的PHP和HTML,写了一套“动态生成旅行卡片”的页面。思路不复杂:把旅行地数据放进PHP数组,在HTML模板里用循环渲染成卡片,再配合筛选参数让页面自己“动起来”。这篇文章不做框架演示,也不引入构建工具,就用PHP原生的数组、foreach和HTML/CSS,把一个完整案例的思考过程和实现细节讲清楚。
无论你是刚开始学后端渲染的新手,还是平时用框架比较多、想回头看看原生PHP怎么干活的人,这个案例都值得花十分钟过一遍。它能帮你理解“数据到底是怎么变成HTML标签的”,也顺带解决一批新手极容易踩的编码、转义、筛选状态保留的坑。整套代码可以直接复制到本地跑。
1. 项目需求分析与整体设计思路
1.1 为什么选 PHP + HTML 这种组合
纯静态HTML不是不能做卡片页,但遇到“每周更新、数据结构差不多、只是内容变了”的场景,静态方案就非常难受:每加一个目的地,就要复制一段几百行的卡片结构,改错一个class或标签,整张卡片样式就崩了。纯前端JavaScript方案我也考虑过,数据用JSON保存、由JS动态生成DOM。它的问题在于内容不在页面源代码里,搜索引擎和分享链接打开时看不到数据,而且如果运营的电脑JS环境有异常,页面会直接空白。
PHP在这里最大的优势是“在服务器端就把HTML拼好”,用户拿到的就是完整的、可以直接展示的网页。它没有Node、Vite那套依赖链,一台能跑PHP的服务器或者本地命令行就能工作。实际开发中,如果项目已经用了ThinkPHP、Laravel这类框架,当然可以走框架的模板引擎;但很多小工具、内部页面、临时活动页,用原生PHP反而最省事。我要做的不是造一个框架,而是拿最少的代码解决“批量生成结构化卡片”这个问题。
1.2 先想清楚卡片上要展示哪些字段
做动态页面前,我养成了一个习惯:先对着设计稿或运营提的需求列一个字段表,而不是急着写代码。旅行卡片看起来只是几行文字配一张图,但它背后对应着一组完整的数据结构。我这次准备展示这几种信息:
| 字段名 | 示例值 | 说明 |
|---|---|---|
| id | 1 | 唯一标识,将来接数据库、做筛选时有用 |
| city | 杭州 | 目的地名称 |
| country | 中国 | 所属国家,也可以叫区域 |
| type | domestic / abroad | 用于筛选:国内或国外 |
| image | images/hangzhou.jpg | 卡片头图地址 |
| price | 2680 | 跟团或套餐价格 |
| rating | 4.8 | 评分 |
| tags | 江南水乡, 摄影 | 展示用的标签,可以存成数组 |
| summary | 西湖、灵隐寺、茶山两日游 | 一句话摘要 |
字段不一定要一次定死,但提前想清楚的好处是:写HTML时有明确的输出目标,写PHP时能决定数据到底用字符串、数组还是数字。价格和评分尽量用数字类型,不要写着“¥2680”“4.8分”这样的带单位字符串,否则后面做大小排序时非常痛苦。图片地址建议统一存相对路径或完整URL,别有的存相对地址、有的存完整地址。数据规范性等于是给自己减少麻烦。
1.3 动态渲染的整体流程
动态生成卡片的本质是“把重复的HTML变成模板,把变化的内容交给数据”。流程可以概括成四步:
- PHP准备数据,数据可以来自写死的数组、独立文件、数据库或者接口。
- 根据用户请求参数(如分类、排序方式)对数据做过滤和整理。
- 在HTML模板中通过foreach循环逐条输出卡片。
- 浏览器拿到服务器返回的完整HTML,配合CSS渲染成整齐的卡片。
理解这个流程对后面的代码很有帮助。整个过程没有“点击后局部刷新”,而是每次请求都会带着当前筛选条件重新生成页面。这样虽然不酷,但胜在直观、稳。真正的单页应用以后可以再演进,目前的需求用这个方案已经非常合适。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 静态页面先行:搭一个漂亮卡片的 HTML/CSS
2.1 卡片的结构别偷懒
我习惯先写一个静态的HTML卡片,把样式调满意了,再改成动态循环。如果一上来就用PHP foreach拼标签,HTML结构出错时排查成本很高。静态卡片的标准结构大致是这样:
html复制<article class="card">
<div class="card-cover">
<img src="images/hangzhou.jpg" alt="杭州">
<span class="tag">国内</span>
</div>
<div class="card-body">
<div class="card-meta">
<span class="country">中国</span>
<span class="rating">4.8</span>
</div>
<h3>杭州</h3>
<p class="summary">西湖、灵隐寺、茶山,一次逛遍江南春色。</p>
<div class="tags">
<span>江南水乡</span>
<span>摄影</span>
</div>
<div class="card-footer">
<span class="price">¥2680</span>
<a href="detail.php?id=1">查看详情</a>
</div>
</div>
</article>
我特意把标签放在图片左上方,而不是让正文出现一长串分类。卡片高度有限,信息层级要分明:图片负责吸引视线,标题和价格是最需要突出的,摘要和标签排在后面。这个语义化结构里每个区块都有独立类名,后续交给PHP循环时不需要破坏内部层级,只需把对应位置换成输出语句即可。写结构时还要注意img必须有alt属性,一方面是无障碍访问的基本要求,另一方面当图片地址失效时,alt能告诉用户这里本来该展示什么。
2.2 用 CSS Grid 安排卡片网格
卡片本身设计好之后,页面上的卡片排列可以交给CSS Grid。Grid做二维网格非常顺手,比手动计算百分比加margin靠谱得多。我的基础布局是这样:
css复制.card-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 24px;
max-width: 1200px;
margin: 0 auto;
padding: 20px;
}
.card {
background: #fff;
border-radius: 16px;
overflow: hidden;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
transition: transform 0.2s ease, box-shadow 0.2s ease;
display: flex;
flex-direction: column;
}
.card:hover {
transform: translateY(-4px);
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.12);
}
.card-cover {
position: relative;
overflow: hidden;
}
.card-cover img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.card-body {
padding: 16px;
display: flex;
flex-direction: column;
flex: 1;
}
repeat(auto-fill, minmax(280px, 1fr))这行值得多说一句:它让浏览器自己决定一行放几张卡片,每张卡片最小280px,如果屏幕足够宽就多放几列,不够宽就自动换行。不需要写媒体查询也能胜任大部分场景,配合容器最大宽度1200px,页面上大约能出现三到四列。这个方案比“每行固定三列”的兼容性好很多,内容多一两张或者少一两张都不会留下空白。
2.3 让图片不“丑”的响应式细节
图片可能是卡片美观度最大的变数。运营上传的图片不可能都是同样的宽高比,如果简单设置width:100%,图片高度会各不相同,卡片网格就会参差不齐。我常用的方案是用CSS aspect-ratio或者经典的padding-top百分比法。现代浏览器直接支持aspect-ratio,代码更干净:
css复制.card-cover {
aspect-ratio: 4 / 3;
}
.card-cover img {
width: 100%;
height: 100%;
object-fit: cover;
}
这里一定要配合object-fit: cover,它的作用是让图片按比例填满容器,超出部分被裁剪,而不是把图片拉伸变形。如果希望图片能完整展示而不是裁剪,可以改成object-fit: contain,但那样往往留白很大,卡片头图很少用。另一个容易忽略的问题是文字行数不固定:摘要有时候写两行,有时候写三行,就会把卡片底部顶得高低不平。我给summary区域设置了一个自动收缩占位,并允许它占满剩余空间:
css复制.summary {
flex: 1;
margin: 8px 0;
line-height: 1.6;
}
这样即使摘要长短不一,footer区域也会自动对齐到卡片底部,视觉上整齐很多。这些细节在动态渲染后尤其重要,因为数据一多,任何“差不多”的样式都会暴露出参差感。
3. PHP 后端:数据准备与动态模板输出
3.1 用关联数组模拟旅行数据源
静态页面调好之后,就可以把数据提取到PHP侧。前期不接数据库,我选择用一个关联数组来模拟数据。关联数组的键名等于字段名,语义清晰,后面从MySQL取出的行记录也天然是这种形状。
php复制<?php
$trips = [
[
'id' => 1,
'city' => '杭州',
'country' => '中国',
'type' => 'domestic',
'tags' => ['江南水乡', '摄影'],
'price' => 2680,
'rating' => 4.8,
'image' => 'images/hangzhou.jpg',
'summary' => '西湖、灵隐寺、茶山,一次逛遍江南春色。',
],
[
'id' => 2,
'city' => '京都',
'country' => '日本',
'type' => 'abroad',
'tags' => ['古都', '红叶'],
'price' => 6980,
'rating' => 4.9,
'image' => 'images/kyoto.jpg',
'summary' => '清水寺、伏见稻荷、岚山竹林,关西人文慢行。',
],
// 继续添加更多目的地……
];
在实际项目里,这份数据大概率来自数据库。之所以先用数组,是因为写文章、跑demo时不需要先建库建表,重点集中在“PHP如何把数组渲染成HTML”上面。等代码跑通,你把$trips替换成从PDO查询得到的结果集,输出模板几乎不用改。要注意保存PHP文件时带不带BOM:编辑器保存为“UTF-8无BOM”最稳,带BOM可能会在header输出前混入隐藏字符,导致页面顶部多出一行空白。
3.2 在 HTML 中插入 PHP 循环,生成卡片
数据准备好了,下面进入核心环节:在HTML文件里直接写PHP标签循环。许多人不适应PHP和HTML混排,其实只要记住“A区域负责逻辑,B区域负责模板”就行,不需要把所有内容都用echo拼接成字符串。下面这段代码放在页面的主要区域内:
php复制<main class="card-grid">
<?php foreach ($trips as $trip): ?>
<article class="card">
<div class="card-cover">
<img src="<?php echo htmlspecialchars($trip['image']); ?>" alt="<?php echo htmlspecialchars($trip['city']); ?>">
<span class="tag"><?php echo htmlspecialchars($trip['country']); ?></span>
</div>
<div class="card-body">
<div class="card-meta">
<span class="rating"><?php echo htmlspecialchars($trip['rating']); ?></span>
</div>
<h3><?php echo htmlspecialchars($trip['city']); ?></h3>
<p class="summary"><?php echo htmlspecialchars($trip['summary']); ?></p>
<div class="tags">
<?php foreach ($trip['tags'] as $tag): ?>
<span><?php echo htmlspecialchars($tag); ?></span>
<?php endforeach; ?>
</div>
<div class="card-footer">
<span class="price">¥<?php echo htmlspecialchars($trip['price']); ?></span>
<a href="detail.php?id=<?php echo (int)$trip['id']; ?>">查看详情</a>
</div>
</div>
</article>
<?php endforeach; ?>
</main>
这里用的是PHP的“备用语法”,foreach:和endforeach;配对。相比传统的{和},这种写法在HTML模板里更直观,能一眼看出循环开始和结束的位置。每一处动态输出我都直接调用了htmlspecialchars,这一步不是可有可无。你从数据库或用户输入拿到字符串后,如果不转义,万一里面有引号或HTML标签,轻则样式错乱,重则变成XSS漏洞。转义操作应该在输出前最后一刻做,不要让变量带着转义后的内容到处跑,否则容易转义两次,反而显示成&lt;这类奇怪字符。
3.3 统一转义,远离 XSS 与页面错乱
代码里反复写htmlspecialchars($trip['city'])很长,而且容易漏。更规范的做法是定义一个小函数,模板里统一用e()来输出:
php复制<?php
function e($value): string
{
return htmlspecialchars((string) $value, ENT_QUOTES, 'UTF-8');
}
有了e()之后,前面模板可以改写成<?php echo e($trip['city']); ?>。函数内部的ENT_QUOTES会把单引号和双引号都转成实体,适合用在被单引号或双引号包裹的HTML属性中。(string)强转是为了避免传入数组时产生警告,虽然正常情况下字段不会是数组。图片URL作为属性输出也必须转义,因为URL中可能含有&字符(例如带查询参数的图片地址),不转义的话,&会被浏览器理解为参数间隔符,可能导致图片地址解析错误。
建议在项目开始就规定:凡是在HTML中输出动态内容,一律用e(),没有例外。即使当前数据是你自己在PHP数组里写死的,也不代表将来不会替换成运营输入的文本。安全习惯越早养成,出现漏洞的概率越低。
3.4 给缺失字段一个兜底方案
数据变多之后,经常会遇到某条记录的字段没填全,比如标签为空、价格没定。如果不做处理,模板会输出空白或者“¥”这种半截内容,特别难看。我习惯给每个字段设计默认显示文本。例如价格字段,可以用空值合并运算符??配合三元逻辑:
php复制<?php
$priceText = (!empty($trip['price']) && is_numeric($trip['price']))
? '¥' . number_format($trip['price'])
: '价格待定';
?>
展示时直接用<?php echo e($priceText); ?>。is_numeric的判断是为了防止将来数据源里出现字符串“待定”,如果直接做格式化会报错。标签数组通常允许为空,可以这样输出一个占位:
php复制<?php if (!empty($trip['tags'])): ?>
<div class="tags">
<?php foreach ($trip['tags'] as $tag): ?>
<span><?php echo e($tag); ?></span>
<?php endforeach; ?>
</div>
<?php else: ?>
<span class="tag-placeholder">暂无标签</span>
<?php endif; ?>
“暂无标签”占位看起来不美观,但比整个区块消失要友好,用户至少知道这里是设计过的,只是没有内容。如果字段缺失严重,还可以用统一的默认头像图。兜底不是鼓励偷懒,而是让程序在异常数据面前不至于露出丑陋的破绽。
4. 让卡片真正“动”起来:筛选与排序
4.1 用 GET 参数接收用户的筛选条件
光能渲染一组卡片还不算“动态”,真正实用的是让用户通过点击筛选条件,看到不同的目的地集合。我先在页面上方放几个链接,例如“全部”“国内”“国外”。链接指向当前页面并携带参数:
html复制<div class="filter-bar">
<a href="index.php">全部</a>
<a href="index.php?category=domestic">国内</a>
<a href="index.php?category=abroad">国外</a>
</div>
点击“国内”之后,页面URL会变成index.php?category=domestic,PHP就要读取这个参数并据此筛选。为什么要用GET而不是POST?因为筛选动作的语义是“获取结果”,而且URL可以被收藏、分享给同事,方便还原同一份列表。POST提交虽然也可以,但刷新页面时会弹提示,而且链接没法直接复制,体验差不少。
4.2 数组过滤与白名单校验
从客户端传来的参数一律不可信,所以要先做一份白名单校验,只允许我预设的几个值,不在白名单内就回退到“全部”。然后利用array_filter对原始数组做过滤:
php复制<?php
$category = $_GET['category'] ?? 'all';
$allowed = ['all', 'domestic', 'abroad'];
if (!in_array($category, $allowed, true)) {
$category = 'all';
}
$filteredTrips = array_filter($trips, function ($trip) use ($category) {
if ($category === 'all') {
return true;
}
return $trip['type'] === $category;
});
?>
array_filter的回调函数需要返回true/false:返回true保留这条数据,返回false丢弃。这里用use把外部变量$category传入匿名函数,是PHP常见的闭包用法,不写use的话匿名函数内部无法读取到$category。后续渲染的循环也需要把$trips改成$filteredTrips,否则筛选等于没做。
白名单校验带来的好处是,即使用户手动把URL改成index.php?category=<script>也不会让恶意代码进入页面,因为$category变量只可能是四种值之一。同理,如果将来做搜索关键词,也不能直接把关键词回显到页面,而要用e()过滤,避免构造出事件属性之类的注入。
4.3 排序和筛选结合,保持 URL 可分享
排序和筛选通常一起出现。我比较常用的排序参数是sort=price_asc、sort=price_desc和sort=rating_desc。先用usort在过滤后的数组上排序:
php复制<?php
$sort = $_GET['sort'] ?? 'default';
$allowedSorts = ['default', 'price_asc', 'price_desc', 'rating_desc'];
if (!in_array($sort, $allowedSorts, true)) {
$sort = 'default';
}
if ($sort === 'price_asc') {
usort($filteredTrips, fn($a, $b) => $a['price'] <=> $b['price']);
} elseif ($sort === 'price_desc') {
usort($filteredTrips, fn($a, $b) => $b['price'] <=> $a['price']);
} elseif ($sort === 'rating_desc') {
usort($filteredTrips, fn($a, $b) => $b['rating'] <=> $a['rating']);
}
?>
箭头函数fn($a, $b) => $a['price'] <=> $b['price']是PHP 7.4以后写法,飞船运算符<=>会比较左右两边的值,左边小返回-1,相等返回0,左边大返回1,天然适合做升序排序。降序就把比较的两个参数调换位置。排序前需要确保字段是数字,这就是我在最前面强调“价格字段存数字而不是带¥字符串”的原因,否则字符串排序会出现“1000小于268”的尴尬结果。
筛选和排序同时存在时,页面上的链接必须保留两种参数。例如按类型筛选后仍然保留当前排序状态,排序链接也要保留当前分类参数。PHP代码可以拼URL:
php复制<a href="index.php?category=<?php echo e($category); ?>&sort=price_asc">价格从低到高</a>
这样用户从“国内低价”切到“国际低价”时,排序状态不会丢失。后续如果增加搜索关键词、页数等参数,可以构造一个公共函数统一拼query string,减少手写出错概率。这里不展开,但思路是提前规划好URL参数的组合关系。
5. 项目维护与扩展建议
5.1 把数据抽离到独立文件
代码跑通之后,我通常马上把数据从页面文件里挪出去。我会新建一个data.php,里面只负责返回数组:
php复制<?php
return [
[
'id' => 1,
'city' => '杭州',
// ...
],
// ...
];
?>
主文件顶部用一行代码引入:
php复制<?php
$trips = require 'data.php';
?>
这样数据文件和模板文件互不干扰。将来运营可能不是自己改代码,而是由后端程序在CMS里录入数据,数据文件只是临时占位。抽离数据更大的意义在于:同一个数据源可以被多个页面共用,例如首页展示前4条热门路线,另一个页面展示全部路线,只需要在各自页面require并处理,不需要复制整份数组。如果之后要把数组改成MySQL数据,也只需要把require 'data.php'换成一次数据库查询。
5.2 把卡片渲染拆成独立模板
当页面的结构越来越多,把全部HTML堆在index.php里会变得难以阅读。最简单的模板拆分方式是把卡片循环单独放一个card_list.php文件,用include引入。那些需要许多小片段拼装的项目,也可以用函数渲染单张卡片:
php复制<?php
function renderCard($trip): void
{
?>
<article class="card">
<!-- 单张卡片HTML,内部使用$trip变量 -->
</article>
<?php
}
?>
然后在循环里调用renderCard($trip)。函数的好处是作用域隔离,$trip参数清清楚楚,要看某个卡片如何渲染时,直接跳到renderCard函数即可。不过函数内使用HTML需要结束PHP标签再写HTML,代码风格稍显跳跃。如果你不习惯,也可以把单个卡片的模板片段存成card.php,循环里用include 'card.php';,include文件内会自动继承外部作用域的$trip。两种方式都有,我个人的体会是:函数更适合传参明确的小片段,include更适合整块模板复用。
5.3 数据库接入、模板引擎与缓存的取舍
聊聊扩展的方向。如果数据量大到数组无法支撑,最常见的下一步是接入MySQL。用PDO查询后得到的结果集形状依旧是一行一行的关联数组,所以原来的模板几乎不用改,只需要在页面顶部完成查询并赋值给$trips。不过要有意识地区分“过滤”是在数据库里做还是在PHP里做。数据量大时,应该用SQL的WHERE和ORDER BY来筛选排序,避免把几万行记录加载到PHP内存后再过滤,白白浪费内存和时间。
也许有人会想:既然原生PHP写模板有点乱,是不是应该引入Smarty或Twig?我的建议是,这个规模的页面完全没必要。原生PHP本身就是一种模板引擎,只要控制好变量转义、逻辑不要过于复杂,非常直接。只有当团队需要强制的模板继承、沙箱机制,或者前端不许接触PHP语法时,才值得考虑。项目主导者应该想清楚为什么加依赖,而不是为了“页面干净”就无脑上模板引擎。
缓存方面,如果目的地列表不常变,可以每次请求把渲染好的HTML整体保存成静态文件,比如第一次访问时生成cache/index.html,之后直接读取静态文件,能大幅降低响应时间。这是很实用的一招,但要注意设置缓存有效期,比如半小时或者内容更新时删掉缓存文件。依赖数据库和大量用户访问的动态站,还应考虑Redis缓存和PHP内置的OPcache加速。这里的demo不需要,但你心里要有这些选项的存在。
6. 常见问题与排查技巧实录
6.1 打开页面显示源代码,卡片没有渲染
最常见同时也是最基础的问题是:你在浏览器地址栏直接双击了index.php文件,或者你的环境根本没启用PHP解析。PHP文件需要被一个能解析PHP的服务端执行,Apache、Nginx + PHP-FPM、PHP内置服务器都可以。本地做代码测试时最简单的办法是给PHP命令行加上内置服务器:
bash复制php -S localhost:8000
然后在浏览器访问http://localhost:8000/index.php。如果页面显示的是完整的PHP代码,比如能看到<?php foreach这类文字,那说明当前文件不是被PHP脚本引擎执行,而是被当作静态文本输出了。检查一下文件扩展名是不是.php,还有服务器配置里有没有把.php交给PHP处理。另一个隐藏坑是文件里有“短标签”写法<?但服务器关闭了short_open_tag,尽量统一写完整的<?php。
6.2 中文全部变成问号或乱码
乱码几乎都是字符集不一致引起的。我一般按下面顺序排查:
- 浏览器打开页面后,查看“页面编码”是不是UTF-8。
- HTML的
<head>里有没有<meta charset="utf-8">。 - PHP文件本身是否以UTF-8无BOM保存,记事本另存为时注意选编码。
- 如果PHP要设置HTTP头,可以先加
header('Content-Type: text/html; charset=utf-8');。
常见情况是:数据库里是UTF-8,PHP文件是GBK,HTML声明是UTF-8,三个环节不一致就会出现问号或乱码。统一写成UTF-8最简单。如果你在Windows上使用古老编辑器保存文件,尤其容易混入GBK或ANSI编码,建议用VS Code这类工具,打开文件时右下角能看到编码,另存为时直接选“UTF-8”。
6.3 图片大小不一致导致卡片高度不统一
我已经在CSS部分给出了aspect-ratio加object-fit: cover的方案,但实际项目里仍然可能发现某张卡片特别高或特别矮。排查顺序是:先看是不是图片容器没有被限制比例。如果图片不是放在.card-cover里,或者图片外层结构顺序不对,aspect-ratio可能无效。然后看是不是项目里还写了一个针对img的height: auto,它可能覆盖了.card-cover img里的高度设置,可以用开发者工具查看最终计算样式。
另一个容易被忽视的点是文本区。标题较长或摘要较长,会自然撑高卡片。如果不希望行数不同导致高度不同,可以为.summary设置固定行高和-webkit-line-clamp: 2做两行截断,配合overflow: hidden;显示省略号。这样可以保证卡片都保持统一高度。无论选哪种方案,设计上都要决定卡片高度要不要随内容变化,不要稀里糊涂一会儿变一会儿不变。
6.4 筛选后页面空白,连提示都没有
筛选条件组合之后,很可能出现“没有符合条件的目的地”,此时我见过很多页面就是一张白纸,用户以为网站坏了。所以模板循环外部一定要加空状态判断。改造后的核心逻辑类似:
php复制<?php if (count($filteredTrips) === 0): ?>
<div class="empty-state">
<p>没有找到符合当前筛选条件的目的地</p>
<a href="index.php">查看全部旅行地</a>
</div>
<?php else: ?>
<main class="card-grid">
<?php foreach ($filteredTrips as $trip): ?>
<!-- 卡片HTML -->
<?php endforeach; ?>
</main>
<?php endif; ?>
同时给空状态写一点样式,比如内边距大一些、字体颜色灰一些。这属于很小的体验细节,但对运营和用户来说有明显差异。我建议把筛选后的变量打印出来调试一下:var_dump($filteredTrips); 看看到底是数据没被筛到,还是筛选条件拼错。php是动态语言,变量名写错不会当场报错,只会默默变成null,所以排查时先确认数组是否为空、是否加载到了数据。
这套用PHP和HTML动态生成旅行卡片的方案,核心就是“数据驱动视图”:数据写好,卡片自动生成;筛选条件一变,列表立刻跟着变。我后来在真实项目里把数组换成了数据库查询、把模板单独抽成文件、用JS给筛选栏加了局部刷新,但底层的实现逻辑仍是当年学的这套foreach加HTML输出。当你在没有现代前端框架的环境里接到类似需求,不妨先试着用PHP原生能力解决,你会对Web运行本质有更清晰的体感。
