一次模板渲染里,最先暴露问题的不是代码检查,而是一张黑掉的海报。模板中的文字本来应该落在已经准备好的背景上,结果待替换标记还没有替换时,文字颜色没有按预期降级,整页看起来几乎全黑。直觉上,这像是少写了一层默认值:给 var() 补一个 fallback,应该就能把未填的颜色拉回安全值。
但这个改动没有效果。代码表面上已经有 fallback,实际渲染却仍然没有恢复。这个反差很容易把人带进错误方向:继续改颜色、换默认色,或者怀疑截图和渲染器。后来我把问题缩小成一个最小浏览器实验,才确认真正的故障不在颜色选得不好,而在浏览器根本没有把这个待替换标记当成“缺失值”。
黑屏只是表面,关键是默认值有没有机会接管
这次的场景很具体:一个海报模板允许后续流程替换若干待替换标记,文字颜色通过自定义属性传给样式。模板尚未填充时,属性里留下了带花括号的标记内容。文字落在深色背景上,最终画面就是一整页发黑。
如果不处理,后果不只是某一张预览不好看。未填模板可能继续进入自动渲染,问题会在批量图片里被放大;更麻烦的是,正常值和异常值都可能被混在一起,直到人工看到成片才发现。那时再回头查,是替换失败、样式失效,还是浏览器降级规则没有触发,成本已经远高于第一次验证解析行为。
我一开始把它理解成常见的“变量为空”。但 CSS 里的空,不等于业务上的“还没填”。只有当属性值在当前语法里无效、或者变量没有提供可用值时,var() 的 fallback 才有机会接管。默认值能不能生效,先取决于浏览器怎样判断这个值,而不是我们有没有把默认值写在括号里。
错误捷径:看见 fallback 就以为有了保险
最容易误判的做法,就是只看 CSS 的表面结构:既然写成了 var(--text-color, #fff),那缺省时自然应该得到白色。这个判断把业务待替换标记直接等同于 CSS 无效值,却没有验证它经过解析后的状态。
单纯增加 fallback 的建议在这次实验中没有生效。原因是浏览器把带花括号的待替换标记视为合法的自定义属性值。它不认识这个值对业务来说代表“待替换”,但它也没有理由把任意自定义属性内容判成错误。对浏览器而言,变量有值;对模板系统而言,变量没有完成。两种判断不一致,fallback 就不会触发。
这件事让我重新意识到,很多看似简单的降级问题,本质上是边界协议没有对齐。模板系统知道花括号是标记,CSS 自定义属性却默认只知道它是一段可以保存的文本。当上游的待替换语义没有被下游的类型系统识别时,所谓 fallback 只是写在代码里的愿望。
先做最小实验,再选择修复机制
我没有继续在完整海报流程里试错,而是先把场景压缩成几个最小状态:正常颜色、带花括号的标记值、没有定义变量,以及带有 fallback 的声明。实验只看浏览器实际解析和计算后的结果,不先根据源代码猜结论。
这个顺序很重要。源代码只能说明“我们写了什么”,不能说明“浏览器接受了什么”。如果只看编辑器里的声明,容易把语法看起来完整当成行为已经成立;而最小实验可以快速回答几个关键问题:待替换值是否被拒绝,变量声明是否被视为存在,fallback 是在变量缺失时触发,还是在变量值经过类型校验失败时触发。
实验结果把问题分成了两层。第一层是普通 var() fallback,它只能处理变量没有可用替换的情况;第二层是自定义属性的类型约束,它要先告诉浏览器这个属性应该是颜色。只有这样,浏览器才有机会把花括号标记按颜色语法重新判断,并在不符合类型时退回初始值。
这一步也解释了为什么最小实验比完整渲染更值得先做。完整页面里有背景、字体、尺寸和导出环节,任何一处异常都可能制造假象;而单独观察一个自定义属性,能够把“变量有没有值”和“这个值是否符合目标类型”分开。两者一旦分开,修复就不再是猜一个颜色,而是在选择一个能让浏览器重新判断的边界。
我选择使用 @property 注册颜色语法和初始值,是因为它处理的是根因:把原本“什么文本都能装”的自定义属性,变成一个有明确类型、有明确初始状态的属性。这里不是为了增加规则,而是为了让浏览器拥有足够的信息做正确的降级。
类型注册让降级真正发生
修复后的思路是,先为颜色属性声明语法约束,并设置一个可见、安全的 initial-value;使用位置仍然保留 var() fallback。这样做不是重复,而是两层保险。类型注册负责拦截那些看起来有值、实际不符合颜色语法的待替换内容;var() fallback 则继续覆盖变量未定义等普通缺省情况。
这两层必须分清职责。若只写 @property,可能仍然没有覆盖所有变量缺失路径;若只写 var(),又无法处理浏览器把待替换标记当成合法自定义属性值的情况。真正有效的降级不是把默认值写得更多,而是让每一层都知道自己要拦截哪一种失败。
这里还有一个容易被忽略的边界:修复不能影响正常出图。模板系统的目标不是把所有值都替换成初始颜色,而是只在待替换标记没有完成替换时提供可见结果。因此我保留了正常颜色路径,没有改变已填变量的取值方式,再分别验证裸模板和正常模板。
验收不能只看测试绿灯
这次最后用了三重验收。第一重是裸开模板的可见性:不进行待替换标记替换,文字应该回到初始颜色,画面不再整页发黑。第二重是自动化测试,覆盖变量正常、缺失、标记值和降级路径,共通过12个用例。第三重是正常渲染的前后像素对比:16:9 的正常出图在修复前后像素差异为零。
三重验收分别回答不同问题。裸开验证“坏输入有没有变得可见”,单测验证“规则是否在多个状态下稳定”,像素对比验证“修复有没有偷偷改坏好输入”。如果只看单测,可能漏掉真实画面的可见性;如果只看裸开,又不能证明正常模板没有被改变。
这件事表面上是 CSS 变量问题,其实是在处理一条跨系统的判断链:模板标记、浏览器解析、自定义属性类型、最终像素。只在其中一层看起来合理,不代表整个链路已经安全。一个降级方案是否可靠,不看它能不能解释异常,而看它能不能同时保护异常输入和正常输出。
下次我会怎么做
下次遇到类似的待替换标记失效,我会先停止“再加一个默认值”的冲动,按固定流程检查。先用最小浏览器实验确认:变量是缺失、值无效,还是被当成合法文本接受;再看目标属性有没有类型约束;最后才决定使用普通 fallback、类型注册,还是两者组合。
如果问题来自自定义属性,我会优先问一句:浏览器有没有足够的信息判断它应该是什么类型?没有类型信息时,业务上的错误标记很可能只是浏览器眼中的普通字符串。此时继续堆默认值,通常只会让代码看起来更完整,却不会改变计算结果。这个判断也适用于其他可计算属性。
在交付前,我会保留正常值路径,并用裸模板可见性、自动化测试、真实渲染对比做验收。方法可以概括成一句话:先验证解析,再约束类型,最后证明降级只修坏输入、不改好输入。
这件事让我重新意识到,浏览器不是在猜我们的意图,它只会依据自己收到的语法和类型做判断。真正成熟的工作习惯,是把业务里的“未填”翻译成浏览器能够识别的“无效”,再用证据确认这个翻译没有伤及正常结果。