尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

六个CSS新特性:用原生CSS替代JS的实战指南

六个CSS新特性:用原生CSS替代JS的实战指南 做前端这些年我见过太多团队把一件小事写成几百行JS菜单hover展开要监听mouseenter、表单校验要bind事件、图片滚动淡入要算scrollY……需求明明很简单代码却越堆越重。直到我系统翻了一遍CSS新特性才意识到一个问题很多本该由渲染引擎解决的问题我们一直在用自己的逻辑硬扛。这篇不聊框架只聊六个已经进入浏览器稳定范围、正在改变前端开发方式的CSS新特性它们能替掉什么JS、适合什么场景、实际落地有哪些坑。如果你在做页面开发或者准备面试想跟上生态变化这篇值得认真看完。1. 先回答最直接的问题CSS为什么能“抢”JS的饭碗1.1 以前这些场景为什么必须写JS页面开发里有一类代码特别典型状态联动。输入框有内容按钮才可点列表项hover时父卡片要有边框滚动到某个位置才触发动画。这些业务逻辑本质上是“元素之间的状态关系”但CSS长期以来缺少表达这种关系的选择器于是大家习惯用JS去监听事件、修改class、再手动控制样式。代码一多主线程繁忙交互反而卡顿。还有一类是尺寸适配。传统媒体查询只能看视口宽度可组件往往存在于不同宽度的父容器里——同一个卡片在侧边栏和主内容区表现完全不同。以前只能靠ResizeObserver去监听容器尺寸再通过JS改class或者直接改style。这在仪表盘、低代码平台里简直是噩梦。1.2 CSS方案的技术底气CSS替代JS并不是“硬撑着搞”底层逻辑是浏览器原生渲染引擎处理这些状态和尺寸变化时直接在布局和绘制阶段完成不经过JS运行时的计算也不触发额外的DOM操作。换句大白话JS管状态CSS管表现但很多状态其实本来就该由CSS自己判断。而且这些特性大多是声明式的。声明式代码意味着同样的效果你只需要描述“什么时候变成什么样”而不是一步步告诉浏览器“该移动多少像素”。这不仅代码量少出错概率也低更利于组件复用。1.3 六个特性一览先给一张表让你快速知道本文要谈什么特性典型替代场景浏览器现状:has() 选择器父级状态联动、表单校验Chrome 105、Safari 15.4、Firefox 121容器查询按容器宽度自适应布局Chrome 105、Safari 16、Firefox 110CSS嵌套减少重复选择器Chrome 112、Safari 17.2、Firefox 117Subgrid跨层级网格对齐Chrome 117、Safari 16、Firefox 71滚动驱动动画滚动进度、元素入场动画Chrome 115其他需关注starting-style元素出现和消失的过渡动画Chromium 117其他需关注单看这张表你可能觉得抽象下面一个一个拆开讲。2. 特性一:has()让父级选择器帮你做状态管理2.1 你还在为“输入框空了按钮置灰”写JS吗以前实现表单校验反馈最自然的思路是这样的给input绑定input事件判断value长度然后给按钮toggle一个disabled类。这还只是单个表单如果字段多、校验规则复杂JS代码量和事件绑定数量会直线上升。:has()让CSS第一次拥有了“根据子元素状态影响父级或兄弟元素”的能力。它不是一个选择器魔法而是从选择器层面解决了“向上看”的问题。写出来的代码更像是在描述规则而不是在描述过程。form:has(input:invalid) button { opacity: 0.5; pointer-events: none; }这段代码的意思是表单里只要存在任何一个校验失败的输入框提交按钮就进入置灰状态。input自身的:invalid状态由浏览器依据required、type、pattern等属性自动判断完全不用JS去维护。2.2 :has() 的实用姿势我实际项目里用得最多的是导航高亮和卡片联动。比如一个商品卡片只要鼠标悬停在卡片内部的图片上卡片底部的“查看详情”按钮就亮起来.card:has(.cover:hover) .detail-btn { background: #f60; color: #fff; }再比如在表格或列表里选中某一行时让整行出现边框tr:has(input[typecheckbox]:checked) { outline: 2px solid #1890ff; }这些场景以前都要挂事件监听现在一行CSS就能稳定复现。:has()还可以和兄弟选择器搭配实现类似“有错误提示时给输入框加红边”的效果.field:has( .error-message) input { border-color: #f5222d; }2.3 踩坑性能不是玄学:has()虽然好用但性能问题必须重视。浏览器在匹配:has()时需要从一个元素反向遍历它的子树如果选择器写得很宽泛比如:has(*)或者挂在全局容器上匹配成本会明显增加。尤其是在频繁增删DOM的大型列表里容易出现卡顿。我给一个可操作的判断标准:has()里的选择器尽量从类名或属性开始不要从div、*这种宽泛标签开始。宁可多写几个字符也别让浏览器把所有节点都查一遍。另外:has()不适合嵌套过深比如:has(.a .b .c)这种能避免就避免。3. 特性二容器查询组件终于能“看自己脸色”行事3.1 媒体查询的最大缺陷媒体查询的问题一句话就能说清它只关心视口不关心组件所在的环境。同样的卡片组件放在宽屏侧边栏里和放在窄屏主内容区里需要完全不同的字号和排列方向。用媒体查询做你得给两种环境各写一套样式或者用JS量宽度再切换class。容器查询的思路是组件响应自己的容器宽度而不是视口。这个思路对组件化开发的意义非常大。你把组件当成一个能感知自己体型的生物它在什么容器里就自动长出什么样子。3.2 三步搭起容器查询第一步给容器设置containment类型.widget { container-type: inline-size; }inline-size表示只追踪容器的横向尺寸这是最常见的需求。如果纵向也要追踪可以用container-type: size但要小心它会同时开启布局、样式、尺寸的containment部分情况下会影响子元素的高度计算。第二步给容器取名字方便后面查询.sidebar .widget { container-name: sidebar-card; container-type: inline-size; }第三步写查询规则container sidebar-card (min-width: 280px) { .widget { display: grid; grid-template-columns: 1fr auto; } }容器宽度超过280px时组件内部自动切换成两列布局。所有逻辑都留在组件自己的样式表里换到哪个页面都能自适配。3.3 容器查询的边界问题这里有个特别容易踩的坑设置了container-type的元素不能通过查询规则来控制自身的宽度。原因很简单它要作为“容器”参与布局计算如果它的尺寸又被自身内部的查询规则改变浏览器就没法确定基线了。想查询自身你应该在组件外面再包一层外层做容器内层做样式变化。另一个细节是容器查询遵循“就近原则”一个元素可能同时处于多个容器里最终生效的是最近的祖先容器。为了避免混乱建议每个组件明确指定container-name。4. 特性三CSS嵌套把Sass的钱省了也把运行时成本降到零4.1 从“重复选择器”到“同层嵌套”用过Sass、Less的老前端都知道预处理器的嵌套语法能极大减少重复选择器。但预处理器终究是多了一层编译而且依赖工程链。现在CSS原生支持嵌套语法基本和Sass用户习惯的方式一致但没有任何编译成本。.nav { display: flex; gap: 8px; ul { list-style: none; } a { text-decoration: none; color: #333; :hover { color: #f60; } } }这段代码的语义非常直观.nav内部的所有ul、a以及a:hover都在同一块结构里层级关系在视觉上一眼就能看出来。对于维护老项目这种可读性提升是实实在在的。4.2 原生嵌套和Sass的两点关键差异原生CSS嵌套的一个重要规则是类型选择器必须显式加。比如你想嵌套p写.card { p { ... } }没问题但直接写.card { p { ... } }有概率被解析出歧义因为浏览器分不清你要的是“后代p”还是“复合选择器”。这个坑我见过不少同事踩编译出来样式不生效排查半天发现少了一个。另外原生嵌套里的是“完整的父选择器引用”它不等于字符串拼接。Sass里的可以拼出.nav--active这种类名但原生CSS嵌套不支持这种动态组合。你想要BEM里那种修饰类名还是老老实实写出完整类名或者用:is()等方法变通。4.3 嵌套不是越深越好嵌套虽然好写但会产生一个副作用最终的选择器特异性可能会变高。每嵌套一层就相当于在选择器里加了一层祖先约束越嵌套越难覆盖。而且过深的嵌套会让DevTools里的样式来源显得很长排查起来很不舒服。我给自己立过一个规矩嵌套不要超过三层。超过三层基本说明组件结构已经复杂到应该拆分子组件了硬用嵌套写反而增加维护成本。5. 特性四Subgrid让不同层级网格终于对齐5.1 为什么不用JS算对齐做过卡片列表的人都知道几张卡片放一排卡片内容高度不一样底部按钮的位置就会参差不齐。以前有几种常见解法给每张卡片固定高度或者用JS测量最高卡片的高度再赋值给其他卡片又或者干脆放弃底部对齐。固定高度不灵活JS测高又面临resize和异步渲染的问題。Grid布局解决了同一层级的对齐但问题往往发生在“外层网格”和“内层网格”之间。外层定义了列轨道内层卡片自己也用了grid内外两套轨道互不相通。Subgrid就是用来打穿这层墙壁的。5.2 Subgrid的使用姿势直接看代码。外层网格负责整体排序内层卡片声明自己的行轨道继承外层轨道.cards { display: grid; grid-template-columns: repeat(3, 1fr); grid-auto-rows: minmax(100px, auto); gap: 24px; } .card { grid-row: span 2; display: grid; grid-template-rows: subgrid; row-gap: 12px; }这里的关键是.card的grid-template-rows: subgrid。它让卡片内部的行轨道直接复用父网格的行轨道而不是自己重新分配。于是每张卡片内部的“标题区”“正文区”“按钮区”都精确地对齐在同一水平线上和内容高度波动无关。配合grid-column: span 2这样的跨列规则还能做出类似杂志排版的分栏效果这在以前几乎必须依赖图片编辑或JS布局计算。5.3 使用Subgrid要注意的细节第一父网格必须有足够的行轨道且这些轨道最好是显式定义的。如果父网格用的是隐式行Subgrid能继承的轨道会比较有限表现会变得不可预测。第二子网格内的项目仍然会参与父网格的尺寸计算。也就是说卡片内部的某一行内容变高会把对应的父网格行轨道撑高其他卡片的同一行也会跟着变高。这既是Subgrid的优势也是需要注意的行为它不是把内容“隔离”开而是让内外层共享尺寸。第三浏览器的支持已经足够好但老项目的Grid写法里如果大量使用grid-template-areas接入Subgrid时要先理清内外层的区域映射关系建议先用最小demo验证效果再推广。6. 特性五滚动驱动动画把scroll监听从主线程里解放出来6.1 老办法为什么又卡又难维护滚动进度条、图片随滚动淡入、视差背景这些效果听着很高级写起来却全是麻烦。最常见的实现是scroll事件监听里套requestAnimationFrame每次滚动都要读取scrollTop、计算元素偏移量、再更新样式。滚动一快主线程被频繁占用动画掉帧不说还要处理事件节流、兼容性等问题。滚动驱动动画的思路是把滚动进度直接交给浏览器的动画系统。你只需要定义一个基于时间线的动画然后告诉它这根时间线是“滚动上下文”还是“元素进入视口的进度”。6.2 用 animation-timeline 实现两个常见效果先看滚动进度条。给一个固定在页面顶部的细条添加动画时间线用scroll()代表整页滚动.progress { position: fixed; top: 0; left: 0; height: 4px; background: #f60; transform-origin: 0 50%; animation: grow linear; animation-timeline: scroll(root); animation-range: 0% 100%; } keyframes grow { from { transform: scaleX(0); } to { transform: scaleX(1); } }scroll(root)里的root表示滚动根元素也就是页面。如果你希望进度条跟随某个局部滚动容器可以改成scroll(容器选择器)。动画的时长不再按秒算而是按滚动进度算。再看图片滚动入场。用view()作为时间线代表“元素在滚动视口中的可见进度”.reveal { animation: rise both; animation-timeline: view(); animation-range: entry 10% entry 80%; } keyframes rise { from { opacity: 0; transform: translateY(24px); } to { opacity: 1; transform: none; } }entry表示元素进入视口这个区间entry 10%到entry 80%的意思是元素刚露出一小部分时开始淡入快完全进入视口时结束。整个过程不需要一行滚动监听代码。6.3 兼容和降级策略这个特性目前主要在Chromium系列浏览器里可用Safari和Firefox要么没正式支持要么还在实验阶段。但好消息是动画本身是增强功能不支持的情况下元素保持默认可见状态不会破坏页面正常阅读。我建议把滚动动画当作“锦上添花”核心内容不要依赖它。如果产品经理坚持要全浏览器一致体验可以先用JS fallback再用supports筛选出支持CSS滚动动画的浏览器把JS逻辑停掉。7. 特性六starting-style 与离散过渡入场动画不再需要JS计时7.1 display:none 和过渡动画的百年矛盾display: none到display: block之间是不能直接做过渡的因为display是一个离散属性元素从“不渲染”到“渲染”是瞬间的。以前想做个菜单淡入效果常规操作是先把opacity设为0用requestAnimationFrame强制浏览器渲染一帧再改成1。这个“rAF hack”成了很多老前端的心头痛。starting-style解决的就是“元素第一次渲染出来时的样式”。它让你能声明元素在渲染初始阶段应该是什么样式然后从那个样式过渡到正常样式。7.2 核心代码长什么样下面是一个最常见的菜单展开效果.menu { display: none; opacity: 0; transform: translateY(-8px); transition: opacity 0.25s, transform 0.25s, display 0.25s allow-discrete; } .menu.open { display: block; opacity: 1; transform: translateY(0); starting-style { opacity: 0; transform: translateY(-8px); } }注意.menu.open的starting-style块它定义了菜单在从display: none切换过来的第一帧应该是什么状态。浏览器会从starting-style的状态过渡到正常状态。关键是transition里写了display 0.25s allow-discrete这个allow-discrete允许display在过渡期间被动画化于是元素才能在隐藏和显示之间平滑过渡而不是瞬间突现。7.3 注意初始样式的优先级这里有个容易犯错的地方starting-style只在元素“第一次出现”的时候生效。如果元素已经显示然后你通过JS移除某个class让它隐藏此时不需要starting-style直接走普通样式就可以。换句话说入场和出场动画是两套逻辑别混在一起写。另外:host、::before、::after等伪元素也支持starting-style所以做Tooltip、角标之类的小元素也可以用它。支持范围主要在Chromium内核浏览器和滚动驱动动画一样建议作为渐进增强来用。8. 组合实战用这套组合拳重写一个导航菜单8.1 需求拆解为了让你看明白这些特性是怎么协同工作的我拿一个真实常见的组件练手后台侧边栏里的登录卡片。它需要在窄容器里变成上下堆叠宽容器里变成左右分栏输入框没填好时按钮置灰卡片出现时要淡入按钮hover时要有反馈。如果按老思路这四个需求至少需要四个独立JS模块。现在用CSS特性组合直接写在一个组件样式表里。8.2 完整HTML与CSSaside classwidget h2登录/h2 form input typeemail required placeholder邮箱 input typepassword required minlength6 placeholder密码 button提交/button /form /aside.widget { container-type: inline-size; display: none; opacity: 0; transform: translateY(10px); transition: opacity 0.3s, transform 0.3s, display 0.3s allow-discrete; } .widget.is-show { display: block; opacity: 1; transform: translateY(0); starting-style { opacity: 0; transform: translateY(10px); } } container (min-width: 280px) { .widget form { display: grid; grid-template-columns: 1fr auto; } } form:has(:invalid) button { opacity: 0.5; pointer-events: none; } button { transition: transform 0.2s; :hover { transform: translateY(-2px); } }8.3 代码背后的逻辑container-type负责让卡片根据自身宽度调整内部布局。这里我没有写container-name因为组件只有一个嵌套层级用最近的容器完全够用。:has(:invalid)替代了所有表单校验的JS逻辑只要required或minlength不满足按钮就不会响应点击。starting-style和transition让卡片在.is-show这个class添加后从透明、下移的状态平滑过渡到正常状态。按钮的hover效果直接写在嵌套声明里不需要单独的父子选择器。这套代码里唯一需要JS的地方只剩下“点击某个按钮给.widget加上.is-show”。其他全部是CSS的活。你可以想想如果这是团队里的通用组件这段CSS能被多少人复用。9. 浏览器支持与渐进增强今天就能用别等“全绿”9.1 支持情况一览我一直不喜欢“等浏览器全部支持再使用”的工作方式。根据项目用户的浏览器分布你完全可以在支持的浏览器里先用起来不支持的浏览器退化为基本功能。下面是概括性的支持情况特性ChromiumFirefoxSafari:has()10512115.4容器查询10511016CSS嵌套11211717.2Subgrid1177116滚动驱动动画115暂未正式暂未正式starting-style117暂未正式暂未正式这里写的“暂未正式”不代表永远不会支持你在实际项目里使用前建议去查一下目标浏览器的最新版本说明。9.2 用 supports 隔离新旧环境不支持的特性也不是完全不能碰前提是做好降级。:has()这类影响状态的选择器可以用supports检测supports selector(:has(*)) { form:has(:invalid) button { opacity: 0.5; pointer-events: none; } }滚动驱动动画可以用属性检测supports (animation-timeline: scroll()) { .progress { animation: grow linear; animation-timeline: scroll(root); } }不支持的浏览器会自动跳过这些样式页面仍然是可用的只是少了一些增强交互。9.3 团队落地建议我建议在团队里推广这些特性时不要一口气全上。先挑收益最明显、兼容性最好的两个:has()和CSS嵌套。这两者对代码量的削减非常直观同事接受度高。容器查询和Subgrid可以在新页面或者重构时引入。滚动驱动动画和starting-style则明确标记为“增强项”只做加分不做依赖。还有一个便宜好用的工具在Chrome DevTools的Rendering面板里勾选“CSS Features”可以直接看到当前页面哪些CSS特性被启用排查问题时很方便。10. 常见问题与避坑记录10.1 现象速查表现象原因解决办法:has()让页面卡顿选择器过于宽泛或挂在全局高频更新节点上用类名锚定避免:has(*)、:has(div)容器查询没生效忘了设container-type或者想查询自身加container-type并考虑多包一层原生嵌套样式不生效嵌套了类型选择器但漏了写成 p、 divSubgrid没对齐父网格没有足够的显式行轨道给父网格定义行轨道或让子项span对应行数滚动动画只在Chrome生效Safari和Firefox暂时未正式支持用supports做降级动画不影响内容可达性starting-style只在进场生效出场用的是普通样式不是starting style把出场动画状态写在普通规则里10.2 我排查最久的三个细节第一个是容器查询的“自身尺寸陷阱”。我好几次在组件最外层铺上容器查询规则结果宽度怎么都撑不开后来才想起设置了container-type的元素自身不能成为查询目标。解决办法是在组件结构里增加一个占位容器外层的负责宽度内层的负责内容。第二个是原生嵌套和预处理器的“特异性错觉”。嵌套写多了以后我发现覆盖样式经常不生效因为每一层嵌套都在增加特异性。尤其是写组件库的同学建议给每个嵌套块都保持克制的深度能用父级类名直接覆盖的就不要依赖深层嵌套。第三个是starting-style的“多元素进场不同步”。如果子元素也想做入场动画要注意过渡的触发时机。我在菜单里同时给容器和子项加了starting-style发现子项偶尔出现闪烁后来把子项的过渡时长稍微调短和容器的显示状态错开效果才稳定。以上这些坑每一个都是我从实际项目里踩出来的。写CSS新特性并不是“追新”而是把那些原本属于渲染引擎的活儿还给渲染引擎。我自己的习惯是拿到一个交互需求先问自己“这个状态能不能用CSS描述”而不是条件反射地去翻JS方法。下次你遇到表单校验、滚动动画、容器自适应这类需求不妨也先想想也许那个写了几百行JS的方案其实只是一条选择器的事。
返回列表