从 HTML 到莱茵生命:我和上一个 AI 建站踩过的坑
一个博客、三版重构、几十个真坑:模型绕着自己转、无限符号左右放反、逐字抄来的 CSS 静默失效、谁偷偷换掉了 fixed 的包含块——一个个怎么查出来的。
这篇文章写过一版,9 月 30 日。那一版只写到「改版式」——把视觉原型搬进正式站、把版式调对。发出去之后日子还在往下走:除了首页,每一页都加了一台可以上手玩的装置;相册推倒重排了一次;底纹前后换过三轮母题。两个星期后再看那篇文章,它对了一半——「真正花时间的不是写出来,是写完之后」这句是对的,但它把边界画小了。
写完之后真正花时间的,是「怎么知道它到底对不对」。
所以这一版把它补全。坑还是那些坑,只是换了一批面孔;底下的道理还是同一批,只是当时我只摸到一半。
一、坑一共五类
| 类型 | 典型症状 | 正确的解法 |
|---|---|---|
| 环境类 | 本地能跑线上不行、改了没反应 | 先排除缓存和路径,别动代码 |
| 静默失效类 | 代码没错、肉眼看得见不对,浏览器一条错都不报 | 去看渲染出来的 DOM / 计算样式,别读源码 |
| 坐标系类 | “绕着自己转”结果在晃、“居中”结果偏了 | 量,不要靠看的 |
| 度量类 | 用错指标,越调越歪 | 换一个能被独立复核的指标 |
| 判据类 | 你以为的“验证”根本没验到东西 | 先让判据自证它量到了什么 |
第三、四、五类最要命,因为它们不报错。第一类最烦,因为它浪费最多时间。
第五类(判据类)是写这版才补上的——它不是我遇到的第五种 bug,是我用来抓 bug 的那把尺子本身也会错。这件事在我第一次写这篇时完全没意识到,见第六节。
二、环境类的坑:全是“看起来像代码问题”
2.1 file:// 下加载 3D 模型必失败
一开始为了省事,直接双击 HTML 用浏览器打开。结果 3D 模型死活不出来,控制台报一个 CORS 错。
原因是 GLTFLoader 内部是用 fetch 去取 .glb 文件的。file:// 协议下 fetch 被浏览器直接拦掉——不是配置问题,是设计如此。
这条记下来:只要页面上有 3D、有 fetch、有 module 脚本,就必须起本地 HTTP 服务。
cd prototype && python -m http.server 8080
(顺带一提:<script type="module"> 在 file:// 下同样会被 CORS 拦。所以“能不能双击打开”这件事,其实是判断这个页面能不能离线跑的快速试纸。)
2.2 改了同名文件,浏览器还是给旧的
同一个文件名反复覆盖测试页,浏览器一直显示第一版。同名 HTML 会走缓存。
修法有两层,两层都要做:
- 服务端回
Cache-Control: no-store, no-cache - 调试时不放心就换文件名(
v1.html、v2.html……),或者开无痕窗口
只改一层有时候还是会被吃掉,尤其是无头浏览器做自动化验收的时候。
2.3 路径被 shell 悄悄改写
在 Git Bash 里跑命令,URL 里的 /probe/x.html 会被 MSYS 当成 Windows 路径自动转义,结果请求发到 http://127.0.0.1:9703C:/Users/.../PortableGit/...,页面打开是空白。
修法是加个环境变量前缀:
MSYS_NO_PATHCONV=1 curl http://127.0.0.1:8080/probe/x.html
这个坑的隐蔽之处在于它不报错——请求确实发出去了,只是发到了错的地方。
(这条后来又踩了一次:给验收探针传页面列表时写 PAGES="/about/",同一个机制把它转成 D:/…/PortableGit/about/,探针于是去加载一个不存在的本地文件,表现为“页面 DOM 是空的”,看着像线上坏了。)
2.4 404 是噪音,不是 bug
开发时控制台一直有一条 favicon 的 404。一开始当成 bug 查了半天,其实只是没放 favicon。验收脚本要按模式过滤掉已知无害的错误,否则每次都要重新判断一遍“这条是不是新问题”。
2.5 直接双击打包出来的 dist/index.html,整站 CSS 都会没
这是 2.1 的进阶版,专门针对打包后的站点。
现象是站主给我反馈的:“DNA 显示不出来,左上角 logo 也没显示。“听起来像个渲染 bug,我当时也是这么查的。
真相要糟得多——不是这两个元素坏了,是整站 CSS 和 JS 都没加载。
原因是站里的资源路径全是 /_astro/xxx.css 这种绝对路径:
http://下,它解析到站点根目录,正常file://下,根目录变成了磁盘根目录,请求实际发到了file:///D:/_astro/xxx.css—— 必然找不到
用同一个对照探针把两种方式各跑一遍,差别一目了然:
file:// dnaCanvasPresent: false logoMaskBg: rgba(0, 0, 0, 0) 6× ERR_FILE_NOT_FOUND
http:// canvas 836×1295 .is-ready logoMaskBg: 正常
所以现在这个站的 package.json 里专门挂了一条 preview:note:想看效果跑 npm run preview,别双击 HTML。
记下来:打包产物里的绝对路径,决定了它只能配着 HTTP 服务看。 判断“到底是代码坏了还是打开方式不对”,最快的办法就是同一份产物用两种方式各加载一次。
2.6 推了,线上却没变
这条是后来才补进这一类的,因为它的症状和“代码写错了”一模一样,但根子在别处。三个真实原因,按命中率排:
- 构建失败了。 有一次我把 239 个临时文件一起提交进去,Cloudflare 那次构建直接 Failure,而线上继续挂着上一个成功的部署——点开站,一切都对,只是不是最新的那次。所以「push 了没变」第一件事是去看那次构建的状态,不是去看代码。
- CDN 缓存。 有一次提交的部署已经是 Active、源正确无误,
curl回来却是旧页面——加一个随机查询串(?cb=$(date +%s))再查,或者直接查部署域名(它不过 CDN),立刻就对上了。 - 看错了判据。 有一次我拿一句 CSS 去线上 HTML 里找,轮询了十几遍全“没命中”,以为没部署——其实样式是外部文件(
/_astro/*.css),HTML 里本来就没有那句话。判据找错了地方,看起来和“没部署”一模一样。
记下来:门闩要绑本次改动本身(改色就查那个色值),而且要绕开缓存。绑错了内容,被缓存骗了,照样是假通过。
三、静默失效类的坑:样式不生效,却一个错都不报
这一类是我以前没遇到过的,也是改版式那一轮最贵的一类。它的特点是:代码一行没写错,肉眼看得见那里出了岔子,但浏览器一个错误都不报。
3.1 现象:从一个跑得好好的原型里逐字抄来的东西,到站上就废了
当时在把一个视觉原型移植进正式的博客站。原型是单文件 HTML,页面上有三个关键元素:
- 左上角一个会轻微摆动的 logo
- 首屏右侧一根倾斜的 DNA 双螺旋(three.js 渲染)
- 背景一层跟着鼠标晃的流场线“蛛网”(几百条 SVG
path)
三样在原型里都是好的。移植时我以为自己是把 CSS 逐字搬过来的(后来发现并没有,见 3.5),素材也换成了新做的一批。结果上线一看:
- DNA 立起来了,像根竖着的柱子,不像之前那样斜着
- 蛛网糊成一片
- logo 看不见
第一反应当然是“three.js 的倾角被我改坏了”——毕竟三个里面最复杂的就是它。
3.2 为什么一开始就猜错了:这三样东西的 DOM 不是写在模板里的
- DNA 的
<canvas>是 three.js 在运行时createElement出来的 - 蛛网的
<path>是脚本逐条createElementNS拼出来的
而这个站用的是 Astro,组件里的 <style> 默认是作用域化的:编译的时候,Astro 会把选择器拆开的每一段都挂上一个 [data-astro-cid-xxxx] 属性限定。而这个属性,只存在于编译期就已经在模板里的那些节点上。
于是这条样式:
.dna-stack canvas { width: 100%; }
在产物里其实是这样的:
.dna-stack[data-astro-cid-x] canvas[data-astro-cid-x] { width: 100%; }
右边的那个 canvas 是运行的时候才喂进来的,它身上没有那个属性,选择器就永远不成立。
这不是报错,这是静默失效——“选择器没匹配上”这件事,浏览器连一条警告都懒得给。
顺带回答一个必然会问的问题:同样的 CSS 在原型里为什么没事?因为原型的 <style> 就是普通样式表,没有人往里面加属性限定。同一个东西换个地方放,行为就可能变了。
3.3 查法:别盯着 CSS 写得对不对,去看 DOM 实际拿到了什么
靠的是一个很小的探针脚本,它把“我以为应该生效的值”和“浏览器实际算出来的值”打在同一行:
canvas_hasCid: false computed width: 3455px ← 期望 100%
path_hasCid: false stroke: "none" fill: "rgb(0,0,0)"
两行指向同一个结论:选择器没匹配上。
这里还连带炸出第二个坑。我原来以为 SVG path 的样式失效,顶多就是“不显示”。错了——SVG 的 path 默认 fill 是黑色。样式一失效,那几百条线不是消失,而是变成几百个黑色实心多边形糊在一起,比不显示难看得多。而那个用来救场的 fill: none,恰恰就写在那条没生效的选择器里。
这个组合特别阴险:你以为是“没出来”,其实是“出来了、而且是错的”,两个方向的排查路径完全不同。
3.4 修法::global()
Astro 给的开关是 :global(),标了它的那一段不吃 cid 限定:
/* ✗ 编译后是 .dna-stack[cid] canvas[cid] —— 运行时注入的 canvas 永远匹配不上 */
.dna-stack canvas { width: 100%; }
/* ✓ canvas 这一段不限定 */
.dna-stack :global(canvas) { width: 100%; height: 100%; display: block; }
同一个 :global() 一共修了四处,其中两处是既有代码里本来就有的老 bug(一个列表页的 li 分隔线和元信息字号,一直没生效、一直没人发现):
.rh-grid :global(path) { fill: none; stroke: var(--on-band-sub); opacity: .62; }
.dna-stack :global(canvas) { width: 100%; height: 100%; display: block; }
/* 下面两处是列表页里本来就存在的同类 bug,顺手一起修 */
.clist :global(li) { border-bottom: 1px solid var(--hair); }
:global(.cmeta) { font-size: var(--fs-xs); color: var(--muted); }
改完再跑一遍探针:
canvas computed 354.359 × 1260 ✓
path stroke: rgb(222,222,222) fill: none ×101 条 ✓
3.5 同一批改动里的另一类错:三个数抄漏了
同一轮里 logo 也坏了,但它跟作用域没关系,纯粹是移植的时候漏了三个值:
| 项 | 原型 | 移植时写成了 |
|---|---|---|
| 遮罩素材 | inf-mask.png(722×570) |
一个更早版本里已经废弃的 webp |
| 宽高比 | 722 / 570 |
1200 / 564(旧素材的比例) |
| 摆动 | ±4.2° / -7px / 7.5s |
±1.6° / 9s(被我“顺手优化”过) |
前两个还好查,第三个最难发现——因为它不是错的,只是变小了。±4.2° 的摆动一眼就能看出在晃,±1.6° 几乎看不出来,于是就停在“感觉不对但说不出哪里不对”的状态里。
最后也是靠脚本:隔一小段时间采两帧(确保落在摆动的不同相位),把 .lg-box 的 computed transform 矩阵反解,和 animation-duration 一起打出来对照:
animation-duration: 9s ← 期望 7.5s
transform: matrix(...) ← 反解出来只有 ±1.6°
记下来:移植 = 逐属性值对齐,不要顺手优化。 那 ±4.2° 是站主之前来回调过的定稿,我却当成了一个可以随便微调的参数。
3.6 这一类的通用规则
只要用的是带组件作用域 / CSS Modules 的框架,凡是运行时才插入的 DOM,写样式时必须显式声明全局。
反过来也成立:验收的时候如果一个动态元素的样子不对,先查它身上有没有选择器要求的那个属性,再去怀疑几何、动画和第三方库。
3.7 后来又撞见四条同族的:文本没问题,只有渲染才现形
这一类到了做装置那一段还在长,而且每一个都骗过了读代码的人:
| 写法 | 表面上 | 实际 |
|---|---|---|
transform: translateX(var(--x) 0) —— 变量值漏了单位 |
代码没写错 | CSS 变量替换后整条 transform 作废,动画根本没跑(但量时长、量透明度全是对的) |
@keyframes 里写 transform |
看着是在加动画 | 它会整体覆盖基础声明里那句居中 ⇒ 第一帧元素就跳到右下角(要把居中拎成一个变量、关键帧里带回来) |
@media (prefers-reduced-motion: reduce){ animation: none } 写在基础 animation 之前 |
降级分支“有了” | 同权重后写胜 ⇒ 被覆盖,动画照播。而断言只查“有没有这个分支”,照样全绿 |
SVG 图标的注释里写了 --accent(两个连续短横线) |
路径、颜色、尺寸的字符串断言全过 | XML 规范禁止注释含 -- ⇒ 浏览器整个文件解析失败,图标不显示。只有真渲染一次才拦得住 |
四条的形状一模一样:文本层看起来无懈可击,错在“浏览器怎么解释它”。 这一类的验收只有一个办法——渲染一遍。
四、坐标系类的坑:最像玄学,其实全是确定性的
4.1 “这个东西要绕它自己的中心转”
一个 3D 模型,要求很简单:绕着自己的中心轴自转。
第一版做出来,效果是左右晃——不是转,是整根模型在画面上左右平移,像被一根看不见的绳子甩。
我猜了两次,都错了:
- 第一次猜是层级嵌套错了,重排了一遍父节点
- 第二次猜是倾斜角度带来的耦合,把倾斜角去掉了
都没用。然后决定停下来读原始数据——不再看渲染结果,直接解析模型文件。
模型的顶点数据可以直接读出来。对全部顶点做主成分分析(PCA),能算出模型的长轴方向:
长轴方向 PCA ≈ [-0.0001, 1, -0.0001] 夹角 0.01°
也就是说,模型的长轴几乎完美地沿着 Y 轴,没有倾斜、没有偏移。那么问题就不可能出在模型本身,只能出在变换上。
于是列了六七种变换方案,全部塞进同一个页面里同时跑,一次性出对照表。结果一眼就看出来了:
方案 旋转一圈的漂移量
──────────────────────────────────────────
旧顺序(先平移再缩放) 292.7 px ← 就是它
新顺序(先缩放再平移) 0.3 px
根因是缩放和位置的施加顺序。
在很多 3D 引擎的语义里,position 是在父空间里平移,它不受自己 scale 的影响。所以如果先写 position = -模型中心,再写 scale = k,那个平移量是按原始尺寸算的,不会跟着缩小。模型被放大了 3.2 倍,平移量却还是原来的数——于是模型的旋转轴被推到了离旋转中心很远的地方,它就在绕着离自己很远的一条平行轴公转。视觉上就是“左右晃”。
修法只有一行顺序的差别:
// ✗ 错:平移量没跟着缩放走
src.position.set(-center.x, -center.y, -center.z);
src.scale.setScalar(k);
// ✓ 对:先缩,再把平移量乘上去
src.scale.setScalar(k);
src.position.set(-center.x * k, -center.y * k, -center.z * k);
漂移量从 292.7px 降到 0.3px。这不是调参,是修 bug。
4.2 拿“包围盒”去量一个转起来的物体
修完上一条之后,又要用包围盒回推一下位置,结果又歪了。
原因是:包围盒(Box3.setFromObject)返回的是轴对齐包围盒(AABB)。一个细长的柱体倾斜 26° 之后,它的 AABB 宽度会从真实的 113 虚胀到 500 以上——因为 AABB 永远是“贴着世界坐标轴”的那个外框,物体一斜,框就鼓起来了。
用这个虚胀的框去算中心,等于把物体推出了画面中轴。
教训:量物体本身的尺寸,必须逐顶点投影,不能用 AABB。 AABB 只适合做“粗略可见性剔除”,拿它当几何尺寸用必翻车。
4.3 “居中”这件事:符号偏了,但我量不出来
这个是耗时最久的一个,因为我连续两次都调反了方向。
现象:一个装饰性的无限符号,站主连着说了两次“偏左,往右调”。我调了一次,他说“你往左边放干嘛”。
第一次的错误很典型。我用“全画布的透明度加权质心”去量符号位置,量出来是 786.6,画布中心是 786.0——差 0.6 像素,看起来完全居中。于是我判断“本来就居中,不用改”。
问题是:这张原图的画布里散布着极淡的噪点,透明度 0–31 那一档占了全图 90.9% 的像素。这些噪点把质心稀释到了画布正中间,掩盖了真正墨迹的位置。
换了量法之后真相立刻出来:
正确量法:先 alpha > 64 去噪,再用累积占比边界
墨迹边界 x[388, 1571] y[211, 666] → 宽 1183 高 455
左留白 388 px 右留白 0 px(右缘出血被裁掉了)
墨迹中心 979.5 画布中心 786.0 → 右偏 +193.5 px (12.3%)
符号本体重心确实偏右 12%。而我第一次“重裁居中”生成的那张图,实测左偏 144.7px——我把符号从偏右推到了偏左,难怪站主说方向反了。
最后用几何边界中心对齐(还试过强度加权重心,结果因为左半边的缎带更亮更实,强度重心整体偏左,反而把符号推右了 +249px),生成了严格居中的版本:
新图 1251×393
墨迹中心 X 626.0 vs 625.5 → +0.5 px
墨迹中心 Y 195.5 vs 196.5 → -1.0 px
左留白 35 px 右留白 35 px ✓
三条教训,都写进备忘:
- 量之前先去噪。 全画布有极淡像素时,裸质心一定是错的。
- 视觉居中对齐要用几何边界中心,别用强度重心。 亮度分布不均匀会把重心带偏。
- 改之前先量化“现在偏多少、偏哪边”。 第一版的实际偏移是 -144.7px,我连这个数都没量出来就开始改,方向不反才怪。
4.4 同一类问题的第二个:图形相减
还有一次是“两个形状相减,减完的位置不对”。原因类似——参与相减的两个形状,一个自带偏移、一个在原点。任何“组合/运算”类操作,先确认两边的坐标系原点是否一致。
4.5 后来这一类最反复的一条:谁在给 fixed 的子孙定“包含块”
这一条在做装置那一段反复出现,前后撞了三次,每次症状都不一样,所以单独列出来。
CSS 里有一个不常被提起的规则:某些属性会“提级”成为它内部 position: fixed 后代的包含块**。**
平时 fixed 是相对视口定位的;但只要它有一个祖先带了 transform / filter / backdrop-filter / perspective / will-change / contain 里的任何一个,fixed 的参照系就从“视口”变成”那个祖先的盒子“。
三次的表现:
- 关于页:底纹层被放进了会被页面过渡加
transform的容器里 ⇒ 一打开页面,背景从下往上跳了一步(实测偏 91px)。 - 归档页:右侧那把卷尺同理,过渡那 900ms 里整个偏了 596px——站主截图里两个「2026」叠在一起。
- 手机端导航:内页顶栏那层毛玻璃用了
backdrop-filter,于是它顺带给抽屉(fixed; inset:0)当了包含块 ⇒ 抽屉不再铺满屏幕,只铺顶栏那条 62px 的细带,十个链接全被裁掉。站主给的截图里,抽屉只剩「导航菜单」四个字。
同一个“提级”还会顺手做第二件事:建立层叠上下文。所以那一刻会同时冒出两个问题——几何被裁 62px、抽屉的 z-index 被锁进顶栏内部(而正文留在外面)⇒ 只修几何,会得到一个“位置对了但被正文盖住”的抽屉。
三次撞完,沉淀成三句话:
- 判“包含块是谁”要把整组属性一次查全——只查
transform会漏掉filter(我第一次就漏了,还据此下了“没有包含块”的错误结论)。 - 别让装饰层待在会建立包含块的祖先里——这个站的
plate槽就是为此存在的:它在.shell之外,天然免疫。 - 改一个会“提级”的属性,先看它的两个后果——包含块和层叠上下文是同一句话的两半,修一半等于没修。
(顺带:无头浏览器里查这个,靠的是“面板正中那一点 elementFromPoint 命中了谁”。修前跑九个页面,八个红,直接报出 div.topbar[backdrop-filter];修完全绿。)
五、度量类的坑:不报错,但一直在错
5.1 存 WebP 把透明压成了黑
一张只有符号是实心、其余 95.4% 是透明的图,存成 WebP 之后铺在白底上,出现了一大块黑色方块。
原因是保存时做了一次 .convert('RGB')。这个操作会把 alpha 通道直接丢掉,而丢掉 alpha 之后透明区默认变成黑色,不是白色。
修法是直接存 RGBA:
canvas.save(out, 'WEBP', quality=93, method=6) # 不要 .convert('RGB')
这类坑的共同点是:它不报错、不警告,等你在浏览器里看到那个黑块才知道。所以涉及到“带透明的素材”时,保存完一定要回读一遍验证 alpha 通道还在。
5.2 “看起来在动”和“确实在动”
验收 3D 自转的时候,肉眼看完全没问题。但我还是写了个像素级的复核:连续截 14 帧,每帧都去量“非背景像素的包围盒和质心”。
14 帧亮像素包围盒:恒定 276 × 739
质心漂移:3.26 px
包围盒恒定就证明它是在原地自转,而不是在画面上漂移。肉眼看 14 帧的差异很难,但量化之后是确定的。
这条是这次最大的收获:任何一个“看起来对/不对”的视觉问题,都能找到一个可以被独立复核的数字指标。找到它,就不用猜了。
5.3 别用 aspect-ratio 反推宽度:那是个循环
接着上一节的思路再补一个同类——这次是布局里的循环依赖。
DNA 那个容器,原型里是 900 : 3200 的细长一条,我也照抄了:
.dna-stack {
position: absolute;
height: min(140vh, 1520px);
aspect-ratio: 900 / 3200; /* 指望它自己算出 354px 的宽度 */
}
在原型里它算出 354.375px,完全正确。搬到站上,同一个写法算出 3455px,把首屏撑开了近十倍。
根因是这个宽度压根不是由那条 CSS 比例决定的,它是一个循环依赖:
- 容器是
position: absolute且宽度auto⇒ 走“收缩适应”,由内容撑开 - 容器里唯一的内容是
<canvas>,它的 CSS 宽度是100%⇒ 又回头取决于容器宽度
“容器宽 ← canvas 宽 ← 容器宽”。浏览器处理这种循环有自己的一套规则,而收敛点取决于父容器的可用宽度,跟我写在 CSS 里的比例关系不大。原型里父容器 1440px,恰好收敛到 354px;站上父容器 1216px,就发散到 3455px。
同一个写法,换个父容器宽度,结果差一个数量级。这不是浏览器的问题,是我用了一个依赖隐变量的写法。
修法很朴素:把宽度显式算出来,别让它去猜。
.dna-stack {
width: calc(min(140vh, 1520px) * 900 / 3200); /* = 354.375px,写死 */
height: min(140vh, 1520px);
aspect-ratio: 900 / 3200; /* 留着只当文档说明 */
}
通例:aspect-ratio 适合在“一边已经确定”的时候推另一边。想让它同时决定两边,实际生效的是浏览器那条谁也记不住的循环规则。
5.4 拿不准倾斜了多少:直接对渲染出来的像素做 PCA
回到这一轮最开始那个问题——“DNA 怎么竖着了,之前不是斜着的”。
我的第一猜是 three.js 那一层错了:相机宽高比被拉伸了,或者旋转轴写反了。这个猜测很贵,因为要动的是整套 3D。
动它之前,先做一件事:量一下它现在到底倾斜多少。
两个现成的工具都不可靠:
- 欧拉角:先绕哪个轴、什么顺序,我自己都得查一遍,而且很容易把模型内部的旋转和容器外层的旋转搞混
- 包围盒:见 4.2——物体一斜,AABB 就虚胀,量出来的不是真实尺寸
最后用的是最笨也最稳的办法:截图,然后对渲染结果做 PCA。
- 截图取这一片区域
- 挑出明显亮于背景的像素(DNA 是浅色,背景是深墨绿,很好分)
- 对这些像素的坐标算协方差矩阵,取主成分方向
- 主成分方向与竖直方向的夹角,就是它的视觉倾角
这招的好处是:它量的就是屏幕上肉眼看到的那个东西本身,中间不经过任何坐标系换算,也就无从搞错。
亮像素数 倾角
原型 65925 25.55°
本站 65960 25.56°
差 0.01°。3D 那一层分毫不差——我完全猜错了方向。
真正的差别在容器:原型是一个 354×1260 的细长条,我给它套了一个 520×702 的偏方的框,还漏掉了 translate(0, -50%) 的垂直居中和那层径向蒙版。细长的东西被塞进偏方的框、又没居中,看起来就成了“竖着的柱子”。
这条值得单独强调:越是你敢肯定它错的那层,越应该先量一下再动手。 我本来准备去改复杂的 3D,实际错的只是一行容器尺寸。
5.5 同一把尺子,量不了两种东西
做完十台装置之后,底纹换过几轮母题。每换一张新素材,都要重新定“它该多浓”。站上有一把自己攒的尺子——差分:同一页渲染两遍,一遍把底纹关掉,逐像素相减,于是量到的就是底纹自己贡献的那点墨。
用这把尺子量“相册页那张新 ∞ 该多浓”的时候,我差点栽了。
新素材的 mean 明显低于它要替代的那张旧底纹,我第一反应是“太淡了,得调浓”。但那个判断是错的:mean 的分母不一样。
- 新 ∞ 是紧裁过的手绘线描,墨迹只占图面 30.5%,铺到屏幕上只碰到 3.4% 的像素;
- 旧底纹是一整张铺满型的褶纸,铺满 10.45%。
两个不同分母的 mean 直接互比,会得出完全相反的结论。换成看峰值(笔画最深的那一档)立刻就对上了:
相册新 ∞ 峰值 20
日志页 ∞ 峰值 20 ← 用户已接受的那一档
旧百褶 ∞ 峰值 21
记下来:一个指标只有在“分母/口径相同”的前提下才能跨界比较。 拿 mean 比 mean 之前,先问一句:这两张图的“面积”是同一个东西吗?
5.6 形态量过一次,就不能“推演”下一次
有一台装置要求“线再稀疏一点”。我以为这只是把几个数字改小,改完再看效果就行。
错了。那台装置的线、碎屑、分叉、同心环,全部从同一条带种子的随机流里取数。改任何一个计数,后面所有取值的位置整体错位——得到的不是“原来那张网减掉几条线”,而是另一张网。
后果很反直觉:我把线和碎屑一共减掉了近一半(画布非透明像素 113609 → 55999),“随处能碰到”的覆盖率反而从 92.86% 升到了 94.67%——因为新那张网的环恰好铺得更匀。
记下来:带种子的程序里,“改一个常量”和“改整个形态”是同一件事。 种子给的是可复现,不是可微调。形态类的读数(覆盖率、密度、分布)每次都必须重新量,一步都不能推演。
5.7 “画了但太弱”和“根本没画”,是两回事
有一次站主说“那个效果我没看到”。
我先怀疑是没触发、没执行。插桩一看,链路全通——只是整段生命的着色像素峰值只有 229(正常划过整屏是 3~4 万)。
它不是没画,是画得太弱,弱到看不出来。按像素数量级重做之后,峰值到了 9610。
记下来:视觉验收必须有一条“像素数量级”的判据。 “有没有”和“够不够”是两个问题,症状却都是“我没看到”。
六、第五类:量它的那把尺子本身,也会错
这一节是写这版才敢加的。前面几节讲的都是“怎么把东西做对”,这一节讲的是**“怎么知道自己做对了”**——而这两件事的难度,我以前严重低估了第二件。
我有过好几次这样的经历:写了一组断言,跑,全绿,安心了;过了几天发现问题还在。不是断言失效了,是它从来就没验到我要验的东西。
6.1 一条判据,先得能证明它量到了东西
最典型的一次:我给一个底纹换素材,想量它到底有多浓。写了个采样框,量绝对灰度。跑一遍,五档取值(包括把整层关掉的对照档)全部读回 最暗 249 / 墨量 1.0——五档一模一样。
表面上看,“五档数值一致”很像个稳结论。实际上它什么都没量到:那个采样框落在了一片空白上。
判据错的方式有很多种,但最根本的一种是:它根本没在量你要量的东西,而它不会告诉你。 所以现在的规矩是——每条判据都要先自证:给它喂一个“本应显著不同”的对照,读不出来,就说明判据废了,而不是“结论稳”。
(后来给装置做的差分口径就是这么定的:底部放一条色带,按实际计算值变色,绿了才算这次读数有效;还把它量的是哪一层也印在读数里,让每行数字能自报身份。)
6.2 判据的“范围”会漏
给相册页的新底纹写契约时,我断言“产物里的 CSS 必须有那条关掉旧底纹的规则”。跑,红的。查了半天源码——源码明明是对的。
真相是:Astro 会把小组件样式内联进页面,那份样式整段躺在 dist/gallery/*/index.html 的 <style> 里,而我的断言只扫了外链的 dist/_astro/*.css。字面量一个字没错,是我查的范围漏了。
6.3 “函数里出现过某个调用”是弱判据
有一处几何,命门在于“环的两个端点都必须从放射线上反查”。我写的断言是“这段代码里出现了 crossAt(”。
问题:那个函数里有两处 crossAt(两个端点各一处)。把第一个端点改成“按角度直接算”之后,第二个仍然满足这条断言 ⇒ 一条半悬空的线照样过门闩。
判据要落在“这条数据是从哪来的”,不是“这个名字出现过”。 只要同一个调用出现两次以上,“存在性”断言就必然漏掉一半。
6.4 只测落点、不测线段
有一类几何是“线要绕开文字”。我最初只判“新落点有没有撞上文字盒”。
错了——线段两端都在盒子外面,中间照样能从盒角上切过去。 视觉上就是“线从字角上划了一下”。后来补上“取线段上 1/4、1/2、3/4 三个内点”才算数。
(同族的还有“只测顶点、不测相邻顶点连成的那一段”,在两条不同的装置上各踩了一次。)
6.5 门闩也会被“换一种写法”绕过
有一条门闩是“某个盒子不许有底色”。我实现成“取这个块里的第一条 background 声明,断言它是 none”。
而 CSS 是后者胜——在后面悄悄再补一条 background,整个门闩就被绕过去了。
修法是取最后一条。 教训是:门闩要按“浏览器实际会怎么做”来写,不是按“我期望源码长什么样”来写。同一件事还有更狠的一版:写 CSS 时手滑漏了个分号,那条声明连同后面一条一起被浏览器丢弃,而我的断言读的是文本里的那个数字——数字还在,断言全绿,声明早就死了。所以断言必须连“终止符”一起查。
6.6 门闩要能被咬住——而且要经常重新咬一遍
以上这些都指向同一个动作:证伪。
我现在的做法是给每条重要门闩准备一个“变异”——故意把源码改坏,看门闩是不是真的会红。第一次做的时候,一组十三条只有一个被咬住。那一次很打击人,但也很有用:它说明另外十二条都是摆设。
坑还有两层:
- 变异脚本自己的期望标签也会写错。 见到“没咬住”先别改源码,先看套件到底红了没有——可能是门闩没问题、是我把期望写错了。
- 变异脚本会被中途杀掉,把改坏的源码留在仓里。 而它多半是个未跟踪文件,
git status看不出来。所以现在每轮跑完都要回读源码确认已还原。
七、从“搬原型”到“造装置”
前面六节讲的,大多是“把东西做对”。第九轮往后,站主给了一句新要求:“除了主页,其他页太素了,每页都要不一样、都要有个能上手玩的东西。”
于是这个站从“把视觉原型搬进正式站”,变成了“自己做装置”。这是这一路真正的转折,也是那篇 9 月 30 日的文章完全没覆盖到的一段。
后来一共做了十台。归档页一条随滚动下滑的索引卷尺、关于页的亲笔签名、文章页跟着滚动抽出来的阅读尺、说说页斜飘的雨线与涟漪浮游、日志页那支不用一行 JavaScript 的走时仪、友链页越连越密的星座、留言页会自己收干净的墨滴、标签页贯穿全屏的准星、404 页鼠标划过才缓慢裂开的裂痕、相册页那块被指针搅动的显影板……以及首页那串滚到底就进相册的蝴蝶切片。
做装置的过程里,有几件事和“搬原型”完全不是一回事:
第一,审美主张要能被翻译成一条可验证的关系。 相册页那台螺旋盘,要求是“一眼看出是螺旋,而不是一条斜着排的照片带”。这不是审美题,是几何题:第一版每片只转 18°、可见窗口只扫 64.8°,投影在可视范围内单调递增,读出来就是一条斜带;改成每片 28°、窗口扫 140° 之后,远端收回来了。于是门闩钉的是一条关系——“窗口扫角 ≥ 110°“且”卷回率 ≤ 0.75”——两个旋钮以后随便怎么调,这条性质都守得住。
第二,形状必须是时间的确定函数。 那台显影板有一版用了 Math.random,结果同一时刻两次加载形状就不同,“同一时刻逐点比对”(唯一可信的形状类验收手段)直接失效。从那以后硬规矩是:形状一律是 f(performance.now());如果站主要“每次打开随机出现一张照片”,随机只准出现在选片那一行,别处一律不许。
第三,先造尺子,再调旋钮。 有一轮站主说“友链页那个星链出现得太慢,快一点”。我第一次靠猜(把速度乘 2.3),他还是说慢。第二次不猜了:写一把尺子——在页面最前面同时劫持帧循环和时钟,把动画变成可按 60fps 精确步进、每帧读一次画布。量出来才知道,线其实 67ms 就已经看得见,慢的是“浓度爬满”那 0.37 秒,而且只该动一个时间常数。改完到顶时间 100ms,快了 4.3 倍。
第四,“像水一样”这种观感,要去读参考站的主循环,不是对着看得见的形状调参。 显影板做前五轮,我一直在调笔触(形状、大小、软边),一次都没调对。第六轮推倒重来,把参考站的实现扒开逐行看才发现:“像水”根本不在笔触里,在场是怎么推进的——密度场每帧被一个卷曲噪声流场输运,少了那一行,擦痕盖上去就冻住。
八、几条方法论
按实际用的频率排:
1. 别猜,先读原始数据。 3D 模型可以直接解析顶点做 PCA;图像的墨迹可以逐像素量边界;色彩可以读计算后的样式。渲染结果只是“数据的表现”,数据本身永远可以查。
2. 做“多变体同页对照”。 一个页面里同时跑 5–6 种方案,一次渲染出对照表。比“改一版看一眼”快一个数量级,而且不会因为来回改而记混。
3. 度量要选对指标。
这是最容易偷懒的一步。AABB 不是几何尺寸,裸质心不是视觉位置,文件体积不是语言占比,mean 不是浓度。换个指标重算一遍,如果结论变了,说明原来那个指标是错的。
4. 环境问题优先排查,别改代码。 “改了没反应”99% 是缓存或路径,不是逻辑。先开无痕、先换文件名、先看请求到底发到了哪个地址。
5. 把“确认”变成脚本。 一次验收写完,跑一遍几十秒,比手动点一遍可靠得多,下次还能用。(也是因为这条,才有了第五节那台“差分尺”。)
6. 移植不等于复制粘贴——“换个地方放”是会改变行为的。
同一个选择器,在单文件 HTML 里对整页生效,进了带作用域的组件就只对静态 DOM 生效;同一个 aspect-ratio,换个父容器宽度就收敛到另一个值;同一层底纹,挂进会被 transform 的容器里就改了包含块。搬完之后按原属性逐条复核一遍,比搬本身更费时间,但这一步省不掉。
7. 先让判据自证。(第六节) 凡是要“量一个东西”,先喂它一个“本应显著不同”的对照。读不出来,是判据坏了,不是结论稳。
8. 门闩要能被咬住。 写完断言,故意把源码改坏一次,看它红不红。不红,说明它是摆设。
9. 只有真渲染拦得住。
作用域、CSS 变量、@keyframes 覆盖、XML 注释里的 --、backdrop-filter 的包含块……这一整族的共同点是:字符串断言全绿,页面是坏的。 该截图就去截图。
10. 形态量过一次,不能推演下一次。 带种子的程序里,改一个常量等于换一张图。形态类读数每次都重新量。
九、关于“和 AI 一起建站”这件事
9 月 30 日那版写到:我的角色从“下指令的人”变成了“验收的人”。
做完十台装置之后,我想把这句话再往下推一层。“验收”这个说法其实还是乐观了——它假定我知道“对”长什么样。 但很多次我都不知道:
- 我不知道“一眼看出是螺旋”该怎么验收,直到把它翻译成“窗口扫角 ≥ 110°”;
- 我不知道“星链出现得太慢”到底慢在哪一段,直到造出一把能精确步进的尺子;
- 我不知道新底纹该多浓,直到发现我之前一直在拿两个不同分母的
mean互比。
所以现在这个角色更像:我负责定义“什么叫做对了”,AI 负责把它做出来、也负责帮我一起守住这个定义。 而这个定义往往一开始是不存在的——它是从“看着不对劲”里,被逼出来的一个数、一条关系、一把尺子。
剩下几条体会,和第一版一样真实,只是例子更多了:
- 描述清楚“不对在哪”——“他看着在左右移”比“动画有 bug”有用一百倍。而“那里有个绿框,跟齿没关系,是边缘”这一句,比我说十句“再检查一下”都值钱。
- 判断 AI 给的答案是不是在猜——同一个问题它会给出三种解释,三次都信心十足。
- 不接受“应该好了”——每次它说“已修复”,都要重新量一遍。
- 它会“顺手优化”你已经定稿的东西——logo 那个 ±4.2° 的摆动被改小成 ±1.6°,不是写错,是悄悄改小了,所以肉眼说不出哪里不对。重要的视觉参数要写死成不许微调的约定,并在验收时逐个对值。
- 它的“验证”也会自欺——这是这版新加的。断言全绿不等于做对了,可能是断言压根没验到。所以我现在会要求它:先把尺子拿出来,证明这把尺子量得到东西,再去量。
相关:上一篇写了从零搭这个博客的流程(旧文已撤下);这一篇原本是那之后改版式时踩的坑,这次补上了做装置这一段。
评论
还没有人留言。你可以是第一个。
留个言