🤖
AI审核中

READING IN THE WILD · 读一篇手记

博客六套主题的实现与踩坑

文章摘要

博客在六天里从一套样式变成了六套主题。第一版只叠了一张 1528 行的覆盖样式表,颜色换了、骨架没变,第二天推倒重来:每套主题一套独立模板,评论、表情、AI 助手等组件抽成共享片段,脚本一行不改。本文记录这套结构的划分与回退机制、Thymeleaf 片段选择器和 CSS 层叠带来的几个坑、去掉点击后的焦点框时要对付的 120 条规则,明暗切换的圆形扩散为什么在流萤上看不出来、又为什么一顿一顿,用无头 Chrome 做流畅度测试找到的几处主线程空转,以及不部署就在线上页面验证本地样式的方法。

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

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

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

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

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

评论区 0

头像
 0 条评论