返回知识中心
指纹

CreepJS CSS 媒体查询:一种偏好,两条读取通道

拆解固定版本的 CreepJS CSS 媒体实现:同一偏好如何经由 @media 自定义属性和 matchMedia 读取、两边的十四个键,以及屏幕范围扫描。

BotBrowser Team

文档中心

想直接进入 指纹 文档吗?

这篇文章属于知识中心的指南库。若你要步骤化配置、参考说明和持续更新,请直接进入对应 docs 分区。

显示偏好是网页无需权限提示就能读取的少数表面之一,同一段代码可以对每项偏好读取两次:一次让浏览器引擎应用媒体查询,再读取产生的自定义属性;另一次通过 matchMedia 询问查询是否匹配。两次答案来自同一个引擎,却走不同的代码路径,因此同时保存两者,就得到对同一偏好的两份独立观察。固定到具体提交的开源实现展示了这种分流、两个通道各自报告的十四个键、何时会显示 blocked 提示而不是空对象,以及通过媒体查询恢复设备尺寸的范围扫描;它不是直接读取 screen。BotBrowser 在浏览器核心内应用 profile 的显示配置,因此媒体查询结果和屏幕报告值来自同一份配置;但它不会控制第三方代码如何解释两条通道之间的分歧。这里的内容不是评分、评级,也不表示任何网站会如何分类一次会话。

TL;DR

  • 这段实现把三十条 @media 规则写入 style 元素,再用 getComputedStyle 读取十四个自定义属性;值来自 CSS 级联,不是一次 API 调用的返回值。
  • 随后它用 matchMedia 提出相同问题,并保存另一个包含相同十四个键的对象。因此,每项偏好都有两份记录。
  • 两个键刻意查询顶层窗口的 matchMedia,而非 frame 的窗口,因为 frame 继承的配色方案可能与其所在窗口不同。
  • 一个通道的十四个键都为假值时会显示 blocked 提示;单个键不匹配时会保存为 undefined,这两者含义不同。
  • 设备宽度和高度先用一个候选值查询,再各自扫描十个、每段 1000 像素的范围。得到的是媒体查询测量结果,而非直接读取 screen.width。

示意图展示一种媒体偏好的两种读取通道:@media 规则设置由 getComputedStyle 读取的自定义属性,matchMedia 查询返回 matches,独立范围扫描恢复设备宽度和高度。

目录

两个通道读取什么

两个通道回答相同的十四个问题,差别在于答案由什么路径计算。

自定义属性通道

第一个通道完全不调用匹配函数。它把含三十条媒体规则的 style 元素赋给文档的 body,每条规则在一个条件下设置一个自定义属性;然后读取 body 的计算样式,从中取出十四个属性:

body.innerHTML = `<style>@media (prefers-color-scheme: light) {body {--prefers-color-scheme: light}}</style>`;
const style = getComputedStyle(body);
style.getPropertyValue('--prefers-color-scheme').trim() || undefined;

理解输出时,这种做法有两个重要性质。值由样式引擎应用条件规则后产生,因此某个键没有匹配时,属性保持未设置,表达式返回 undefined,而不是空字符串或 false。另外,规则位于文档中;答案是引擎对这个文档的条件作出的处理结果,并非脚本根据其他读数计算出的值。单独的 CreepJS 计算样式模块会枚举 CSSStyleDeclaration 名称,并另外读取系统颜色和字体;它不走这里的偏好通道。

规则也编码了成对结果:(monochrome) 设置 --monochrome: monochrome,(monochrome: 0) 设置 --monochrome: non-monochrome。所以保存的是两个分支之一的标签,而不是布尔值。

matchMedia 通道

第二个通道通过 Window.matchMedia 向引擎提出同样的问题。每个键按三元表达式链从最具体的答案检查到最宽泛的答案:

pointer: win.matchMedia('(pointer: fine)').matches
  ? 'fine'
  : win.matchMedia('(pointer: coarse)').matches
    ? 'coarse'
    : win.matchMedia('(pointer: none)').matches
      ? 'none'
      : undefined;

pointer 和 any-pointer 各尝试三个分支;color-gamut 依次尝试 rec2020、p3、srgb;display-mode 依次尝试 fullscreen、standalone、minimal-ui、browser。所有分支都不匹配时,该键为 undefined,与第一个通道使用的标记相同。因此两个对象可以按键直接比较。

每个通道查询哪个窗口

十四个键并不表示十四个键使用同一作用域。大多数查询通过 win.matchMedia 执行,其中 win 是这段代码自己的窗口引用。两个例外是 prefers-color-scheme 和 orientation:它们调用未限定对象的 matchMedia 标识符,源码注释说明优先使用主窗口。

这种选择是有意且可观察的。frame 所处文档的配色方案或方向,可能与 frame 自身上下文报告的不同;如果这段代码需要容器窗口的答案,就必须询问窗口,而不是 frame。比较两次运行时,应记录每个键使用的作用域,因为两次 prefers-color-scheme 不同,可能来自作用域差异,也可能来自配置差异。

出于结构原因,@media 通道也有相同的不对称:级联规则只应用于拥有 style 元素的文档,因此 frame 读取自己的 body 时看不到写入父文档 body 的规则。两个通道对同一键使用的作用域不同,所以它们的答案不一致,并不能自动证明存在不一致配置。

键@media 值来源matchMedia 值来源
prefers-reduced-motionno-preference 或 reduceno-preference 或 reduce
prefers-color-schemelight 或 darklight 或 dark,在主窗口查询
monochromemonochrome 或 non-monochrome相同的两个标签
inverted-colorsinverted 或 none相同的两个标签
forced-colorsnone 或 active相同的两个标签
any-hoverhover 或 none相同的两个标签
hoverhover 或 none相同的两个标签
any-pointerfine、coarse 或 none相同的三个标签
pointerfine、coarse 或 none相同的三个标签
device-aspect-ratio化简后的比例字符串相同的比例字符串
device-screenwidth x height相同的一对值
display-modefullscreen、standalone、minimal-ui 或 browser相同的四个标签
color-gamutsrgb、p3 或 rec2020相同的三个标签
orientationlandscape 或 portrait相同的两个值,在主窗口查询

这张表是不运行代码也能核对报告的部分:如果已保存对象中的某个键取值不在对应列里,那么该值就不是从那个通道得到的。

已保存的答案能说明什么、不能说明什么

一个有十四个键的对象看起来像测量结果,实际保存的是一组标签。更有用的问题是哪些标签缺失,以及缺失代表什么。

undefined 是一个分支结果

所有分支都不匹配时,每个键最终都会是 undefined,这段代码会原样保存。因此,报告可以区分单个键的三种状态:键匹配到了标签;没有任何分支匹配,所以该键缺失;或者每个键都失败,导致整个对象没有显示。只有第三种是阻断状态。

记录运行结果时要分别保存两个对象,不要把它们合并。合并后的对象无法说明 pointer: fine 来自级联还是 matchMedia;而来源正是第二个通道带来的信息。

blocked 分支

绘制内容前,渲染函数会检查两个对象。它逐个通道统计值为真的键;当数量为零时,会显示 blocked 提示,而不是空列表:

!Object.keys(mediaCSS).filter((key) => !!mediaCSS[key]).length ? HTMLNote.BLOCKED : modal(...)

这一区分很重要,因为两种空结果的原因不同。空的 @media 对象表示级联没有应用三十条规则中的任何一条,问题可能在 style 元素、文档或样式表限制。空的 matchMedia 对象表示十四个查询没有一个分支匹配;在普通桌面会话中这种情况很少见,说明上下文受到了严格限制,而不是偏好缺失。

如果报告显示一个通道 blocked,而另一个通道有标签,它描述的是作用域或样式表问题,不是隐私设置。首先应将其当作配置问题检查。

通道分歧代表什么

两个通道回答同一组问题,因此无需额外插桩就能逐键比较。需要区分三种结果:

  • 两个通道返回相同标签。引擎在两条代码路径上得到一致结果,这只是该键的一致性证据。
  • 一个通道返回标签,另一个返回 undefined。先检查作用域:prefers-color-scheme 和 orientation 在主窗口查询,因此 frame 自己的上下文不同就可能产生这种结果。
  • 两个通道返回相互矛盾的标签。这种结果值得检查,但说明的是文档样式表与媒体状态,不是实体设备。

这些结果都不会标识某个人、某个模型或一个判定。检测器使用的信号组会把这类输出归入不同家族;区别在于,家族标签是在作出决定后分配的,而通道比较在决定之前就可以得到。

触控设备这一行

渲染面板会增加一行派生值 touch device,它只读取一个已保存值:当 any-pointer 为 coarse 时显示 true,否则显示 unknown 提示,而不是 false。粗指针常见于手机和平板,也会出现在一些带触控功能的桌面显示器上。因此,这一行提示的是指针能力,不是硬件类别。

屏幕范围扫描

这段代码的第三部分不会读取 screen.width 和 screen.height 就结束,而是使用媒体查询验证它们。

单个候选值探测

第一次尝试会用屏幕对象报告的精确值提出两个查询:(device-width: W px) 和 (device-height: H px)。两者都得到匹配时,原样返回这个数值对,扫描就此结束。

只有其中一个查询失败时才会开始扫描。这个顺序让常见情况开销较小,并把成本更高的路径留作诊断,而不是默认行为。

十段范围遍历

回退路径每步生成一千条规则,共遍历十步,覆盖零到一万像素,并补齐尚未得到答案的维度。宽度和高度会在同一次遍历中独立搜索,所以宽度在第一段匹配后,高度仍会继续检查。这种做法不只用于媒体查询:同一实现会验证值,而不是直接信任值;CSS 指纹信号面列出了使这种验证成为可能的查询。

结果是浏览器引擎通过自己的设备查询恢复出的一对数字,而不是从 screen 对象读取的结果。这让测量有了实际用途:如果配置经 screen 报告一种分辨率,却经 device-width 回答另一种分辨率,同一份报告里就会出现两个不同数字,分歧也无需其他操作便能看出来。

宽高比与无任务栏检查

同一代码块还产生两个派生值。宽高比通过最大公约数辅助函数约分宽度与高度,再格式化为 W/H;随后这个值既会通过 matchMedia 查询,也会写入 @media 规则。

第二个值是对可用面积的启发式检查。这段代码计算报告的宽高是否分别等于可用宽高,并把大屏幕上两组数完全相等视为较低熵条件:

const noTaskbar = !(width - availWidth || height - availHeight);
if (screen.width !== width || (width > 800 && noTaskbar)) {
  LowerEntropy.IFRAME_SCREEN = true;
}

这个标记有两点值得注意。桌面会话通常会为任务栏或 dock 留出一部分区域,因此大屏幕上的精确相等呈现出合成表面的形态。其次,这个标记写入一个只声明六个键的共享记录,而其中没有它;所以它是临时附加的注释,不是文档化字段。如果其他脚本读取该记录,不能仅凭某个键存在就认定它原本是预定字段。

字段来源能说明什么
screenQuery.width第一次候选探测,然后十段 1000 px 范围引擎对 device-width 的回答
screenQuery.height同一次遍历,独立搜索引擎对 device-height 的回答
device-aspect-ratio宽高用最大公约数约分一个比例字符串,不是面板类型
device-screen通过 matchMedia 查询 W x H宽高是否作为一个条件匹配
LowerEntropy.IFRAME_SCREEN报告的屏幕与可用面积低熵显示提示,不是判定

触控行与范围遍历都描述显示情况,而 screen 和 window 几何是另一组表面,涉及报告分辨率、可用面积和布局视口之间的区别。屏幕与窗口指纹讨论这些值;这段代码只用媒体查询验证其中两个。

一次运行的成对记录

只要保留两个对象,这段代码的输出就很容易整理成一条记录。

值得保存的字段

把两个对象分别保存在不同键下,同时保存扫描恢复出的屏幕宽高、浏览器构建标识和文档 URL。再加一个这段代码本身不会生成的字段:每个敏感键查询时所在的作用域。这个字段能把分歧转成解释,而不是警报。

保存原始标签,不要只留 hash。这段代码会在每个模态框旁边显示短 hash,快速比较两份报告时很方便,但诊断单份报告时没有帮助,因为十四个标签的 hash 不会说明具体哪个标签发生了变化。

重复运行并比较

相同配置下的两次运行,对每个键应产生相同标签。键值在运行间变化,提示配置或时序不同:某次运行的样式表可能没有生效,frame 可能处于不同作用域,或者文档可能在不同环境中加载。

按键比较,而不是按对象比较,并记录变化方向。pointer 从 fine 变成 undefined,与从 fine 变成 coarse 是不同结果;只有第二种说明指针能力发生变化。

记录无法确定什么

记录描述的是文档内引擎的回答,不是显示硬件、操作系统,也不是某个网站根据这些标签建立的模型。两个通道对照一致,只说明这次构建在这个文档上的两条代码路径结果相同;它说明一致性,不说明身份。

BotBrowser 在浏览器核心中应用 profile 显示设置,因此媒体查询解析出的值与 screen 报告的值来自同一份已加载配置;CSS 信号参考列出了可用设置。这不会改变第三方代码对分歧的结论,也不能让低熵显示表现得像某一块具体的实体面板。

常见问题

两个通道总是相同吗?

不一定,原因是结构性的,不是随机的。级联通道只应用于拥有 style 元素的文档,而两个 matchMedia 键在主窗口查询,因此处于不同上下文的 frame 对同一个键可以合法地给出不同答案。

blocked 提示表示什么?

它表示该通道中的每个键都返回了假值。级联通道出现该提示,说明样式表没有应用;matchMedia 通道出现该提示,则说明上下文受到限制,因为普通桌面会话通常会匹配十四个键中大多数键的至少一个分支。

为什么查询媒体条件而不是读取屏幕值?

因为探测为同一个问题提供了独立答案。配置可以通过 screen 报告一种分辨率,通过 device-width 回答另一种;范围扫描会直接让这种差别出现在报告里,而不是留给另一项独立检查。

实践结论

先看两个对象,不要从绘制出的面板开始。一个通道来自级联,另一个来自匹配函数;它们回答相同的十四个问题,而每个键可能缺失、带有标签,或者属于从未渲染的通道。然后把屏幕值当作测量结果:扫描会用 device-width 和 device-height 验证报告的宽高,无任务栏检查则只是关于可用面积的提示,不是判定。

下一步是在自己控制的文档上建立一条成对记录。保存两个对象、恢复出的屏幕宽高,以及每个敏感键使用的作用域;然后重复运行并逐键对照。某个键变化时,检查样式表或作用域;所有键都稳定时,只能说这次构建上的两个通道结果一致。

来源

#CreepJS#CSS 媒体查询#matchMedia#Prefers-Color-Scheme#屏幕

本文属于指南: 什么是浏览器指纹?原理、风险、信号与隐私边界

在 BotBrowser 里跑一遍同样的检查

加载 profile,让刚读到的这些信号在浏览器内部保持一致,并在正式上线前先在自己的机器上确认结果。