我做前端这行也有年头了,周末不怎么写代码,就爱翻翻浏览器的更新日志。说实话,过去一年在Chrome的版本记录里,藏着一条非常清晰的暗线:CSS正在把“怎么做”的权力,从JavaScript手里一点点拿回来。行业里管这个叫“声明式Web”——你告诉浏览器你要什么最终状态,剩下的事情交给浏览器自己安排。2025年前后,这些能力开始大规模落地,主流浏览器跟进得也很快,所以我更愿意把它看作一场迟到许久的革命。这篇文章不是去罗列发布会资料,而是站在一个普通业务前端的角度,聊聊我理解的声明式CSS究竟是什么、哪些特性值得立刻用,以及踩过坑之后你会感激今天看到的这些避坑点。
1. 声明式Web:从“告诉代码怎么做”回到“描述一个结果”
1.1 两种开发心智,差别在哪里
命令式和声明式的区分,用一个生活场景比较好懂:开手动挡汽车是命令式,你每一步都要踩离合、挂挡、踩油门;打车是声明式,你只需要告诉司机目的地,路线怎么最优是司机的事。过去很多年Web开发被JavaScript训练成了“手动挡”,因为浏览器提供的原生能力太弱,想弹出一个浮层,你得写事件监听、管理class、控制显隐,甚至要注意焦点和焦点陷阱。而这些体力活,其实跟业务目的毫无关系。
今天的变化在于,平台终于把基础设施补齐了。HTML和CSS把这些常见需求变成了属性、伪类、独立特性和一套语法。比如一个弹窗,不再需要“if visible 就渲染”,只需要一个popover属性;一条响应式规则,不再盯着window.innerWidth,而是让组件根据自身可用空间来做决定。写的是样式,但这些样式同时承载了布局、状态与行为。这就是我理解的“声明式Web”:用描述代替过程,把逻辑交给引擎。
1.2 为什么2025年才叫“革命之年”
CSS的发展一直很慢,以前一个特性从提出到能用,动辄七八年。老实说,2025年并没有发明什么石破天惊的新原理,它更像是“很多个特性同时到达了稳定状态”。Chrome在持续发布新能力,另外几家浏览器也在追赶,加上以往的老选择器早已不是瓶颈,现代CSS的完整度第一次接近“一个前端主力语言”的定位。
我这里想强调的是,革命并不是某个月突然发生,而是在你不知不觉中,规范、实现、工具链都齐了。当你发现自己开始同时使用原生嵌套、容器查询、:has()、popover和视图转换,而且还不需要托框架的福,那个时刻,你就站在革命的分界线上了。从开发者体感上说,2025年是把“未来CSS”变成“今天的CSS”的收获期。
1.3 声明式CSS到底解决了什么老问题
老问题的核心是“表达力不够”。过去写一个高频组件,布局逻辑要写CSS、状态逻辑要写JS、显示逻辑还要切换class,同一份视觉需求的代码被拆散到三个地方。声明式CSS把“因为组件里有图片所以我要两栏布局”这种条件直接写进选择器。你不用去数状态变量,不需要为了一个选中态去维护一个JS数组。从这个角度看,CSS更像是一个嵌入式规则引擎,这真不是夸张。
尤其是在组件化流行的今天,一段样式能不能老老实实待在组件内部,决定了一个项目能不能长期维护。声明式CSS推动的方向,恰好就是把“约束”从人的自觉变成语法结构本身的边界。后面聊到的@container、@scope、@layer,本质上都是在帮我们把样式收拢到该在的地方。
2. 2025年这波革命,最值得关注的三个样式基础能力
2.1 CSS原生嵌套:Sass的语法,浏览器的原住民
平时写CSS的都知道,嵌套选择器曾是预处理器的地盘。Sass编译出来的代码是给你用的,但调试时要在地图里找原始行号。现在原生嵌套来了,浏览器直接理解嵌套结构:
.card { border-radius: 1rem; padding: 1rem; & > .header { font-weight: 700; } &:hover { border-color: var(--accent); } @media (width < 40rem) { padding: .5rem; } }它和Sass有个关键差异:原生嵌套的&只能出现在选择器开头,不能把&拼接成类名的一部分。也就是说,Sass里常见的&__title这种BEM写法,在原生CSS里是走不通的。这不难,但你别等真正踩到坑再来回改。
我会优先在新建的样式表里直接写原生嵌套,除非项目还在用老的构建链。预处理器的全局变量管理现在也可以换成原生的@property和自定义属性。好处是调试面板和代码一一对应,DevTools里看到的就是你写的嵌套结构,不需要靠sourcemap来回猜,出问题定位快得多。
2.2 :has():父选择器的回归,让CSS具备“向上看”的能力
以前CSS只能从父到子,不能根据子元素决定父样式。:has()把这个缺口补上了:
.card:has(.badge) .card__title { margin-right: 0.75rem; } .form-group:has(> input:user-invalid) { border-color: #d33; }这背后的意义不只是“省一行JS”,而是让组件样式可以根据自身内容做自我调整。比如列表里有图片和没图片,布局自动变化;表单是否校验通过,错误提示自动显示。这些在过去都是命令式逻辑,现在变成纯声明式规则。单个规则看起来平平无奇,组合起来就是一套“内容驱动的UI逻辑”。
要注意:has()不是免费午餐。浏览器会在选择器匹配阶段做额外检查,尤其是选择器特别长、涉及整个文档范围时,代价会放大。一个实用的克制原则是:把:has()用在结构相对稳定的局部,避免写body:has(.something .deep .selector)这种入口极大的规则,也不建议拿它整天扫描文档根。大部分业务场景里,:has()用在卡片、列表项这种局部容器中,性能影响完全在可接受范围内。
2.3 容器查询与样式查询:组件级的响应式终于闭环
如果说:has()解决了“父子状态互通”,容器查询解决的是“组件尺寸适配”:
.media-object { container-type: inline-size; container-name: media; } @container media (width >= 30rem) { .media-object__thumb { width: 120px; } }媒体查询只能看视口,而容器查询看的是组件所在容器的尺寸,一套组件在小侧栏、内容区、弹窗里都能自动切换布局。这带来的设计心态变化是:你不再为页面断点写“媒体查询全家桶”,而是为真实可复用的组件写局部规则。组件被放到哪里都能活,这才是组件化的底气。
基于同样的机制,样式查询(@style(--theme: dark))虽然目前实现还没完全铺开,但思路已经清晰。声明式就是要让样式能根据“另一个样式属性”动态切换,而不是在JS里换类名。Chrome的稳定支持让这些能力离生产更近,跨浏览器支持度也在上升。至少在2025年,新建一个组件库时如果不考虑容器查询,我会觉得有点说不过去。
3. 行为与交互也声明式:从弹层到动画,浏览器接管状态机
3.1 popover:弹窗不再是“JS的专属地盘”
popover可能是近几年存在感最强的HTML属性之一。给一个元素加上popover,再给按钮加上popovertarget,一个原生弹层就完成了:
<button popovertarget="menu">打开菜单</button> <div id="menu" popover> <p>我是原生弹层,自带Top Layer。</p> <button popovertarget="menu" popovertargetaction="hide">关闭</button> </div>不用自己管层级顺序、不用管ESC关闭、不用管点击外部关闭,浏览器全都处理了。假如再用:popover-open配合@starting-style,连展开/收起的过渡动画都能声明式搞定:
#menu { transition: opacity 0.2s, transform 0.2s; opacity: 0; transform: translateY(-4px); } #menu:popover-open { opacity: 1; transform: translateY(0); } @starting-style { #menu:popover-open { opacity: 0; transform: translateY(-4px); } }这里有个非常容易踩的坑:很多人习惯用display: none去隐藏弹层,但[popover]元素自己有一套“进入/离开渲染树”的原生机制。如果你强行覆盖它的display,可能把整个弹出机制搞坏,打开状态永远不对。真要在关闭态做视觉隐藏,应该用opacity、visibility这类不影响渲染树建立的属性,或者直接依赖平台默认行为。
3.2 dialog:另一个声明式交互面
<dialog>元素配合showModal()其实也属于平台替我们实现状态管理的典型案例,比起自己造“模态框组件”,原生对话框在焦点、滚动锁定和顶层渲染上默认就是对的。更重要的是CSS这边多了:modal伪类,你可以在样式里显式区分模态状态和非模态状态。新增的::backdrop伪元素也让遮罩样式彻底声明化:
dialog::backdrop { background: rgba(0, 0, 0, 0.5); backdrop-filter: blur(4px); } dialog:modal { border-radius: 12px; }即使你不打算用原生dialog,这套“平台默认状态”的思路也值得借鉴:把交互状态从业务JS里剥离出去,让HTML结构自带行为能力。这也是声明式Web最有价值的地方——降低心智负担。写业务的人不需要记住“我现在到底有没有给modal容器加hidden”,只需要关心结构里有什么。
3.3 视图转换和进入动画:动画终于不再靠“硬编码时间点”
View Transitions API这两年逐渐成熟,跨页面的平滑过渡只需要给对应元素设置view-transition-name,再配合::view-transition-old()和::view-transition-new()的快照选择器描述过渡效果。本质上是让浏览器托管整段过渡的时机:旧页面快照和新页面快照同时存在,由平台计算生命周期。你不用再计算两份状态的渲染时序,这是命令式时代很难做到的。
再加上@starting-style的出现,元素首次出现时的动画也能顺畅声明式表达。比如列表新增一项,你根本不需要在JS里加“进场类名”,直接在CSS里描述初始状态即可:
.list-item { transition: opacity 0.3s, translate 0.3s; opacity: 1; translate: 0 0; } @starting-style { .list-item { opacity: 0; translate: 0 12px; } }我个人的体会:这一整套组合拳,让“进入/离开”这类原本要依赖计时器或requestAnimationFrame的动画,变得和普通CSS动画一样好写、好维护、可被浏览器优化。传统JS动画最大的问题在于打断和重叠,浏览器接管后就很少出现“上一次动画还没结束,下一次又来了”的尴尬。
4. 落地工程:特性检测、降级策略与可维护性设计
4.1 别把“2025”默认成“所有浏览器都支持”
虽然题目叫Chrome CSS 2025,但真正放上生产环境,该做特性检测还是要做。CSS这边可以用@supports:
@supports (container-type: inline-size) { .card { container-type: inline-size; } } @supports selector(:has(*)) { .form-group:has(input:user-invalid) { border-color: var(--danger); } }JS侧也有对应的CSS.supports('selector(:has(*))'),适合你要根据能力动态渲染不同交互时使用。渐进增强的原则依旧是:老浏览器拿到完整布局,新浏览器在布局基础上获得交互与动画。我见过一个反面案例,团队把整个侧边栏的显隐都押在popover上,结果某个老版本浏览器不识别属性,侧边栏直接变成常驻区域,页面布局崩了。所以越依赖新特性,越要提前想好没有它的时候长什么样。
4.2 和现有框架怎么共存:组件本身就长着声明式的脸
用React、Vue这类框架的开发者,其实早就熟悉“声明式UI”:模板/JSX描述状态对应视图,状态变了视图跟着变。原生CSS做声明式交互后,并不会和框架打架,反而会少掉很多Effect和事件绑定。举个常见配合:
<div className="media-card"> <img src={thumbnail} alt="" /> <p>内容</p> </div>配合CSS:
.media-card:has(img) { display: grid; grid-template-columns: 120px 1fr; }只要结构里有图,网格布局就生效;没图的时候自动退回单列。组件不再需要额外维护一个hasImage的状态,UI和状态之间的映射由浏览器完成。框架把“状态”管好,CSS把“状态到UI的映射”管好,边界清晰得多。我实际项目里最爽的改变是:删掉了一堆is-active、has-image之类的布尔class,选择器自己就能表达意图。
4.3 控制级联:@layer、@scope以及写样式的新纪律
声明式特性变多之后,样式系统最大的风险反而是级联失控。现在有两条很好的工具:@layer用来管理优先级来源;@scope用来限制选择器作用范围。例如:
@layer reset, base, components, utilities; @layer components { .card { padding: 1rem; } } @scope (.card) to (.card__footer) { .title { font-size: 1.25rem; } }我在实际项目里会用@layer把“浏览器默认样式、基础样式、组件样式、工具类”排优先级,避免曾经的“!important接力赛”。声明式CSS的特性会鼓励你写更精确的局部规则,但如果不用@layer兜底,第三方依赖的样式很容易和你自己的规则打架。@scope则适合限制某个区块内的样式边界,尤其是卡片、弹层、面板这一类的组合式组件,规则不会漏到外部去。这些能力可能没有前面几个特性抢眼,但它们是让声明式CSS能长期维护的稳压器。否则写一个月后,所有人又开始互相覆盖规则了。
5. 常见问题与排查实录
5.1 写了@container但不生效
| 现象 | 可能原因 | 处理办法 |
|---|---|---|
| 容器查询条件不触发 | 容器祖先没有声明container-type | 给最近的块级父元素添加container-type: inline-size |
| 尺寸变化没反应 | 查询的是父级但父级宽度没有变化,或者父级也是由内容撑开的 | 检查容器是否被内容“弹性撑开”,必要时设置显式尺寸 |
| 组件内部查询不生效 | container-name命名不一致,或查询写在被查询元素自身上 | 确认查询对象使用的是最近的容器名,且查询不是写在容器自身 |
另一个容易漏的点:容器查询只能查容器本身,不能查容器内部的某个子元素尺寸。很多人下意识认为“我写了container-type就万事大吉”,其实关键在于“谁是最接近被查元素的带container-type的祖先”,每一层都得理清楚。调试时可以在DevTools的Elements面板里勾选“Show container outline”,让容器边界可视化,问题会直观很多。
5.2 原生嵌套没编译成功
如果你习惯Sass写法,原生嵌套里有几个差异要绕开:
&不能放在选择器的末尾拼字符串,也就是不能像Sass那样写&__title,这会让BEM命名的用户不太适应。- 嵌套层级建议不超过三层,否则可读性反而下降,也给样式计算带来不必要的深度。
- DevTools里如果看到样式没匹配,先看是不是浏览器版本不支持嵌套。判断方法是写一个
@supports selector(&)做能力检测。
我自己的迁移习惯是:老项目不动,新项目从零开始用原生嵌套。这样不会因为构建链里同时存在两套选择器语法而混乱。等团队熟了一段时间,再考虑把Sass逐步换成纯CSS。
5.3 popover打开后样式不对
常见是开发者为了“平时隐藏”写了[popover] { display: none; }。这在老浏览器可能没问题,但在支持popover的浏览器里会干扰原生显隐逻辑。更好的做法是依赖popover的默认隐藏机制,让平台管理显隐,你只管打开态样式。如果你非要兜底,也建议用hidden而不是覆盖display。
还有一种情况:popover里有一个关闭按钮,但按钮上的popovertargetaction="hide"写成了popoveraction或者忘了写。造成点击后开关都触发,看起来就像一个“闪一下又关掉”的弹层。这类问题不是样式问题,而是HTML属性名拼写问题,排查时优先对照上下文。
5.4 :has()性能焦虑到底要不要有
我会给出一个不绝对但可复用的判断标准:如果:has()被用在每个列表项、每个卡片上,并且扫描范围带有高层级祖先选择器,那是真会拖慢初次样式计算;但如果作用域只限定在一个局部的组件容器,性能损失完全可以接受。实操中可以先不做预优化,等真正遇到卡顿再去剖析选择器范围,浏览器新版的性能面板能看到样式计算耗时。
我自己见过最离谱的写法是body:has(header .nav li:nth-child(2):hover) ...,这种长链选择器会让匹配路径变得很难估算,代码读起来也痛苦。与其追求“一个选择器全集解决”,不如把:has()拆成几个局部规则,让每一段负责一个小判断。声明式的优点是好读,而不是炫技。
5.5 多浏览器共存
Chrome里跑得欢,不等于所有用户都能看到同样的效果。我建议把“能否使用”和“能否渐进增强”分成两个问题:布局、字色、间距这类基础样式,走常规写法;需要交互增强的,用@supports包一层。这样即使某个选择器不被识别,最多失去动效,不会失去内容。
同时要提醒的是,别只看桌面端。移动端WebView和部分国产浏览器的内核升级节奏各不相同,popover、容器查询这类特性的支持情况可能完全不一致。发布前用can I use类工具或者直接写一段特性检测脚本跑一遍关键流程,比上线后发现白屏要划算得多。
玩了一圈这些新特性之后,最后插点我自己的经验吧。我从写jQuery时代一路过来,对“JS控制一切”很有感情,但这两年越来越明显的感觉是:浏览器平台正在变得越来越聪明,CSS能表达的东西越来越多。声明式Web不是让你彻底扔掉JS,而是让JS专注于真正的业务数据、复杂的异步交互和状态流转;把常见的浮层、显隐、响应式、动画这些“琐事”还给平台。如果你还没有碰过原生嵌套、容器查询和popover,我建议挑一个周末的小项目,强制自己不用任何UI库,只用原生HTML+CSS去实现一套包含弹窗、列表自适应、进出场动画的页面。试过一次你就明白,这种写代码的感觉,真的很不一样。2025年的这批特性,值得你为它留出时间。