给站点做两套皮肤,并且切换时不跳转
从参考站反推出机制、用 <html> 上的一个属性承载主题、以及在 Astro 里踩到的那个坑——站内跳转会把根属性换掉,于是用户切了主题、一点进文章就丢。
技术
这个站现在有两套完整外观:一套 WinUI 3 风味的深色界面(默认), 一套终末地风味的浅色工业界面。左下角有一项「主题:WinUI 3」, 点一下整页当场换掉 —— 不跳转、不重载、页面也不刷新。
这一篇记的是它是怎么做出来的,重点是其中三个坑。
先看别人怎么做的,别急着写
我拿到的是一份设计规范(token + 规则 + 模板),但没有实现细节。 找参考的时候看到某个站点有类似的主题切换,于是把它加载进浏览器直接查:
document.documentElement.attributes // → data-site-theme="Claude"
localStorage // → ["af:dev-theme"]
document.querySelectorAll("button") // → 一个写着「主题:Claude」的按钮
document.styleSheets // → theme-boot.css, index-*.css, endfield-*.css
信息量很大:
- 主题是
<html>上的一个 data 属性,不是 class 也不是单独的页面 - 它用 localStorage 记住选择
- 左下角那一项显示当前主题名,点一下换一个
- 它的样式表里有一份
endfield-*.css—— 和我手上这份规范是同一套
更关键的是 <head> 最前面那个 theme-boot.js:
try {
if (localStorage.getItem('af:dev-theme') === 'endfield') {
var r = document.documentElement;
r.dataset.siteTheme = 'endfield';
r.style.backgroundColor = '#f4f4f2'; // ← 连底色都内联好
r.style.color = '#161616';
r.style.colorScheme = 'light';
}
} catch (_) {}
这段短得让人怀疑它凭什么有用。但它把整套机制说完了, 而且默认主题不打任何属性 —— 只有选了非默认的才写。
照着做:三个要点
第一,主题是数据属性,不是两套页面。
<!-- 默认(WinUI 3):不打属性 -->
<html lang="zh-CN">
<!-- 选了终末地 -->
<html lang="zh-CN" data-site-theme="endfield">
所有皮肤规则都挂在 html[data-site-theme="endfield"] 下面。
这样默认主题一个字节都不受影响,切换也只是翻属性 ——
没有导航、没有重载、连历史记录都不写一条。
第二,引导脚本必须在 <head> 最前面,而且不能 defer。
浏览器解析到它的时候 body 还没开始绘制。所以“按哪个主题画第一帧” 在这一刻就定死了,不会出现“先按默认主题画一帧、再闪成另一套”。
那三行内联的 backgroundColor / color / colorScheme 也是为这个:
CSS 还在下载的那一瞬间,浏览器也得先铺一个背景 ——
内联值保证那一帧就是对的颜色,而不是先白闪一下。
第三,属性名和存储键散在两处,必须写清楚。
首屏引导脚本是内联的(不能等到模块加载),
而运行时切换在 src/lib/theme.ts 里。两处的键名必须一致,
所以我在两边都写了注释指明对方的位置。这种“同一个字符串出现在两个地方”
的情况,不写注释迟早会出事。
坑一:站内跳转会把根属性换掉
实现完,切换测试很顺:
初始 (无) / 主题:WinUI 3
点一次 endfield / 主题:终末地
再点一次 (无) / 主题:WinUI 3
但换一个页面就露了馅:
跳转前 endfield | 跳转后 (无) ← 主题丢了
localStorage 里明明还是 endfield,页面却变回了默认的深色。
原因是 Astro 的客户端路由在换页时,会把新文档的 <html> 属性搬到当前文档上。
而服务端渲染出来的 HTML 里不可能有 data-site-theme ——
那是每个访问者各自的选择,构建期无从知道。
于是每跳一次页,这个属性就被新文档的(干净的)根属性覆盖掉一次。
修法是在路由的换页事件里重新贴一次:
document.addEventListener("astro:after-swap", apply);
astro:after-swap 的时机很关键:新 DOM 已经换好,但页面还没绘制。
在这儿补属性不会闪。如果是放在 astro:page-load(更晚)里,
用户会看到明确的一下跳色。
这个坑不看源码基本猜不到 —— 现象是“主题会丢”,但 localStorage 又是对的, 很容易往“存储没写进去”或者“模块没加载”上查,而真正被改的是 DOM 属性。
坑二:CSS 特异性打平,皮肤只生效一半
第一版皮肤上线后是这样的:纸色背景对了、选中项的黄色块也对了, 但侧栏没变深、标题还是白字、按钮还是绿的。
查下来是特异性打平:
页面里的 scoped 样式 .hero-title[data-astro-cid-xxx] → (0,2,0)
我的皮肤 [data-site-theme] .hero-title → (0,2,0)
同权重下由源码顺序决定,而按页面切的 CSS 排在后面 —— 它赢。
把全部 92 处选择器统一加上 html 前缀就够了:
html[data-site-theme="endfield"] .hero-title → (0,2,1)
多一个元素选择器,稳定压过,而且不用 !important。
顺带还有一个:侧栏的可见底色并不在 .win-nav-left-panel 上,
而在它的子元素 .win-nav-pane-surface 上。只改父级是看不到效果的 ——
这类“改了半天没反应”的问题,量一下子元素的 backgroundColor 就清楚了。
坑三:内联样式只能用 !important 盖
设置页的卡片怎么改都是白字,我用选择器加了 html[...] 前缀、提高权重,
全都没用。
最后把元素的 style 属性打出来才明白:
style="... padding: 5px 11px 6px; border-color: ...; opacity: 1;"
上游这些控件会把主题画刷以内联样式写在元素上。
内联样式的优先级高于任何选择器 —— 这是 CSS 的规定,不是权重问题。
唯一的办法是 !important。
!important 通常是要避免的,但“覆盖组件运行时算出来的内联值”
是它正当的用途之一。我在每一处都写了为什么,免得以后有人顺手删掉。
验收时我改了检查方法
“换肤换对了没有”不能靠看。
第一轮我肉眼看截图,觉得“差不多”;结果归档页整页的文章标题
是 rgb(255,255,255) —— 白色字铺在纸色底上,截图缩小后根本看不出来。
于是写了个检查:遍历内容区所有叶子节点,取出 computed color, 再往上找最近的不透明背景色,只有“浅色字 + 浅色底”才报。
这个“往上找背景”的一步很重要:按钮是墨底白字, 如果不看背景,它会被误报成不可读 —— 我第一版就这么误报了。
七种页面跑下来:
| 页面 | 浅色底上的浅色字 |
|---|---|
| 首页 / 文章列表 / 归档 / 标签 / 文章详情 / 标签详情 / 404 | 0 处 |
| 设置页 | 修之前 24 处,修完 0 处 |
而那 24 处不是逐个组件修的 —— 根因是本站的通用 token 当初是按深色定的
(--c-ink 是纯白、--line-* 是白色低透明度)。
所以在主题下把这批 token 整体换掉,一处改完,全站页面同时正确。
两个可以带走的判断
一、“我写了”和“生效了”是两件事。 皮肤只生效一半那次,我盯着 CSS 看了很久想不通 —— 因为规则确实写了、也确实被解析了,只是被同权重的后加载规则压掉了。 这类问题靠读代码解决不了,得把 computed style 打出来看实际值。
二、验收脚本要像“定义”一样写。 “找浅色字”不够,得是“找浅色底上的浅色字”。 少一个条件,就会把正确的墨底白字报成错误 —— 然后你会去修一个没坏的东西。
(顺带:这次还发现自己的一个提交习惯有问题 ——
我习惯提交前只跑 build,而类型检查是单独的 check。
结果 en 属性加错了组件,build 照过,check 才报出来。
现在两个都跑。)