LegspCpd Blog

给站点做两套皮肤,并且切换时不跳转

从参考站反推出机制、用 <html> 上的一个属性承载主题、以及在 Astro 里踩到的那个坑——站内跳转会把根属性换掉,于是用户切了主题、一点进文章就丢。

技术

· 约 1 分钟

这个站现在有两套完整外观:一套 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 才报出来。 现在两个都跑。)