1. 为什么到了今天CSS兼容性还是一门必修课1.1 一次线上小事故backdrop-filter 带来的“玻璃盖糊字”先讲一个我记忆很深的事。那次跟 CSS 兼容性有关的线上事故让我彻底改变了处理样式的方式。前两年做某个营销活动页时团队想做一个“玻璃拟态”弹窗背景稍微模糊文字浮在上面效果很出彩。开发阶段全程在 Chromium 内核浏览器里调试截图、交互、动画都没问题结果上线第二天有人在低版本 WebKit 内核设备上反馈弹窗的标题、说明、按钮全部叠在一起视觉上像一块糊掉的玻璃。查到最后根源就是backdrop-filter这个属性在部分环境下需要-webkit-前缀在另一部分环境下整条声明会被直接忽略。被忽略以后背景没有模糊但文字层原有的重叠布局又依赖这个模糊带来的视觉分割最终就成了“看不清的 bug”。那次事故以后我把 CSS 兼容性从“遇到再查”改成“动手前先想”。这其实也是很多前端进阶者的共同经历CSS 特性越来越强工具链越来越自动但兼容性问题并没有消失它只是换了形态。以前是“这个属性在某个浏览器里不显示”现在是“这个属性在部分环境里部分生效、部分退化、部分需要前缀”。如果只靠调试自己手头那台浏览器永远发现不了这些问题。这也是我把 CSS 兼容性当成一门必修课来看的原因它教的不是某个具体属性的记忆而是一套判断、分层、兜底的思维方式。1.2 兼容性的本质不是“能用/不能用”而是“分层可用”做兼容性的第一件事是承认浏览器对 CSS 特性的支持不是一个开关而是一个矩阵。我见过很多同学把问题简化成“这个属性支持吗”然后查一下兼容性数据支持就用不支持就不用。真实情况比这复杂得多。常见的状态至少有这么几档完全支持行为和标准一致完全支持但需要加厂商前缀支持但语法和标准不同比如早期的 flex 语法支持一部分值另一部分值被忽略完全不支持声明被忽略或导致周边布局退化。举几个高频例子。display: flex在不同年代有display: box、display: flexbox、display: flex三套语法position: sticky早期 WebKit 内核需要-webkit-stickygap在 flex 布局中的支持比在 Grid 中晚aspect-ratio是近几年的新值CSS 变量在 Trident 内核里完全不认识。每一个都是“不完全支持”的变体。所以兼容性不是“做或不做”的二元决策而是“核心体验在什么环境必须成立增强体验在什么环境可以退化成普通样式”的分层决策。理解了这一点再回看“高级技巧”这四个字就会更通透。真正高级的 CSS不是说用最潮的属性而是能在不同能力环境里各自给出可用的表达旧浏览器看到结构完整的朴素版本新浏览器看到平滑的动效和精致布局谁也不崩。接下来这套方法就是我这些年沉淀下来、在项目里反复用的兼容性排查和降级方案。2. 兼容性排查三板斧把“玄学”变成工程流程2.1 先定目标浏览器矩阵而不是“尽量支持所有浏览器”兼容性第一步永远不是查属性而是定目标。没有目标的兼容性等于没有边界你想覆盖所有浏览器最后一定会被老环境的奇怪 bug 拖垮。正确的做法是先跟产品和用户数据对齐明确哪些环境是主力哪些环境只需要保证“内容可读可用”。我自己通常在项目启动时维护一个表格目标环境目标等级判断依据主力 Chromium 系浏览器近两个大版本完整支持后台统计占比最高移动端 WebKit 系浏览器主流版本完整支持移动访问占比高Gecko 系浏览器近两个大版本完整支持占比不高但不能出事故更早的 WebKit / Trident 系环境可读可用不做新特性增强这个表格不是拍脑袋定的它来自线上统计。没有统计的时候就按成本最低的常识框架来把最近两年左右的现代浏览器设为完整支持把更老的环境设为基础可用。重点是把这个矩阵写进项目文档而不是放在某一个人的脑子里。团队里有人新写了一个特性先对照矩阵问一句它在目标矩阵里是全支持还是需要降级这一步能过滤掉大量后期返工。2.2 supports 特性查询用能力而不是版本号判断目标矩阵定完后具体到一个属性时优先用特性查询而不是浏览器版本判断。supports是 CSS 原生的判断能力写法很直接.feature-box { display: block; } supports (display: grid) { .feature-box { display: grid; grid-template-columns: 1fr 120px; } }这段代码的意思是浏览器支持display: grid就启用网格布局不支持就用 block 布局兜底。相比“判断浏览器再写一堆 hack”特性查询的思路更接近“能力探测”浏览器自己最清楚自己支持什么。对应到脚本里也可以用CSS.supports(display, grid)做同类型判断。要注意的是supports本身在 Trident 系旧浏览器里不认识但这也无妨因为不认识它的浏览器本来也会忽略整个花括号里的规则所以基础样式依然生效。对很多“增强型”功能这一招就是最干净的降级工具。2.3 构建期加前缀 运行期 polyfill别把两者搞混另一个常见误区是把“自动加前缀”和“polyfill”混为一谈。自动加前缀只是在构建阶段给 CSS 属性补上-webkit-、-moz-、-ms-这类厂商前缀它解决的是“需要前缀才能生效”的问题。polyfill 则是用脚本或其它 CSS 手段在浏览器不认某个特性的时候模拟出类似效果。两者的作用范围完全不同。项目里如果用了构建工具通常会在配置里维护一份 browserslist比如last 2 versions 0.5% not dead然后交给 autoprefixer 这类插件在产出 CSS 时自动生成合理的前缀。这套流程上手很快但别以为它什么都能救。比如早期 Grid 的 Trident 语法里很多布局能力根本没有对应实现前缀补齐了也还是降级gap在 flex 里也一样。所以构建期工具负责“能救的部分”剩下的要么写降级要么用运行期 polyfill。运行期 polyfill 不是不好是成本高。比如 CSS 变量在 Trident 系里没有社区有对应的脚本可以在运行期帮你做替换但脚本本身可能让首屏样式闪烁多一层解析和执行开销。我的经验是如果某个降级方案能用一个简单的普通 CSS 声明解决就不要为了“像素级一致”去引 polyfill。兼容性目标从来都是“可用”不是“在所有环境长得一模一样”。3. 高频兼容点逐个复盘不是背文档是理解浏览器各自的“脾气”3.1 flex 老语法、gap 缺失、sticky 失效三大高发区flex 的“老语法”是最典型的版本差异。早期浏览器对弹性布局实现的是一套以display: box为核心的属性后来才演变成display: flex。如果你只写display: flex在那些老环境里整段布局会退化成普通块状排列谈不上报错但视觉效果会差很多。处理方式不是靠手写老前缀而是让 autoprefixer 根据目标浏览器列表自动补齐。真正要人判断的是“补齐后是否还能达到设计目标”老环境的 flex 语法能力有限很多写法需要手工调整层级或者接受简化版布局。gap在 flex 里的坑更隐蔽。CSS Grid 很早就支持gap但 flex 里支持gap要晚不少一段时期内有些 WebKit 内核浏览器只支持 Grid 的 gap却在 flex 容器里把所有间距都忽略掉。于是视觉上像 grid 时间距正常切回 flex 时又挤成一团。如果要做兼容与其用supports (gap: 1px)去猜不如直接在需要兼容的容器上用 margin 先摆好间距后续再在能确认支持的环境中替换成 gap。这个看起来“笨”的写法反而最稳。position: sticky是另一个“文档看会了、真机翻车”的特性。它要求所有祖先容器的overflow都不能是非visible的值否则滚动到某个位置时 sticky 会失效直接表现成“吸顶突然不吸了”。排查时先看父元素有没有overflow: hidden、overflow: auto再看是不是被套了一层transform或filter——这些都会改变包含块。还可以在外层加上-webkit-sticky兼容更早的 WebKit 内核。这些细节写再多文档都容易漏只能靠长期踩坑形成肌肉记忆。3.2 100vh 不是你以为的“一屏高度”100vh大概是移动端兼容问题里被吐槽最多的单位之一。很多同学把它理解成“手机屏幕可视高度”实际在移动浏览器里vh一般对应的是布局视口高度而布局视口在地址栏缩起、弹出的过程中会变化。结果就是你写了一个height: 100vh的弹层或首屏在部分手机上底部被工具栏挡住或者滚动时高度忽大忽小。现在主流浏览器逐渐支持动态视口单位dvh、svh、lvh分别表示动态视口、小视口和大视口。一个简单稳妥的写法是连续声明.full-screen { height: 100vh; height: 100dvh; height: 100svh; }浏览器认识哪一行就用哪一行一行都不认识就用最上面的兜底。需要精确控制“安全区域”时还可以配合env(safe-area-inset-bottom)给底部留出刘海屏的避让空间。这类问题用真机测比用模拟器可靠得多因为模拟器里的视口状态和真机并不完全一致。3.3 aspect-ratio、backdrop-filter、滚动条能用和好用是两回事aspect-ratio现在已经是几乎所有现代浏览器都认识的值但真要“放心用”还得看你的目标矩阵里有没有更早的环境。如果没有就直接写.video-box { aspect-ratio: 16 / 9; }如果还有更早的环境常见的回退是高度塌陷法.video-box { position: relative; height: 0; padding-top: 56.25%; } .video-box iframe { position: absolute; inset: 0; width: 100%; height: 100%; }先让旧浏览器看到撑开的容器新浏览器再用 aspect-ratio 接管。注意inset这个简写在很老的环境里不认识所以前面最好单独补top/right/bottom/left。这类“能用一个新属性解决但要为了旧环境多写几行”的场景很多关键不是拒绝新属性而是让新属性负责增强老属性负责地基。backdrop-filter的问题我在开头已经提过。它能在背景上做模糊、调色等效果效果确实漂亮但它也是“支持但不稳定”的典型一部分环境要加-webkit-前缀一部分环境虽然支持却会吃掉大量 GPU 资源还有一部分环境完全不支持。我的建议是把它当成纯装饰增强核心的文字可读性绝不能依赖它。如果背景模糊效果丢了界面也应该是一个干净、能读、不叠字的普通面板。滚动条样式属于“能用但体验分裂”的典型。Gecko 内核有scrollbar-width和scrollbar-colorWebKit 内核需要用::-webkit-scrollbar系列伪元素两套写法完全不一样而且伪元素细节在不同版本里差异很大。一般我会限制自己只改宽度、圆角和颜色不要试图跨浏览器复刻完全一致的拇指样式.scroll-area { scrollbar-width: thin; scrollbar-color: #888 #eee; } .scroll-area::-webkit-scrollbar { width: 8px; } .scroll-area::-webkit-scrollbar-thumb { background: #888; border-radius: 4px; }这样在主流环境里至少是一致的“细滚动条”而不会因为过度定制导致某个环境里连滚动交互都变得奇怪。3.4 单位、字体平滑与中文断词的细节单位选择看起来基础但很多高级别的样式问题都从这里引爆。rem依赖根字号好处是整站字号可统一缩放风险是如果根字号被某些插件或用户设置改掉所有 rem 尺寸会一起变。vw适合按视口比例设置但在移动端会跟随视口状态波动。所以现在更推荐把clamp()用起来.title { font-size: 1.5rem; font-size: clamp(1.25rem, 2vw 1rem, 2.5rem); }clamp()给最小值、理想值、最大值三档既能响应视口又不会小到看不见或大到离谱。老环境不认识clamp()时前面先写一行固定值不认识的浏览器自然会忽略后面的声明。字体渲染也是很容易被忽略的“跨平台细节”。在 WebKit 内核上汉字和英文混排时经常会出现明显的锯齿感很多人会加body { -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale; }这不算是一个能“测试出 bug”的技术但它直接决定了观感。另一个容易被忽略的点是中文断词一长串英文 URL 或连续数字放进窄容器时浏览器可能不知道在哪里断行导致撑破布局。通常我不会用word-break: break-all去硬切因为它会把中文标点和单词也切得稀碎。更好的做法是.text { overflow-wrap: break-word; overflow-wrap: anywhere; hyphens: auto; }overflow-wrap: anywhere支持度更广一些可以给长单词留一个可断点hyphens: auto则让英文文本按音节断词需要配合lang属性才有最佳效果。这些细节单独看都不起眼叠加起来才是“高级感”的来源。4. 高级技巧把兼容性意识“内化”进日常写法4.1 连续声明做降级利用层叠规则当兼容工具“连续声明”是我最常用的降级写法没有之一。原理很简单CSS 层叠规则规定同样优先级下后面的声明覆盖前面的。于是可以先把基础写法写在前面再把高级写法写在后面.container { display: block; display: grid; grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); }不认识display: grid的浏览器会忽略第二行继续用display: block认识的浏览器使用后面的 grid。两行代码之间不需要任何 polyfill也不需要 supports这就是“渐进增强”最朴素的形态。同样的思路可以用在很多地方先写position: static兜底再写position: sticky先写padding-top: 56.25%再写aspect-ratio: 16 / 9先写font-size: 20px再用clamp()覆盖。它的局限是要保证前后两条声明属于同一属性并且后面的声明只在支持时才能生效。只要满足这两点它就是零成本、零风险的降级。这也是我在 code review 里最愿意看到的写法不打扰旧环境也不拖累新环境。4.2 CSS 自定义属性主题切换默认值兜底 可选增强CSS 自定义属性也就是很多人口中的 CSS 变量是设计系统主题化的核心工具。它本身在 Trident 系里不支持所以兼容策略通常是在不支持的环境里所有用到var()的声明直接失效。这时只要你把普通默认值写在前面即使变量没了页面也不会裸奔.button { background: #2563eb; background: var(--brand-color, #2563eb); }支持变量的浏览器会用变量不支持的浏览器用第一行兜底。做到了这一步主题系统就可以放心往下做在:root里定义亮色变量在[data-themedark]里覆盖成暗色变量然后通过 JS 切换>:root { --bg: #ffffff; --text-color: #1f1f1f; } :root[data-themedark] { --bg: #151515; --text-color: #f5f5f5; } body { background: var(--bg, #ffffff); color: var(--text-color, #1f1f1f); }这里要注意优先级。:root和一个属性选择器在特异性上其实是同一档所以挂主题变量时最好把选择器写得比:root更具体比如:root[data-themedark]否则换主题时不生效的情况非常常见。主题切换这种“增强型功能”天然适合放在supports (--css: variables)里不支持变量的旧浏览器就用默认配色不会被暗色覆盖。4.3 性能向高级技巧content-visibility、contain、will-change 的正确姿势兼容性的话题做到后面一定会碰到性能。因为同一套 CSS 在高级浏览器和低级浏览器上的渲染开销完全不同所以“高级技巧”也包括怎么让新浏览器更快、旧浏览器不至于更卡。content-visibility: auto是目前很值得用的性能优化属性。它可以让浏览器跳过视口外元素的渲染类似“懒渲染”的效果。配合contain-intrinsic-size给出一个预估尺寸能避免滚动条跳动.section { content-visibility: auto; contain-intrinsic-size: 0 600px; }不支持这个属性的浏览器会直接忽略不影响布局所以它天生就是安全的。注意别把它加在首屏内容上那样反而可能拖慢首次渲染的判断我一般只用于首屏以下的大区块。contain: layout paint也是类似的思路明确告诉浏览器某个子树的布局或绘制变化不会影响外部方便浏览器把重排重绘限制在局部。比较常见的做法是给独立卡片加contain: layout paint副作用很小。will-change则是把双刃剑。它告诉浏览器这个元素将要发生动画请提前准备合成层。合理使用能让动画更顺滥用则会让浏览器同时维护大量图层内存占用飙升。我的经验是只对确实要长期动画或频繁变化的元素加而且不要一次给十几个元素都加。以前流行的“transform: translateZ(0)强制 GPU 化”已经不那么推荐了图层不是越堆越好。4.4 可访问性与打印容易被当成“兼容性”漏掉的边界兼容性边界不只有浏览器还有用户的使用偏好和输出场景。prefers-reduced-motion就是这几年越来越重要的一个媒体查询。很多用户会在系统层面关闭动画网页如果照常播放大幅动效轻则让人不适重则诱发眩晕。一个常见的兜底方案是media (prefers-reduced-motion: reduce) { *, *::before, *::after { animation-duration: 0.01ms !important; animation-iteration-count: 1 !important; transition-duration: 0.01ms !important; scroll-behavior: auto !important; } }这段代码不是所有场景都合理某些用户主动触发的动画可能还是应该保留但作为默认兜底很好用。它同样是一个“不支持也没关系”的媒体查询不支持的浏览器直接忽略不会报错。:focus-visible值得单独说。它用来区分“鼠标点击产生的焦点”和“键盘 Tab 产生的焦点”核心价值是让键盘用户看得到焦点同时不让鼠标点击后的焦点圈污染视觉。实际使用可以这样写:focus { outline: 2px solid #0066cc; } :focus:not(:focus-visible) { outline: none; }老浏览器不认识:focus-visible时整条:focus:not(:focus-visible)会被忽略基础:focus的 outline 还在键盘和鼠标用户都不会丢焦点提示。新浏览器里鼠标点击就不会额外画一个圈了。打印样式也常常被忽略。写media print时我的原则是去掉不必要的背景、边框和阴影让正文用黑色再收紧间距。然后检查display: grid或 flex 在纸面上是否会被切掉必要时把多列改成单列。这个“兼容性”不针对浏览器而针对纸张但它和浏览器兼容一样都是在不同输出环境下保证可用。5. 团队落地的验收清单与“什么时候可以放弃兼容”5.1 发布前的兼容性验收清单逐项打勾个人经验再丰富也要靠流程兜底。我习惯在项目发布前跑一遍这个验收清单检查项具体内容目标矩阵是否仍和产品、统计一致有没有新增要支持的设备新特性登记本次用到的 CSS 新属性是否已列出并标注降级方案构建产物查看产物 CSS 是否自动加了该加的厂商前缀视口相关首屏、弹窗、吸顶这类是否在窄屏和带“刘海”的设备上测过动效降级prefers-reduced-motion是否覆盖了主要动画打印输出核心内容页面在打印预览里是否信息完整焦点可见性键盘操作是否能看到清晰的焦点区域已知问题记录仍未修复的兼容问题是否记录在文档里而不是散落在群里这张表看着繁琐实际跑起来很快关键是把责任落到具体的人和具体的时间点。如果项目里只有一个人懂其他人遇到问题不会查流程就形同虚设。5.2 自动化、真机测试与“已知问题记录表”自动化的作用是在最底层拦下低级问题。比如构建阶段通过 browserslist 和 autoprefixer 可以处理大部分前缀样式检查工具可以强制团队在写新属性时补充降级声明现代浏览器的开发者工具也提供了设备模拟。但自动化能拦住的只是“规则已知”的问题很多兼容性 bug 是逻辑性的比如某个属性在目标环境里支持但和另一个属性组合时会出现渲染 bug。这种问题模拟器不一定能复现真机测试反而最高效。所以我更看重一份“已知问题记录表”。每次在真机或用户反馈里发现一个兼容问题就记下触发环境、表现、根因和临时解法。别小看这张表它会成为团队自己的兼容性词典。比如你记录过“某个容器加了 transform 后内部 sticky 失效”下次再遇到类似布局你会更快地判断该不该动手。这比反复查文档更贴近实际场景。5.3 放弃兼容也是一种决策三个判断标准最后说点反直觉的成熟的前端团队会把“放弃兼容”也当成一个正式决策而不是偷偷不管。放弃兼容不代表页面在旧环境里直接崩而是“不保证高级体验只保证基础可用”。判断标准一般有三条。第一是数据。如果目标环境在访问量里占比已经降到个位数而维护成本却很高就可以把完整支持降级为可读可用。第二是业务核心。如果一个效果只是锦上添花的动效放弃毫无压力如果它是用户完成任务的关键路径比如支付按钮、信息展示那就不能只靠一行 CSS 赌支持。第三是维护成本。用几行连续声明就能兜底的特性别轻易放弃需要引一个大体积 polyfill、还要长期跟进上游 bug 的特性就要慎重。落到执行上“放弃”的方式通常是把基础体验做成默认值把高级体验用supports或连续声明包起来。这样旧浏览器看到的是完整信息和可用操作的朴素版新浏览器看到的是增强版。整件事不叫“不支持某个浏览器”而是“给所有浏览器一个体面的结果”。我个人在项目里最常用的一句话是先保证没有人的任务被卡住再去追求视觉上的领先。这比“用上最新属性”更能体现一个前端工程师的真实水平。