博客原来只有一套样式,现在叫它“云笺”:蓝紫渐变、玻璃卡片、顶部导航加右侧小组件。九月底的六天里,我给它加了五套主题——纸墨、画报、格致、星河、流萤。
六套主题读的是同一个数据库,跑的是同一个后端,用的是同一批交互脚本,但页面结构完全不同:有单栏阅读的编辑风,有左侧固定侧栏的杂志风,有便当格拼图,也有三栏布局。后台“外观 → 主题”里点一下就能整站切换。
主题本身画出来不难,难的是“改了一处,别处跟着变”。本文按时间顺序,记录方案是怎么定下来的,以及一路踩到的坑。
一、第一版:一张 1528 行的覆盖样式表
最早的想法是最省事的:模板只有一套,主题就是叠在原样式上面的一层覆盖样式。云笺不加载任何覆盖,纸墨多加载一张 skin-paper.css。
好处很明显:所有交互脚本、所有契约测试都不用动,两套主题只在观感上分岔。这一版在 9 月 25 日晚上上线,覆盖样式写了 1528 行,后台另有 372 行。
上线后自己看了一圈,结论是失败的。颜色和字体都换了,但页面还是原来那个页面:导航在同一个位置,卡片是同样的网格,侧栏里是同样的小组件。换肤只能换“皮”,换不了“骨架”。
更麻烦的是,原样式里有大量高优先级的规则:字体用 html body … !important 钉在标题、按钮和输入框上,评论区沿用 Semantic UI 的选择器。覆盖样式要一条条去跟它们比优先级,写得越多越难维护。
第二天一早推倒重来,这次提交改了 64 个文件,新增 13341 行。
二、第二版:骨架归主题,组件归公共
新方案把“页面骨架”和“功能组件”拆开。
骨架归主题:每套主题在 templates/themes/<主题>/ 下有自己的 8 个页面模板(页头、页脚、首页、文章、留言板、归档、分类、独立页),在 static/css/themes/<主题>/ 下有自己的前台和后台样式,再加一个主题脚本。
组件归公共:评论区、表情与表情包、AI 文章助手、分享海报、目录、弹幕墙、视频卡片、天气、每日一句……这些带交互的东西,抽成 templates/components/ 下的 6 个共享片段文件,所有主题引用同一份。
| 主题 | 风格 | 8 个页面模板 | 前台样式 | 主题脚本 |
|---|---|---|---|---|
| 云笺 | 原样式 | 2251 行 | 原样式 | 原脚本 |
| 纸墨 | 单栏编辑风 | 409 行 | 992 行 | 64 行 |
| 画报 | 左侧固定侧栏 | 395 行 | 882 行 | 76 行 |
| 格致 | 便当格拼图 | 401 行 | 882 行 | 52 行 |
| 星河 | 星空首屏 | 378 行 | 1034 行 | 130 行 |
| 流萤 | 通栏大图三栏 | 513 行 | 1178 行 | 197 行 |
新主题 8 个页面加起来只有 380 到 510 行,是云笺同样 8 个页面的五分之一左右,因为评论区这类又长又复杂的结构都挪进了共享片段(6 个文件共 881 行)。组件的底座样式从云笺里抽出来,单独放在 themes/shared/components.css。星河参考了 ThriveX 的首屏和文章卡片,流萤参考了 Astro 主题 Firefly,内容都换成了本站真实的数据。
一次请求用哪套主题、哪个模板,是这样决定的:
graph LR
A["请求"] --> B["解析主题"]
B --> C["找模板"]
C --> D["主题自己的模板"]
C -. "没有这个页面" .-> E["云笺的模板"]
解析主题的顺序是:URL 参数优先,其次是预览 Cookie,最后才是后台设置。
对应的代码很短(节选):
public static String resolve(HttpServletRequest request, HttpServletResponse response) {
String requested = request.getParameter("theme");
if (requested != null) {
if (isValid(requested)) {
writePreviewCookie(response, requested, -1); // 会话级,只影响当前浏览器
return requested;
}
if ("off".equals(requested) || !StringUtils.hasText(requested)) {
writePreviewCookie(response, "", 0); // ?theme=off 清掉预览
return configured();
}
}
// 其次看预览 Cookie,最后才是后台设置
...
}
public static String templateFor(String theme, String pageName) {
String id = isValid(theme) ? theme : DEFAULT;
if (!PINGHSU.equals(id)) {
String candidate = "themes/" + id + "/" + pageName;
if (TEMPLATE_PRESENT.computeIfAbsent(candidate, BlogTheme::templateExists)) {
return candidate;
}
}
return "themes/" + PINGHSU + "/" + pageName;
}
这里有两个设计点:
预览不改站点设置。任意页面带上 ?theme=galaxy,只会写一个会话级的 HttpOnly Cookie,之后在这个浏览器里一直按星河渲染,别人看到的仍然是后台设置的主题。上线新主题前,我都是先这样在线上预览。
没做的页面自动回退。烟花、纪念日这类独立页面,新主题都没有做,templateFor 查一次 classpath 就缓存结果,找不到就回退到云笺的模板。
三、DOM 契约:脚本一行不改的前提
组件能共用,是因为脚本只认 id 和 class。提交评论的脚本找 #comment-btn、#comment-container;展开回复的脚本找 .show-replies、.replies-container 和 data-commentid;表情面板找 #emoji-button、#emoji-panel。共享片段的开头把这些依赖逐条列了出来,并注明“只能加类,不能改 id”。
契约靠测试守着,主题相关的契约测试检查四件事:
| 检查项 | 防止的问题 |
|---|---|
| 页面与样式齐全 | 漏页面时悄悄回退 |
| 片段引用可解析 | 改名后引用断掉 |
| 脚本钩子都在 | 误删 id 或 class |
| 后台列出全部主题 | 上线了却选不到 |
但测试只能守住写下来的东西。星河和流萤的文章标题没有套 <a> 链接,而分享海报脚本取标题用的是 .post-title a,取不到就退回占位文字。结果这两套主题生成的海报,标题一直是“文章标题”四个字,页面上没有任何报错。
这类隐式的结构假设不在契约清单里,只能靠把每个功能实际点一遍。修法是让脚本先找链接,找不到再取标题本身:
var articleTitle = document.querySelector('.post-title a') || document.querySelector('.post-title');
四、Thymeleaf 片段选择器的一个坑
做流萤时,窄屏顶栏里多出一行淡淡的文字,看着像虚影。查下来,是分类条被复制进了顶栏。
页头文件里定义了好几个片段。导航片段叫 nav,分类条是另一个片段,它的根元素恰好也写成了 <nav>(简化后):
<th:block th:fragment="nav">
… 顶栏 …
</th:block>
<th:block th:fragment="catBar">
<nav class="ff-catbar"> … 分类条 … </nav>
</th:block>
每个页面用 ~{themes/firefly/header :: nav} 引入顶栏。问题在于,Thymeleaf 的片段选择器本质上是标记选择器:nav 既匹配 th:fragment="nav",也匹配文件里所有名为 nav 的元素。于是分类条跟着顶栏,被插进了每一页。
修法很简单:页头文件里除了导航片段,其他片段一律不用 <nav> 标签,改成 <div role="navigation">,无障碍语义不丢。
五、层叠:让主题的样式真正生效
新主题最费劲的不是写样式,而是让样式生效。底座里有三类“硬”规则:
- 字体样式用
html body … !important钉在标题、按钮、输入框、时间上; - Semantic UI 的评论区规则,比如
.ui.comments限宽 650px、头像float加height: 100%; - 从云笺抽出来的组件底座,不少规则带
!important,黑夜模式还挂在html[data-blog-theme="dark"]上。
最后固定下来三条规矩。
能改变量就改变量。字体只改 --blog-font-title、--blog-font-body 这类变量,颜色、代码块配色也都走变量:底座读变量,主题只负责给变量赋值。
要压规则就统一抬一级。主题里写 html .gx-page .ui.comments …,比底座多一个元素选择器和一个类,优先级稳定地高一截,不用每条单独琢磨。
后台用层叠层。后台的主题令牌放在 @layer theme-admin { … } 里,并加上 !important。层叠层有个反直觉的规则:普通声明是“不在层里的赢”,!important 声明却反过来,“在层里的赢”。所以层里的 !important 能压过后台原样式里所有的 !important,又不用去拼选择器的长度。
搜索框被裁掉一半
规矩定了,漏网之鱼还是有。今天发现,除了云笺,其余五套主题的搜索弹窗都不对:输入框贴着弹窗底边,下面那行“Enter 搜索 · Esc 关闭”看不见。原因是底座里的这条规则:
.search-overlay .search-dialog {
height: 64px !important;
overflow: hidden !important;
}
这条是从云笺的样式里抽出来的,云笺的弹窗里只有一个输入框。新主题给弹窗加了 8px 内边距、约 60px 高的输入区,下面还有一行提示,实际需要 100 到 111px。弹窗被写死在 64px 并裁掉溢出,输入区就被挤到底边,提示行整行消失。纸墨的弹窗没有内边距,输入框看着正常,但提示行同样被裁掉了。
去掉写死的高度后,弹窗按内容撑开。
同一条规则被复制了五次
回复弹窗底部有三个按钮:表情、表情包和照片。表情的图标比另外两个小一圈,因为五套新主题的样式里各有一条:
html .gx-page #reply-modal .emoji-trigger i { font-size: 17px !important; }
另外两个图标是 19px。新主题大多是从前一套派生出来的,这条规则就这样一路被带了下来。
派生留下的尾巴还有更隐蔽的。星河从格致的样式派生时,我按行删掉了导航和标签栏,留下了一段没有选择器的声明块和一个多余的 }。CSS 解析器遇到它,会把紧跟其后的整条规则当作错误丢掉。被丢掉的恰好是评论框的图标对齐,于是图标错位,而浏览器不会给出任何报错。派生主题之后,要跑一遍括号配平检查。
另一个是 display: grid 的容器。如果不写 grid-template-columns: minmax(0, 1fr),轨道的最小宽度默认取内容的最小宽度,文章里的表格和代码块会把整列撑宽,手机上的文章页一度被撑到 632px。
回复和父评论不是一个样子
楼中楼回复原来整条铺了一块底色,内容气泡又是同一个颜色,看不出层次;属地还单独掉在第二行。这次改成和父评论同一套结构:整条回复不铺底色,只有内容气泡有背景,属地挪进时间那一行。
这需要改回复的渲染脚本,而云笺也在用这个脚本。改前改后各截一张图对比,确认云笺的显示没有变化。
改完又被优先级坑了一次。手机上我把回复头像缩到 32px,网格的第一列确实缩了,头像却还是 44px:主题里另有一条 .gx-page .ui.comments .comment > .avatar,给头像写了带 !important 的固定宽度,优先级是 (0,5,0),比我写的 html .gx-page .replies-container .comment > .avatar((0,4,1))高。两条都带 !important,比的还是优先级。结果头像溢出到 10px 的间距里,纸墨只剩 2px,另外四套主题直接压住了昵称 2px。选择器里补上 .ui.comments 之后才生效,间距也改成和父评论一样的 14px。
六、去掉点击后的焦点框:和 120 条规则打交道
最后一个需求听起来最简单:点击之后,不要再出现蓝色的框。
先数了一下。前台加载的样式表里,写在 :focus、:focus-visible、:focus-within 上、会改描边、阴影或边框的规则,一共 120 条,分布在 23 个文件里,其中 45 条带 !important,16 条的选择器里有 id。逐条改不现实,也保证不了以后不再冒出来。
第一个念头是只保留 :focus-visible,因为浏览器只在键盘操作时才让它生效。但实测下来,按钮确实如此,鼠标点击后不匹配;输入框却不是:鼠标点进跳页输入框,主题里写在 :focus-visible 上的 2px 描边照样出现了。按照规范建议的判断方式,能接受键盘输入的元素获得焦点时总是匹配 :focus-visible,因为用户需要知道光标在哪。所以这条路去不掉输入框的框。
于是换个思路:新建一个 focus-reset.css,前台所有页面都加载,统一去掉焦点时的描边和光圈。难点在于,它要压过那些带 id、带 !important 的规则,比如:
.reply-modal #reply-editable:focus {
border-color: rgba(73, 104, 242, .45) !important;
box-shadow: 0 0 0 4px rgba(73, 104, 242, .10) !important;
}
这条的优先级是 (1,2,0)。重置规则既要能匹配任何元素,又要比它高,这里借了一下优先级:
html body :is(#focus-reset, *):not(#focus-reset):focus,
html body :is(#focus-reset, *):not(#focus-reset):focus-visible,
html body :is(#focus-reset, *):not(#focus-reset):focus-within {
outline: none !important;
}
html body :is(#focus-reset, input):not(#focus-reset):focus:not(:-webkit-autofill),
html body :is(#focus-reset, textarea, select, [contenteditable]):not(#focus-reset):focus {
box-shadow: none !important;
}
:is() 的优先级取参数里最高的那个,哪怕实际匹配上的是 *,也按 #focus-reset 算作 (1,0,0);:not(#focus-reset) 再加一个 (1,0,0)。页面上并没有这个 id,所以选择器对任何元素都成立,优先级却是 (2,1,2),稳稳压过上面那条。
还有两个细节:
自动填充要排除。黑夜模式下,站点用一圈 1000px 的内阴影盖掉浏览器自动填充的浅色底。输入框的焦点光圈也是 box-shadow,重置时如果不排除 :-webkit-autofill,这层遮盖会被一起去掉。
变色的边框只能逐条改。有些规则不是加描边或光圈,而是在聚焦时把边框换成主题色,比如黑夜模式的跳页输入框、AI 助手的输入框、表情包的搜索框。原来的边框是什么颜色,通用的重置规则无从知道,这几处只能回到原规则里把变色删掉,一共 13 条。
验证用的是 CDP 的 Input.dispatchMouseEvent,真实地点击输入框、按钮和回复框,对比点击前后元素的描边、阴影和边框颜色。六套主题,白天和黑夜,点击后都不再出现描边和光圈,输入框的边框颜色也和点击前一致。
代价也要说清楚:焦点框对只用键盘的访客是有用的,去掉之后按 Tab 键切换,同样看不到焦点在哪。这是一次有意做出的取舍。
七、验证:不部署,也能在线上页面看改动
主题的问题大多出在真实数据上:超长的无空格链接、带图的评论、只有一两篇文章的分类……本地造的数据很难覆盖全。所以我的验证基本都在线上页面上做,但不用先部署。
做法是用无头 Chrome 打开线上页面,通过 CDP 的 Fetch 域拦截请求。页面引用的样式和脚本都是带内容哈希的地址,形如 /css/themes/galaxy/theme-<哈希>.css,拦下来后用本地文件回填:
// 只拦样式和主题脚本,其余请求照常走线上
await send('Fetch.enable', { patterns: [{ urlPattern: '*/css/*' }, { urlPattern: '*/js/themes/*' }] });
function onRequestPaused({ requestId, request }) {
const m = request.url.match(/\/css\/themes\/([a-z]+)\/theme-[0-9a-f]+\.css/);
if (!m) return send('Fetch.continueRequest', { requestId });
const body = fs.readFileSync(`${LOCAL}/css/themes/${m[1]}/theme.css`).toString('base64');
return send('Fetch.fulfillRequest', {
requestId, responseCode: 200,
responseHeaders: [{ name: 'Content-Type', value: 'text/css' }], body,
});
}
页面里的文章、评论、图片都是真的,只有样式和脚本是本地正在改的版本。线上还不存在的新文件,比如这次的 focus-reset.css,用 Page.addScriptToEvaluateOnNewDocument 在页面加载时注入。
截图之外,还会在页面里跑一段检查脚本:逐个文本节点往上找真实的底色、计算对比度,找黑夜模式里扎眼的大块亮色,查横向溢出和脚本报错。四套主题做黑夜模式优化时,它查出日期、阅读数这类弱化文字的对比度只有 3.2 到 3.8,主色偏亮的主题上白字只有 2.5 到 2.9,都低于 WCAG 对正文要求的 4.5:1。
这里我自己犯过一个错。横向溢出一开始用 scrollWidth - innerWidth 判断,手机模拟下结果永远是 0。原因是内容超宽时,模拟视口的 innerWidth 会跟着被撑大(那一次是 469px),拿它来比当然没有差值。改用 document.documentElement.clientWidth 之后,才发现流萤的手机端首页被分页条撑宽了。
另一条经验是:整页长图缩小之后,什么都看不出来。评论框图标错位、评论照片撑满整列、恋爱计时显示 0,都是一屏一屏按视口截图才发现的。
这次的几项修改上线前,六套主题、两种宽度(390 和 1440)、八种交互状态(搜索、表情、表情包、回复弹窗、展开回复、视频卡片、菜单、AI 助手),白天跑了 96 组,黑夜又跑了 60 组,没有横向溢出,也没有脚本报错。
总结
| 问题 | 根因 | 改法 |
|---|---|---|
| 主题像换了配色 | 只叠覆盖样式 | 独立模板 |
| 海报标题是占位字 | 假设标题带链接 | 回退取文字 |
| 分类条出现两次 | 选择器匹配 nav 元素 | 改用 div |
| 搜索提示被裁 | 写死 64px 高 | 去掉固定高度 |
| 表情图标偏小 | 派生时复制旧规则 | 统一 19px |
| 回复头像压住昵称 | 优先级不够 | 补上 .ui.comments |
| 点击后有蓝框 | 120 条焦点规则 | 统一重置 |
| 溢出测不出 | 用了 innerWidth | 改用 clientWidth |
回头看,六套主题真正的工作量不在“画”,而在“边界”:
骨架和组件分开。结构差异交给模板,交互交给共享片段,脚本就不用跟着主题分叉。
契约要写下来,也要知道它守不住什么。id 和 class 能写进测试,“标题里有个链接”这种隐式假设写不进去,只能靠把每个功能点一遍。
层叠要有规矩。先改变量,要压规则就统一抬一级,实在需要全局兜底,再去借优先级。
在真实数据上,一屏一屏地看。

评论 0
先审后发 · AI 会先回复一句