【JS逆向百例】某 _rand DOM 检测分析:CSS 动画终态与样式回读

0 阅读19分钟

NuT5Ms.png

声明

本文章中所有内容仅供学习交流使用,不用于其他任何目的,不提供完整代码,抓包内容、敏感网址、数据接口等均已做脱敏处理,严禁用于商业用途和非法用途,否则由此产生的一切后果均与作者无关!

本文章未经许可禁止转载,禁止任何修改后二次传播,擅自使用本文讲解的技术而导致的任何意外,作者均不负责,若有侵权,请在公众号【K哥爬虫】联系作者立即删除!

前言

_rand 是前段时间新增的参数,哪个里的就不说了,本文将对其相关环境检测点进行研究分析,参数都有时效性,主要分享思路。部分代码同步上传至知识星球,仅供学习交流。

逆向目标

  • 目标:_rand dom 环境分析
  • demo 样例:aHR0cHM6Ly9sb2dpbi50YW9iYW8uY29tL2hhdmFuYW9uZS9sb2dpbi9sb2dpbi5odG0/Yml6TmFtZT10YW9iYW8=

抓包分析

全局搜索 _rand,定位到如下位置:

NuTV3a.png

继续跟栈,最终定位到入口函数。这里重点关注两个参数:一个是索引 234 取出的值,另一个是常量数字 3

NuTWA7.png

NuTcCI.png

这两个参数最终会进入一段 vm 逻辑,_rand 也是在这里生成的:

NuTaBV.png

继续跟栈,可以定位到 362.js 文件中的 vm 生成逻辑:

NuTDgL.png

直接全局搜索 eval,很快就能定位到如下位置:

NuTORJ.png

这里的 i,就是最终生成 _rand 的 vm 代码:

NuTSZG.png

同时,这个文件里还有不少字符串调用、逗号表达式之类的混淆。星球里已经给出了对应的解混淆代码,方便小伙伴们自行对照参考。

原 vm 代码如下:

NuT1uB.png

解混淆后代码如下:

NuTPDt.png

逆向分析

本文重点分析 rand 的动画生成逻辑。

我们直接顺着 vm 代码简单跟一下,大致知道代码是怎么运行的,就能确定插装点,主要有下面几个位置。

字符串拼接:

NuTRfb.png

数组赋值:

NuTr3e.png

对象取值:

NuTtAP.png

插装后拿到的日志如下,这里重点看和动画、样式计算相关的部分。

创建相关的标签容器:

NuTzJw.png

设置相关 CSS 规则:

NuTCB6.png

给标签容器设置类属性:

NuTJgO.png

动画 animationend 事件触发,校验相关的属性:

NuTXYQ.png NuTjZf.png

NuTIuc.png

NuTMO3.png

NuTffj.png

NuTsl5.png

根据上面的日志,可以大致推断出整个检测流程:

  1. 动态创建一个容器节点;
  2. 再往里面插入大量 @keyframes@media@supports 规则;
  3. 然后批量生成一组 span 标签;
  4. 最后等待浏览器触发 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 标签为例,因为我们上面日志截图部分也是这个标签:

NuT2Vm.png

对比后可以发现,这里还原出来的结果和浏览器日志是一致的,说明我们拿到的核心逻辑没有问题。接下来要做的,就是把这套流程搬到 Node.js 环境里模拟出来。

Node.js 模拟思路

从上面的还原代码可以看出,Node.js 模拟并不是单独补某一个函数,而是要把整条链路串起来。大致可以拆成下面几个步骤:

  1. 使用 domino 构建基础 DOM 环境,补齐 documentwindow、节点创建、事件绑定等能力;
  2. 使用 cssom 解析 style 标签和动态插入的 insertRule 规则;
  3. @media@supports 做条件命中判断,筛出当前环境真正生效的规则;
  4. 将命中的样式合并到具体标签上,再继续计算 animationfont-size 的影响;
  5. 最后手动模拟 animationend 事件,并伪造 getComputedStyle 的返回结果。

为了简化工作量,这里直接借助两个现成库来完成基础 1-2 的部分:dominocssom

安装下面两个库:

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 当成纯字符串处理。

如下图所示,借助在线工具可以很直观地看到它的解析结果:

NuT6J4.png

对应的 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("---------------------------");
}

输出结果和在线工具里看到的结构基本一致:

NuTZTh.png

真正难点在哪

上面虽然已经可以借助 domino 创建 DOM,借助 cssom 解析 CSS 文本,但距离真实浏览器的执行结果还差几步。

因为这里的检测逻辑,并不是简单读取某个标签的静态样式,而是包含了下面几个关键步骤:

  1. 先通过 insertRule 动态插入大量 @keyframes@media@supports 规则;
  2. 再批量创建 span 标签,并给每个标签设置不同的 classNamedataset
  3. 浏览器根据当前环境,判断哪些 @media@supports 规则成立;
  4. 对命中的标签应用 colorbackgroundborder-coloranimation 等样式;
  5. 动画结束时触发 animationend 事件;
  6. 最后通过 getComputedStyle 读取标签最终的 colorbackground-colorborder-colorfont-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,当前场景并不复杂,我们只需要补常见的几个判断即可,例如:

  • all
  • not all
  • screen
  • not screen
  • print
  • not print
  • min-width
  • max-width
  • min-height
  • max-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))

也就是说,我们只要先判断最小原子表达式是真是假,再把 andornot 的关系算出来即可。

这里有一个很容易踩坑的点:如果直接用正则把 (...) 里的原子表达式替换成 true / false,那么遇到 color:rgb(...) 这种值里自带括号的条件时,就会把表达式替换坏。

所以这里更稳妥的做法是:

  1. 先维护一张“原子表达式真假表”;
  2. 再写一个轻量解析器,专门处理 andornot 和括号分组;
  3. 当某个括号内容是原子表达式时,直接查表返回真假;否则继续递归求值。

代码如下:

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 里面补即可。

标签样式合并逻辑

拿到“条件成立”的规则之后,下一步就是把命中的样式真正合并到标签上。

这里的核心点有两个:

  1. 某个标签可能会同时命中多条规则;
  2. 多条规则之间需要按照 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

对当前场景来说,我们真正需要关心的,其实只有:

  • direction
  • fill-mode
  • name

因为最终 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 + forwards100%
  • reverse + forwards0%
  • 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(...) 格式,否则即便规则命中了,最后和浏览器结果对比时也会匹配不上。

这里还有一个容易被忽略的细节:本文样例中实际出现的颜色远不止 whitegrayteal 这几个。下面这张表主要是演示写法,如果你想让本文样例直接跑通,至少要把样例里出现的命名颜色全部补齐,或者接入专门的颜色解析库。

处理方式和普通的颜色检测逻辑一致,维护一张颜色映射表即可:

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 环境没有真实渲染线程,也不会真的帮我们播放动画,因此这里最省事的做法,就是:

  1. 先手动算出每个标签最终样式;
  2. 再主动构造一个 animationend 事件对象;
  3. 最后直接调用回调函数。

为了简化处理,我们甚至不需要完整实现事件系统,只需要把回调函数先存起来即可:

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 代码已放到知识星球了,供小伙伴还原参考。

结果验证

浏览器

NuTdh9.png

node

NuTeYY.png