City Background
Article cover

博客模板更新史 · 希尔维特卷 · 第三章

就在你读这篇文章的时候,本博客的渲染引擎已经从 Hexo 完整地切换到了 SvelteKit。老读者应该知道,为了方便做一些交互效果,这几年博客的架构一直都是 Hexo 混 Svelte,把 Svelte 的 SSR API 抽出来当成 SSG 来用,水和的部分就用全局变量来解决,就像初代 React 的 SSR 一样。虽然实际用起来没啥大问题,但整套模板的调试体验和扩展性太差了,每次做微调的时候都得进跳进那一大堆令人困惑的代码里来来回回地掏。心想着为什么要这么为难自己,索性花了三天把整个博客从头到尾重构了一遍。

跟前两次模板调整一样,这次也没有做任何大改,依然是爆改 Hexo Landscape 主题的版本。甚至为了追求视觉风格的一致性,我刻意对前后两版的细部样式做了对齐。「如果一个设计没有什么问题,那就不要动它」,是我一贯遵循的原则。特别是这模板的设计质量还挺好的,以至于经常有朋友私下问我模板哪里来的,能不能开源。在这里再次统一答复一下:

不能哦,这个涉及到个人品牌辨识度的问题,所以真的不能开源 ⸜(* ॑꒳ ॑* )⸝。

主要变动

大框上,这个版本的博客做了一些视觉微调。原本博客模板的风格就是 Material Classic 和初代 Fluent Design 的混合物,这次我进一步调整了一下两种设计风格的「配比」,让整体更加趋向于 Windows 10 的硬朗风格。比如去掉了所有我能找到的圆角矩形,把之前乱七八糟的图标风格统一到了 Fluent Design MDL2

老实讲我个人视觉的加上个两三像素的圆角会更好看一点,但考虑到「现代 UI 设计」对于大圆角的滥用,我应当踩一个比较硬的立场来作出回应,所以就有了这个「次优」的设计决策。当然我做这种行为艺术不可能在行业中掀起任何波澜,最多算是在自家花园里赌烂。不过在某种层面上,我觉得这也可以被视作是一种呼吁:你不一定非要用圆角、滥用圆角、滥用大圆角来达到视觉上的精致度,它只是一种可选项,而且不一定每次用都会加分。

另外内容上的大改动是删掉了右上角所有的社交媒体图标,因为里面列的大多数联系方式都已经找不到我了:G+ 已经去了天国、Skype 被微软砍了、Steam AFK 了好几年、 Instagram、Twitter 也不更新了,可能唯一还在更新的就是我的 Telegram 频道,还在每天查的只有我的邮箱。但俩这也撑不起来一个单独的版块,索性统统删掉,至少视觉上还能更清爽一点。

说到社交媒体,自从我把日用手机换成了墨水屏 + Jelly 2 之后就(被动地)远离了「世俗的多巴胺触发器」,现在活得像个赛博僧侣一样。这样的生活的确是对我造成了一些改变,不过并不属于本文的射程范围,后面我会单独写篇文章讲。

系统层面,虽然评论区的样子没啥大变化,但是后端变了,从 Isso 变成了挂在 Cloudflare Worker 上的服务,存储直接用的 Cloudflare D1。老实讲 Isso 的 API 设计挺莫名其妙的,而且内部有一些 Anti-pattern 的实现,看了让人不由得叹气。想着做都做了不如做彻底点。于是手气刀落,用 Gemini 2.5 那个新模型堆了个新的评论系统,全程不足一小时,迁移过程流畅愉快。

想重构评论系统,主要还是为后面留言板的第二次大改板做准备。现在的留言板已经有了一点雏形,我把 Google Plus 的多栏瀑布流设计从坟里挖了出来,结合了「匿名版」的理念,把原本的评论区爆改成了匿名瀑布流。之后想把它进一步变成一个小社区,但具体怎么做还不是很清楚,想明白了再写报告跟大家介绍 (◍•ᴗ•◍)ゝ。

被爆改的评论区
被爆改的评论区

一项很有趣的变更是 RSS 的格式。现在你访问 RSS 页面会发现它有了样式,而不再是光秃秃的 XML。这是因为我给它加了一个 XSLT,你可以把它理解成「RSS 的 Hexo」,你输入一段 XML,XSLT 就能把它转换成带样式的 HTML。说实话这东西挺复杂的,所以我没亲自下场撰写,而是让 Gemini 输出了一个「基础款」结构,然后我再来微调样式,尽量保证和主站的设计语言一致。

最后就是修一修打印样式,确保你打印文章的时候不会顺便把评论区、边栏这些非主干的信息印出来,也尽量不要出现会影响印刷品质的彩色和灰阶。不过写到这里我才想起来,是不是应该用 CMYK 色域来规定色彩,不然彩色打印机会不会蠢到打出四色灰来?我现在手上没有打印机不能试,不过做得严谨点总归是好的,下次得记得修一下。

设计决策

除了这些我没讲你不一定能注意得到的细节之外,还有一些我讲了你也不一定能注意到的细节(我是在公三小……)。

卡片动效

现在,页面之间的切换动画变得更加细致了。原本是整个内容区一起上浮消失,但是这一版设计给每个页面元素都单独添加了一个延迟,包括边栏上的每一张卡片。这样的设计更加符合 Material Design 「量子纸」的隐喻,让每一张纸片都能表现出自己的主体性。

在传统的 CSS 实践中,想要实现入场动画,我们可以给元素加一个 class,要播出场动画的时候再把这个 class 删掉,这样我们就得到了「元素上浮入场、下沉出场」的画面。至少是有了个动画,可以打 75 分。余下的 25 分来自动效本身的设计逻辑,在我们所经历的、朴素的日常生活中,你并不太能找得到什么东西能轻轻地浮上来,过一会又轻轻地沉下去。

因此我坚持了上一版的动效逻辑:如果元素的入场动画是从下向上浮出的,那么它就应该继续上浮消失,营造出一种利落、轻盈的意向。你可以很直觉地想象出与之对应的现实场景,像是缓缓升起的气球、自由飘荡的水母。

谈完动效本体的设计,我们再来看播放动效的时机。为了呈现出入涓流般的流畅感,元素需要以错落有致的方式逐个进入视野。同时,为了避免「迟滞、割裂、跳跃」的异常感,不能一个元素入场动效完全放完了才放下一个。最佳实践是,在上一个动画播到后半段时,后续动效就开始播放。

当然,这种播放策略在内容很多的页面上会显得有些冗长、单调。比如 Archive 页面,有非常非常多的小卡片、每个页面右侧的边栏也有非常多挂件,串在一起播可能需要好几秒。这个时候就得做动效编组。比如 Arhive 页面里,每个年份都会单独计时,不同编组之间的时间间隔 100 毫秒、组内的每张卡片出现的时间间隔 50 毫秒,这样就能创造出一种虽然动效丰富但颇具秩序感的体验。

最后,一个值得注意的地方是:入场动画是为了提供视觉线索,出场动画是为了给下一个页面的加载抢出来几百毫秒的时间。动画的存在本身是有功能性的,设计师不应该以「炫耀动画的存在」为动机来设计自己的动效系统。

本着这个原则,出场动画的播放应当有一个时间上的上限,只要播放时机迟于 200 毫秒,就应当被统一拉齐到 200 毫秒以避免过度挤占用户的使用时间。

其实这些林林总总的设计需求叠在一起,想要在 Svelte 里完整实现还挺麻烦的。因为它的 Transition API 只跟状态变化绑在一起,没跟组件生命周期绑在一起,所以动画在「组件加载时播放」这个实现就得徒手实现。

GitHub 上有一种已知的实现方式是,在组件被加载完毕后再通过设置状态的方式强行激活动画,但这么做就破坏了预渲染带来的 SEO 优化。因为页面中没有包含卡片里的内容。另外一种做法是在组件挂载生命周期里面切换组件的 Key,强行重新加载一次以确保动画一定播得出来。但这么做的弊端是 JS 加载出来之前、页面渲染之后这段时间里,卡片会照常显示,只有 JS 完成加载之后卡片才会弹出来,这会导致你的元素在很边缘的情况下有闪烁。

我们当然也可以直接让组件的初始状态时的透明度为 0,组件挂载后再播放显示动画,但这么做的弊端是无 JavaScript 环境下页面就会变成一片空白,这一定是我们不希望看到的。因此有一个处理细节:在 head 中插入一个阻塞的同步脚本给 html 元素上添加一个启用 JS 的 class 标记,只有这个标记存在的时候,组件的初始状态才是透明的。这样就妥善处理了无脚本环境的渲染问题。

另外 SvelteKit 有一个「预载」机制,如果一个路由没有被加载过,框架会在切换路由、「出场动画」播放前把组件提前渲染到 DOM 里。如果被加载过了,那么就在「出场动画」播放完毕后再进行组件挂载。这一行为会导致你的动效会被提前播放,但播放时组件还没出现在画面里,产生了一种「动画掐掉了」的感觉。熟悉 Svelte 开发的朋友可能会推荐试试 afterNavigation 生命周期,但经过实测,这个生命周期和 onMount 几乎是同时触发的,并不能解决眼下的问题。

最后的解决方案是把所有路由根部用一个带 class 标签的组件包裹住,然后用 Observer 监听当前页面是否有两个同 class 的路由根部,如果有就先不播放,没了才播。

onMount(() => {
  if (document.querySelectorAll('.blog3-page').length > 1) {
    const observer = new MutationObserver((mutations) => {
      if (document.querySelectorAll('.blog3-page').length > 1) return;

      for (const mutation of mutations) {
        if (mutation.type === 'childList' && document.body.contains(card)) {
          intro(card, { delay: fadeDelay });
          observer.disconnect();
          break;
        }
      }
    });

    observer.observe(document.body, { childList: true, subtree: true });
  } else {
    setTimeout(() => {
      intro(card, { delay: fadeDelay });
    }, 0);
  }
});

else block 里面的那个 setTimeout 是为了错开 SvelteKit 内部的生命周期,确保动画一定能触发。JS,很神奇吧 (›´ω`‹ )。

图片动效

所有的文章都有一张尺寸 1900 x 500 的题头图。老实讲这图还挺大的,为了优化图片的加载,我们采用了懒加载这一业界通行做法。同时,为了提供基本的「氛围感」,在图片还没加载出来的时候,显示一个 BlurhHash

BlurHash 是一种图片摘要算法,它能将任意位图变成包含 20-30 个字符的字符串。这个字符串包含图片的色彩信息,可以被渲染成模糊的占位图。

我见过很多实际引入 BlurHash 的网站(包括 BlurHash 自己的官网)对加载过程的处理都很粗糙。没追求的就在图片加载完毕后直接替换掉 src,让内容直接「闪现」出来。稍微有点追求的会加一个透明度渐变的动画,让图片缓缓地浮现出来。但这类动画的设计并不那么符合「直觉」。在现实世界中,你几乎找不到什么东西是以这种形式从模糊变清晰的。

因此我给这里的动画多加了一些处理:用 blur filter 让模糊逐渐变到清晰。但有一处比较 Tricky,因为摘要算法算出来的并不是真正的「模糊版本图片」,而是「气氛上差不多的图片」,因此你需要先施加一个透明度渐变动画,把 BlurHash 渐变到模糊图片上,再让图片聚焦。这样整个视觉体验就是连续的了。

这样的动画流程模拟了现实世界当中的「图片聚焦」,更加符合日常生活的感知,不会让用户产生「冲突」的感觉,进而吸引注意力耗费不必要的认知资源。

其他值得说的观念

设计有很多不同的流派、取向和价值,我个人比较推崇的价值是:尽量贴合我们日常所见的现实,不让用户产生「诧异感」消耗认知资源,从而达成设计和谐的目的。

这套价值可以衍生出很多具体的实践原则,比如阴影(box-shadow, text-shadowfilter: drop-shadow)的使用。

很多博客模板上都在用错误的方式使用阴影——亮色主题用黑色的阴影、暗色主题用彩色阴影。这种使用方式展现了这样的思维:

我要使用这个 CSS 属性、并且我一定要让别人看到我用了这个属性。

「阴影」之所以叫「阴」影,是因为光投射在了物体上,在地面上形成了一块「暗色」的区域。那块区域本身是照不到「光」的,所以不可能随着环境光变暗而出现鲜艳的颜色。「阴」影唯二可能出现彩色的情况是:彩色在环境中引发了漫反射导致阴影区域被染上了些许颜色,或是彩色光源打在了半透明物体上,致使背后的阴影出现了颜色。但这两种设计意象的表达需要精心调整色彩的使用,以确保你脑子里想的东西能够正确地传达给用户。

而「高饱和彩色阴影」唯一合法的出现姿势是霓虹光效:容器本身有一个低饱和度、高亮度的彩色描边,这时如果只有外发光(单向朝外有彩色阴影),意味着容器的边缘发光,将色彩投射到了背景板上;亦或者同时包含内发光(内部和外部都有阴影),则代表容器的边缘、面向观察者的方向上被镶嵌了发光物体,同时照亮了背景板和前景。

霓虹灯,由 Mikita Yo 拍摄
霓虹灯,由 Mikita Yo 拍摄

文字阴影和元素投影属性的使用遵循同样的原理。通常在物体背景很复杂的时候,我们会给前景元素浅浅地加一个阴影,以使其与背景作出区隔,增加画面的层次感。阴影颜色的选择通常是中性色,或者与背景图片接近的颜色。只有设计者想要表达「发光意象」的时候才会在暗色背景上使用高亮度、高饱和、高反差的色彩。

另外,永远不会有物体在亮色背景上发出黑色的光。一旦阴影参数是暗色的,我们就在表达阴影的隐喻,而在有透视存在的情况下,除非在物体的正上方和正前方观看,大多数阴影都不会沿着物体中央均匀分布,而是偏离物体中心的。因此为了让它看起来像是「浮起来的、能投射出阴影的」,绘制参数上最好施加距离和角度两个参数,并且这个参数在全局应当保持一致,以避免透视混乱的出现。这样用户的认知上才能明确,这个元素上施加的特效是究竟是发光还是阴影。

再比如半透明材质的表达,也是个巨坑。单纯把容器的 opacity 拉低很容易搞出非常廉价的视觉效果。因为现实世界中略微透明、又能忠实呈现出背后内容的物体几乎只有廉价的塑料包装纸。因此除非有明确的设计目的,否则你几乎不应该直接给容器的简单地加个半透明色,进而来把背景上的「二次元美少女」透出来:你不想用廉价包装纸包装你的二次元老婆。

让我们来回忆一下日常能见到的半透明物体。大多数玻璃是全透的,没有半透明这个概念。磨砂玻璃虽然是半透的,但是「磨砂」本身是一种质感,为了表达它我们需要在背景上叠一个很淡的噪点材质,同时确保背后的内容是模糊的。另外比较常见的是彩色玻璃,在物理上它比较接近滤光片,我们依然不能简单地给容器加个半透明的颜色应付了事,得对背后的色彩做滤光处理。

设计者的参数越偏离使用者的常识,带来的「冲突感」就会越强。这种冲突会打断使用者对页面内容的注意。我们一定希望用户能好好地读完我们写的文章,而不是在那边对着各种稀奇古怪的形状和设计参数满脑子问号。

一言以蔽之:博客模板的设计目的是更好地辅佐内容的表达,所有与之相悖的价值都应当被克制地表达。

技术决策

最后,来聊聊为什么我抛弃了 Hexo,选择了 Svelte。

Hexo 是一个很好的平台,如果你不是一名开发者,想要不过脑袋地快速开始写作,那么我会毫不犹豫地推荐你使用它。但如果你对设计有一些细致追求,想要下手微调模板的各种细节,或者加一些新的设计、交互元素,那么 Hexo 就不再是一个好的选项了。

Hexo 系统是对各种内容处理库的封装,它的内部存在着各式各样的抽象,像是数据处理、内容预处理、SEO 优化。而模板是对 Hexo 内部散装 API 的再次封装。倘若你想细调某一处行为的表现,那你最好得先搞明白对应的实现到底是在这三层抽象当中的哪一层。最恐怖的情况是,每一层各处理了一丢丢,这时你就得开始玩找线头的游戏了。

考虑到 Hexo 的 API 文档质量本身就没有很高,各类博客模板你更是不要指望它能把设计事无巨细地写清楚,平台本身就可能有自己的 Bug 谱系,而它粘进来的库又各有各的脾气,所以最后就会变成你在跟这一大堆你想要的和不想要的玩意斗法。

因为博客系统本身并不是什么有很高技术含量的东西,数据存储、SEO 优化之类的知识往上一查一大把,现在你甚至可以直接丢给大语言模型来处理。因此如果你对模板定制有需求,我还是推荐自己从头搞一个,照着现成的模板扒设计不到一下午就能做出来了。

绝大多数情况下,你只需要针对自己需要的功能做开发就行,所以完全不需要以「我要搓个大火箭」的心态来设计架构,整套系统会变得相对简洁而且贴合你对「理想博客」的想象。日后维护起来也更省心,想怎么改空间都很大,不会被太多平台技术债所阻碍。而且因为东西是你自己写的,只有你自己一个人用,所以想怎么 Breaking 都行,不用一层一层地搞兼容让系统变得越来越臃肿复杂。

性能方面,以我对大多数「博主」的理解,一年能写十篇就已经很了不起了,攒十年也才一百篇,交给任何一个靠谱的前端脚手架都能以可接受的速度把网站生成出来,差那上下几秒根本不应该构成技术选型决策的考虑因素。

至于这次为什么我选择了 Svelte,主要还是因为 SvelteKit 比较省心,bunx 起一个新工程一边跟着文档过一边就把整个站搭起来了。不用先考个网络前端打包学专业八级证书,也不用对着不存在的某框架版 Can I Use 研究什么库支持了哪些平台特性、是否遵循什么设计哲学、水和会不会出问题、样式表到底要怎么生成。我只想赶紧把东西写出来,性能上别出什么大毛病,以后维护着方便。毕竟这就是个博客而已,又不是什么企业级的大项目。

在这种轻量需求下 Svelte 脱颖而出也就不难理解了。尽管直到 2025 年,我依然能从其设计中看到一些当年决策错误留下的祸根,以及开发者的激进和偏执。但相对 Sapper 年代,这东西的可用性已经挺高的了。相较 2020 年的那次失败的博客重构尝试,这次可以说是相当地轻松愉快。

那么,写到这里,我觉得自己可以非常愉快地宣布:博客的第三次大改版轻松愉快地完成了。

可喜可贺,可口可乐 ( ゚∀ ゚ )。

Comments

Loading animation

Loading comments...