一个反复出现的漂移现场
Astro 页面有两种脚本:打包模块脚本(可 import)与 is:inline 脚本(原样输出、不打包)。后者常见于必须在首屏前执行、或刻意避免 hydration 时序问题的场景——主题应用、防闪烁脚本、页面级增强。
麻烦从这里开始:很多运行参数其实定义在 TypeScript 模块里(动效配置、成就规则表、工具示例数据),而 is:inline 脚本碰不到模块系统。于是同一份数据出现两种实现:
// src/lib/achievements.ts —— 事实 A
{ id: 'streak-3', label: '三日之约', condition: { kind: 'streakDays', atLeast: 3 } }
// 某页面 is:inline 脚本 —— 事实 B(手抄)
{ id: 'streak-3', label: '三日之约', test: (s) => s.streakDays >= 3 }
两处手抄迟早漂移:改了 lib 忘了脚本,或反过来。更隐蔽的是语义漂移——字段从 threshold: 3 改成 atLeast: 3,另一处没跟上,构建照样通过。
模式:结构化数据进 payload,函数留在消费端
服务端 setup 里 import 配置模块,把可序列化部分写进 JSON script 标签;浏览器端从 DOM 读取并解释。以成就规则为例,规则表在 lib 里定义成纯数据:
// src/lib/achievements.ts
export type AchievementCondition =
| { kind: 'completedPosts'; atLeast: number }
| { kind: 'totalSeconds'; atLeast: number }
| { kind: 'streakDays'; atLeast: number }
| { kind: 'flag'; flag: string };
export const achievementBadges = [
{
id: 'first-read',
label: '初次点亮',
description: '完成 1 篇文章阅读',
condition: { kind: 'completedPosts', atLeast: 1 },
},
// …
];
页面模板注入(set:html 对 script 内容是安全的,JSON 中已无用户输入):
<script
type="application/json"
id="achievement-badge-data"
is:inline
set:html={JSON.stringify(achievementBadges)}
></script>
内联脚本负责解释——函数不能进 JSON,但解释器只写一份:
const badgeRules = JSON.parse(
document.querySelector('#achievement-badge-data')?.textContent || '[]',
).map((badge) => ({
...badge,
test: (state) => evaluateCondition(badge.condition, state),
}));
关键约束:payload 里是 kind/atLeast 这样的判别式数据,switch 解释器只有内联脚本一处。lib 修改规则表(阈值、文案、新增徽章)无需动任何页面;只有新增条件种类时才需要在解释器里加一个 case——这正是一个显式、可测试的扩展点,而不是隐式的手抄同步。
与 define:vars 的区别
define:vars 是变量插值:把值展开成源码文本塞进脚本。它在模板阶段生成形如 const cfg = {...} 的顶层声明,在客户端路由(多次执行同一脚本体)下容易触发重复声明错误,调试时看到的产物也充满插值痕迹。JSON script 方案则把数据与代码物理分离:
- 脚本体是稳定的 IIFE,数据永远来自
textContent,客户端路由下重新初始化只是重新读一次 DOM; - 数据可以先在浏览器 DevTools 里直接查看,不需要在编译产物里找插值;
- 类型检查发生在服务端(setup 的 TS),JSON 结构错误在构建期就暴露。
两者都合理,但“传数据”这件事上,payload 方案在动态化页面里更稳。
工程细节清单
- fallback 要有,且要小。 payload 节点丢失或被篡改时,脚本应回退到“最保守可用”的内置子集,而不是整段功能死亡。
- 给 JSON script 固定
id。 便于 DevTools 检查,也便于测试里断言注入成功。 - 解释器函数保持纯函数。 上面
evaluateCondition(condition, state) => boolean是纯函数,可直接在 Node 层为 lib 的数据驱动版本写单测。 - 别把秘密放进 payload。 JSON script 会原样出现在 HTML 源码里,只放本就公开的数据;页面内联脚本读的是 DOM,不是运行时权限。
小结
“服务端知道,浏览器也要知道”的数据,优先设计成可序列化的判别式数据 + 单一解释器。lib 是事实源,HTML 是事实快照,页面脚本只是快照的阅读者。手抄一遍看似省五分钟,漂移一次赔一整个重构。