🤖
AI审核中

不止换肤:博客六套主题的实现与踩坑

本站实践 106 阅读 0 评论 约 16 分钟 原创
文章摘要

博客在六天里从一套样式变成了六套主题。第一版只叠了一张 1528 行的覆盖样式表,颜色换了、骨架没变,第二天推倒重来:每套主题一套独立模板,评论、表情、AI 助手等组件抽成共享片段,脚本一行不改。本文记录这套结构的划分与回退机制、Thymeleaf 片段选择器和 CSS 层叠带来的几个坑、去掉点击后的焦点框时要对付的 120 条规则,以及不部署就在线上页面验证本地样式的方法。

博客原来只有一套样式,现在叫它“云笺”:蓝紫渐变、玻璃卡片、顶部导航加右侧小组件。九月底的六天里,我给它加了五套主题——纸墨、画报、格致、星河、流萤。

六套主题读的是同一个数据库,跑的是同一个后端,用的是同一批交互脚本,但页面结构完全不同:有单栏阅读的编辑风,有左侧固定侧栏的杂志风,有便当格拼图,也有三栏布局。后台“外观 → 主题”里点一下就能整站切换。

主题本身画出来不难,难的是“改了一处,别处跟着变”。本文按时间顺序,记录方案是怎么定下来的,以及一路踩到的坑。

一、第一版:一张 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 条评论

如果你觉得文章对你有帮助,那就请作者喝杯咖啡吧

评论 0

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

此刻天气

每日一句

Loading...
正在获取每日一句...

在一起

和琪琪在一起 0天 0时 0分 0秒