声明
本文章中所有内容仅供学习交流使用,不用于其他任何目的,不提供完整代码,抓包内容、敏感网址、数据接口等均已做脱敏处理,严禁用于商业用途和非法用途,否则由此产生的一切后果均与作者无关!
本文章未经许可禁止转载,禁止任何修改后二次传播,擅自使用本文讲解的技术而导致的任何意外,作者均不负责,若有侵权,请在公众号【K哥爬虫】联系作者立即删除!
前言
_rand 是前段时间新增的参数,哪个里的就不说了,本文将对其相关环境检测点进行研究分析,参数都有时效性,主要分享思路。部分代码同步上传至知识星球,仅供学习交流。
逆向目标
- 目标:_rand dom 环境分析
- demo 样例:
aHR0cHM6Ly9sb2dpbi50YW9iYW8uY29tL2hhdmFuYW9uZS9sb2dpbi9sb2dpbi5odG0/Yml6TmFtZT10YW9iYW8=
抓包分析
全局搜索 _rand,定位到如下位置:
继续跟栈,最终定位到入口函数。这里重点关注两个参数:一个是索引 234 取出的值,另一个是常量数字 3:
这两个参数最终会进入一段 vm 逻辑,_rand 也是在这里生成的:
继续跟栈,可以定位到 362.js 文件中的 vm 生成逻辑:
直接全局搜索 eval,很快就能定位到如下位置:
这里的 i,就是最终生成 _rand 的 vm 代码:
同时,这个文件里还有不少字符串调用、逗号表达式之类的混淆。星球里已经给出了对应的解混淆代码,方便小伙伴们自行对照参考。
原 vm 代码如下:
解混淆后代码如下:
逆向分析
本文重点分析 rand 的动画生成逻辑。
我们直接顺着 vm 代码简单跟一下,大致知道代码是怎么运行的,就能确定插装点,主要有下面几个位置。
字符串拼接:
数组赋值:
对象取值:
插装后拿到的日志如下,这里重点看和动画、样式计算相关的部分。
创建相关的标签容器:
设置相关 CSS 规则:
给标签容器设置类属性:
动画 animationend 事件触发,校验相关的属性:
根据上面的日志,可以大致推断出整个检测流程:
- 动态创建一个容器节点;
- 再往里面插入大量
@keyframes、@media、@supports规则; - 然后批量生成一组
span标签; - 最后等待浏览器触发
animationend事件,并读取对应标签的计算样式。
把日志整理之后,还原出来的 HTML 代码大致如下:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Title</title>
</head>
<body>
<script>
// 创建div元素
const div = document.createElement('div');
// 获取body并添加div
const body = document.body;
body.appendChild(div);
// 创建style元素并添加到div中
const styleElement = document.createElement('style');
div.appendChild(styleElement);
// 设置div的样式
div.style.cssText = 'font-size:29px;';
// 获取样式表对象
const sheet = styleElement.sheet;
// 定义要插入的CSS规则数组
const cssRules = [
"@keyframes ____322640{0%{font-size:31px}100%{font-size:33px}}",
"@keyframes ____322641{0%{font-size:21px}100%{font-size:21px}}",
"@keyframes ____322642{0%{font-size:45px}100%{font-size:36px}}",
"@keyframes ____322643{0%{font-size:40px}100%{font-size:50px}}",
"@keyframes ____322644{0%{font-size:37px}100%{font-size:35px}}",
"@keyframes ____322645{0%{font-size:48px}100%{font-size:25px}}",
"@keyframes ____322646{0%{font-size:28px}100%{font-size:36px}}",
"@keyframes ____322647{0%{font-size:33px}100%{font-size:47px}}",
"@media (min-height:24011px){.____1595618{color:white;background:darkorange;border-color:teal;animation:1ms linear 1ms 1 reverse backwards running ____322645}.____159566{color:gray;background:darksalmon;border-color:darksalmon;animation:1ms linear 1ms 1 reverse forwards running ____322643}.____1595621{color:lightgray;background:teal;border-color:lightgray;animation:1ms linear 1ms 1 reverse forwards running ____322645}}",
"@media (max-width:8px){.____159565{color:brown;background:black;border-color:brown;animation:1ms linear 1ms 1 normal backwards running ____322646}.____1595619{color:cyan;background:darksalmon;border-color:darkviolet;animation:1ms linear 1ms 1 normal backwards running ____322644}}",
"@media not print{.____159567{color:gray;background:darksalmon;border-color:darkviolet;animation:1ms linear 1ms 1 reverse backwards running ____322641}.____1595627{color:yellow;background:darkred;border-color:navy;animation:1ms linear 1ms 1 normal backwards running ____322641}}",
"@supports (border-width:medium) and ((display:none) and ((not (border-width:medium)) and ((not (overflow:hidden))))){.____159566{color:lime;background:darkgoldenrod;border-color:lightblue;animation:1ms linear 1ms 1 normal backwards running ____322644}}",
"@supports (not (overflow:hidden)) and ((not (display:table-flex))){.____1595617{color:lightblue;background:fuchsia;border-color:lime;animation:1ms linear 1ms 1 reverse backwards running ____322640}.____1595610{color:green;background:green;border-color:darkgreen;animation:1ms linear 1ms 1 reverse forwards running ____322643}}",
"@supports (not (font-style:italic)) and ((not (position:sticked))){.____1595615{color:darkgray;background:white;border-color:black;animation:1ms linear 1ms 1 normal forwards running ____322645}}",
"@media not print{.____1595614{color:darkgreen;background:brown;border-color:yellow;animation:1ms linear 1ms 1 normal backwards running ____322644}.____1595628{color:silver;background:cyan;border-color:purple;animation:1ms linear 1ms 1 normal forwards running ____322640}}",
"@supports (not (display:none)) and ((not (display:table-flex))){.____159564{color:green;background:black;border-color:brown;animation:1ms linear 1ms 1 reverse backwards running ____322645}}",
"@supports (not (text-align:middle)) and ((not (border-width:thick)) and ((not (border-width:thin)) and ((not (text-align:center))))){.____1595617{color:aqua;background:darkcyan;border-color:darkred;animation:1ms linear 1ms 1 normal backwards running ____322642}.____1595621{color:lightblue;background:darkgreen;border-color:lightblue;animation:1ms linear 1ms 1 normal forwards running ____322645}}",
"@supports (font-style:normal) and ((not (text-align:left)) and ((not (overflow:hidden)) and ((not (position:absolute))))){.____1595628{color:maroon;background:darkorchid;border-color:gray;animation:1ms linear 1ms 1 reverse backwards running ____322640}.____1595616{color:darksalmon;background:red;border-color:olive;animation:1ms linear 1ms 1 normal backwards running ____322643}.____1595614{color:darkgoldenrod;background:gray;border-color:darkgoldenrod;animation:1ms linear 1ms 1 reverse forwards running ____322640}}",
"@media not print{.____1595617{color:fuchsia;background:navy;border-color:darkgray;animation:1ms linear 1ms 1 normal backwards running ____322641}}",
"@media (min-width:23px){.____1595625{color:darkgoldenrod;background:black;border-color:aqua;animation:1ms linear 1ms 1 reverse forwards running ____322647}.____159566{color:white;background:fuchsia;border-color:darkviolet;animation:1ms linear 1ms 1 normal forwards running ____322640}.____159567{color:aqua;background:brown;border-color:maroon;animation:1ms linear 1ms 1 reverse forwards running ____322646}}",
"@supports (display:block){.____159564{color:darkgreen;background:orange;border-color:gray;animation:1ms linear 1ms 1 normal forwards running ____322641}.____159565{color:darkgoldenrod;background:darkred;border-color:black;animation:1ms linear 1ms 1 normal forwards running ____322646}}",
"@supports (not (color:white)) and ((not (display:inline)) or ((not (color:#C)))){.____1595630{color:lightblue;background:darkred;border-color:darkmagenta;animation:1ms linear 1ms 1 reverse backwards running ____322647}.____1595628{color:darkcyan;background:black;border-color:green;animation:1ms linear 1ms 1 reverse forwards running ____322641}.____159565{color:aqua;background:maroon;border-color:darkgray;animation:1ms linear 1ms 1 reverse forwards running ____322643}}",
"@supports (not (border-width:10px)) and ((color:#486)){.____159561{color:cyan;background:green;border-color:darkcyan;animation:1ms linear 1ms 1 normal backwards running ____322640}.____159562{color:lightblue;background:gray;border-color:darkblue;animation:1ms linear 1ms 1 normal backwards running ____322644}}",
"@supports (display:none) or ((not (font-style:bolder))){.____1595612{color:teal;background:purple;border-color:brown;animation:1ms linear 1ms 1 reverse backwards running ____322647}.____1595627{color:darkmagenta;background:blue;border-color:darkmagenta;animation:1ms linear 1ms 1 normal backwards running ____322647}.____159569{color:red;background:orange;border-color:aqua;animation:1ms linear 1ms 1 normal backwards running ____322642}}",
"@media not screen{.____1595625{color:aqua;background:silver;border-color:blue;animation:1ms linear 1ms 1 normal backwards running ____322641}.____1595630{color:teal;background:purple;border-color:purple;animation:1ms linear 1ms 1 reverse backwards running ____322642}}",
"@media all{.____1595622{color:darkgray;background:teal;border-color:purple;animation:1ms linear 1ms 1 normal backwards running ____322643}.____159562{color:fuchsia;background:darkgreen;border-color:silver;animation:1ms linear 1ms 1 normal backwards running ____322640}}",
"@supports (not (overflow:hidden)) and ((not (overflow:visible)) and ((not (text-align:right)) and ((display:table-flex)))){.____1595617{color:aqua;background:green;border-color:olive;animation:1ms linear 1ms 1 reverse forwards running ____322642}.____1595614{color:fuchsia;background:darkgoldenrod;border-color:blue;animation:1ms linear 1ms 1 reverse backwards running ____322645}}",
"@supports (position:sticked) or ((overflow:visible) and ((not (position:sticked)) and ((not (text-align:right))))){.____1595622{color:red;background:aqua;border-color:darkgreen;animation:1ms linear 1ms 1 normal backwards running ____322646}.____159567{color:darkblue;background:darkgray;border-color:darkviolet;animation:1ms linear 1ms 1 reverse backwards running ____322641}}",
"@media not screen{.____1595615{color:yellow;background:white;border-color:darkmagenta;animation:1ms linear 1ms 1 normal backwards running ____322641}}",
"@media screen{.____1595619{color:silver;background:lightblue;border-color:orange;animation:1ms linear 1ms 1 reverse forwards running ____322647}.____1595629{color:yellow;background:aqua;border-color:brown;animation:1ms linear 1ms 1 reverse forwards running ____322644}}",
"@supports (not (color:brown)) and ((not (position:absolute))){.____1595628{color:darkviolet;background:gray;border-color:darkred;animation:1ms linear 1ms 1 normal backwards running ____322646}.____159564{color:aqua;background:blue;border-color:darkgreen;animation:1ms linear 1ms 1 reverse backwards running ____322642}}",
"@supports (not (display:block)){.____1595620{color:green;background:darkmagenta;border-color:darkviolet;animation:1ms linear 1ms 1 normal backwards running ____322641}.____1595621{color:aqua;background:teal;border-color:darksalmon;animation:1ms linear 1ms 1 reverse backwards running ____322642}}",
"@supports (font-style:oblique) and ((font-style:oblique) and ((not (color:#1A)) and ((color:rgb(234%,9,177))))){.____1595630{color:darkcyan;background:maroon;border-color:gray;animation:1ms linear 1ms 1 normal forwards running ____322643}.____1595629{color:darksalmon;background:teal;border-color:darkmagenta;animation:1ms linear 1ms 1 reverse backwards running ____322644}}",
"@media (min-width:79084px){.____1595612{color:cyan;background:gray;border-color:darkblue;animation:1ms linear 1ms 1 reverse forwards running ____322643}.____1595624{color:aqua;background:brown;border-color:lightblue;animation:1ms linear 1ms 1 reverse backwards running ____322641}}",
"@media not all{.____1595623{color:darkmagenta;background:darkgray;border-color:black;animation:1ms linear 1ms 1 normal forwards running ____322640}.____1595613{color:navy;background:cyan;border-color:lightgray;animation:1ms linear 1ms 1 reverse forwards running ____322640}}",
"@media (max-height:23px){.____1595630{color:darkcyan;background:blue;border-color:yellow;animation:1ms linear 1ms 1 reverse backwards running ____322641}}",
"@supports (not (font-style:normal)){.____1595622{color:darkgreen;background:red;border-color:darkred;animation:1ms linear 1ms 1 reverse forwards running ____322643}.____1595618{color:white;background:gray;border-color:darkmagenta;animation:1ms linear 1ms 1 reverse forwards running ____322647}}",
"@supports (not (text-align:middle)) and ((not (color:rgb(82%,223,50)))){.____1595622{color:maroon;background:green;border-color:darkgoldenrod;animation:1ms linear 1ms 1 reverse backwards running ____322641}}",
"@media not all{.____159563{color:darkred;background:darkcyan;border-color:fuchsia;animation:1ms linear 1ms 1 reverse backwards running ____322642}.____1595611{color:darkcyan;background:darksalmon;border-color:darksalmon;animation:1ms linear 1ms 1 reverse backwards running ____322646}.____159563{color:darkred;background:purple;border-color:black;animation:1ms linear 1ms 1 normal backwards running ____322640}}",
"@media not all{.____1595610{color:brown;background:darkgreen;border-color:darkorange;animation:1ms linear 1ms 1 normal backwards running ____322647}.____159563{color:fuchsia;background:darkred;border-color:darkgoldenrod;animation:1ms linear 1ms 1 normal backwards running ____322647}}"
];
// 插入所有CSS规则
cssRules.forEach((rule, index) => {
try {
sheet.insertRule(rule, index);
} catch (e) {
console.error(`插入规则失败 (索引 ${index}):`, e);
}
});
// 创建并添加span元素
const spanData = [
{className: '____159560', dataset: {x19: '5', x18: '7'}},
{className: '____159561', dataset: {x12: '4', x25: '10'}},
{className: '____159562', dataset: {x20: '14', x23: '8'}},
{className: '____159563', dataset: {x13: '15', x28: '11'}},
{className: '____159564', dataset: {x29: '4', x26: '4'}},
{className: '____159565', dataset: {x8: '6', x31: '8'}},
{className: '____159566', dataset: {x23: '6', x3: '0'}},
{className: '____159567', dataset: {x29: '5', x10: '2'}},
{className: '____159568', dataset: {x27: '15', x20: '15'}},
{className: '____159569', dataset: {x1: '14', x28: '11'}},
{className: '____1595610', dataset: {x2: '6', x1: '5'}},
{className: '____1595611', dataset: {x29: '2', x3: '5'}},
{className: '____1595612', dataset: {x20: '10', x15: '13'}},
{className: '____1595613', dataset: {x8: '14', x21: '2'}},
{className: '____1595614', dataset: {x29: '15', x11: '14'}},
{className: '____1595615', dataset: {x12: '3', x19: '7'}}
];
// 修改 className
const newPrefix = '____15956';
const updatedSpanData = spanData.map((item, index) => {
return {
...item,
className: newPrefix + index
};
});
// 创建所有span元素并添加到div中
updatedSpanData.forEach(data => {
const span = document.createElement('span');
span.className = data.className;
// 设置dataset属性
for (const [key, value] of Object.entries(data.dataset)) {
span.dataset[key] = value;
}
div.appendChild(span);
});
// 给div添加animationend事件监听器
div.addEventListener('animationend', function (r) {
var n = r.target;
var i = getComputedStyle(n);
// 美化排版输出
console.log('=========================================');
console.log('✅ 动画结束标签:', n.className);
console.log('🎬 动画名称:', r.animationName);
console.log('');
console.log('🎨 样式信息:');
console.log(' 颜色 color:', i.color);
console.log(' 背景色 background-color:', i.backgroundColor);
console.log(' 边框色 border-color:', i.borderColor);
console.log(' 字体大小 font-size:', i.fontSize);
console.log('=========================================');
}, 1);
</script>
</body>
</html>
我们可以验证一下,以 ____159562 标签为例,因为我们上面日志截图部分也是这个标签:
对比后可以发现,这里还原出来的结果和浏览器日志是一致的,说明我们拿到的核心逻辑没有问题。接下来要做的,就是把这套流程搬到 Node.js 环境里模拟出来。
Node.js 模拟思路
从上面的还原代码可以看出,Node.js 模拟并不是单独补某一个函数,而是要把整条链路串起来。大致可以拆成下面几个步骤:
- 使用
domino构建基础 DOM 环境,补齐document、window、节点创建、事件绑定等能力; - 使用
cssom解析style标签和动态插入的insertRule规则; - 对
@media、@supports做条件命中判断,筛出当前环境真正生效的规则; - 将命中的样式合并到具体标签上,再继续计算
animation对font-size的影响; - 最后手动模拟
animationend事件,并伪造getComputedStyle的返回结果。
为了简化工作量,这里直接借助两个现成库来完成基础 1-2 的部分:domino 和 cssom。
安装下面两个库:
npm install domino cssom
下面先简单介绍一下这两个库。
domino 库
domino 的作用是在 Node.js 中模拟 DOM 环境,提供一套和浏览器比较接近的 DOM API。它和 jsdom 的定位比较类似,但这里我没有选 jsdom,主要是因为 jsdom 更重、内部耦合也更强;当前这个场景其实不需要完整浏览器实现,用 domino 会更轻一些。基本使用方式如下:
const domino = require('domino');
const html = `
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Title</title>
</head>
<body>
</body>
</html>
`;
// 用 domino 创建 window 和 document
const window = domino.createWindow(html);
const document = window.document;
cssom 库
cssom 用于解析 CSS 文本,把 style 标签里的规则转换成 JavaScript 对象。这样后面在 Node.js 中就可以按规则逐条判断,而不是把整段 CSS 当成纯字符串处理。
如下图所示,借助在线工具可以很直观地看到它的解析结果:
对应的 Node.js 代码使用方法如下:
const cssom = require("cssom");
const cssTag = `
@keyframes ____322647{0%{font-size:33px}100%{font-size:47px}}
@media (min-height:24011px){.____1595618{color:white;background:darkorange;border-color:teal;animation:1ms linear 1ms 1 reverse backwards running ____322645}.____159566{color:gray;background:darksalmon;border-color:darksalmon;animation:1ms linear 1ms 1 reverse forwards running ____322643}.____1595621{color:lightgray;background:teal;border-color:lightgray;animation:1ms linear 1ms 1 reverse forwards running ____322645}}
`;
const sheet = cssom.parse(cssTag);
const rules = sheet.cssRules || [];
for (let i = 0; i < rules.length; i++) {
console.log("cssRules::", rules[i].cssRules);
console.log("---------------------------");
}
输出结果和在线工具里看到的结构基本一致:
真正难点在哪
上面虽然已经可以借助 domino 创建 DOM,借助 cssom 解析 CSS 文本,但距离真实浏览器的执行结果还差几步。
因为这里的检测逻辑,并不是简单读取某个标签的静态样式,而是包含了下面几个关键步骤:
- 先通过
insertRule动态插入大量@keyframes、@media、@supports规则; - 再批量创建
span标签,并给每个标签设置不同的className和dataset; - 浏览器根据当前环境,判断哪些
@media和@supports规则成立; - 对命中的标签应用
color、background、border-color、animation等样式; - 动画结束时触发
animationend事件; - 最后通过
getComputedStyle读取标签最终的color、background-color、border-color、font-size。
因此,Node.js 模拟的重点,不是单纯 “把 CSS parse 成对象”,而是把下面这条链路补齐:
条件命中 -> 样式合并 -> 动画终态 -> animationend 回调 -> getComputedStyle 回读
这里并不需要完整实现浏览器渲染引擎,只需要围绕当前检测场景,补一套最小可用的 CSS 渲染流程即可。
CSS 规则分类处理
为了方便后续模拟,我们需要先把 style 标签中的所有规则做一层分类。
当前场景下,主要分成三类:
- 普通样式规则:如
.xxxx { color:red } - 条件规则:如
@media、@supports - 动画规则:如
@keyframes
其中普通规则最终会直接作用到标签本身,条件规则决定某段样式是否生效,@keyframes 则决定动画结束后 font-size 最终取什么值。
这段代码的目标,不是直接算出最终样式,而是先把原始 CSS 规则整理成统一的数据结构,方便后面继续做条件命中和样式计算。
代码可以先整理成下面这种结构:
const cssom = require("cssom");
// 把 cssom 返回的 style 对象转成普通 JS 对象
// 例如:CSSStyleDeclaration -> { color: "red", animation: "..." }
function extractStyleObject(style) {
const result = {};
for (let i = 0; i < style.length; i++) {
const prop = style[i];
result[prop] = style.getPropertyValue(prop);
}
return result;
}
// 从 @media / @supports 规则文本中提取条件部分
// 例如:@media screen and (min-width: 100px) {...}
// 提取后得到:screen and (min-width: 100px)
function parseConditionText(cssText, keyword) {
const start = cssText.indexOf(keyword) + keyword.length;
const end = cssText.indexOf("{");
return cssText.slice(start, end).trim();
}
// 把 @keyframes 规则整理成帧映射表
// 例如:
// {
// "0%": { "font-size": "31px" },
// "100%": { "font-size": "33px" }
// }
function parseKeyframesObject(keyframesRule) {
const frameMap = Object.create(null);
const frameRules = keyframesRule.cssRules || [];
for (const frameRule of frameRules) {
frameMap[frameRule.keyText] = extractStyleObject(frameRule.style);
}
return frameMap;
}
// 把整段 CSS 文本解析成统一的数据模型,便于后续做:
// 1. 条件命中判断
// 2. 样式合并
// 3. 动画终态计算
function buildCssModel(cssText) {
const model = {
normalRules: [], // 普通样式规则:.a { color:red }
mediaRules: [], // @media 内部规则
supportsRules: [], // @supports 内部规则
keyframesMap: Object.create(null), // @keyframes 动画表
};
// 用 cssom 解析整段 CSS
const sheet = cssom.parse(cssText);
// 记录规则原始顺序,后面做样式覆盖时要用
let globalOrder = 0;
// 递归遍历规则
// parentCondition 用来标记当前规则是否处于 @media / @supports 内部
function walk(rules, parentCondition = null) {
for (const rule of rules) {
const ruleText = rule.cssText || "";
// 普通选择器规则,例如:.xxx { color:red }
if (rule.selectorText && rule.style) {
const item = {
selector: rule.selectorText, // 选择器
style: extractStyleObject(rule.style), // 样式对象
order: globalOrder++, // 原始出现顺序
};
if (!parentCondition) {
model.normalRules.push(item);
} else if (parentCondition.type === "media") {
model.mediaRules.push({
...item,
condition: parentCondition.text,
});
} else if (parentCondition.type === "supports") {
model.supportsRules.push({
...item,
condition: parentCondition.text,
});
}
continue;
}
// 处理 @keyframes
if (ruleText.startsWith("@keyframes")) {
model.keyframesMap[rule.name] = parseKeyframesObject(rule);
continue;
}
// 处理 @media,递归进入其内部 cssRules
if (ruleText.startsWith("@media")) {
walk(rule.cssRules || [], {
type: "media",
text: parseConditionText(ruleText, "@media"),
});
continue;
}
// 处理 @supports,递归进入其内部 cssRules
if (ruleText.startsWith("@supports")) {
walk(rule.cssRules || [], {
type: "supports",
text: parseConditionText(ruleText, "@supports"),
});
}
}
}
walk(sheet.cssRules || []);
return model;
}
处理完之后,就可以把所有 CSS 规则收敛成一个统一对象,后面只需要围绕这个对象做“条件命中”和“样式计算”即可。
@media 与 @supports 条件命中
浏览器处理这类样式时,并不会无脑应用全部规则,而是会先判断:
- 当前窗口尺寸是否满足
@media - 当前 CSS 能力是否满足
@supports
只有条件成立,这段规则才会真正参与后面的样式计算。
对于 @media,当前场景并不复杂,我们只需要补常见的几个判断即可,例如:
allnot allscreennot screenprintnot printmin-widthmax-widthmin-heightmax-height
代码如下:
const runtimeEnv = {
width: 1920, // 当前环境宽度(模拟浏览器视口宽度)
height: 1080, // 当前环境高度(模拟浏览器视口高度)
mediaType: "screen", // 当前媒体类型,可选如 "screen" / "print"
};
function matchMediaCondition(conditionText, env = runtimeEnv) {
// 统一格式:
// 1. 把连续空白压成一个空格
// 2. 去掉首尾空格
// 3. 转小写,避免大小写影响判断
const text = conditionText.replace(/\s+/g, " ").trim().toLowerCase();
// ===== 处理基础媒体类型 =====
if (text === "all") return true; // 所有环境都匹配
if (text === "not all") return false; // 永远不匹配
if (text === "screen") return env.mediaType === "screen"; // 仅 screen 匹配
if (text === "not screen") return env.mediaType !== "screen"; // 非 screen 匹配
if (text === "print") return env.mediaType === "print"; // 仅 print 匹配
if (text === "not print") return env.mediaType !== "print"; // 非 print 匹配
// ===== 处理 min-width 条件 =====
// 例如: (min-width: 1000px)
const minWidth = text.match(/\(min-width:\s*(\d+)px\)/);
if (minWidth && env.width < Number(minWidth[1])) return false;
// 如果当前宽度 < 要求最小宽度,则不匹配
// ===== 处理 max-width 条件 =====
// 例如: (max-width: 800px)
const maxWidth = text.match(/\(max-width:\s*(\d+)px\)/);
if (maxWidth && env.width > Number(maxWidth[1])) return false;
// 如果当前宽度 > 允许最大宽度,则不匹配
// ===== 处理 min-height 条件 =====
// 例如: (min-height: 700px)
const minHeight = text.match(/\(min-height:\s*(\d+)px\)/);
if (minHeight && env.height < Number(minHeight[1])) return false;
// 如果当前高度 < 要求最小高度,则不匹配
// ===== 处理 max-height 条件 =====
// 例如: (max-height: 500px)
const maxHeight = text.match(/\(max-height:\s*(\d+)px\)/);
if (maxHeight && env.height > Number(maxHeight[1])) return false;
// 如果当前高度 > 允许最大高度,则不匹配
// 如果没有任何条件冲突,则默认认为匹配
return true;
}
再来看 @supports。
这一块如果完全按照浏览器规范实现,工作量会比较大;但对于当前场景,其实没有必要把整个 CSS supports 语法都做满。因为这里用到的表达式虽然长,但本质上还是由下面几种组合拼出来的:
(display:block)(not (display:table-flex))(position:absolute)(not (position:sticked))(color:#486)(not (color:#C))
也就是说,我们只要先判断最小原子表达式是真是假,再把 and、or、not 的关系算出来即可。
这里有一个很容易踩坑的点:如果直接用正则把 (...) 里的原子表达式替换成 true / false,那么遇到 color:rgb(...) 这种值里自带括号的条件时,就会把表达式替换坏。
所以这里更稳妥的做法是:
- 先维护一张“原子表达式真假表”;
- 再写一个轻量解析器,专门处理
and、or、not和括号分组; - 当某个括号内容是原子表达式时,直接查表返回真假;否则继续递归求值。
代码如下:
function checkFeaturePair(featureText) {
// 去掉所有空白并转小写,统一格式,方便做 key 匹配
const key = featureText.replace(/\s+/g, "").toLowerCase();
// 这里是“当前环境支持情况”的模拟表
// key 是 CSS supports 条件里的单个属性对,例如:
// (display:block)
// (font-style:italic)
// (overflow:hidden)
//
// true = 当前环境支持 / 匹配
// false = 当前环境不支持 / 不匹配
const featureMap = {
"display:block": true,
"display:none": true,
"display:inline": true,
"display:table-flex": false,
"font-style:normal": true,
"font-style:italic": true,
"font-style:oblique": true,
"font-style:bolder": false,
"position:absolute": true,
"position:sticked": false,
"overflow:hidden": true,
"overflow:visible": true,
"text-align:left": true,
"text-align:right": true,
"text-align:center": true,
"text-align:middle": false,
"border-width:medium": true,
"border-width:thin": true,
"border-width:thick": true,
"border-width:10px": true,
"color:white": true,
"color:brown": true,
"color:#486": true,
"color:#c": false,
"color:#1a": false,
"color:rgb(234%,9,177)": false,
"color:rgb(82%,223,50)": false,
};
// 如果命中映射表就返回对应结果,否则默认 false
return featureMap[key] ?? false;
}
function createSupportsParser(conditionText) {
// 整个条件统一转小写,避免大小写影响解析
const text = conditionText.toLowerCase();
// 当前解析游标位置
let index = 0;
// 跳过空格、换行、tab 等空白字符
function skipSpaces() {
while (index < text.length && /\s/.test(text[index])) {
index++;
}
}
/**
* 尝试消费指定关键字(如 not / and / or)
* 如果当前位置能匹配上,就移动 index 并返回 true
* 否则返回 false
*/
function consumeWord(word) {
skipSpaces();
if (text.slice(index, index + word.length) === word) {
index += word.length;
return true;
}
return false;
}
/**
* 读取一组括号内容
* 例如:
* "(display:block)" -> "display:block"
* "((not (overflow:hidden)) and (display:block))"
* 会返回最外层括号内部的完整内容
*/
function readGroupContent() {
skipSpaces();
// 当前必须是 "(",否则说明语法不对
if (text[index] !== "(") {
throw new Error(`unexpected token: ${text.slice(index, index + 20)}`);
}
index++; // 跳过开头 "("
let depth = 1; // 括号层级
const start = index; // 记录内容起始位置
// 一直读到当前这一组括号闭合
while (index < text.length && depth > 0) {
const ch = text[index];
if (ch === "(") depth++;
if (ch === ")") depth--;
index++;
}
// 返回括号里的内容(不含最外层括号)
return text.slice(start, index - 1).trim();
}
/**
* 判断一段括号内容是不是“最小原子条件”
*
* 原子条件示例:
* display:block
* font-style:italic
* overflow:hidden
*
* 非原子条件示例:
* not (display:block)
* (display:block) and (overflow:hidden)
*/
function isAtomicFeature(content) {
return content.includes(":") && !/\b(and|or|not)\b/.test(content);
}
/**
* 解析最基础表达式
*
* 两种情况:
* 1. 如果括号内容是原子条件 -> 直接查 featureMap
* 2. 如果括号内容还是复合表达式 -> 递归继续解析
*/
function parsePrimary() {
const groupText = readGroupContent();
if (isAtomicFeature(groupText)) {
return checkFeaturePair(groupText);
}
// 如果不是原子条件,递归创建新的 parser 继续解析
return createSupportsParser(groupText).parseExpression();
}
/**
* 解析一元运算:not
*
* 例如:
* not (display:block)
* not (not (overflow:hidden))
*/
function parseUnary() {
skipSpaces();
if (consumeWord("not")) {
return !parseUnary();
}
return parsePrimary();
}
/**
* 解析 and 表达式
*
* 优先级高于 or
* 例如:
* (a) and (b) and (c)
*/
function parseAnd() {
let value = parseUnary();
while (consumeWord("and")) {
value = value && parseUnary();
}
return value;
}
/**
* 解析完整表达式
*
* 支持:
* (a) or (b)
* (a) and (b)
* not (a)
* 多层括号嵌套
*
* 运算优先级:
* not > and > or
*/
function parseExpression() {
let value = parseAnd();
while (consumeWord("or")) {
value = value || parseAnd();
}
return value;
}
// 返回解析器接口
return {
parseExpression,
};
}
function matchSupportsCondition(conditionText) {
return createSupportsParser(conditionText).parseExpression();
}
虽然这个写法比“正则替换 + Function 求值”多了一点代码,但它能稳住 rgb(...) 这类带内层括号的值,也更适合后面继续扩展。再遇到新的 @supports 原子表达式时,主要还是往 featureMap 里面补即可。
标签样式合并逻辑
拿到“条件成立”的规则之后,下一步就是把命中的样式真正合并到标签上。
这里的核心点有两个:
- 某个标签可能会同时命中多条规则;
- 多条规则之间需要按照 CSS 优先级和出现顺序决定谁覆盖谁。
因此,我们先把所有“当前真正生效”的规则筛出来:
function getActiveRules(cssModel) {
return [
...cssModel.normalRules,
...cssModel.mediaRules.filter(item => matchMediaCondition(item.condition)),
...cssModel.supportsRules.filter(item => matchSupportsCondition(item.condition)),
];
}
然后,借助 domino 自带的 matches 方法,筛出当前元素匹配到的规则:
function getSpecificity(selector) {
// 计算选择器优先级(specificity)
// CSS 优先级通常按三段比较:
// [id 数量, class/属性/伪类 数量, tag 数量]
// 匹配 ID 选择器,例如:#app
const idCount = (selector.match(/#[\w-]+/g) || []).length;
// 匹配:
// 1. class 选择器,例如:.box
// 2. 属性选择器,例如:[data-x="1"]
// 3. 伪类,例如::hover / :nth-child(2)
const classCount = (
selector.match(/\.[\w-]+|\[[^\]]+\]|:[\w-]+(?:\([^)]*\))?/g) || []
).length;
// 匹配标签选择器,例如:div / span / p
const tagCount = (
selector.match(/(^|[\s>+~])([a-zA-Z][\w-]*)/g) || []
).length;
// 返回优先级三元组
return [idCount, classCount, tagCount];
}
function compareSpecificity(specA, specB) {
// 比较两个优先级数组
//
// 规则:
// 从左到右比较
// 先比 id,再比 class,再比 tag
//
// 返回值:
// > 0 : specA 更高
// < 0 : specB 更高
// = 0 : 两者相同
for (let i = 0; i < 3; i++) {
if (specA[i] !== specB[i]) {
return specA[i] - specB[i];
}
}
return 0;
}
function mergeMatchedStyles(element, activeRules) {
// 作用:
// 找出当前 element 命中的所有 CSS 规则,
// 按 CSS 优先级 + 出现顺序排序,
// 最后合并出“最终生效样式”
const matched = [];
// ===== 第一步:筛出当前元素命中的规则 =====
for (const rule of activeRules) {
try {
if (element.matches(rule.selector)) {
matched.push(rule);
}
} catch (e) {
// 某些非法 / 特殊 selector 可能会报错
console.log("selector match error", rule.selector, e);
}
}
// ===== 第二步:按优先级排序 =====
matched.sort((a, b) => {
// 先比较选择器优先级
const cmp = compareSpecificity(
getSpecificity(a.selector),
getSpecificity(b.selector)
);
// 优先级不同,直接按优先级排
if (cmp !== 0) return cmp;
// 优先级相同,则按原始出现顺序排
// CSS 中“后写的覆盖前写的”
return a.order - b.order;
});
// ===== 第三步:按顺序合并样式 =====
const finalStyle = {};
for (const item of matched) {
// 后面的同名属性会覆盖前面的
// 模拟 CSS 层叠覆盖效果
Object.assign(finalStyle, item.style);
}
// ===== 第四步:补一个 background-color 兼容映射 =====
if (finalStyle.background && !finalStyle["background-color"]) {
finalStyle["background-color"] = finalStyle.background;
}
// 返回当前元素最终合并后的样式
return finalStyle;
}
这样拿到的 finalStyle,就已经是当前标签在“静态样式层面”最终应当命中的结果了。
不过这里还没完,因为当前检测还引入了 animation,而 font-size 的最终值,并不是简单看静态规则,而是还要继续看动画结束后的终态。
animation 简写拆解
这里的 animation 值用的是简写形式,例如:
animation: 1ms linear 1ms 1 reverse backwards running ____322645
真实浏览器会把它拆成下面几部分:
- 动画时长
duration - 缓动函数
timing-function - 延迟时间
delay - 播放次数
iteration-count - 播放方向
direction - 填充模式
fill-mode - 播放状态
play-state - 动画名称
name
对当前场景来说,我们真正需要关心的,其实只有:
directionfill-modename
因为最终 font-size 是否保留动画结果,主要就是由这三个字段决定。
拆解代码如下:
function parseAnimationShorthand(animationText) {
// 如果没有 animation 字符串,直接返回 null
if (!animationText) return null;
// 把 animation 简写按空白拆成 token
const tokens = animationText.trim().split(/\s+/);
// 找出所有时间值(duration / delay)
const timeTokens = tokens.filter(token => /ms$|s$/.test(token));
return {
// 动画持续时间(通常是第一个时间值)
duration: timeTokens[0] || "0s",
// 动画延迟时间(通常是第二个时间值)
delay: timeTokens[1] || "0s",
// 动画迭代次数
iterationCount: tokens.find(token => /^(\d+|infinite)$/.test(token)) || "1",
// 动画方向
direction: tokens.find(token =>
["normal", "reverse", "alternate", "alternate-reverse"].includes(token)
) || "normal",
// 动画填充模式
fillMode: tokens.find(token =>
["none", "forwards", "backwards", "both"].includes(token)
) || "none",
// 动画播放状态
playState: tokens.find(token =>
["running", "paused"].includes(token)
) || "running",
// 动画名称
name: tokens[tokens.length - 1],
};
}
@keyframes 终态计算
这里最容易写错的一点,就是把 backwards 理解成“动画结束后取起始帧”。
实际上并不是这样。
animationend 回调触发时,动画已经执行结束了,因此我们真正要关心的是:
- 动画结束后,样式是否会被保留下来;
- 如果保留,保留的是哪一帧。
对于当前场景里这种 1ms linear 1ms 1 ... 的单次动画来说,可以直接归纳为下面几条:
forwards:动画结束后,保留结束帧;both:动画结束后,同样保留结束帧;backwards:只影响延迟阶段,不影响animationend时刻的最终结果;none:动画结束后不保留结果;reverse:只改变播放方向,因此结束帧会从100%切换成0%。
也就是说,在 animationend 阶段:
normal + forwards取100%reverse + forwards取0%normal + backwards回退为基础样式reverse + backwards同样回退为基础样式
因此,当前场景下 font-size 的处理逻辑可以写成:
function getInheritedFontSize(element) {
// 向上查找当前元素“继承到的 font-size”
let current = element;
while (current) {
// 如果当前节点有 style.fontSize,就直接返回
if (current.style && current.style.fontSize) {
return current.style.fontSize;
}
// 继续往父节点找
current = current.parentElement;
}
// 如果整条祖先链都没找到,就返回浏览器常见默认值 16px
return "16px";
}
function resolveAnimationFontSize(baseFontSize, keyframesMap, animationInfo) {
// 如果没有动画信息,直接返回原始字体大小
if (!animationInfo || !animationInfo.name) {
return baseFontSize;
}
// 根据动画名取对应的 keyframes
const frameMap = keyframesMap[animationInfo.name];
// 如果没找到对应 keyframes,就回退到原始字体大小
if (!frameMap) {
return baseFontSize;
}
// 判断这个动画结束后是否“保留最终帧样式”
const keepEndValue =
animationInfo.fillMode === "forwards" ||
animationInfo.fillMode === "both";
// 如果动画结束后不保留最终值,那字体还是原始值
if (!keepEndValue) {
return baseFontSize;
}
// 计算动画“最终停留在哪一帧”
const endKey = animationInfo.direction === "reverse" ? "0%" : "100%";
// 返回最终帧里的 font-size,没有 font-size,就回退到原始字体大小
return frameMap[endKey]?.["font-size"] || baseFontSize;
}
这里的关键点是:
当前检测并不需要真的把动画 “一帧一帧播放出来”,只需要准确算出 animationend 那一刻应该读到什么值即可。
颜色值统一格式化
浏览器里的 getComputedStyle 返回的是标准化后的结果,例如:
white->rgb(255, 255, 255)gray->rgb(128, 128, 128)teal->rgb(0, 128, 128)
因此,我们在 Node.js 里面也需要把颜色名称统一转成 rgb(...) 格式,否则即便规则命中了,最后和浏览器结果对比时也会匹配不上。
这里还有一个容易被忽略的细节:本文样例中实际出现的颜色远不止 white、gray、teal 这几个。下面这张表主要是演示写法,如果你想让本文样例直接跑通,至少要把样例里出现的命名颜色全部补齐,或者接入专门的颜色解析库。
处理方式和普通的颜色检测逻辑一致,维护一张颜色映射表即可:
function normalizeColor(colorValue) {
if (!colorValue) {
return "rgba(0, 0, 0, 0)";
}
const colorMap = {
white: "rgb(255, 255, 255)",
gray: "rgb(128, 128, 128)",
teal: "rgb(0, 128, 128)",
black: "rgb(0, 0, 0)",
brown: "rgb(165, 42, 42)",
cyan: "rgb(0, 255, 255)",
yellow: "rgb(255, 255, 0)",
red: "rgb(255, 0, 0)",
green: "rgb(0, 128, 0)",
blue: "rgb(0, 0, 255)",
// 这里只演示写法,完整跑通本文样例时需要把实际出现的颜色补全
};
const key = String(colorValue).toLowerCase().trim();
return colorMap[key] || colorValue;
}
手动触发 animationend 事件
真实浏览器中,动画结束后会自动触发 animationend 事件,然后在回调里面执行:
var i = getComputedStyle(n);
但 Node.js 环境没有真实渲染线程,也不会真的帮我们播放动画,因此这里最省事的做法,就是:
- 先手动算出每个标签最终样式;
- 再主动构造一个
animationend事件对象; - 最后直接调用回调函数。
为了简化处理,我们甚至不需要完整实现事件系统,只需要把回调函数先存起来即可:
let onAnimationEnd = null;
div.addEventListener = function (type, handler) {
if (type === "animationend") {
onAnimationEnd = handler;
}
};
function emitAnimationEnd(target, animationName) {
if (!onAnimationEnd) return;
onAnimationEnd.call(div, {
type: "animationend",
target,
animationName,
});
}
这样,后面当我们把某个 span 的最终样式算完之后,直接手动调用一次 emitAnimationEnd 即可。
至此,对于该 _rand 参数动画检测分析完毕,动画模拟的 js 代码已放到知识星球了,供小伙伴还原参考。