一个让我纠结了三个小时的卡片需求几年前我接到一个需求产品列表页的卡片有图片的卡片要显示大图、加阴影、标题加粗没有图片的卡片要显示占位图标、边框变虚、文字居中。设计师给了两套样式但要求根据卡片内容自动切换。我当时想这还不简单JS 遍历卡片检测有没有图片元素然后加上不同的类。结果写了 30 行 jQuery还要监听图片加载失败的情况。最麻烦的是这些卡片是后台动态渲染的我得在 Ajax 回调里再次执行检测逻辑。后来需求变了有些卡片图片可能被嵌套在a标签里有些卡片图片在figure里——我的 JS 选择器改了一次又一次。那时我就想如果 CSS 能知道“这个卡片里面有没有图片”该多好。2023 年底:has()终于在所有主流浏览器里站稳了脚跟。我第一时间用它重写了那个卡片组件删除所有 JS只用 20 行 CSS 就实现了“有图片的卡片不同样式”。设计师在浏览器里拉伸窗口、动态加载内容卡片样式自动切换他惊呼“这不科学”。今天我把这个实战过程写下来带你从零开始用纯 CSS 打造一个“智能卡片”——它能够自动识别自己是否包含图片并应用截然不同的视觉风格。我们还会用到 CSS 嵌套、layer来管理样式确保代码优雅且可维护。第一章需求与设计1.1 卡片结构设计我们设计一个通用的卡片组件可能出现在文章列表、产品展示、博客首页等地方。卡片有两种变体含图片卡片图片在上方或左侧标题大且加粗有阴影描述文字正常。无图片卡片居中显示一个占位图标标题和描述居中边框虚线背景浅灰。为了演示我们使用以下 HTML 结构divclasscarddivclasscard__mediaimgsrcphoto.jpgalt.../divdivclasscard__contenth3classcard__title卡片标题/h3pclasscard__desc卡片描述文字.../pahref#classcard__link/a/div/div对于无图片卡片我们直接省略.card__media部分或者.card__media内不包含图片。1.2 两种样式的视觉定义样式项有图片的卡片无图片的卡片布局图片上方内容在下内容居中无图片区域背景白色有阴影浅灰色#f5f5f5虚线边框标题左对齐粗体深色居中正常粗细深灰色描述左对齐居中额外元素无显示一个占位图标FontAwesome 或 emoji1.3 为什么不用 JSJS 需要等待 DOM 加载可能看到样式闪烁。JS 需要监听图片加载失败、动态添加卡片等情况增加复杂度。在 SSR/CSR 混合应用中更容易出现状态不一致。纯 CSS 方案性能更好更简洁更容易维护。第二章基础样式与判断逻辑2.1 基础样式所有卡片共享我们先用 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()判断是否有图片关键代码行/* 有图片的卡片样式覆盖 */.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()块内我们可以覆盖任何属性.card:has(.card__media img){box-shadow:0 10px 15px -3pxrgba(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 无图片卡片的样式对于没有图片的卡片我们可能会添加一个伪元素作为占位图标并且更改背景、边框和对齐。.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内部提高可读性。.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 -3pxrgba(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确保卡片组件的样式不被意外覆盖。layercomponents{.card{/* 所有卡片样式 */}}并且在引入第三方 CSS 时把它们放在较低的层中。第五章响应式与动画增强5.1 响应式图片卡片在不同屏幕宽度下有图片的卡片可能会切换图片大小或布局。我们可以利用容器查询或媒体查询但:has()依然有效。media(max-width:640px){.card:has(.card__media img) .card__media img{height:160px;object-fit:cover;}}5.2 悬停动画有图片的卡片悬停时图片缩放无图片的卡片悬停时边框变色。.card:has(.card__media img):hover{transform:translateY(-4px);box-shadow:0 20px 25px -5pxrgba(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检测。/* 基础样式假设所有卡片都像无图片卡片 */.card{background:#f7fafc;border:2px dashed #cbd5e0;text-align:center;}supportsselector(: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 图片懒加载的影响如果图片使用loadinglazy在图片加载完成前img 标签已经在 DOM 中:has()会立即匹配不会有闪烁。很好。第七章真实案例 —— 博客首页卡片列表假设我们有一个博客首页从 CMS 获取文章列表有些文章有配图有些没有。我们使用纯 CSS 实现卡片样式自适应。HTML 示例divclassposts-griddivclasscarddivclasscard__mediaimgsrcarticle1.jpgalt/divdivclasscard__content.../div/divdivclasscard!-- 没有 media 块 --divclasscard__content.../div/div/divCSS使用前面定义的规则。最终效果每个卡片根据是否有 img 显示不同样式。如果 CMS 返回的某些文章虽然配了图但图片 URL 404我们接受破碎图标的出现或者依赖图片服务器的默认占位图。第八章性能与最佳实践避免过于复杂的:has()选择器例如.card:has(.wrapper .nested .deep img)会降低性能。尽量使用直接子代组合如:has( .card__media img)。限制:has()匹配的范围如果卡片数量过多一千个以上:has()的开销可能会增加。但现代浏览器足够快通常不是问题。与:is()结合可简化为.card:has(:is(img, video))。不要嵌套:has()规范不允许嵌套应避免。第九章扩展 —— 多种条件组合你不仅可以判断是否有图片还可以判断是否有视频、音频、特定类等。/* 包含图片或视频的卡片 */.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()然后继续喝咖啡。完整代码示例!DOCTYPEhtmlhtmllangzh-CNheadmetacharsetUTF-8metanameviewportcontentwidthdevice-width, initial-scale1.0title智能卡片 - 有图片 vs 无图片/titlestyle*{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 -3pxrgba(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 -5pxrgba(0,0,0,0.15);}.card:not(:has(.card__media img)):hover{border-color:#3182ce;background:#edf2f7;}/style/headbodydivclassgrid!-- 有图片的卡片 --divclasscarddivclasscard__mediaimgsrchttps://picsum.photos/id/1015/400/250alt风景/divdivclasscard__contenth3classcard__title山水之间/h3pclasscard__desc这是一张有配图的卡片展示自然风光阴影效果明显标题加粗。/pahref#classcard__link →/a/div/div!-- 无图片的卡片 --divclasscarddivclasscard__contenth3classcard__title文字卡片/h3pclasscard__desc这张卡片没有任何图片因此显示虚线边框、居中文本和一个占位图标。/pahref#classcard__link →/a/div/div!-- 另一个有图片的卡片 --divclasscarddivclasscard__mediaimgsrchttps://picsum.photos/id/26/400/250alt建筑/divdivclasscard__contenth3classcard__title城市建筑/h3pclasscard__desc现代都市天际线卡片有阴影标题粗体。/pahref#classcard__link →/a/div/div/div/body/html运行这段代码你会看到三张卡片两张有图的显示阴影、左对齐一张无图的显示虚线边框、居中和 emoji 占位。无需任何 JavaScript完美自适应。