
我做前端这行也有年头了周末不怎么写代码就爱翻翻浏览器的更新日志。说实话过去一年在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 popovertargetmenu打开菜单/button div idmenu popover p我是原生弹层自带Top Layer。/p button popovertargetmenu popovertargetactionhide关闭/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这边可以用supportssupports (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 classNamemedia-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里有一个关闭按钮但按钮上的popovertargetactionhide写成了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库只用原生HTMLCSS去实现一套包含弹窗、列表自适应、进出场动画的页面。试过一次你就明白这种写代码的感觉真的很不一样。2025年的这批特性值得你为它留出时间。