对 filter 和 backdrop-filter 来说,Lightning CSS 会把带前缀和不带前缀的两条声明合并成一条,只保留你最后写的那条的前缀标记。所以 backdrop-filter 后面再跟一条 -webkit-backdrop-filter,编译出来就只剩 WebKit 版本。Chrome 根本不支持 -webkit-backdrop-filter,于是模糊效果在生产环境里悄无声息地消失了,而源码看上去完全正常。大多数其他带前缀的属性经过同样的处理都毫发无损——这正是这一个容易被漏掉的原因。
症状
我某个模板里的 sticky header 有一个毛玻璃效果:半透明背景,背后带模糊。在我本机上跑了好几周都没事。后来我在 Chrome 里打开部署好的站点,header 只剩下平坦的半透明。没有模糊。内容从它底下滚过去,清晰得一塌糊涂。
源码却是另一回事。规则就明明白白写在 globals.css 里,class 也在元素上,没有 media query 把它藏起来,也没有 @supports 门槛。DevTools 显示元素确实匹配到了那条规则。
我当时的猜测
我以为是 CSS 优先级问题,或者 stacking context 问题——backdrop-filter 出了名地对它背后的东西、以及对创建 containing block 的祖先元素敏感。我在父元素的 transform 和 filter 上浪费了多到不好意思说的时间。
这个方向是错的,而它错的原因在于:我读的是源码,不是浏览器下载到的那份样式表。
真正的机制
这些模板用 Next.js 16.2.10 构建(在 package.json 里锁死);tailwindcss 和 @tailwindcss/postcss 声明为 ^4,在我这套安装里解析到 4.3.2。PostCSS 配置就这么点东西:
// launch-template/postcss.config.mjs
const config = { plugins: { "@tailwindcss/postcss": {} } };
export default config;Tailwind v4 通过 Lightning CSS 编译 CSS——我这套安装里是 lightningcss@1.32.0。Lightning CSS 不会把 -webkit-backdrop-filter 和 backdrop-filter 当成两个互不相干的属性。它把两者解析成同一个内部属性,这个属性带着一组厂商前缀标记,然后再把存活下来的标记重新输出出去。
同一个属性写两条声明,按照层叠规则后者胜出。但"胜出"的是整条声明连同它的前缀标记。先写标准属性、后写 WebKit 版本,标准的那个标记就没了。
我直接跑了一遍编译器来确认。用的是 build 里同一个 lightningcss@1.32.0:
### Tailwind v4 default targets (safari 16.4, chrome 111, firefox 128)
standard, then -webkit- => .a { -webkit-backdrop-filter: blur(16px); }
-webkit-, then standard => .b { -webkit-backdrop-filter: blur(16px); backdrop-filter: blur(16px); }
standard alone => .c { -webkit-backdrop-filter: blur(16px); backdrop-filter: blur(16px); }仔细读最后一行。只写标准属性,输出的是两条。编译器早就知道 Safari 需要前缀,会自己加上。我那条"双保险"的第二条声明并没有多给我一层安全——它毁掉了标准属性。
这确实是最后一个标记胜出,而不是偏爱 WebKit。完全不配置 targets 的话,合并会朝另一个方向走:
### no targets at all
standard, then -webkit- => .a { -webkit-backdrop-filter: blur(16px); }
-webkit-, then standard => .b { backdrop-filter: blur(16px); }出问题的是 filter 家族,不是每个属性
我的第一反应是把代码库里所有手写的前缀全部拔掉。动手之前,我把同样的"先标准后 webkit"这一对喂给编译器,跑了一批属性,结果让我停了下来:
filter => -webkit-filter: blur(4px) ← standard GONE
backdrop-filter => -webkit-backdrop-filter: blur(16px) ← standard GONE
mask-image => both emitted
user-select => both emitted
background-clip => both emitted
hyphens => both emitted
box-decoration-break => both emitted
appearance => appearance: none (collapses to standard)
clip-path, transform => standard only, prefix dropped只有 filter 家族会丢掉标准属性。其他要么两种形式都输出,要么朝着标准属性合并,所以在那些地方多写一个前缀确实无害。
filter 可以说是两者里更阴的那个。在这套 targets 下,单独写 filter: blur(4px) 编译出来还是 filter: blur(4px)——没有任何目标浏览器需要前缀——所以在它后面写 -webkit-filter,等于用一条你支持矩阵里没人要的声明,替换掉了一条本来能用的声明。
这种不一致才是真正的陷阱。你去查 user-select,看到输出里两种形式都在,于是断定自己的防御性前缀没问题,然后压根不会想到去查那两个恰恰有问题的属性。
为什么偏偏 Chrome 什么都不渲染
我曾半信半疑地以为 Chrome 会把 -webkit-backdrop-filter 当作遗留别名接受,毕竟 Blink 是从 WebKit 派生出来的。并没有。在 Chrome 151 里:
CSS.supports('backdrop-filter', 'blur(16px)') // true
CSS.supports('-webkit-backdrop-filter', 'blur(16px)') // false内联设置它会被直接丢掉——它甚至不会映射到标准属性上。于是我在一个真实元素上探了一下:
const mk = (css) => { /* build a div, apply css, read computed style */ };
mk('-webkit-backdrop-filter:blur(16px)') // "none"
mk('-webkit-backdrop-filter:blur(16px);backdrop-filter:blur(16px)') // "blur(16px)"
mk('backdrop-filter:blur(16px)') // "blur(16px)""none"。编译器输出的那条声明,Chrome 完全无视。Safari 一直好好的,这正是它能在评审里活这么久的原因。
一锤定音的诊断方法
别再读你的源码了。去读浏览器实际拿到的字节:
# find the stylesheet the deployed page links
curl -sL https://launch.violettadev.com/en | grep -oE '[^"]+\.css' | sort -u
# then count the two forms in it
curl -sL https://launch.violettadev.com/_next/static/chunks/<hash>.css \
| grep -o -- '-webkit-backdrop-filter' | wc -l
curl -sL https://launch.violettadev.com/_next/static/chunks/<hash>.css \
| grep -o -- '[^-]backdrop-filter' | wc -l如果这两个数字对不上,你就有被合并掉的声明。当前这个 build 里它们对得上——11 和 11。下面是线上真正发出去的 nav 规则,逐字摘自部署后的样式表:
.lv-nav{-webkit-backdrop-filter:blur(16px);backdrop-filter:blur(16px)}而下面是我为了得到它所写的东西,出自 launch-template/src/app/globals.css:
.lv-nav {
position: sticky; top: 0; z-index: 100;
height: 64px; display: flex; align-items: center;
border-bottom: 1px solid transparent; transition: all .2s;
background: color-mix(in srgb, var(--bg) 80%, transparent);
backdrop-filter: blur(16px);
}进去一条声明,出来两条。编译器甚至在 overlay 变体上帮我给 transition 的属性列表加了前缀:transition:...,-webkit-backdrop-filter .4s,backdrop-filter .4s。
如果你想在一个已经坏掉的页面上确认因果关系,在 DevTools 里做个反向操作:选中元素,在 Styles 面板里手动加上 backdrop-filter: blur(16px)。如果模糊立刻出现,那就是编译器吃掉了你的标准属性。
我怎么防止它再发生
我把手写的 backdrop-filter 成对声明删掉了,并在容易手痒的地方留了条注释,五个模板里有四个的 globals.css 都加了(措辞一致,按每个文件的宽度折行):
/* Do NOT hand-write `-webkit-backdrop-filter` here — or anywhere else in this
file. The build (Tailwind v4 → Lightning CSS) treats the prefixed and
unprefixed property as ONE declaration and keeps only the prefix it saw LAST,
so an authored `backdrop-filter` + `-webkit-backdrop-filter` pair silently
ships as webkit-only and Chrome renders no blur at all. Author the standard
property alone; the build re-adds `-webkit-` when targets need it. */注释是提醒,不是守卫。真正能抓住这个问题的是一个脚本:扫描规则体,找出任何在同一个 block 里同时以带前缀和不带前缀形式出现的属性——目前是按需运行,还没接进 CI。跑遍全部五个模板、933 个 CSS/TSX/TS 文件,它找到的 filter 或 backdrop-filter 成对声明是零个。会咬人的就是这两个。
它确实还找到了其他属性的成对声明:一个模板里有三对 mask-image 和两对 background-clip,另一个里有两对 user-select 和一对 appearance。决定之前我把每一个都过了一遍编译器,然后原样留着——它们都会输出两种形式。冗余,但不危险。剩下的 -webkit- 声明都是压根没有标准对应物的属性:-webkit-font-smoothing、::-webkit-scrollbar、-webkit-text-size-adjust、::-webkit-details-marker、-webkit-overflow-scrolling、-webkit-user-drag。
如果你也要做同样的守卫,别让它对每一对都报错——你会把两个真正的问题埋在一堆无害的东西底下。对 filter 和 backdrop-filter 让 build 直接失败;其余的只警告。
更普遍的规则,也是值得记住的那部分:现代 CSS 流水线会替你加前缀,手写的前缀不是冗余的保险——它是一条可能覆盖掉真正那条的多余声明。 如果你迁移到了 Tailwind v4,又继承了一份塞满 2019 年防御性前缀的样式表,其中大部分只是死重量,但 filter 和 backdrop-filter 那些是活的 bug——而且它们在源码里看不见,在你恰好用来测试的浏览器里看不见,在你的测试套件里也看不见。它们唯一会现身的地方,是浏览器下载到的那份样式表。
这就是我在 launch 里交付的模式——在线演示见 launch.violettadev.com。