后端前端【免费下载链接】freshThe framework so simple, you already know it.项目地址https://gitcode.com/gh_mirrors/fr/fresh点击查看免费下载当 Fresh 在服务端渲染页面时Island 组件的 props 必须被序列化为 JSON 并随 HTML 发送到浏览器供客户端水合hydration使用。由于标准JSON.stringify只能处理有限的类型Fresh 实现了一套自研的序列化系统支持Date、URL、RegExp、Set、Map、bigint、Signal乃至Temporal.*等远超 JSON 标准能力的数据类型。读完本文你将掌握 Fresh 序列化支持的完整类型清单、底层编码格式与引用去重原理、Signal/Computed 信号的跨端重建机制以及在实际开发中规避 props 序列化失败的正确姿势。为什么 Fresh 需要一套自定义序列化系统Fresh 的渲染模型是服务端渲染 客户端水合服务端把页面 HTML 渲染出来同时把每个 Island 的 props 序列化后内联进页面脚本浏览器端再以此恢复组件的初始状态。这个流程对序列化能力提出了比标准 JSON 更高的要求标准JSON.stringify无法表达undefined、NaN、Infinity、-0等特殊值会被转为null或丢失Date会被转成 ISO 字符串、Map/Set被转成空对象水合后无法还原成原类型bigint直接抛错更关键的是无法保留对象引用关系——同一个对象在 props 树中出现两次时标准 JSON 会复制两份客户端得到的是两个互不相同的对象。Fresh 的解决方案位于 packages/fresh/src/jsonify/ 目录由两个核心文件构成stringify.ts服务端序列化把任意值编码成字符串parse.ts客户端反序列化把字符串还原为对应的 JavaScript 值。二者的注释文档明确列出了完整支持清单null、undefined、布尔值、数字、bigint、字符串、数组、无原型对象、Uint8Array、URL、Date、RegExp、Set、Map以及Temporal命名空间下的 8 种日期时间类型。同时支持循环引用circular references且同一引用只会被序列化一次。支持的数据类型一览官方文档给出了可以作为 Island props 传递的完整类型清单结合源码实现整理如下类型说明string、number、boolean基础原始类型直接编码null、undefined分别用专用标记表示bigint以[BigInt,值]标签数组编码NaN、Infinity、-Infinity、-0特殊数值有独立编码标记Array包括稀疏数组空洞用HOLE标记普通对象字符串键 可序列化值不带自定义原型Date序列化为 ISO 字符串还原为Date实例URL序列化为href字符串RegExp同时保留source与flagsSet元素必须可序列化Map键与值都必须可序列化Uint8Array二进制数据以 base64 编码传输Signal来自preact/signals详见 SignalsComputed Signal只读信号Temporal.*Instant、ZonedDateTime、PlainDate、PlainTime、PlainDateTime、PlainYearMonth、PlainMonthDay、DurationJSX 元素服务端渲染后作为 slot 传给 Island底层编码格式索引表 特殊值标记Fresh 的序列化输出不是普通 JSON 对象而是一个 JSON 数组。理解这一点是读懂整个系统的钥匙。stringify.ts 的stringify函数把整个 props 树摊平成一张索引表export function stringify(data: unknown, custom?: Stringifiers): string { const out: string[] []; const indexes new Mapunknown, number(); const res serializeInner(out, indexes, data, custom); if (res 0) { return String(res); } return [${out.join(,)}]; }每个首次遇到的值都会被分配一个递增的数组下标后续再遇到同一个值或循环引用回指时只写入它已有的下标数字。这就是引用去重的实现基础详见下文引用去重与循环引用。特殊值则使用 constants.ts 中定义的负整数标记与正常的下标区间 0天然区分export const UNDEFINED -1; export const NULL -2; export const NAN -3; export const INFINITY_POS -4; export const INFINITY_NEG -5; export const ZERO_NEG -6; export const HOLE -7; // 稀疏数组的空洞在 parse.ts 的unpack函数中这几个负整数被逐一还原为对应的原始值——-0通过ZERO_NEG精确还原为负零NaN也不会被丢失。这也解释了为什么 props 中可以放心传递NaN和±Infinity而不会像标准 JSON 那样被静默改写。基础类型的编码规则见 stringify.tsnumber、boolean直接String(value)bigint[BigInt,数字]stringJSON.stringify(value)数组递归序列化每个元素稀疏数组的空洞位置写入HOLE标记。复杂类型如何编码标签数组tagged array格式对于Date、URL等复杂类型序列化器采用标签数组格式数组的第一个元素是类型名后续元素是还原所需的数据。在 parse.ts 中可以看到完整的还原分支类型序列化格式反序列化方式bigint[BigInt,值]BigInt(str)URL[URL,href]new URL(href)Date[Date,ISO 字符串]new Date(iso)RegExp[RegExp,source,flags]new RegExp(source, flags)Uint8Array[Uint8Array,base64]base64 解码Set[Set,[元素下标...]]逐个还原后new Set()Map[Map,[键下标,值下标...]]交替还原键值对后new Map()Temporal.*[Temporal.X,字符串]Temporal.X.from(str)几个值得注意的实现细节Date有异常兜底序列化时如果toISOString()抛错即Invalid Date会编码为字符串Invalid Date反序列化时new Date(Invalid Date)依旧得到无效日期对象保证往返一致stringify.tsUint8Array走 base64底层用b64encode把底层ArrayBuffer编码为 RFC4648 的 base64 字符串stringify.ts解析端用atob还原parse.ts二进制数据不会因文本编码而损坏RegExp同时保留 flags[RegExp,source,flags]使得/foo[]/gi这类带特殊字符和修饰符的正则也能无损还原Temporal类型做了运行时探测序列化前会先检查typeof Temporal ! undefined避免在不支持 Temporal 的环境下直接访问造成 ReferenceErrorstringify.ts。引用去重与循环引用Fresh 处理循环引用是自动的如果同一个对象或信号在 props 树中出现多次它只会被序列化一次客户端会恢复出指向同一个对象的引用关系。const shared { value: 42 }; const data { a: shared, b: shared }; // 客户端上 data.a 与 data.b 指向同一个对象 MyIsland data{data} /;底层原理就在 stringify.ts 的serializeInner中const seenIdx indexes.get(value); if (seenIdx ! undefined) return seenIdx; // ...首次遇到分配新下标并写入 outindexes是一个Mapunknown, number对象引用本身作为键。任何值在编码前先查表已存在则直接返回其下标引用回指否则分配新下标并继续展开。这正是round_trip_test.ts中references与circular两个测试用例所验证的行为const inner { foo: 123 }; const references { a: inner, b: [inner, inner] }; // 同一对象出现 3 次 const circular { a: 1, b: null as unknown }; circular.b circular; // 自引用round_trip_test.ts 对每一个测试值执行stringify → parse往返并用toEqual深度断言还原结果与原始值完全相等。整个TESTS数组round_trip_test.ts涵盖了上表所有支持类型包括new Date(Invalid Date)、-0、NaN、[1, , 3]稀疏数组、带特殊字符的正则以及全部 8 种Temporal类型——这是理解哪些类型能安全传递最直接的源码级答案。对应的客户端解析在 parse.ts 中同样有还原缓存if (idx in hydrated) return hydrated[idx]; // 已还原过的下标直接复用hydrated数组保证同一个下标只会被还原一次之后所有引用点都拿到同一个实例。Signal 与 Computed 的跨端重建服务端读取当前值在服务端渲染阶段preact_hooks.ts 定义了传给stringify的自定义序列化器const stringifiers: Stringifiers { Computed: (value: unknown) { return isComputedSignal(value) ? { value: value.peek() } : undefined; }, Signal: (value: unknown) { return isSignal(value) ? { value: value.peek() } : undefined; }, Slot: (value: unknown) { /* JSX slot 相关见下文 */ }, };关键行为序列化的是信号在服务端的当前值通过.peek()读取不建立订阅而不是信号对象本身。isSignal通过鸭子类型判断——对象上有peek函数且存在value属性preact_hooks.tsisComputedSignal进一步通过内部字段x或_fn区分 computed 信号preact_hooks.ts。客户端重建为响应式信号在浏览器端reviver.ts 定义了对应的自定义解析器export const CUSTOM_PARSER: CustomParser { Signal: (value: unknown) signal(value), Computed: (value: unknown) computed(() value), Slot: (value: { name: string; id: number }): SlotRef { return { kind: SLOT_SYMBOL, name: value.name, id: value.id }; }, };普通Signal值被包进一个新的signal()在客户端创建出活的响应式信号——之后在浏览器里对它的任何修改都会正常触发组件更新Computed 信号由于原始计算函数无法跨端传输客户端只能把它重建为computed(() value)——一个持有静态值的只读信号即原始计算逻辑已丢失只剩当前快照。同一信号跨多个 Island 共享文档强调如果同一个信号对象被传给多个 Island它只会被序列化一次客户端上所有 Island 会收到同一个信号实例从而保持同步。这得益于上文介绍的索引去重机制——同一个信号对象在 props 树中只会获得一个下标客户端parse时对该下标只还原一次。custom_test.ts 的referenced Signals测试直接验证了这一行为const s signal(2); expect(stringify([s, s], { Signal: (s2: unknown) s2 instanceof Signal ? { value: s2.peek() } : undefined, })).toEqual( [[1,1],[Signal,2],2], );输出[[1,1],[Signal,2],2]中[1,1]表示数组的两个元素都引用下标1而下标1处才是唯一的[Signal,2]定义——同一信号确实只编码了一次。该测试文件还覆盖了信号值为null、undefined等边界场景custom_test.ts。JSX 元素作为 propsslot 机制把服务端渲染的 JSX 元素作为 props 传给 Island 是 Fresh 的特殊能力。实现上它并不走数值序列化而是由渲染阶段的 patcher 特殊处理preact_hooks.tsif ( name children || (isValidElement(value) !isSignal(value)) ) { const slotId RENDER_STATE!.slots.length; RENDER_STATE!.slots.push({ id: slotId, name, vnode: value }); props[name] h(Slot, { name, id: slotId }, value); }凡是children或其他 JSX 元素 prop都会被替换成一个Slot占位组件并登记到渲染状态中服务端最终把 slot 的 vnode 渲染进带idfrsh-id-name的template元素里preact_hooks.ts客户端水合时再通过domToVNode把这些服务端渲染好的 DOM 还原为 vnode 注入 Islandreviver.ts。不可序列化的类型以下类型不能作为 Island props 传递函数与闭包可执行代码无法传输。序列化器遇到函数会直接抛错stringify.ts错误消息为Serializing functions is not supported.stringify_test.ts 中有对应测试类实例只有无原型的普通对象受支持。类实例序列化时会丢失原型客户端还原出的是普通对象SymbolJSON 无法表示WeakMap / WeakSet键是弱引用无法枚举遍历Stream、Promise异步值无法在传输时冻结。// 错误示范——函数无法序列化会触发运行时错误 MyIsland onClick{() console.log(clicked)} / // 错误示范——类实例会丢失原型 MyIsland data{new MyCustomClass()} /另外值得注意普通对象序列化时会走Object.keys枚举stringify.tsstringify_test.ts 验证了对象字面量里__proto__这类特殊键会被忽略只保留自有可枚举属性。常见陷阱与最佳实践不小心传入不可序列化的 props向 Island 传入函数或类实例会在序列化阶段触发运行时错误。正确的做法是保持 props 为纯数据把事件处理逻辑放在 Island 内部// 不要传回调…… MyIsland onSave{handleSave} / // ……而是传数据在 Island 内部处理事件 MyIsland itemId{item.id} /类似地如果需要在组件间共享行为优先考虑把共享逻辑抽取为客户端模块由 Island 直接 import而不是试图把函数塞进 props。过大的 props序列化后的 props 每个字节都会内联进 HTML并随页面一起被客户端解析preact_hooks.ts 中FreshRuntimeScript会把stringify的结果拼进boot()调用脚本。大 props 的直接代价是HTML 体积膨胀首屏传输变慢客户端需要解析更大的 JSON水合耗时增加。最佳实践是保持 props 精简只传 ID 或最小化的数据其余数据在客户端按需获取例如在 Island 的useEffect中请求接口。小心服务端与客户端的环境差异由于信号在服务端只传输当前值快照如果信号的值在服务端渲染之后、水合之前被修改客户端拿到的仍是旧快照。对于依赖实时数据如用户登录态、时区的场景应在 Island 挂载后主动同步最新状态而不是依赖 props 里的初始快照。从源码验证序列化链路全览整个序列化链路在源码中清晰可循收集渲染阶段preact_hooks.ts 把每个 Island 的 props 压入RENDER_STATE.islandProps编码FreshRuntimeScript调用stringify(islandProps, stringifiers)preact_hooks.ts 与 L758-L759产出序列化字符串后内联进script typemodule或 partial 响应的__FRSH_STATE_JSON 中传输序列化结果以 HTML 内联脚本形式到达浏览器解码客户端 reviver.ts 调用parse(islandProps, CUSTOM_PARSER)还原 props再经revive函数把 props 交给 Preact 完成水合。想深入验证每种类型的往返行为可以直接阅读 round_trip_test.ts覆盖全部支持类型的完整往返与 custom_test.tsSignal/Computed/自定义类型的编解码用deno test packages/fresh/src/jsonify/即可在本地运行这些测试。总结Fresh 的序列化系统用索引表 标签数组 负整数标记三种编码手段在标准 JSON 之上扩展出了完整的类型体系特殊数值、bigint、二进制、正则、日期、集合映射、Temporal 类型乃至响应式信号都能无损跨端传输同时通过引用去重自动处理共享引用与循环引用。理解这套机制既能帮助你放心地在 Island props 中传递各种数据类型也能让你在遇到序列化错误或性能问题时快速定位是类型不支持还是props 过大。赞分享后端前端【免费下载链接】freshThe framework so simple, you already know it.项目地址https://gitcode.com/gh_mirrors/fr/fresh点击查看免费下载相关推荐跨平台调用系统命令Dart FFI 的 gh_mirrors/samples4/samples system_command 示例详解跨平台调用系统命令Dart FFI 的 gh_mirrors/samples4/samples system_command 示例详解 在 Dart 生态中Neko 如何启用文件传输插件在客户端与服务器之间传文件Neko 如何启用文件传输插件在客户端与服务器之间传文件 Neko V3 把 V2 时代的 GET /file 文件传输功能整体移到了内置的 File Tra后端音视频BrewUI对比第三方GUI工具为什么Homebrew官方界面更值得用BrewUI对比第三方GUI工具为什么Homebrew官方界面更值得用 BrewUI是Homebrew官方推出的macOS图形界面GUI应用一个专为ma桌面应用开发工具上一篇WPF中的拖放操作HandyControl的DragDropEffects终极指南下一篇JavaParser完全指南如何快速掌握Java代码解析利器 创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考