Fresh序列化机制揭秘:Islands间传递Props的JSON编解码原理
在 Fresh 框架中,Islands(岛屿)组件运行在浏览器,而页面的数据与 Props 诞生于服务器端。两者之间隔着一条"网络边界"——服务器如何把组件属性无损地传给客户端?答案藏在 Fresh 专门实现的 JSON 序列化机制里。本文将用通俗的方式,带你快速看懂这套编解码原理,以及它如何让 Signal、日期、循环引用等特殊数据都能安全"过桥"。
为什么原生 JSON.stringify 不够用?
🤔 先想一个问题:JSON.stringify 是我们最常用的序列化工具,Fresh 直接用它不行吗?
其实不行。原生 JSON 在以下场景会"掉链子":
| 数据类型 | JSON.stringify 的表现 | Fresh jsonify 的表现 |
|---|---|---|
undefined | 字段直接消失 | 保留为占位标记 |
Date | 变成字符串,还原不了 | 可还原为 Date 对象 |
NaN / Infinity | 变成 null | 各自有专属占位符 |
Set / Map | 变成 {} | 完整保留键值 |
| 循环引用 | 直接抛错 | 支持,同一对象只编码一次 |
Fresh 的解决方案是内置一个独立的 jsonify 模块,位于 jsonify/ 目录,核心就三个文件:
- 编码器:stringify.ts
- 解码器:parse.ts
- 特殊值常量表:constants.ts
配套还有 round_trip_test.ts 等测试文件,验证"编码→解码"往返后数据完全一致。
编码原理:扁平数组 + 索引引用 🔁
Fresh 序列化后的产物看起来像一个数组,例如 [{"name":"Fresh","count":1}]。它有两个关键设计:
1️⃣ 负数索引当"特殊值占位符"
原始数字和负数不会同时出现在同一层面,于是 Fresh 用负数编码"无法用 JSON 表达"的值(见 constants.ts):
-1→undefined-2→null-3→NaN-4/-5→ 正负无穷-6→ 负零-7→ 数组"空洞"
2️⃣ 每个唯一值只编码一次,之后用索引引用
编码时维护一张"已见对象"索引表:对象第一次出现时被完整写入,再次出现(哪怕在完全不同的位置)只写入一个数字索引。这个设计带来两个好处:
- ⚡ 省空间:同一份数据在多个 Props 间共享时不重复传输
- 🔄 天然支持循环引用:引用先于定义出现也没关系,解码时再回溯
对于 Date、URL、RegExp、BigInt、Uint8Array(Base64 编码)等结构化类型,则使用"标签数组"格式,如 ["Date","2026-01-01T00:00:00.000Z"],解码端按第一个字符串标签分派还原逻辑。
服务端:Props 如何嵌入 HTML 🚚
真正触发序列化的是 SSR 渲染阶段。在 preact_hooks.ts 中,服务器做三件事:
- 收集:渲染页面时,把每个岛屿的 Props 依次压入
islandProps数组 - 打标:给每个岛屿的 HTML 前后包裹注释标记
frsh:island:岛屿名:propsIdx:key,其中的propsIdx就是该岛屿 Props 在数组里的下标 - 注入:调用
stringify(islandProps, stringifiers)把整个 Props 数组压成一段紧凑字符串,随内联脚本boot(...)一起写入 HTML
这里还注册了自定义字符串化器,处理框架专属类型:
Signal→ 序列化为它当前的值(调用peek())Computed→ 同样取当前值Slot→ 序列化为{name, id}的轻量描述,而不是整个虚拟节点
⚠️ 注意:函数(Function)无法序列化,传入会被明确报错——这是所有 SSR 框架的共同边界。
对于局部更新(Partial)场景,序列化结果会被写进独立的 <script type="application/json"> 标签中,结构如下图所示的"分区替换"思路一致:
客户端:一键复活 Props ⚡
浏览器加载页面后,客户端引导代码 reviver.ts 的 boot() 函数接管:
- 扫描 DOM,找到所有
frsh:island:...注释标记,读出岛屿名和propsIdx - 解码:用
parse(字符串, CUSTOM_PARSER)把序列化的 Props 还原成完整的 JS 对象 - 重建响应式:解码时通过自定义解析器"复活"特殊类型——
Signal重新变成signal(),Computed变成computed(),Slot变成指向 DOM 片段的引用 - 渲染:把还原好的 Props 交给岛屿组件,完成激活
也就是说,你在服务端传给岛屿的 count Signal,客户端拿到的还是一个真正可响应更新的 Signal,而不是死掉的数字。
实战要点清单 ✅
| 场景 | 建议 |
|---|---|
| 传递函数 | ❌ 不可行,请改为传数据 |
| 传递 Signal / Computed | ✅ 自动取当前值并在客户端重建 |
| 多个岛屿共享同一对象 | ✅ 自动去重,只传输一份 |
| 传递 Date、URL、Set、Map | ✅ 原生支持,无需转换 |
| 传递 Uint8Array | ✅ 自动 Base64 编解码 |
想亲手验证编解码行为,可以阅读 custom_test.ts 中的自定义类型示例;更多岛屿相关概念可参考官方文档 docs/latest/concepts/islands.md 与 docs/latest/advanced/serialization.md。
总结
Fresh 的序列化机制看似只是"换了一个 JSON 编码器",实则是岛屿架构的隐形基石:扁平数组加索引引用解决了去重与循环引用,负数占位符补齐了 JSON 的类型盲区,标签数组让结构化数据无损往返,自定义字符串化器则把框架特有的 Signal 与 Slot 也纳入传输范围。理解这套编解码原理后,你就能明白为什么在 Fresh 中"往岛屿里传什么"比想象中自由得多——也更能写出高性能的服务端渲染应用。
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考





