WebToApp CSS 模块开发指南:站点主题化、夜间模式与纯样式覆盖
本文基于 WebToApp 的扩展体系,讲解 CSS 模块(纯样式覆盖模块)的完整开发方式:如何用module.json+style.css为指定站点做主题化、重设样式或夜间模式。读完你可以掌握 CSS 模块的文件布局、清单字段、DOCUMENT_START时机选择、CSS 在 WebView 中的注入原理(含源码级证据),并能参照内置web-tint模块产出一个可发布的样式扩展。
什么是 CSS 模块
WebToApp 提供四类扩展:JS 模块、CSS 模块、油猴脚本、Chrome MV3,它们都由同一个ExtensionManager管理,并由 WebView 在页面生命周期钩子处注入(见 扩展开发总览)。
CSS 模块是其中的纯样式子类——它为某个站点做主题化、重设样式或夜间模式。它使用与 JS 模块 相同的module.json清单,但实质是一个样式表:逻辑代码退化为一个最小桩,真正的"功能"全部写在style.css里。
文件布局
一个 CSS 模块的目录结构如下:
my-theme/ ├── module.json # 必需 —— 清单 ├── main.js # 必需 —— 可以是近乎为空的桩 ├── style.css # 实际样式 └── icon.png # 可选注意一个容易踩的坑:即便是纯 CSS 模块,main.js也是必需的,它不能省略,可以是最小的桩。把runAt设为DOCUMENT_START,让样式尽早生效,避免未设样式内容的闪现(FOUC)。
module.json清单
一个完整的 CSS 模块清单示例(夜间主题):
{ "id": "dark-reader-lite", "name": "Dark Reader Lite", "description": "一个简单的夜间主题", "category": "THEME", "runAt": "DOCUMENT_START", "urlMatches": [ { "pattern": "*://news.ycombinator.com/*" } ], "permissions": ["CSS_INJECT"] }关键字段说明:
category:CSS 模块应使用"STYLE_MODIFIER"或"THEME"之一。全系统允许的category取值还包括CONTENT_FILTER、CONTENT_ENHANCE、FUNCTION_ENHANCE、AUTOMATION、READING、ACCESSIBILITY等二十余个(见 modules/README.md);未知取值不会破坏安装,只会让模块从分类筛选中隐藏。runAt:DOCUMENT_START、DOCUMENT_END(默认)、DOCUMENT_IDLE、CONTEXT_MENU、BEFORE_UNLOAD。对 CSS 模块,DOCUMENT_START是最优选择,因为样式需要尽早落地。urlMatches[]:每条规则形如{pattern, isRegex=false, exclude=false}。isRegex: false(默认)是 Chrome 风格 glob:*匹配任意字符,*://展开为(https?|ftp|file)://,*或<all_urls>匹配一切 URL;若 glob 无法匹配则回退为子串包含判断。isRegex: true时按 Java 正则求值,带 200ms 超时,超时算作不匹配。exclude: true表示该规则从结果集中扣除匹配的 URL。permissions:对样式模块,声明CSS_INJECT即可。权限列表在安装页仅作展示,运行时不据此沙箱化,主要用于审核时识别危险能力。
此外还有一个发布侧的约束:在市场registry.json中,hasCss标志必须为true且目录中必须存在style.css——校验器会检查两者一致,不匹配时 CI 直接报错(见 modules/README.md 的校验规则一节)。App 端的数据模型也对应声明了该字段,见 ModuleMarketModels.kt;安装流程会依据它决定是否下载style.css,见 ModuleMarketRepository.kt。
CSS 如何注入:源码级原理
文档给出的注入时机是:当模块携带 CSS(cssCode/style.css)时,它会在main.js运行之前,以<style id="ext-module-<id>">元素的形式注入页面。
这一行为可以直接在 ExtensionModule.kt 的注入模板中得到印证。运行时把模块代码拼装成一段完整的脚本,顺序为:
- 定义
__MODULE_INFO__、__MODULE_UI_CONFIG__等全局; - 若
cssCode非空,先注入一个自执行函数:document.createElement('style'),设置style.id = 'ext-module-${id}',写入 CSS 内容,再appendChild到document.head(或document.documentElement); - 之后才在
try/catch中执行你的main.js用户代码,异常只写入console.error,不会中断页面。
// CSS 注入(cssCode 非空时生成) (function() { const style = document.createElement('style'); style.id = 'ext-module-${id}'; style.textContent = `${cssCode}`; (document.head || document.documentElement).appendChild(style); })();这段结构意味着两件事,对写 CSS 模块的人很关键:
- CSS 先于 JS 生效。如果你的
main.js里还保留 DOM 操作(例如动态插入一个覆盖层节点),该节点出现时样式已经就位,不会出现"先裸后着色"的闪现。 - 样式节点有确定的 id 规则(
ext-module-<模块id>)。你可以在自己的main.js中用document.getElementById('ext-module-' + __MODULE_INFO__.id)找到并操作它——内置web-tint模块的开关逻辑就依赖对注入节点的直接操纵。
同时,你的main.js仍可在需要时操作 DOM;对纯 CSS 模块而言它通常只是个空桩,但如上文所述不可省略。
style.css示例
一个最小可用的夜间主题样式(继承自 css-module.md 原文档示例):
:root { color-scheme: dark; } body { background: #111 !important; color: #ddd !important; } a { color: #60a5fa !important; }几点实践说明:
!important在这里是常态。页面自身样式表会持续参与层叠,主题覆盖通常需要靠!important(或更精确的选择器 +:where()降低特异性)才能稳定压过站点自身规则。color-scheme: dark声明会让表单控件、滚动条等浏览器原生 UI 跟随暗色外观,是夜间主题中容易被忽略的一笔。- 若你用了
DOCUMENT_START,注入时document.head可能尚未完全构建——这正是源码中(document.head || document.documentElement)回退写法的原因,你无需在 CSS 层面处理,但 JS 桩里不应假设document.body存在。
完整参考实现:内置web-tint模块
仓库modules/目录即 WebToApp 的模块市场(App 直接拉取该目录内容渲染市场列表),其中的 web-tint 是一个可工作的DOCUMENT_START样式模块,同时展示了"CSS 打底 + 最小 JS 交互"的协作模式:
- 清单 modules/web-tint/module.json:
runAt: "DOCUMENT_START"、urlMatches全匹配*、权限["CSS_INJECT", "DOM_ACCESS"],并声明了 5 个configItems(滤镜模式SELECT、强度NUMBER、色温SELECT、自定义颜色COLOR、对比度NUMBER)。 - 样式 modules/web-tint/style.css:定义全屏覆盖层
#wta-tint-overlay(position: fixed; inset: 0; pointer-events: none; z-index: 2147483647),night/custom 模式用mix-blend-mode: multiply,灰度/反色模式改用根元素filter,并对img/video/canvas/iframe单独反向补偿滤镜,避免媒体内容被二次过滤。 - 脚本 modules/web-tint/main.js:用
getConfig(key, defaultValue)读配置,按模式插入覆盖层或切换html上的类;关键细节是它先判断document.documentElement是否就绪,未就绪则挂DOMContentLoaded回调,最后通过__WTA_MODULE_UI__.register({...})注册浮动面板按钮——点击按钮即可在开/关滤镜间切换(切换实现就是对注入节点的opacity取反)。
它证明了 CSS 模块不必是完全零逻辑的:style.css负责静态样式契约,main.js桩负责把用户配置翻译为节点状态,两者通过覆盖层 id 与类名约定衔接。
发布与校验
发布到市场时,除模块目录外还要在 modules/registry.json 中登记一条记录,保持id、name、version、runAt、permissions与module.json一致,并把hasCss置为true(前提是style.css确实存在)。CI 校验脚本(python3 .github/scripts/ci/validate_modules.py)会检查:
hasCss标志与style.css是否存在不一致(对 CSS 模块这是最常见的打回原因);registry.json与module.json的字段不匹配(id/name/version/runAt/漏报权限);- 缺失必需文件(
module.json、main.js——再次强调 CSS 模块也需要main.js桩); main.js顶层return(注入时被包在 IIFE 内,顶层return是语法错误)。
完整规则与审核清单见 modules/README.md,字段级参考见 JS 模块文档(两者共用同一套module.jsonschema)。
runAt 时机对照
CSS 模块对时机的要求最严格,完整对照(引自 扩展开发总览):
| 运行时机 | 触发于 |
|---|---|
DOCUMENT_START | onPageStarted |
DOCUMENT_END | onPageFinished(DOMContentLoaded) |
DOCUMENT_IDLE | 加载后(默认) |
CONTEXT_MENU | 上下文菜单时 |
BEFORE_UNLOAD | 卸载前 |
对主题/夜间模式类 CSS 模块,结论直接:DOCUMENT_START是唯一正确选择——样式必须抢在任何内容渲染前落地,否则用户会先看到一帧原始(浅色)页面。
小结
CSS 模块 =module.json(category: THEME/STYLE_MODIFIER+runAt: DOCUMENT_START)+ 最小main.js桩 + 承载全部逻辑的style.css+ 市场侧hasCss: true。运行时保证<style id="ext-module-<id>">先于 JS 注入,你可以放心地把覆盖层、类名等约定写在 CSS 里,由桩代码负责把用户配置接到 DOM 上。仓库内置的web-tint是可直接模仿的完整样例。
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考