博客原来只有一套样式,现在叫它“云笺”:蓝紫渐变、玻璃卡片、顶部导航加右侧小组件。九月底的六天里,我给它加了五套主题——纸墨、画报、格致、星河、流萤。
六套主题读的是同一个数据库,跑的是同一个后端,用的是同一批交互脚本,但页面结构完全不同:有单栏阅读的编辑风,有左侧固定侧栏的杂志风,有便当格拼图,也有通栏大图加侧栏的博客风。后台“外观 → 主题”里点一下就能整站切换,现在站点用的是流萤。
主题本身画出来不难,难的是“改了一处,别处跟着变”。本文按时间顺序,记录方案是怎么定下来的,以及一路踩到的坑。
一、第一版:一张 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 个页面模板 | 前台样式 | 主题脚本 |
|---|---|---|---|---|
| 云笺 | 原样式 | 2252 行 | 原样式 | 原脚本 |
| 纸墨 | 单栏编辑风 | 402 行 | 1044 行 | 87 行 |
| 画报 | 左侧固定侧栏 | 392 行 | 952 行 | 130 行 |
| 格致 | 便当格拼图 | 398 行 | 938 行 | 52 行 |
| 星河 | 星空首屏 | 377 行 | 1099 行 | 162 行 |
| 流萤 | 通栏大图两栏 | 465 行 | 1273 行 | 209 行 |
新主题 8 个页面加起来只有 380 到 470 行,是云笺同样 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 就缓存结果,找不到就回退到云笺的模板。
局部刷新也按主题渲染。天气卡片首屏先放占位,页面加载后再请求 /weather/fragment 换成真实天气。流萤的天气卡长得和别的主题不一样,这个接口同样走 templateFor:流萤有自己的 weather.html,其余主题回退到云笺那份。接口允许浏览器缓存 10 分钟,所以请求地址要带上当前主题(?t=firefly),不然预览时切了主题,拿到的还是上一套主题渲染的片段。
三、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,又不用去拼选择器的长度。
但它压得太彻底了:10 月 7 日后台排版重做时,新写的侧栏、表格、按钮全被这一层 !important 盖回旧样子。现在后台的界面结构统一放在 admin-ui.css,每套主题的后台样式只剩一个 :root 块,只改底色、文字、边框、主色、圆角和字体这些令牌,不再碰具体组件。
搜索框被裁掉一半
规矩定了,漏网之鱼还是有。今天发现,除了云笺,其余五套主题的搜索弹窗都不对:输入框贴着弹窗底边,下面那行“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 键切换,同样看不到焦点在哪。这是一次有意做出的取舍。
七、明暗切换:圆为什么在流萤上看不出来
六套主题的明暗切换是同一个脚本:用 View Transitions 把切换前后的页面各拍一张,新画面从点击的位置以圆形扩散铺满,约半秒。各主题自己的明暗按钮(顶栏开关、抽屉里的按钮)都是转发给一个公共按钮的,真正被 click() 的元素不在点击位置,所以脚本在捕获阶段记下最近一次 pointerdown 的坐标,拿它当圆心。不支持 View Transitions 的浏览器退回颜色渐变,系统开了“减少动态效果”就直接切换。核心只有几行(简化后):
const transition = document.startViewTransition(() => commitTheme(nextTheme));
transition.ready.then(() => {
document.documentElement.animate({
clipPath: [`circle(0px at ${x}px ${y}px)`, `circle(${radius}px at ${x}px ${y}px)`]
}, { duration: 560, easing: 'cubic-bezier(.4, 0, .2, 1)', pseudoElement: '::view-transition-new(root)' });
});
云笺上这个效果很明显,流萤上却像没有动画。把动画放慢 10 倍逐帧截图,扩散其实在发生:流萤的首屏是一张铺满整屏的大图,明暗两套配色下它一模一样,圆扫过去,变色的只有顶栏和底部的波浪。修法是黑夜模式给大图多压一层暗色(遮罩从 18% 加到 46%),圆扫过首屏时能看出明暗交界,夜里看大图也不刺眼。
第二个问题在手机上。扩散的那半秒里,有四五帧都要 150 到 220ms(无头 Chrome,CPU 按 4 倍降速),圆是一顿一顿扩出来的。原因是 View Transitions 的新画面不是一张静态截图,而是实时渲染的。主题里很多元素自己带着 transition: color .15s, background .2s 这类过渡,配色一换,这些过渡就在圆里逐帧播放,整页每帧都要重绘,露出来的颜色也是半路上的。扩散期间把它们全关掉:
html.blog-theme-revealing *,
html.blog-theme-revealing *::before,
html.blog-theme-revealing *::after {
transition: none !important;
}
配色一次切到位,之后圆只管扩散。改完只剩切换配色的那一帧比较长(两三百毫秒,整页换配色本来就要重算一遍样式),其余每帧都是 17ms。
顺手改了两处:切换时为了让按钮里的日月图标重新播一遍入场动画,脚本先摘掉类、读一次 offsetWidth 强制重排、再把类加回去,配色刚换就又整页重排一次,现在只有上一次的动画还没播完时才这么做;文章里的 Mermaid 图会按新配色重画,一张图在手机上要两百多毫秒,原来正好撞在扩散中间,现在等过渡播完再画。
八、流畅度:页面停着,主线程也在忙
最后对流萤做了一轮流畅度测试。无头 Chrome 打开六个页面,桌面 1440 宽、手机 390 宽(CPU 按 4 倍降速,模拟手机),每个页面录三段:静止的 3 秒、一次 4000px 的滚动手势、页面加载。看的是 requestAnimationFrame 的帧间隔、能归因到具体脚本的长动画帧(Long Animation Frames),以及 trace 里样式重算、重排、重绘的次数;再打开 invalidation tracking,看每次样式失效是谁引起的。
首屏的波浪:页面不动,也在每帧重画。流萤首屏底部是参考站同款的四层波浪,原来是一张 svg 里四个 <use> 做横移动画。SVG 内部元素的 transform 动画走不了合成线程,结果页面停着不动,桌面上 3 秒里也有样式重算 203 次、共 483ms,重排 205 次,重绘 388 次。改法是每层单独一个 <svg>,动画加在 <svg> 元素自己身上:它是普通的 CSS 盒子,transform 可以交给合成线程。几何要换算一下,原来的位移是 -90 到 85 个用户单位,路径全长 352 个单位,换成自身宽度的百分比:
.ff-wave {
position: absolute; left: -74.6667%; width: 234.6667%; height: 100%;
animation: ff-wave 25s cubic-bezier(.55, .5, .45, .5) infinite;
}
@keyframes ff-wave {
0% { transform: translate3d(-25.568%, 0, 0); }
100% { transform: translate3d(24.148%, 0, 0); }
}
把新旧两版的动画停在同一时刻截图,波浪的形状一模一样。改完同样的 3 秒,样式重算降到 86 次、143ms,重排 31 次,重绘 70 次。阅读进度条也是同一类问题:原来过渡的是 width,改成 transform: scaleX()。
留言板:两个盯着整页的观察者。留言板在手机上滚动最卡,帧间隔的 p95 是 67ms,有 24 帧超过 50ms。归因下来,静止的 3 秒里有 417 次强制样式计算来自同一个函数,是留言“查看完整内容”的折叠。它在 document.body 上挂了 MutationObserver 观察整棵子树,任何节点增删都把整页留言逐条 getComputedStyle、量一遍高度;而这个页面上的节点一直在变,弹幕条每秒进出,“在一起”的计时每秒改一次字。返回顶部按钮的脚本也挂着一个同样的观察者,连 class 和 style 的变化都算,每次都去查一遍“有没有弹窗开着”;滚动时阅读进度条每帧都改 style,于是每帧都查。
修法是只认相关的变化:折叠只在新留言加进来时重算;返回顶部把滚动和 DOM 变化合并到每帧最多一次,弹窗状态只在弹窗元素自己变化时才重查。
屏幕外的动画也要算。跑在合成线程上的动画并不是完全免费的:只要页面在出新帧,比如滚动时进度条在动,Chrome 每一帧都会在主线程上把正在播的动画的样式更新一遍,不管它在不在屏幕里。弹幕墙、首屏波浪、“在一起”的心跳和天气图标,现在滚出视口就用 IntersectionObserver 暂停。弹幕墙要多做一步:恢复时,各条轨道“什么时候空出来”的时间要顺延暂停的时长,不然新弹幕会叠到停在半路的旧弹幕上。
代码块美化:先全写,再全读。文章页加载时,给代码块加标题栏、折叠长代码的脚本在手机上要 284ms。它每包好一个代码块就读一次 offsetHeight 判断要不要折叠,下一个再插 DOM、再读,每次读都要把整页重排一遍。改成先把所有代码块包好,再统一量高度,最后统一折叠,降到 75ms 左右。
| 手机端指标 | 改前 | 改后 |
|---|---|---|
| 留言板滚动 p95 | 67ms | 17ms |
| 超过 50ms 的帧 | 24 帧 | 0 帧 |
| 留言板静止 3 秒 | 1131ms | 96ms |
| 代码块美化 | 284ms | 75ms |
表里第三行是静止 3 秒内样式计算的总耗时。这些数字来自无头 Chrome 的模拟,和真机的绝对值不同,但改前改后用的是同一套条件。
九、验证:不部署,也能在线上页面看改动
主题的问题大多出在真实数据上:超长的无空格链接、带图的评论、只有一两篇文章的分类……本地造的数据很难覆盖全。所以我的验证基本都在线上页面上做,但不用先部署。
做法是用无头 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 |
| 切换动画看不出 | 首屏大图明暗一样 | 黑夜压暗大图 |
| 扩散一顿一顿 | 圆里逐帧播过渡 | 扩散时关过渡 |
| 静止时也在重绘 | SVG 内部元素动画 | 每层一个 svg |
| 留言板滚动卡 | 整页观察者 | 只认相关变化 |
回头看,六套主题真正的工作量不在“画”,而在“边界”:
骨架和组件分开。结构差异交给模板,交互交给共享片段,脚本就不用跟着主题分叉。
契约要写下来,也要知道它守不住什么。id 和 class 能写进测试,“标题里有个链接”这种隐式假设写不进去,只能靠把每个功能点一遍。
层叠要有规矩。先改变量,要压规则就统一抬一级,实在需要全局兜底,再去借优先级。
动画先问它跑在哪个线程。SVG 内部的元素、width 这类属性动不了合成线程;屏幕外的动画、盯着整页的观察者,会在页面看起来什么都没做的时候占着主线程。
在真实数据上,一屏一屏地看。

评论区 0