backdrop-filter 属性详解:语法、10 个滤镜函数与三条硬边界

2026-07-24 前端

backdrop-filter 这个属性只有两种取值:none 或者一串滤镜函数。麻烦从来不在"怎么写",而在"为什么这次没生效"。这篇文章只讲属性本身——每个滤镜函数的参数与默认值、计算值长什么样、背后那块内容是怎么算出来的、以及三条会让它看起来"失效"的硬边界。

三年前的 Safari 还在用 -webkit-backdrop-filter,Firefox 要等到 103 才支持,如今它已经是 Baseline 里"新可用"的一员(MDN 标注 Baseline 2024,自 2024 年 9 月起跨最新浏览器版本可用)。所以现在值得把细节一次讲清楚。

本文所有数字都是在本机 headless Chromium 151.0.7922.34 上真跑出来的,量测方法写在第 15 节,示例代码可以直接复制。如果你想看的是"怎么把毛玻璃铺到整个网站、以及铺完之后文字对比度怎么保",那是另一篇(给网站换上毛玻璃效果);这篇不重复那部分,只在相关处给出链接。

一、先来一个能跑的最小示例

/* glass.css —— 外部样式表,class 驱动 */
.glass-panel {
  /* 回退:不支持时用接近实色的底,保证文字可读 */
  background-color: rgb(13 17 23 / 92%);
  color: #f0f6fc;
  border-radius: 12px;
}

@supports (backdrop-filter: blur(1px)) {
  .glass-panel {
    /* 必须留出半透明的部分,否则规范判定效果不可见 */
    background-color: rgb(13 17 23 / 45%);
    backdrop-filter: blur(10px) saturate(160%);
  }
}
<div class="glass-panel">面板内容</div>

代码里三件事是刻意的:底色先给不透明的、@supports 里才降透明度;滤镜写成函数列表(blur() 加 saturate());样式全部走外部样式表——本站的 CSP 是 script-src/style-src 'self',内联样式和 style 属性会被拦掉,所以下面所有示例都按外部样式表的写法给。

二、语法与属性表

属性取值就两种:

backdrop-filter: none;
backdrop-filter: blur(4px) saturate(150%);
backdrop-filter: url("#svg-filter") blur(2px);

规范给出的形式语法(Filter Effects Level 2 与 Level 1 一致):

backdrop-filter = none | <filter-value-list>
<filter-value-list> = [ <filter-function> | <url> ]+
<filter-function> = <blur()> | <brightness()> | <contrast()> | <drop-shadow()> |
                    <grayscale()> | <hue-rotate()> | <invert()> | <opacity()> |
                    <sepia()> | <saturate()>
<blur()>       = blur( <length>? )
<brightness()> = brightness( [ <number> | <percentage> ]? )
<contrast()>   = contrast( [ <number> | <percentage> ]? )
<drop-shadow()> = drop-shadow( [ <color>? && <length>{2,3} ] )
<grayscale()>  = grayscale( [ <number> | <percentage> ]? )
<hue-rotate()> = hue-rotate( [ <angle> | <zero> ]? )
<invert()>     = invert( [ <number> | <percentage> ]? )
<opacity()>    = opacity( [ <number> | <percentage> ]? )
<sepia()>      = sepia( [ <number> | <percentage> ]? )
<saturate()>   = saturate( [ <number> | <percentage> ]? )

其他几条属性信息:

项目 值
初始值 none
是否继承 否
计算值 as specified(按指定值,见第六节)
动画类型 滤镜函数列表(Filter Effects 1 §14)
适用范围 所有元素;在 SVG 中适用于除 <defs> 外的容器元素与所有图形元素

<filter-value-list> 里可以出现 url(),引用一个 SVG 滤镜(<filter> 元素)。它和 filter 属性用的是同一套取值语法,但两者的作用对象完全不同:filter 过滤的是元素自己(含后代)画出来的像素,backdrop-filter 过滤的是元素背后已经被画出来的内容。动画一个元素的时候前者会连内容一起变形,后者只动"背景玻璃"。

三、十个滤镜函数逐个拆解

十个函数都在 Filter Effects Level 1 §6.1 里有定义,backdrop-filter 直接复用。参数写法统一,但几个默认值上的差异是最容易写错的地方。

函数 参数 省略参数时 关键行为
blur() <length>(不接受百分比) 0px 参数是高斯函数的标准差 σ,不是 box-shadow 的模糊半径;负值不允许
brightness() <number> 或 <percentage> 1 线性乘数;0% 全黑,100% 不变,可大于 100%
contrast() 同上 1 0% 全灰,100% 不变,可大于 100%
drop-shadow() [<color>? && <length>{2,3}] 缺省长度为 0,缺省颜色取自 color 第三个长度是标准差;不收 spread,也不收多重阴影
grayscale() 同上 省略时 1 100% 全灰;超过 100% 被实现钳到 1
hue-rotate() <angle> 或 <zero> 0deg 实现不得归一化该值,以便支持超过 360deg 的动画
invert() <number> 或 <percentage> 省略时 1 100% 完全反相
opacity() 同上 1 把输入样本乘上该值;>100% 被钳到 1
saturate() 同上 1 0% 完全不饱和,可大于 100% 得到超饱和
sepia() 同上 省略时 1 100% 完全棕褐

四个细节值得单独说:

blur() 的参数是 σ,不是半径。 规范专门写了一句提示:Standard deviation is different to box-shadow's blur radius. 高斯模糊理论上范围无限,实现里都用有限区域近似——规范注释说,主流实现在写作时(2024 年 1 月)都用"三遍盒式模糊"近似,其影响范围是 (3 * sqrt(2 * π) / 4) * σ,约等于 1.88σ。所以 blur(12px) 大约会影响面板边缘外 22px 的范围,这个数值在做密集排布时有用。

grayscale() / sepia() / invert() 的"省略即 1"是给作者方便的。 规范原文区分了两个概念:这三个函数省略参数时默认取 1(也就是"应用 100% 效果"),而它们插值时的初始值是 0("无效果")。也就是说 grayscale() 等于 grayscale(100%),而在动画补间里它的起点是 0 而不是 1 —— 这个区别在写关键帧动画时会直接决定方向。

opacity() 不是 opacity 属性的简写。 规范明确指出这一点,并提醒:如果它出现在滤镜列表的最后,元素自身的 opacity 会再乘一次,结果可能比预期更透明。写 .glass { opacity: .8; backdrop-filter: opacity(50%) } 得到的是两层相乘。

drop-shadow() 的第三个数是标准差。 和 box-shadow 的"第三个数是模糊半径"不是一个意思,而且它只接受 2 到 3 个长度值,没有 spread、也没有多重阴影。

十二种写法放在同一张背景图上长这样(面板底色统一是 rgb(255 255 255 / 18%),只有滤镜不同):

十二个 backdrop-filter 取值的效果对比

图 1:none、blur(6px)、brightness(140%)、contrast(160%)、drop-shadow(0 6px 10px #000)、grayscale(100%)、hue-rotate(120deg)、invert(100%)、opacity(40%)、saturate(200%)、sepia(100%)、blur(8px) saturate(180%)。

四、三条硬边界

规范 §2.1 的注释只有一句关键话:除非元素的某个部分是半透明的,否则看不出 backdrop-filter 的效果。 加上"背后得有东西"和"别被祖先截断",合起来是三条边界。

4.1 元素本身要有半透明的部分

我把两套完全相同的面板叠在同一位置(一层有滤镜一层没有),逐帧切换 visibility 后再逐像素比较——背景完全相同,差值只可能来自 backdrop-filter:

场景(面板尺寸、位置完全一致) 平均绝对像素差 最大像素差
对照组:两层都无滤镜 0.000 0
不透明底 + blur(12px) 0.000 3
半透明底(rgb(255 255 255 / 18%))+ blur(12px) 20.762 157
透明底 + blur(12px) 37.097 164

不透明底那一行的平均差是 0.000,最大 2~3 是边缘抗锯齿的噪声——滤镜确实执行了,但你看不见任何变化。规范里给了对应的写法:想要"完整透出过滤后的背景",用 background-color: transparent;那只剩纯滤镜,没有底色的遮挡,这也解释了为什么透明底那行差值最大。

4.2 背后要有内容

把面板放在一块纯色(#1f6feb)上面,blur(12px) 与无滤镜的平均差是 0.090(最大 2)。模糊一片纯色得到的还是同一片颜色。这条在真实项目里对应两种情况:面板背后是纯色页面背景,或者背景图在这个位置恰好是单色。要么给底色多留一点透明度让内容透过来,要么这个位置就别放毛玻璃。

4.3 不能被祖先截断

backdrop-filter 能"看到"的内容,从最近的 backdrop root 祖先起算——规范 §3 用七个步骤定义 Backdrop Root Image:从最近的 backdrop root 元素开始,把它和当前元素之间按绘制顺序画出的内容展平进一个 2D 缓冲,再按当前元素的 border box 裁剪。所以只要中间某个祖先成了 backdrop root,模糊范围就被截在那个祖先内部。

哪些东西会成为 backdrop root:文档根元素、filter 非 none、opacity 小于 1、mask/mask-image/mask-border/clip-path 非 none、backdrop-filter 非 none、mix-blend-mode 非 normal,以及 will-change 指定了上面任意一个属性。

我把同一种面板放进四种父元素里,父元素都放在同一块棋盘格背景上,结果一目了然:

父元素 平均绝对像素差 最大像素差
will-change: opacity 0.000 0
opacity: .99 0.000 0
filter: blur(0px) 0.000 0
普通父元素(对照组) 24.890 27

前三种父元素都成了 backdrop root,子元素能模糊的只剩"父元素自己画出来的那点内容",在这组实验里父元素没有背景,于是差值精确地是 0;普通父元素那一组能看到页面棋盘格,差值 24.890。注意 opacity: .99 和 filter: blur(0px) 这种"看起来什么都没做"的值同样会截断——它们不是"写了等于没写"的无害值,而是实打实地创建了边界。

好消息是规范明确说了:z-index、fixed/sticky 定位、transform 都不会形成 backdrop root。这比 stacking context 的触发条件宽松得多,所以外层容器用 transform 做位移动画、内部放毛玻璃面板,是允许的。

三个底色状态和两组父元素的直观对比:

半透明条件与 backdrop root 的对比

图 2:上半部分四块面板只有透明度与是否带 blur() 不同;下半部分左右两组结构完全相同,唯一差别是左边父元素带 will-change: opacity,圆内棋盘格因此保持清晰,而右边能看到并模糊页面背景。

五、背后那块内容是怎么算出来的

规范 §2.1 把渲染过程拆成七步,理解它就能预判大多数"效果不对":

  1. 把 Backdrop Root Image 复制进一个临时缓冲 T′;
  2. 对 T′ 的全部内容应用滤镜函数(列表里第一个函数拿 Backdrop Root Image 当输入,后续函数拿前一个的输出);
  3. 如果元素和 backdrop root 之间有 transform,对 T′ 应用这些变换的逆;
  4. 用元素的 border box 裁剪 T′,包含 border-radius;这一步不考虑子元素的大小和位置;
  5. 把元素自身(背景、边框、全部子元素)画进 T′;
  6. 如果元素自身还有变换、效果或裁剪,应用它们;
  7. 用 source-over 把 T′ 合成进父级。

两个可直接利用的推论:

  • 第 4 步意味着圆角会被正确尊重:滤镜结果只出现在 border box 内、按 border-radius 裁切,不会在圆角外漏出一个直角方块。
  • 滤镜列表里含 blur() 时,规范要求以 edgeMode="mirror" 应用,边界由"裁剪并变换后的元素 border box"定义。所以毛玻璃面板的边缘不会像 filter: blur() 那样向透明淡出——它没有可淡出的外部区域,边缘是镜像填充的。

六、计算值长什么样(调试时很有用)

规范 §6.2 说,<filter-function> 里的值按指定值计算,但有两个例外:省略的值会被补上并计算成默认值;drop-shadow() 先把 <color> 的计算值放前面再跟长度。

我在页面上写了十个"故意省略参数"的元素,getComputedStyle() 读回来是:

写的 计算值
blur() blur(0px)
grayscale() grayscale(1)
sepia() sepia(1)
invert() invert(1)
brightness() brightness(1)
hue-rotate() hue-rotate(0deg)
contrast() contrast(1)
saturate() saturate(1)
opacity() opacity(1)
drop-shadow() none

前九行的规律就是第三、四节讲的默认值。表格里唯一落空的是 drop-shadow():它的语法要求至少两个长度值,所以空参数的 drop-shadow() 整个声明非法,属性回落到初始值 none——不是"投影变成 0 偏移",而是没有滤镜。

另外,百分比会被序列化成数字。写 backdrop-filter: brightness(140%) saturate(180%) hue-rotate(0),读回来是:

brightness(1.4) saturate(1.8) hue-rotate(0deg)

这解释了一个常见的困惑:用 JS 比对计算值和写的字符串永远不相等——saturate(180%) 和 saturate(1.8) 是同一个东西的两种写法。要判断"浏览器到底认不认",用 CSS.supports() 或者读计算值再解析,别做字符串比较。

七、顺带改变的两件事

规范 §2.1 有一句容易被忽略的话:计算值不是 none 时,会创建 stacking context,并为 absolute/fixed 定位的后代创建包含块(元素本身是文档根元素时除外),行为与 filter 属性一致。

这不是理论问题。同一个 position: fixed 的子元素,在普通容器里和带 backdrop-filter 的容器里位置完全不同(视口 1300×900):

普通容器 容器带 backdrop-filter: blur(6px)
容器位置 x=40, y=357.3 x=358, y=357.3
fixed 子元素位置 x=1206.9, y=4(视口右上角) x=563.9, y=362.3(容器内右上角)
子元素 position 计算值 fixed fixed

position 的计算值还是 fixed,但包含块换了:左边那个跑到了整个视口右上角,右边那个贴在容器内部的右上角。

position: fixed 的包含块变化

图 3:两个容器的结构相同,各自的 position: fixed 子元素都标成黄色小标签「fixed 子元素」。左边普通容器的标签跑到了画面右上角,右边带 backdrop-filter 的标签留在了容器内部。

所以给工具栏、侧栏、模态层之外的普通容器加毛玻璃之前,先确认它内部没有依赖视口定位的子元素——下拉菜单、浮层、position: fixed 的提示条都属于高危对象。

八、嵌套,以及用 url() 引 SVG 滤镜

嵌套:外层已经有 backdrop-filter 时,它自己就成了内层的 backdrop root。我在一个带 blur(12px) 的外层里再放一块带 blur(12px) 的内层,与"内层不写滤镜"相比:

场景 平均绝对像素差 最大像素差
外层 blur(12px),内层再 blur(12px) 14.897 42

内层不是完全无效——它拿到的是外层已经模糊过的画面,再模糊一次仍然会继续抹掉细节。但代价是第二遍渲染,而收益远小于第一层(对比第 4.1 节里单层模糊的 20.762/37.097 这个量级)。结论很直接:嵌套毛玻璃要当成"额外的渲染开销"来算,别当成免费的分层效果。

url():<filter-value-list> 允许引用 SVG 滤镜,这条是真能用的。我用一个 <feComponentTransfer> 做反相,写成 backdrop-filter: url("#svg-invert") blur(6px),与无滤镜相比平均差 95.743、最大 208 —— 滤镜和模糊都生效了。写法上 <filter> 可以直接内联在页面里(用 id 引用),也可以是同源的 SVG 文件。要注意的是 SVG 滤镜自己有一套颜色空间设定(见下一节)。

<svg width="0" height="0" aria-hidden="true">
  <filter id="svg-invert" color-interpolation-filters="sRGB">
    <feComponentTransfer>
      <feFuncR type="table" tableValues="1 0"/>
      <feFuncG type="table" tableValues="1 0"/>
      <feFuncB type="table" tableValues="1 0"/>
    </feComponentTransfer>
  </filter>
</svg>

九、颜色空间,以及 hue-rotate() 的真相

Filter Effects 1 里有一句很硬的话:color-interpolation-filters has no affect for Filter Functions. Filter Functions must operate in the sRGB color space.(color-interpolation-filters 对滤镜函数无效,滤镜函数必须在 sRGB 颜色空间里运算。)backdrop-filter 的滤镜函数同样如此。

而 SVG 滤镜原语的默认值不是 sRGB——color-interpolation-filters 的初始值是 linearRGB。所以同一个视觉效果,写成 backdrop-filter: contrast(150%) 和写成 backdrop-filter: url("#contrast-filter") 的数值结果可能不一样;后者的 <filter> 上要显式写 color-interpolation-filters="sRGB" 才能对齐(第八节的示例就是这么写的)。

hue-rotate() 更值得单独说:它不是把色相在 HSL 里转一圈,而是一个线性矩阵近似。 规范在 feColorMatrix 的 type="hueRotate" 里给出了矩阵,三行共用一组亮度系数 0.213 / 0.715 / 0.072:

a00 = 0.213 + cos(θ)*0.787 - sin(θ)*0.213
a01 = 0.715 - cos(θ)*0.715 - sin(θ)*0.715
a02 = 0.072 - cos(θ)*0.072 + sin(θ)*0.928
a10 = 0.213 - cos(θ)*0.213 + sin(θ)*0.143
a11 = 0.715 + cos(θ)*0.285 + sin(θ)*0.140
a12 = 0.072 - cos(θ)*0.072 - sin(θ)*0.283
a20 = 0.213 - cos(θ)*0.213 - sin(θ)*0.787
a21 = 0.715 - cos(θ)*0.715 + sin(θ)*0.715
a22 = 0.072 + cos(θ)*0.928 + sin(θ)*0.072

拿一块纯红 #e01b1b 试一下就知道差距有多大。实测结果:

R G B
原色 #e01b1b 224 27 27
规范矩阵、θ=120deg(按上式计算) 0 114 0
本机 Chromium 实测(hue-rotate(120deg),透明底) 0 114 0
如果在 HSL 里把色相 +120° 27 224 27

实测值和规范矩阵算出来的值逐通道一致(0/114/0),说明实现走的就是规范那条矩阵;而它和"真·色相旋转"差了十万八千里——纯红转 120° 之后是暗绿而不是亮绿,因为负值被钳到 0 之后亮度信息被丢掉了。设计上要"换个色相"时,别指望 hue-rotate() 等于在取色器里拖色相环;要做精确调色,用 filter: url() 或直接在源头上换颜色。

十、动画与过渡

backdrop-filter 的动画类型是"滤镜函数列表",逐函数插值规则(规范 §6.4):

  • blur() 按长度插值;
  • brightness()、contrast()、grayscale()、invert()、opacity()、saturate()、sepia() 先把百分比换算成数字(0% 对应 0、100% 对应 1),再按数字插值;
  • hue-rotate() 按数字插值;
  • drop-shadow() 按阴影列表插值。

配合第三节那条"hue-rotate() 不得归一化",就能写出跨越 360° 的连续旋转:

/* hue-rotate.css */
.scan {
  backdrop-filter: hue-rotate(0deg);
  animation: scan 3s linear infinite;
}

@keyframes scan {
  to { backdrop-filter: hue-rotate(720deg); }
}

如果实现把 720deg 归一化成 0deg,这段动画就会纹丝不动;规范正是为了这种用法才禁止归一化。当然,动画一个毛玻璃表面意味着每一帧都要重新渲染它背后那块缓冲,第 11 节的代价会按帧数乘上去——页面级的大面积毛玻璃别这么玩。

十一、性能:规范自己怎么说

Filter Effects 2 §3.1 的动机一节把话说得很直白:如果把 backdrop-filter 定义成"过滤它之前绘制的一切",每次应用都需要单独的一次渲染,好把半成品 stacking context 临时补完;那会让渲染时间翻倍,并可能需要两倍的内存与 GPU 带宽来存放待过滤的中间纹理;而如果允许嵌套,这个翻倍会变成指数级的性能崩塌。

也就是:

  • 每个毛玻璃元素都会让它背后的内容多渲染一份——数量是要算成本的;
  • 嵌套会叠加,一层比一层贵;
  • 规范没有给任何"一个页面能放几个"的阈值,任何具体数字都是站点自测结论,不是规范结论。

可操作的判断只有一条:固定不动的、面积可控的表面(工具栏、侧栏、底栏、弹层)比列表项划算得多。一个虚拟滚动列表里放 50 个毛玻璃卡片,等于让浏览器为一屏内容反复处理 50 份背景缓冲。真要做"每个卡片都像玻璃",用一张固定背景 + 半透明色块加边框的静态方案,视觉差别在下滑过程中几乎看不出来。

十二、兼容性与稳妥写法

MDN 的 browser-compat-data 给出的版本:

浏览器 支持情况
Chrome 76 起
Edge 无前缀 79 起;-webkit- 前缀 17 起、79 移除
Firefox 103 起;123 之前,在 GPU 厂商未知的系统上不支持(bug 1867937)
Safari 无前缀 18 起;-webkit- 前缀 9 起

两个直接结论:

旧 Safari 必须带前缀。 这是 -webkit-backdrop-filter 至今仍有意义的唯一理由。写法是两条一起给(现代浏览器会忽略认不出的那条):

/* prefix.css */
.glass-toolbar {
  -webkit-backdrop-filter: blur(14px) saturate(160%);
  backdrop-filter: blur(14px) saturate(160%);
}

新 Chromium 已经不认这个前缀了。 我在 151 上实测:CSS.supports('backdrop-filter', 'blur(1px)') 返回 true,CSS.supports('-webkit-backdrop-filter', 'blur(1px)') 返回 false;只写前缀的属性计算值是 none,像素差为 0.000。所以前缀是"发给老 Safari 的",不是"多写一条更保险"。

Firefox 123 之前那条 GPU 限制说明回退分支必须"可读"而不是"糊成一片":那类环境会直接命中你的回退路径。默认给不透明底色、@supports 通过后才降 alpha,这个顺序不能反。

十三、实战:工具栏 + 卡片 + 对话框

把上面几节拼起来就是一个可用界面。工具栏压在页面顶部、卡片排在正文里、对话框带一层模糊遮罩:

三个数值不是随手写的:工具栏和卡片用 / 55% 的底——半透明但要够深,文字才能压在确定的范围里;对话框用 / 72%,因为弹层里的文字最需要读清楚;遮罩用 / 35% 加 blur(6px),只负责"把后面推远"。

工具栏、卡片与对话框遮罩的组合示例

图 4:同一个页面里三种用法。工具栏贴顶不遮挡内容、卡片承担正文、右下角的对话框放在局部模糊遮罩上;未被覆盖的背景保持清晰。

对话框那一处还有个细节值得留意:它在遮罩内部,而遮罩自己有 backdrop-filter,所以对话框自己的 blur(10px) 只能拿到遮罩内部的内容(第八节的嵌套情形)。视觉效果主要来自它更实的底色和阴影,模糊只是额外的柔化。想省掉一遍渲染,把对话框内层的 backdrop-filter 删掉是安全的。

至于文字对比度——半透明底意味着文字背后的颜色是不确定的,WCAG 2.2 对正文有 4.5:1(大号文本 3:1)的量化要求,这套核算方法在另一篇里逐条列过,这里只提醒一句:毛玻璃面板上的文字要按"背后最亮的情况"核算,别按截图里那一眼看着还行来定。

十四、落地清单

  • [ ] 每个毛玻璃表面都有半透明部分(不透明底等于没加滤镜,实测像素差 0.000);
  • [ ] 背后确实有内容(纯色背后实测差值 0.090,等于白做);
  • [ ] 从元素往上检查祖先有没有 will-change: opacity、opacity < 1、filter、mask / clip-path、mix-blend-mode——它们都会截断模糊范围;
  • [ ] 容器内部有没有 position: fixed / absolute 的子元素(浮层、下拉菜单),它们会改挂到这个容器上;
  • [ ] 嵌套毛玻璃按"额外的渲染开销"记账,能删就删;
  • [ ] 列表项不要逐个加毛玻璃,改用"固定背景 + 半透明色块"的静态近似;
  • [ ] 大面积表面不做逐帧动画,除非确认过动画期间的帧率;
  • [ ] 默认给不透明可读底色,@supports 通过后才降 alpha;旧 Safari 补 -webkit- 前缀;不要只写前缀。

十五、这些数字是怎么来的

量测页面是自包含的:一张自己用 PIL 生成的彩色测试图(竖细线 + 色块 + 同心圆 + 灰阶条,明暗反差大,模糊/去色/反相一眼可见)、一个 index.html 放可视对比、一个 measures.html 放测量盒。测量手法是本文数字可靠的关键——同一个位置叠两层,一层有滤镜一层没有,逐帧切 visibility 再对比:

/* 关键:两层绝对定位重叠在同一位置 */
.stack { position: relative; width: 240px; height: 130px; }
.layer { position: absolute; inset: 0; border-radius: 10px; }
.layer.b { visibility: hidden; }   /* 默认只显示带滤镜的 .a */
# 逐帧切换后逐像素比较(Playwright + Pillow)
TOGGLE_JS = """
([id, which]) => {
  const stack = document.getElementById(id);
  for (const layer of stack.querySelectorAll('.layer')) {
    layer.style.visibility = layer.classList.contains(which) ? 'visible' : 'hidden';
  }
}
"""

def shoot_stack(page, stack_id):
    out = {}
    for which in ("a", "b"):
        page.evaluate(TOGGLE_JS, [stack_id, which])
        page.wait_for_timeout(50)
        path = f"/tmp/shots/{stack_id}-{which}.png"
        page.locator("#" + stack_id).screenshot(path=path)
        img = Image.open(path).convert("RGB")
        out[which] = img.crop((6, 6, img.width - 6, img.height - 6))  # 裁掉圆角抗锯齿
    return out

def compare(a, b):
    d = ImageChops.difference(a, b)
    st = ImageStat.Stat(d)
    return {"mean_abs": round(sum(st.mean) / 3, 3),
            "max_abs": max(e[1] for e in d.getextrema())}

同一个位置的对照组差值是 0.000 / 最大 0,证明这套方法本身没有噪声,剩下的差值只可能来自 backdrop-filter。环境是 headless Chromium 151.0.7922.34,device_scale_factor=2,视口 1300×900(页面 scrollWidth - innerWidth = 0,没有横向溢出)。临时服务只绑 127.0.0.1,脚本退出即关闭。

需要注意的是:这些数字是"某个浏览器某个版本"的实测,不是规范结论。 规范只规定行为(半透明才可见、backdrop root 会截断、模糊用 edgeMode="mirror"),具体像素差取决于渲染实现和背后的图。要复核,把你的页面用上面这段脚本跑一遍即可。

十六、参考来源