实战:纯 CSS 实现“有图片的卡片不同样式”

一个让我纠结了三个小时的卡片需求

几年前,我接到一个需求:产品列表页的卡片,有图片的卡片要显示大图、加阴影、标题加粗;没有图片的卡片要显示占位图标、边框变虚、文字居中

设计师给了两套样式,但要求根据卡片内容自动切换。我当时想:这还不简单?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,完美自适应。

相关推荐
子兮曰4 小时前
Bun v1.3.14 深度解析:Image API、HTTP/3、全局虚拟存储与五十项变革
前端·后端·bun
kyriewen5 小时前
今天,百年巨头一次砍了9200人,而一个离职科学家的实话让全网睡不着觉
前端·openai·ai编程
问心无愧05135 小时前
ctf show web 入门42
android·前端·android studio
kyriewen5 小时前
老板逼我上AI,我偷偷在浏览器里跑LLaMA,省下20万API费
前端·react.js·llm
Beginner x_u6 小时前
前端八股整理(手写 02)|数组转树、数组扁平化、随机打乱一个数组
前端·数组·数组转树·数组扁平化
KaMeidebaby6 小时前
卡梅德生物技术快报|禽类成纤维细胞 FISH 实验:鸟类性别染色体基因定位技术实现与数据验证
前端·数据库·其他·百度·新浪微博
天若有情6736 小时前
前端高阶性能优化:跳出传统懒加载与预加载,基于用户行为做轻量预判加载
前端·性能优化
小小小小宇6 小时前
前端转后端:SQL 是什么
前端
张元清7 小时前
React Observer Hooks:7 种监听 DOM 而不写样板代码的方式
前端·javascript·面试
广州华水科技7 小时前
单北斗GNSS变形监测是什么?主要有怎样的应用与优势?
前端