一个让我纠结了三个小时的卡片需求
几年前,我接到一个需求:产品列表页的卡片,有图片的卡片要显示大图、加阴影、标题加粗;没有图片的卡片要显示占位图标、边框变虚、文字居中。
设计师给了两套样式,但要求根据卡片内容自动切换。我当时想:这还不简单?JS 遍历卡片,检测有没有图片元素,然后加上不同的类。
结果写了 30 行 jQuery,还要监听图片加载失败的情况。最麻烦的是,这些卡片是后台动态渲染的,我得在 Ajax 回调里再次执行检测逻辑。后来需求变了:有些卡片图片可能被嵌套在 <a> 标签里,有些卡片图片在 <figure> 里------我的 JS 选择器改了一次又一次。
那时我就想:如果 CSS 能知道"这个卡片里面有没有图片",该多好。
2023 年底,:has() 终于在所有主流浏览器里站稳了脚跟。我第一时间用它重写了那个卡片组件,删除所有 JS,只用 20 行 CSS 就实现了"有图片的卡片不同样式"。设计师在浏览器里拉伸窗口、动态加载内容,卡片样式自动切换,他惊呼"这不科学"。
今天,我把这个实战过程写下来,带你从零开始,用纯 CSS 打造一个"智能卡片"------它能够自动识别自己是否包含图片,并应用截然不同的视觉风格。我们还会用到 CSS 嵌套、@layer 来管理样式,确保代码优雅且可维护。
第一章:需求与设计
1.1 卡片结构设计
我们设计一个通用的卡片组件,可能出现在文章列表、产品展示、博客首页等地方。卡片有两种变体:
- 含图片卡片:图片在上方(或左侧),标题大且加粗,有阴影,描述文字正常。
- 无图片卡片:居中显示一个占位图标,标题和描述居中,边框虚线,背景浅灰。
为了演示,我们使用以下 HTML 结构:
html
<div class="card">
<div class="card__media">
<img src="photo.jpg" alt="...">
</div>
<div class="card__content">
<h3 class="card__title">卡片标题</h3>
<p class="card__desc">卡片描述文字...</p>
<a href="#" class="card__link">阅读更多</a>
</div>
</div>
对于无图片卡片,我们直接省略 .card__media 部分,或者 .card__media 内不包含图片。
1.2 两种样式的视觉定义
| 样式项 | 有图片的卡片 | 无图片的卡片 |
|---|---|---|
| 布局 | 图片上方,内容在下 | 内容居中,无图片区域 |
| 背景 | 白色,有阴影 | 浅灰色 #f5f5f5,虚线边框 |
| 标题 | 左对齐,粗体,深色 | 居中,正常粗细,深灰色 |
| 描述 | 左对齐 | 居中 |
| 额外元素 | 无 | 显示一个占位图标(FontAwesome 或 emoji) |
1.3 为什么不用 JS?
- JS 需要等待 DOM 加载,可能看到样式闪烁。
- JS 需要监听图片加载失败、动态添加卡片等情况,增加复杂度。
- 在 SSR/CSR 混合应用中,更容易出现状态不一致。
- 纯 CSS 方案性能更好,更简洁,更容易维护。
第二章:基础样式与判断逻辑
2.1 基础样式(所有卡片共享)
我们先用 CSS 定义卡片的基础外观,不依赖于是否有图片。
css
.card {
border-radius: 12px;
overflow: hidden;
transition: all 0.3s ease;
background: white;
}
/* 共享内容区样式 */
.card__content {
padding: 1.25rem;
}
.card__title {
font-size: 1.25rem;
margin-bottom: 0.5rem;
}
.card__desc {
color: #4a5568;
line-height: 1.5;
margin-bottom: 1rem;
}
.card__link {
color: #3182ce;
text-decoration: none;
font-weight: 500;
}
2.2 核心:使用 :has() 判断是否有图片
关键代码行:
css
/* 有图片的卡片样式覆盖 */
.card:has(.card__media img) {
/* 这里放有图片时的特殊样式 */
}
/* 无图片的卡片样式覆盖 */
.card:not(:has(.card__media img)) {
/* 无图片时的样式 */
}
注意:.card:has(.card__media img) 选择包含图片元素 的卡片。如果 .card__media 存在但没有图片,或者图片加载失败但 DOM 结构仍在,:has() 仍会匹配 img 元素,所以逻辑上正确。我们希望只要 img 标签存在(无论 src 是否成功),就当作"有图片卡片"。
2.3 定义有图片卡片的样式
在 :has() 块内,我们可以覆盖任何属性:
css
.card:has(.card__media img) {
box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1);
background: white;
}
.card:has(.card__media img) .card__title {
color: #1a202c;
font-weight: 700;
}
.card:has(.card__media img) .card__content {
padding: 1.5rem;
}
2.4 无图片卡片的样式
对于没有图片的卡片,我们可能会添加一个伪元素作为占位图标,并且更改背景、边框和对齐。
css
.card:not(:has(.card__media img)) {
background: #f7fafc;
border: 2px dashed #cbd5e0;
text-align: center;
}
.card:not(:has(.card__media img)) .card__title {
color: #4a5568;
font-weight: 500;
}
.card:not(:has(.card__media img)) .card__desc {
text-align: center;
}
/* 伪元素添加占位图标 */
.card:not(:has(.card__media img)) .card__content::before {
content: "🖼️";
font-size: 3rem;
display: block;
margin-bottom: 0.75rem;
opacity: 0.5;
}
注意:我们必须确保 .card:not(:has(...)) 与 .card:has(...) 的优先级相同,避免冲突。它们互斥,且顺序不影响。
第三章:进阶 ------ 完全无 JS 的图片加载失败处理
上面的方案依赖 img 标签的存在。如果图片加载失败(src 无效),img 标签仍然存在,所以卡片会显示成"有图片"样式,但图片区域是破碎图标。为了处理这种情况,我们可以利用 CSS 的 ::after 伪类在图片加载失败时显示占位,但无法改变父级 :has() 的判断。不过我们可以额外添加一个属性:给 img 加上 onerror 属性将自身隐藏或替换,但这就引入了 JS。但我们可以用 :has(img[src$="error"]) 的hack?不现实。
更好办法:服务器端或者 JS 框架在图片加载失败时,移除 img 或添加一个 data-fallback 属性,然后用 CSS 判断。但为了"纯 CSS",我们可以接受破碎图标是"有图片"的一种表现------毕竟 DOM 中有图片元素,用户知道该位置应有图。或者我们隐藏 img,用背景色代替。但这些都需要 JS 检测加载失败。
因此,我们假设实际项目中图片 src 是可靠的。或者我们可以忽略边界情况,毕竟 :has() 主要解决的是"有无 img 标签"的问题。
第四章:结合 CSS 嵌套与 @layer
为了让样式更易于维护,我们可以使用 CSS 原生嵌套和 @layer 来组织代码。
4.1 使用原生嵌套
将卡片的所有样式嵌套在 .card 内部,提高可读性。
css
.card {
border-radius: 12px;
overflow: hidden;
&__content {
padding: 1.25rem;
}
&__title {
font-size: 1.25rem;
margin-bottom: 0.5rem;
}
&__desc { ... }
&__link { ... }
/* 有图片时的样式 */
&:has(&__media img) {
box-shadow: 0 10px 15px -3px rgba(0,0,0,0.1);
& .card__title {
font-weight: 700;
}
}
/* 无图片时的样式 */
&:not(:has(&__media img)) {
background: #f7fafc;
border: 2px dashed #cbd5e0;
text-align: center;
& .card__title {
font-weight: 500;
}
& .card__content::before {
content: "🖼️";
display: block;
font-size: 3rem;
}
}
}
注意:原生嵌套中的 & 需要作为一个独立的标记,某些浏览器可能要求 & 后有空格,但现代浏览器都支持。
4.2 使用 @layer 管理优先级
如果你的项目中有多个来源的样式(例如第三方库),你可以用 @layer 确保卡片组件的样式不被意外覆盖。
css
@layer components {
.card {
/* 所有卡片样式 */
}
}
并且在引入第三方 CSS 时,把它们放在较低的层中。
第五章:响应式与动画增强
5.1 响应式图片卡片
在不同屏幕宽度下,有图片的卡片可能会切换图片大小或布局。我们可以利用容器查询或媒体查询,但 :has() 依然有效。
css
@media (max-width: 640px) {
.card:has(.card__media img) .card__media img {
height: 160px;
object-fit: cover;
}
}
5.2 悬停动画
有图片的卡片悬停时,图片缩放;无图片的卡片悬停时,边框变色。
css
.card:has(.card__media img):hover {
transform: translateY(-4px);
box-shadow: 0 20px 25px -5px rgba(0,0,0,0.1);
}
.card:not(:has(.card__media img)):hover {
border-color: #3182ce;
background: #edf2f7;
}
第六章:兼容性与降级
6.1 浏览器支持现状
:has() 自 2023 年底被所有主流浏览器支持:Chrome 105+, Firefox 121+, Safari 15.4+。如果你的用户群体使用这些版本以上,可以放心使用。
6.2 使用 @supports 降级
对于不支持 :has() 的旧浏览器,我们可以提供一个基础样式(例如默认都按有图片的样式显示,或者都按无图片样式),并通过 @supports 检测。
css
/* 基础样式:假设所有卡片都像无图片卡片 */
.card {
background: #f7fafc;
border: 2px dashed #cbd5e0;
text-align: center;
}
@supports selector(:has(img)) {
/* 覆盖为默认白色,并应用 :has 逻辑 */
.card {
background: white;
border: none;
text-align: left;
}
.card:has(.card__media img) { /* 有图片样式 */ }
.card:not(:has(.card__media img)) { /* 无图片样式 */ }
}
这样,不支持 :has() 的浏览器至少能看到一个可用(虽然可能不够完美)的样式。
6.3 图片懒加载的影响
如果图片使用 loading="lazy",在图片加载完成前,img 标签已经在 DOM 中,:has() 会立即匹配,不会有闪烁。很好。
第七章:真实案例 ------ 博客首页卡片列表
假设我们有一个博客首页,从 CMS 获取文章列表,有些文章有配图,有些没有。我们使用纯 CSS 实现卡片样式自适应。
HTML 示例:
html
<div class="posts-grid">
<div class="card">
<div class="card__media"><img src="article1.jpg" alt=""></div>
<div class="card__content">...</div>
</div>
<div class="card">
<!-- 没有 media 块 -->
<div class="card__content">...</div>
</div>
</div>
CSS 使用前面定义的规则。最终效果:每个卡片根据是否有 img 显示不同样式。如果 CMS 返回的某些文章虽然配了图但图片 URL 404,我们接受破碎图标的出现(或者依赖图片服务器的默认占位图)。
第八章:性能与最佳实践
- 避免过于复杂的
:has()选择器 :例如.card:has(.wrapper .nested .deep img)会降低性能。尽量使用直接子代组合,如:has(> .card__media img)。 - 限制
:has()匹配的范围 :如果卡片数量过多(一千个以上),:has()的开销可能会增加。但现代浏览器足够快,通常不是问题。 - 与
:is()结合 :可简化为.card:has(:is(img, video))。 - 不要嵌套
:has():规范不允许嵌套,应避免。
第九章:扩展 ------ 多种条件组合
你不仅可以判断是否有图片,还可以判断是否有视频、音频、特定类等。
css
/* 包含图片或视频的卡片 */
.card:has(:is(img, video)) { }
/* 包含多个图片的卡片 */
.card:has(img:nth-of-type(2)) { }
/* 包含图片且描述超过3行的卡片 */
.card:has(img):has(p:line-clamp(3)) { /* 结合其他伪类 */ }
这些组合可以让卡片样式更加智能化。
第十章:总结与反思
:has() 改变了我们编写 CSS 的方式。曾经需要 JS 的条件样式,现在可以优雅地写在样式表里。对于"有图片的卡片不同样式"这个场景,:has() 是完美的解决方案------它让卡片完全根据自身内容决定外观,无需任何命令式代码。
从今往后,你可以专注于写 HTML 和 CSS,让浏览器自动判断、应用正确样式。这也符合现代 Web 开发"声明式优于命令式"的理念。
下次设计师再对你说"这个卡片如果有图片就加阴影,没有就加虚线框",你只需微笑着打开 CSS 文件,写上两行 :has(),然后继续喝咖啡。
完整代码示例
html
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>智能卡片 - 有图片 vs 无图片</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
background: #e2e8f0;
font-family: system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif;
padding: 2rem;
}
.grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
gap: 2rem;
max-width: 1200px;
margin: 0 auto;
}
/* 卡片基础样式 */
.card {
border-radius: 1rem;
overflow: hidden;
transition: all 0.2s ease;
background: white;
height: 100%;
display: flex;
flex-direction: column;
}
.card__media img {
width: 100%;
height: 200px;
object-fit: cover;
display: block;
}
.card__content {
padding: 1.25rem;
flex: 1;
display: flex;
flex-direction: column;
}
.card__title {
font-size: 1.25rem;
margin-bottom: 0.5rem;
font-weight: 600;
}
.card__desc {
color: #4a5568;
line-height: 1.5;
margin-bottom: 1rem;
flex: 1;
}
.card__link {
color: #3182ce;
text-decoration: none;
font-weight: 500;
}
/* 有图片的卡片样式 */
.card:has(.card__media img) {
box-shadow: 0 10px 15px -3px rgba(0,0,0,0.1);
}
.card:has(.card__media img) .card__title {
color: #1a202c;
font-weight: 800;
}
/* 无图片的卡片样式 */
.card:not(:has(.card__media img)) {
background: #f7fafc;
border: 2px dashed #cbd5e0;
text-align: center;
}
.card:not(:has(.card__media img)) .card__title {
color: #4a5568;
font-weight: 500;
}
.card:not(:has(.card__media img)) .card__desc {
text-align: center;
}
.card:not(:has(.card__media img)) .card__content::before {
content: "🖼️";
font-size: 3rem;
display: block;
margin-bottom: 0.75rem;
opacity: 0.6;
}
/* 悬停效果 */
.card:hover {
transform: translateY(-4px);
}
.card:has(.card__media img):hover {
box-shadow: 0 20px 25px -5px rgba(0,0,0,0.15);
}
.card:not(:has(.card__media img)):hover {
border-color: #3182ce;
background: #edf2f7;
}
</style>
</head>
<body>
<div class="grid">
<!-- 有图片的卡片 -->
<div class="card">
<div class="card__media">
<img src="https://picsum.photos/id/1015/400/250" alt="风景">
</div>
<div class="card__content">
<h3 class="card__title">山水之间</h3>
<p class="card__desc">这是一张有配图的卡片,展示自然风光,阴影效果明显,标题加粗。</p>
<a href="#" class="card__link">阅读更多 →</a>
</div>
</div>
<!-- 无图片的卡片 -->
<div class="card">
<div class="card__content">
<h3 class="card__title">文字卡片</h3>
<p class="card__desc">这张卡片没有任何图片,因此显示虚线边框、居中文本和一个占位图标。</p>
<a href="#" class="card__link">阅读更多 →</a>
</div>
</div>
<!-- 另一个有图片的卡片 -->
<div class="card">
<div class="card__media">
<img src="https://picsum.photos/id/26/400/250" alt="建筑">
</div>
<div class="card__content">
<h3 class="card__title">城市建筑</h3>
<p class="card__desc">现代都市天际线,卡片有阴影,标题粗体。</p>
<a href="#" class="card__link">阅读更多 →</a>
</div>
</div>
</div>
</body>
</html>
运行这段代码,你会看到三张卡片:两张有图的显示阴影、左对齐,一张无图的显示虚线边框、居中和 emoji 占位。无需任何 JavaScript,完美自适应。