搞前端特性检测这么多年,我一直觉得有一道题比检测CSS属性难得多:怎么判断浏览器支持某个CSS伪类。属性检测可以往元素的style对象上戳一下,伪类检测却没法这么干——你总不能往某个HTMLElement实例上挂一个':hover'属性然后等浏览器回答。正是这个别扭之处,让Modernizr里围绕伪类的那部分代码显得非常有嚼头。这篇文章我想从源码逻辑角度拆一下Modernizr到底是怎么做伪类检测的,顺便把底层通用套路抽出来,让我们在业务代码里也能自己复刻这套能力。
1. 都说检测特性,为什么一到伪类就卡壳
1.1 特性检测的常规套路:给style对象“把脉”
大多数前端工程师对Modernizr的第一印象是检测flexbox、grid这类布局特性,它的核心原理其实非常简单:浏览器会把自己支持的CSS属性映射到DOM元素的style对象上,所以我们只要判断某个属性名是否存在于style对象里,就能知道浏览器认不认识它。
js复制// 这是我记忆里Modernizr内部最常见的一段逻辑
function testProperty(prop) {
var element = document.createElement('div');
return prop in element.style;
}
testProperty('display'); // true
testProperty('flexDirection'); // true(现代浏览器)
testProperty('gridTemplateColumns'); // true(支持的浏览器)
这套机制成立的前提是:CSS属性有对应的DOM接口或至少会被解析成style对象上的成员。属性检测基本就是这个思路,顶多加一步读取计算样式来确认是否真的生效,避免浏览器“吃了属性但不干活”的假阳性。
1.2 伪类检测的难点:属性检测的基因里没有它
但伪类完全不是一回事。:hover、:checked、:nth-child(2n+1)这些选择器不是CSS属性,它们不会被映射到element.style上,你不可能写一句'hover' in element.style来验证浏览器支持与否。伪类的本质是“选择器层面的状态匹配规则”,它的检测必须回到选择器的执行环境里。
这里就是我们通常说的“检测维度”问题:CSS特性可以分成属性、选择器、规则、API这些不同层级,属性检测的常规套路天然覆盖不到选择器这一层。伪类是选择器的一部分,所以检测伪类,本质上是在检测“浏览器对CSS选择器的解析和匹配能力”,而不是检测某个样式属性。
1.3 CSS.supports真的能救场吗——先泼一盆冷水
有人可能会想:那直接用CSS.supports('selector(:hover)')不就行了?我一开始也这么试过,但后来发现这条路并不顺畅。CSS.supports的设计目标是判断“属性和属性值组合是否合法”,对selectors的支持是后来补充的能力,而且各浏览器的实现口径相当不统一。
js复制// 这段代码的结果在不同浏览器里并不一致
CSS.supports('selector(:has(a))');
CSS.supports('selector(:focus-visible)');
有的浏览器支持解析但返回false,有的干脆抛异常,还有的会把它当成普通属性规则去判断造成误判。在实际项目里,我见过不少用CSS.supports('selector(...)')做降级判断的代码在Safari上翻车。所以Modernizr在做这类检测时,反而坚持走“DOM元素+样式注入+计算样式读取”这条更底层的路,这套路子虽然绕,但更接近浏览器的真实渲染行为。
2. Modernizr源码里的检测引擎:modElem与testStyles
2.1 隐藏的测试元素:视觉不可见,布局引擎可见
Modernizr做伪类检测时,第一步要准备一个“测试容器”。这个容器不能让人看见,但又必须真实存在于DOM树里,因为如果元素不在文档流里,很多涉及渲染状态和动态匹配的伪类根本不会生效。Modernizr内部维护了一个专门用于测试的根节点,我习惯叫它测试宿主元素,源码逻辑里对应的是modElem。
js复制// 这段是我个人基于源码思路整理的等效实现
function createTestRoot() {
var root = document.createElement('div');
var refText = '__modernizr__' + Math.floor(Math.random() * 1e9);
root.style.cssText = 'position:absolute;left:-9999px;top:-9999px;width:0;height:0;overflow:hidden;';
root.setAttribute('id', refText);
document.documentElement.appendChild(root);
return root;
}
这里有几个关键细节。首先,不能简单把它设置成display:none,因为不少伪类的匹配依赖布局、渲染乃至用户交互状态,元素一旦脱离渲染流程,检测结果就失真了。其次是left:-9999px这种绝对定位偏移的做法,虽然老土但非常稳,它在IE和现代浏览器里都能让元素视觉不可见,同时保留布局上下文。最后,这个根节点最好挂在documentElement下,也就是<html>里,不要挂在<body>上,因为有些场景下body本身可能有奇怪的样式或状态干扰匹配。
2.2 testStyles的核心执行流程
Modernizr里负责注入样式并完成检测的公共工具,在源码层面大致能做到这样几件事:插入<style>标签、把规则绑定到测试宿主上、等待样式生效、读取计算结果、最后清理掉临时节点。我把它简化成下面这个流程模型:
js复制function testStyleWithCss(rule, createFixture, assert) {
var root = createTestRoot();
var fixture = createFixture(root);
var style = document.createElement('style');
style.type = 'text/css';
style.textContent = '#' + root.id + ' ' + rule;
document.head.appendChild(style);
var result = assert(fixture);
document.head.removeChild(style);
root.parentNode.removeChild(root);
return result;
}
调用方可以传入一条“只有伪类匹配时才让某个属性值生效”的规则,然后准备一段能触发伪类匹配的DOM结构,最后通过getComputedStyle检查那个属性是否变成了预期值。
js复制var supported = testStyleWithCss(
':checked + span { color: rgb(255, 0, 0); }',
function(root) {
root.innerHTML = '<input type="checkbox" checked><span></span>';
return root.querySelector('span');
},
function(span) {
return getComputedStyle(span).color === 'rgb(255, 0, 0)';
}
);
这个流程模型几乎可以套用到所有伪类检测上。每次检测,Modernizr不是去“查文档”,而是直接逼浏览器执行一次完整的样式匹配流程,再把结果摊开给你看。
2.3 为什么检测规则要挂在“后代”上
注意上面注入的规则是#root-id 规则体,也就是规则选择器部分挂在根节点的ID后面,目标元素必须是测试宿主的后代。这个设计不是随手写的,而是为了隔离检测规则对页面其他元素的影响。
如果规则直接写成全局的input:checked + span,一旦测试节点被插入DOM,页面里其他input和span也可能被这个规则命中,尤其在同一页面跑了多次检测或检测框架和业务样式共存时,极容易互相污染。而把选择器限制在测试宿主内部,相当于给检测规则画了一个“实验隔离区”,即使匹配生效,影响范围也只局限在测试节点里,检测完成后立刻整体移除,页面样式不受半点影响。
3. 三类伪类检测的底层逻辑:从:hover到:nth-child再到:checked
3.1 状态类伪类:伪造一个“正在发生”的状态
:hover、:focus、:checked这类伪类描述的是元素的“动态状态”。要验证浏览器是否支持它们,最简单的思路就是真的把元素置于那种状态里,然后观察样式是否生效。
以:checked为例:创建一个被选中状态的复选框,再通过相邻兄弟选择器把目标元素和复选框绑定起来,只要浏览器支持:checked,目标元素的计算样式就会变成预定颜色。
js复制function detectChecked() {
return testStyleWithCss(
':checked + span { color: rgb(255, 0, 0); }',
function(root) {
root.innerHTML = '<input type="checkbox" checked><span></span>';
return root.querySelector('span');
},
function(span) {
return getComputedStyle(span).color === 'rgb(255, 0, 0)';
}
);
}
:focus类似,但麻烦一点,需要先让测试元素获得焦点。这里有个很容易踩的坑:如果你让元素display:none,再对它调用focus(),浏览器通常会静默失败。所以检测:focus的时候,测试节点必须可见(至少对布局引擎可见),否则状态根本不会生效。我惯用的做法是把测试元素做成opacity:0.001加position:fixed,既能拿到焦点又不会产生视觉影响。
3.2 结构化/位置类伪类:构造DOM结构,让匹配“物归原主”
:first-child、:nth-child(2n)、:last-of-type这类伪类不依赖用户交互,它们只依赖元素在DOM树里的位置关系。检测的核心就变成了造假结构:我搭一个父节点,里面放几个子元素,目标子元素必须精准地落在伪类选择器要匹配的位置上。
js复制function detectNthChild() {
return testStyleWithCss(
'p:nth-child(2) { color: rgb(255, 0, 0); }',
function(root) {
root.innerHTML = '<p>one</p><p>two</p><p>three</p>';
return root.querySelectorAll('p')[1];
},
function(p) {
return getComputedStyle(p).color === 'rgb(255, 0, 0)';
}
);
}
这里有一个必须强调的细节:构造DOM结构时,子元素之间不能有空白文本节点。因为:first-child、:nth-child的计数会把文本节点、注释节点也算进去。我在实际写测试时碰到过一种极其隐蔽的误判:用了模板字符串在<span>标签之间插入了换行符,结果:first-child怎么测都不对,最后发现是换行产生了文本节点,把结构计数打乱了。所以检测这种伪类时,建议把HTML压缩成一行,或者用createElement逐个构建节点。
另外,:nth-of-type系列还要注意元素类型,如果测试容器里既有<p>又有<span>,计数逻辑会按类型分组,目标元素必须精确放在对应类型里的正确位置。
3.3 文档树类UI伪类::focus、:target的特殊处理
:target是另一个有点微妙的伪类,它表示“元素是当前URL片段标识符指向的锚点”。要检测它,理论上得修改location.hash,但修改hash会触发页面跳转记录,在测试里非常脏。我在读相关实现经验时学到的一种做法是:用document.createElement('a')创建一个锚点,设置name属性,然后临时把location.hash改成指向它,等样式计算完再改回来。
js复制function detectTarget() {
var anchor = document.createElement('a');
anchor.id = 'modernizr-target-anchor';
anchor.href = '#' + anchor.id;
document.body.appendChild(anchor);
var supported = testStyleWithCss(
':target { color: rgb(255, 0, 0); }',
function() { return anchor; },
function(node) {
return getComputedStyle(node).color === 'rgb(255, 0, 0)';
}
);
document.body.removeChild(anchor);
return supported;
}
location.hash的改动需要在读样式前完成,并且最好在try/finally里恢复,避免测试失败后URL残留脏数据。这类检测的共性问题是:它会改变全局状态,所以一定要做好清理,否则会影响同一页面里继续跑的其它测试。
3.4 一张表看三类伪类的检测策略差异
| 伪类类型 | 典型代表 | 关键操作 | 容易踩的坑 |
|---|---|---|---|
| 动态状态类 | :hover、:focus、:checked |
伪造状态,让元素真正进入匹配态 | 元素不可见导致状态无法触发 |
| 结构位置类 | :first-child、:nth-child、:last-of-type |
构造精确DOM结构 | 空白文本节点干扰计数 |
| 文档/URL相关 | :target、:root、:empty |
修改全局状态或构造骨架 | 状态残留、污染页面 |
4. 源码之外的血泪史:浏览器差异与边界Case
4.1 检测里的“薛定谔的显示”:藏起来但没完全藏
伪类检测最矛盾的一点在于:测试元素既要“不影响人眼观察”,又要对浏览器渲染引擎“完全可见”。如果你直接display:none,:focus不会触发,:checked虽然HTML属性还在但部分伪类的匹配机制会走样。如果你用visibility:hidden,元素占据布局空间但视觉不可见,好些状态类伪类就能正常工作了,但:target这种依赖视觉片段定位的又可能出问题。
我个人的经验是:默认用position:absolute;left:-9999px这套组合拳,它既能保证元素脱离视觉区域,又不破坏布局计算。但针对特定伪类,比如:focus,建议改用position:fixed;top:0;left:0;opacity:0.001,因为绝对定位配合left:-9999px在某些浏览器里会计算出“元素不在视口内”,导致焦点的滚动相关行为失效。这种细节只有真跑到业务场景里才会暴露,文档上基本不会提。
4.2 样式注入的时序问题与清理机制
如果连续做多次伪类检测,样式标签的注入和浏览器样式重算之间是有时序性的。你不应该在同一帧内插入样式标签后立刻同步读取计算样式,虽然绝大多数现代浏览器为了兼容已经做到了同步生效,但老一点的浏览器或低功耗设备上,偶尔会出现“样式尚未应用”的假阴性。
稳妥的做法是:把检测放进requestAnimationFrame里,强行让样式在下一帧应用后再读取。代价是检测变成了异步,使用方需要接受回调或Promise。我在给一个数据可视化大屏项目做过一次运行时特性探测,环境里有不少老旧内部浏览器,就是靠异步读取才把误判率降下去。
清理机制也很关键。每次检测完,一定要把临时注入的<style>和测试节点都移除。我见过有人图省事只移除测试节点不删<style>,结果页面里堆积了大量选择器规则,后期样式互相覆盖,排查了整整半天。Modernizr在这点上做得很干净,每个testStyle调用都有配套的清理操作,干净到检测完了页面像一个测试都没跑过一样。
4.3 当伪类进入Selectors Level 4::has()和:focus-visible的检测尝试
CSS选择器到了Level 4,新增了:has()、:focus-visible、:focus-within、:placeholder-shown这些更复杂的伪类。它们背后依赖全新的选择器解析器和匹配引擎,传统的“样式注入+计算样式”思路依然适用,但要注意前置条件变了:这些伪类可能还会改变元素的可匹配性,比如:has()的设计是基于“相对选择器”的,它允许你在一个选择器后面接一个参数列表,普通属性规则里没法表达这种复杂逻辑。
比如检测:focus-visible时,你不能只靠元素默认状态,因为:focus-visible只有在键盘导航等特定条件下才匹配。很多检测脚本的做法是:先调用element.focus(),再判断匹配状态,但:focus-visible在这种人工聚焦下浏览器通常会当成“用户点击产生”的处理,结果不一定符合预期。这里能做的只是“看解析器认不认识这个选择器”,通过样式注入后读取一个独占属性是否被覆盖来间接确认。
至于:has(),如果你的目标是兼容到2023年之前的浏览器,最保险的做法就是不做运行时检测,而是把:has()相关样式组织成「渐进增强层」,用@supports selector(:has(a))包起来。这比Modernizr自作主张检测要靠谱得多,因为:has()的匹配语义极其依赖选择器解析器的内部实现,运行时硬检测很容易产生额外开销。
5. 手写一个轻量版伪类检测器:把源码思想搬到业务代码里
5.1 最小实现
理解了Modernizr的套路之后,我们完全可以在不引入完整库的情况下,写一个几十行的伪类检测器。核心就是三点:可控的测试根节点、按需注入的样式规则、精确的DOM结构断言。下面这份代码是我在项目里精简过的版本,去掉了很多边界处理,保留了主干思想。
js复制function detectPseudo(pseudo, fixtureHTML, ruleTemplate) {
// 1. 创建视觉不可见但布局可见的测试根节点
var root = document.createElement('div');
var seed = 'detect' + Date.now() + Math.floor(Math.random() * 1e5);
root.id = seed;
root.style.cssText = 'position:absolute;left:-9999px;top:-9999px;width:0;height:0;overflow:hidden;';
document.documentElement.appendChild(root);
// 2. 放入调用方准备的DOM结构
root.innerHTML = fixtureHTML;
// 3. 注入检测规则
var style = document.createElement('style');
style.textContent = ruleTemplate.replace(/\{root\}/g, '#' + seed);
document.head.appendChild(style);
// 4. 读取断言节点样式
var target = root.querySelector('[data-target]');
var prop = 'color';
var expectedValue = getComputedStyle(target).color;
var result = false;
// 5. 用伪类包裹的规则再查一次,如果颜色变化则视为支持
style.textContent = ruleTemplate.replace(/\{root\}/g, '#' + seed) + ' { color: rgb(255, 0, 0); }';
result = getComputedStyle(target).color === 'rgb(255, 0, 0)';
// 6. 清理
document.head.removeChild(style);
root.parentNode.removeChild(root);
return result;
}
上面这段代码其实还有个隐藏问题,它把“默认样式”和“伪类触发后的样式”放在同一个节点上通过覆盖来判断,但不是所有属性都适合这种覆盖式对比。更稳定的写法应该是准备两个节点,一个基准节点不受伪类影响,一个目标节点依赖伪类触发,直接比较两者计算样式差异。这里我把简化版写出来是为了让你看清主干,不要照搬到生产环境。
5.2 用它检测:checked和:nth-child
下面是我们实际业务里用到的两个检测用例,结构比较清晰。
js复制// 检测 :nth-child,注意结构里不能有空白文本节点
var nthSupported = detectPseudo(
':nth-child(2)',
'<p>one</p><p data-target>two</p><p>three</p>',
'{root} p:nth-child(2) { color: rgb(255, 255, 0); }'
);
// 检测 :checked,用相邻兄弟选择器
var checkedSupported = detectPseudo(
':checked',
'<input type="checkbox" checked><span data-target></span>',
'{root} input:checked + [data-target] { color: rgb(255, 255, 0); }'
);
如果在检测:nth-child时,你的结构里不小心在<p>之间加了换行或空格,结果一定会变false,因为第二个<p>不再是父容器的第二个child了。检测:checked则要保证复选框处于选中状态,在HTML字符串里加checked属性,或者先创建input节点再设置checked = true,这两种方式都可行,但前者在旧浏览器中更稳。
5.3 从Modernizr源码中学到的三个设计取舍
第一个取舍是“用真实匹配替代解析判断”。现代浏览器内置了document.querySelector,你确实可以扔一个:hover进去看它抛不抛异常,这确实能判断选择器解析器认不认识这个语法,但解析成功不代表匹配逻辑健全。比如:focus-visible在部分浏览器里能通过解析检查,但实际匹配行为完全不对。Modernizr选择走完整匹配链路,宁可多付出一点DOM操作成本,也要拿到一个更接近真实的结果。
第二个取舍是“检测结果要能回滚”。Modernizr的所有临时节点和样式都做到了“检测结束即清理”,这让它可以在任何页面安全地执行多次检测而不污染业务样式。我们自己写检测时最应该学的也是这点——如果你的检测代码会留下痕迹,那你就不应该把它放在生产环境里反复执行。
第三个取舍是“通用框架比一次性脚本更可靠”。我早期写检测脚本都是为某个伪类单独写一套逻辑,结果每加一个伪类都要重写一遍。后来照Modernizr的方式抽象出“测试根节点+样式注入+计算样式断言”这套通用流程,再复杂的伪类检测都只是换个选择器和DOM结构的事,维护成本瞬间降下来了。这也是读源码最值回票价的地方:不是背下一个函数,而是理解作者面对一类问题时沉淀出的通用解法,然后把它迁移到自己的场景里。
