Skip to content

VitePress 换肤实录:博客背景怎么知道你那边在下雨 ​

博客背景有一层 canvas 粒子:紫色微粒悬浮漂移、彼此连线,建站初期抄的经典粒子背景。它有个问题——一年三百六十五天一个样。三月没有花,十二月没有雪,访客那边暴雨倾盆,页面依旧岁月静好。

这篇文章记录给它「换肤」的全过程:先按季节换装,再跟着实时天气下雨飘雪,末了连「访客在哪」的定位都收进了自己的服务器。一天折腾下来,比特效本身更值钱的是翻车现场。

第一层:按季节换装——四季花历 ​

季节逻辑收在一个函数里,按月份、明暗、时刻挑皮肤:

js
function pickSkin() {
  if (isNight() && isDark()) return 'stars'; // 23 点后且暗色:星空流星
  const w = weatherSkin(weatherCode);        // 实时雨雪(后来才有的层)
  if (w) return w;
  const m = new Date().getMonth() + 1;
  if (m >= 3 && m <= 4) return 'sakura';     // 樱花 + 桃花
  if (m >= 6 && m <= 8) return isDark() ? 'firefly' : 'lotus';
  if (m >= 10 && m <= 11) return 'foliage';  // 枫叶 + 银杏
  if (m === 12 || m <= 2) return 'winter';   // 梅花 + 冰晶
  return 'plain';                            // 5、9 月素净兜底
}

所有皮肤都是 canvas 2D 现场绘制,零图片,凑成了一套「四季花历」(这部分后来又折腾了两轮,这里写的是现状):春樱、夏荷、秋枫银杏、冬梅——

  • 春(3-4 月)樱 + 桃:樱花瓣带标志性的尖端缺刻(两段贝塞尔拼的),桃花则是整朵五瓣、灼灼深粉——两种粉混飘,才分得清谁是谁;
  • 夏(6-8 月)荷塘:亮色下是一整片会动的荷塘——底缘两簇半透明荷叶(波浪缘 + 分枝叶脉 + 径向渐变,随风摇摆),一两朵呼吸开合的荷花(基部白、瓣尖粉的尖瓣 + 莲蓬),一两秒一圈的双圈涟漪,一只在荷叶间起落的蜻蜓(「小荷才露尖尖角,早有蜻蜓立上头」),偶尔一片花瓣落在水面横漂;暗色下换成流萤;
  • 秋(10-11 月)枫银杏:枫叶按真实红枫轮廓手调——顶裂、左右上裂、两枚下小裂,裂缘带锯齿、主脉五出(三档枫红、翻滚下落),和扇面外缘波浪起伏的银杏(大摆幅慢落、带叶脉和叶柄)按 6:4 混飘——枫红配银杏金,秋天的两张好牌不必二选一;
  • 冬(12-2 月)梅 + 冰晶:晴日里整朵五瓣小梅花疏落慢落(浅粉近白、五瓣相叠、带六枚花蕊,比樱花小一号、淡一号),配上原地明灭的四角星芒冰晶——傲雪寒梅;实际下雪天由实时天气切雪花,雪归雪、花归花;
  • 深夜(23 点后)且暗色切星空流星,5、9 月素净微粒兜底。

飘落类共用一个 Faller 基类——正弦横摆 + 匀速下落 + 横向出界回绕,子类只管长相。细节上有几处容易忽略的打磨:

  • 首页 hero 本身带粉色渐变,花瓣的明度得压到 56–66 才不致融进背景;
  • 雪在亮色主题下降级成灰蓝——白底上画白点等于没画;
  • 荷塘只住页面边角,中间完全留白给内容,荷叶的半透明青绿压在 0.16——装饰不该跟正文抢地盘;
  • 冰晶的四角星芒直接复用 logo 上的 ✦ 几何,品牌语言顺手统一;
  • 粒子数量随屏幕面积缩放,上下限按视觉重量各自定:雨丝 40–120,雪花 30–90,流萤只给 8–26;
  • 切主题、回前台、跨过 23 点都会当场重估皮肤(MutationObserver 盯 <html> 的 class);
  • 移动端(≤768px)和 prefers-reduced-motion 用户整个不开——动效是给不嫌它吵的人的。

开工之前先记两笔旧账,都是粒子系统本身的坑:

坑一:物理像素和 CSS 像素混着用。 canvas.width 按物理像素设,绘制坐标却按 CSS 像素算,高缩放屏上所有粒子挤在左上角四分之一区域。修法是坐标一律用 CSS 像素,物理像素映射交给 ctx.setTransform(dpr, 0, 0, dpr, 0, 0),dpr 上限 2 防 4K 屏膨胀。canvas 2D 的第一课,年年有人栽。

坑二:被 requestIdleCallback 吞掉的 TypeError。 这个尤其阴。为了省电,给粒子循环加了页面隐藏时停帧的机制(visibilityWatchers),声明时手滑写成了数组——而注册方用的是 .add()。数组没有 .add,TypeError 应声抛出,但它抛在 requestIdleCallback 的异步回调里,被时序埋掉,页面其它逻辑照常跑。后果是停帧和切主题重估皮肤自提交起就静默失效,直到做天气层时才顺手逮住。修复是一个字:数组改 Set。

教训:挂在 idle 回调里的初始化代码,坏了不会喊。凡是「失效也不会立刻被发现」的地方,值得留一行注释说明不变量——比如「Set 而非数组:注册方用 .add」。

第二层:跟着实时天气 ​

季节版上线当天就觉得不对:日历知道月份,不知道天气。十月落雨的午后,背景照样飒飒地飘秋叶——不是不能接受,是差一口气。

方案是拿 Open-Meteo 的 weather_code 映射成皮肤意图:雨系(毛毛雨 51–57 / 雨 61–67 / 阵雨 80–82 / 雷暴 95+)换雨丝,雪系(71–77 / 85–86)换雪;晴、多云、雾不干预——背景不该替天气播报每一个细节。霜在 weather_code 里没有对应值,按定案不做。优先级:深夜星空 > 实时雨雪 > 季节。

雨丝是本轮新增的一支皮肤:细线段沿固定风向快速斜落,没有横摆。线段方向就是速度方向——尾端坐标 = 头部 − 单位速度 × 线长,明暗两套颜色。有了共享的 Faller 基类,新皮肤只剩三十来行。

天气请求同时提成了共享模块 weather.js:首页时钟卡的天气胶囊和粒子吃同一份数据,全站只发一次请求、30 分钟自刷,失败静默、各回各家(胶囊隐藏 / 粒子退季节)。

事故:一个 404 的杀伤半径 ​

部署上服务器当晚,线上实测 weather chunk 闪现闪没——同一分钟内 200 和 404 反复横跳,而同一批构建的其它 chunk 稳定 200。根因在服务器侧,嫌疑名单有上传工具漏传、宝塔防篡改误删、定时同步还原旧快照,至今没抓到现行。

但前端这一侧的连带故障是确凿的:weather.js 原来被特效模块静态 import。它一 404,effects 整个模块加载失败——粒子、光晕、阅读进度条、图片灯箱全灭,连路由过渡动画都不初始化;时钟卡组件的静态依赖还会中断 enhanceApp 里排在后面的组件注册链。一个装饰性 chunk 的 404,杀伤半径是半个站点。

修法分两层,都是运行时 import('./weather.js') + catch 后静默降级:退到底就是「没有天气」——胶囊隐藏,粒子退回季节逻辑。天气 chunk 从此可以随便死。

这一课值得单独记:部署边界要等于故障边界。 静态 import 把两个模块焊死在同一个 chunk 图里,一处缺失全线连坐;动态 import 把故障关进单间,catch 决定降级姿势。

天气该跟访客走,不跟站长走 ​

链路稳了才发现更根本的问题:天气取的是扬州——站长所在城市。扬州下雪,北京的访客背景飘雪,窗外大晴天。换肤换的是访客的沉浸感,数据却按站长的经纬度算,南辕北辙。

获取访客位置有两条路:

  • 浏览器 geolocation API:精确,但顶着一个吓人的授权弹窗——为了粒子皮肤不值得;
  • IP 定位:城市级精度,对「下不下雨」这个粒度绰绰有余,且完全无感。

选 IP。主源 ipwho.is、备源 geojs.io,结果进 sessionStorage 缓存 6 小时(一次会话只定位一次),全失败回退扬州兜底。天气胶囊随即带上城市名——访客看得出「这是我所在地的天气」,这个功能才立得住。

定位收进自己家 ​

免费 IP 接口终究是别人的:限速、抽风、说不准哪天改条款,还多一跳。而手里本来就有自建的统计服务(见《自建访客统计实录》),加个端点的成本几乎为零,/api/weather 就挂在上面:

  1. nginx 把访客 IP 塞进 X-Real-IP;
  2. ip2region 离线库查城市:v3 xdb 双库(v4 11MB + v6 37MB)整体载入内存,检索不走文件句柄、天然线程安全免锁;查询器是内联的零依赖实现——向量索引定位段、段内二分,v4 索引是小端字节序要逐字节反转比较(v6 直接大端);
  3. 只对中国城市负责:非中国 IP、内网、未收录一律 502,交回前端浏览器直连定位链接管——国际库判海外更准;市级缺失退省级,剥掉「市」「省」后缀再查;
  4. 城市 → 坐标走 Open-Meteo geocoding,进程内常驻缓存;天气按城市缓存 30 分钟。全站访客共享这几份缓存,上游压力可以忽略。

前端随之变成三级链,每级失败静默落下一级:

级做什么失败时
1 · 同源 /api/weatherip2region 定位 + Open-Meteo,按城市缓存静默落到 2
2 · 浏览器直连ipwho.is → geojs.io 定位(缓存 6h)+ Open-Meteo静默落到 3
3 · 扬州兜底站长所在城市坐标都不成:不推送,维持现状,下个 tick 自愈

本地实测 8 组伪装 IP(v4 / v6 / 内网 / 海外 / 省级回退 / 缓存命中 37ms)全部通过。

坑三:回环 curl 502 不是 bug。 验证时直接 curl 本机,返回 502 "no cn city"——这是设计行为:回环请求没有 X-Real-IP,服务端只对中国公网城市负责。有效的验证命令是伪装一个:

bash
curl -s -H 'X-Real-IP: 114.114.114.114' http://127.0.0.1:8787/api/weather

顺带一提:xdb 数据文件不进仓库,从 ip2region 仓库的 data/ 目录下载,放 /var/lib/site-stats/。文件缺失只影响这一个端点,统计服务本体照常跑。

一条原则 ​

装饰性功能的工程标准不是「炫」,是「坏了没人发现」。

整个换肤功能的每一层都按这个标准收口:天气挂了退季节,定位挂了退扬州,weather chunk 挂了退无天气层,粒子整个挂了也只是背景回到素净——任何一层失效,页面都还是完整的,内容照读,功能照用。特效的底线是关掉它之后页面依然成立;顺着这条纪律,才敢放心往页面上加东西。

更新于: