00 / ORIGIN ·
为什么是一艘船
这个站的标志是一个 3×3 的九宫格:八颗像素待在格子里,第九颗离开格子进入环绕轨道。 想法脱离网格的束缚,成为绕行的卫星——这就是「想把想法变成现实」的图形化, 也是整个站点的结构:你坐在驾驶舱里,四颗行星通往四个栏目, 中央控制台通往四个世界的档案。
下面这些事实与预算全部来自这个站自己的构建产物。你可以打开开发者工具逐条核对, 也可以去仓库里翻源码——这正是把它们写下来的意义。
01 / FONT
数 MB 的字,和最后活下来的几十 KB
像素风需要像素字体,站点用的是 zpix。最初的做法很朴素:@font-face 直接加载数 MB 的 TTF。换成 woff2 之后母本降到不足 1MB, 但这仍然意味着每个访客都要为几千个自己永远看不到的汉字付费。
真正解决问题的是构建期子集化。scripts/subset-font.mjs 在每次 dev 与 build 之前扫描 app/ 下的全部源码字符,加上一份安全字符集 (ASCII、CJK 标点、界面图形符号),只把用得到的字打包进public/fonts/zpix.woff2。此刻你正在用的这份子集只有几十 KB, 具体大小由当前文案决定,以构建脚本的输出为准。
子集会随文案增长,而且这篇笔记自己就是证据:它加入了新的汉字, 下一篇文章还会继续改变结果。因此正文不固定一个很快过期的字节数: 站里的仪表不装无谓的精度,文章也一样。 为此 Nginx 对这个 URL 单独配了 expires -1 走 ETag 协商缓存, 免得回访者拿着 30 天强缓存里的旧子集,看到一页豆腐块。
02 / IMAGE
像素画不该按摄影编码
四张行星图本质上是 512×512 的像素画,却一直按摄影级参数编码,平均每张 231KB。 像素画的信息量集中在硬边缘上,用高质量连续色调参数去存它,省下的每一个字节都花错了地方。
全部以 quality 88 / alphaQuality 92 / smartSubsample false 重编, 再把新旧两版放大三倍逐像素比对确认边缘无损。四张合计控制在 300KB 内。 分享封面 og.png 也从原来的大尺寸文件重制为标准 1200×630,并控制在 50KB 内。
连同清掉的无引用资源,首页首载从最初的数 MB 降到 1.6MB 门禁以内。准确值由每次构建自动测量, 而不是写死在文章里。
03 / SKY
让仪表说真话
驾驶舱里原本有一行「航向 // 112.4°」。它很好看,但它是编的。 一个以「想把想法变成现实」为口号的站点,仪表上摆一个假读数, 这件事本身就自相矛盾。
所以有了 app/_lib/sky.ts:641 行、零依赖的天文数学, 按 Meeus《Astronomical Algorithms》实现儒略日、格林尼治恒星时、 太阳位置与月相。星图按访客所在地的真实地方恒星时旋转 (南北半球旋向正确),星空浓度由当地真实太阳高度角决定—— 白天来星更少更淡,入夜逐渐满亮。
这些公式经过独立重算验证:恒星时与教科书例题吻合到 1e-7 度量级, 太阳位置与一份独立的 NOAA 实现交叉比对最大偏差 0.0034°, 月相以四次日食月食时刻锚定(新月照亮率落在 1e-5 量级,满月 0.99999 以上)。
观测点由 IANA 时区推导,不弹定位权限。代价是精度:时区只能定位到城市量级, 拿不到匹配项时甚至只是洲级近似。所以读数会自动降精度—— 推导为估计值时只显示与地点无关的月相,绝不用多余的小数位假装自己知道你在哪。 宁可少说,不可虚报。
04 / APPROACH
把口号做成一次转场
页面跳转最初是格子擦除。它不难看,问题是通用—— 任何站点都能做格子擦除,它没有从这个站的世界观里长出来。 更要命的是它和目的地自相矛盾:每个栏目页的着陆日志本来就写着[ok] 大气层进入完成,页面自己声称你刚刚降落, 而一个格子擦除既不是降落也不是别的什么。
现在的转场叫 ORBITAL APPROACH,是站点固有的三段式: 锁定(620ms,被点的行星从它在星图上的真实位置飞向画面中心,四角瞄准框咬合)、 进入(520ms,舷窗从行星中心撑开,目的地表面在窗内浮现)、 着陆(760ms,舷窗满屏,遮罩交还给下面已经渲染好的页面)。
签名动作是贴图分级解析:舷窗里的目的地照片不是淡入, 而是以 24 → 96 → 384 像素三级离散跳变,配合image-rendering: pixelated——低分辨率的想法一级级解析成高分辨率的现实。 这既是那句口号本身,也是标志的故事。终点画面正是目的地页面自己的主图, 所以转场结束的那一帧和页面开始的那一帧是同一张图。
05 / DISCIPLINE
2GB 内存教的事
这个站跑在一台 2 vCPU / 2GB 内存的机器上,只有 Nginx,没有 Node、 没有数据库、没有任何服务端运行时。约束反过来定义了工程习惯, 其中最有用的一条是:帧预算优先于字节预算。
全屏 canvas 每帧清屏的成本按设备像素比的平方增长——它才是真正的大头, 远比描边贵。所以转场画布的 dpr 上限压到 1.25;190 条星流条纹用 3 次stroke() 画完,而不是 8400 次 fillRect(); 进度条动 scaleX 而不是 width(后者每帧强制重排); 转场期间首页星空画布直接暂停——它原先会在不透明遮罩后面白白画上两秒。
动效也做了同样的收敛:全站原本有 20 条各自手调的缓动曲线, 其中 15 条是肉眼难辨却确实不同的减速曲线,这正是「每个控件手感都略有不同」的根源。 现在收敛成 4 条语义曲线加 5 档时长,写在一处。做加法之前先问能不能做减法。
06 / DEFECT
被同一个机制打败两次
最值得写下来的不是哪里做对了,是哪里栽了。档案页有两个样式文件, 一个描述「滚动叙事」布局,另一个还留着更早的「单屏面板」布局。 它们靠文件加载顺序决胜负,而不是靠意图。
结果是:定位与溢出被单屏模型接管,唯独一条 min-height: 200svh 在后者里没有对应声明,于是穿透生效。容器成了一个「被钉在视口、 却被拉到两倍视口高、再把溢出裁掉」的杂交体——整个深潜层 (四个世界的全部条目)被永久封死在裁切区里, 而屏幕上那行提示还在写着 SCROLL TO EXPLORE。 界面在指示一个不存在的操作。
修好之后,同一个机制立刻又赢了一次:还有第四个选择器也在两个文件里被同时声明, 上一轮漏掉了。它同时拿到了 top 和 bottom, 高度又是 auto,于是浏览器转去反解高度——在某些视口下正好解成 0。
教训不是「要小心」,而是结构性的:靠加载顺序仲裁的样式, 迟早会在某个没人测过的视口上赢错一次。
07 / NEXT
还在航行
这艘船没有造完,也不打算造完。星图会随你所在的时区与时刻变化, 航行日志只写在你自己的浏览器里、不上传任何一个字节, 而下一段航程会继续记在这个频道。
如果你想验证上面任何一个数字:源码是公开的,构建命令只有一条, 产物就是一个可以直接丢给 Nginx 的目录。
