服务端渲染把组件编译成HTML字符串,同时把初始状态序列化成JSON嵌入页面。当浏览器执行水合脚本时,这个JSON被反序列化并注入客户端应用。问题在于,TypeScript中的接口描述的是理想中的数据结构,而JSON只能表达字符串、数字、布尔值、数组和普通对象。Date对象会变成ISO字符串,Map和Set会退化为普通对象或数组,undefined和NaN甚至会直接消失。如果客户端代码仍然用原来的interface去读取数据,类型检查虽然通过,运行时却会访问到不存在的原型方法。所以,定义支持水合过程的数据类型,核心不是让TypeScript相信JSON能保留一切,而是诚实地划分传输层与运行时层的类型边界。

先定义传输类型,而不是直接复用领域模型
很多团队习惯把服务端的领域模型类型直接导入客户端,例如User类或包含Date的interface。这在纯前端应用里没问题,但在SSR中,服务端到客户端的通道是JSON序列化,任何非JSON原生类型都会被强制转换。更稳妥的做法是单独定义一套传输类型(Wire Type或DTO),它只包含JSON能表达的数据结构。例如,服务端有一个User领域类型,其中createdAt是Date,roles是Set<string>,那么在传输类型中,createdAt应该是string,roles应该改成string[]。这样客户端拿到的数据才能真正匹配类型声明。
传输类型与领域类型分离,也带来了额外的转换成本。你需要在服务端序列化前把领域类型转换成传输类型,在客户端水合后再从传输类型恢复成领域类型。这不只是类型层面的问题,更影响到代码架构。但好处是,类型系统会强制你处理Date、Map等边界情况,而不是等到生产环境报错。代码示例:
// 服务端领域模型
interface User {
id: string;
name: string;
createdAt: Date;
roles: Set<string>;
}
// 传输类型:只包含JSON可表达的数据
interface UserDTO {
id: string;
name: string;
createdAt: string; // ISO 字符串
roles: string[];
}
// 服务端转换函数
function toUserDTO(user: User): UserDTO {
return {
id: user.id,
name: user.name,
createdAt: user.createdAt.toISOString(),
roles: Array.from(user.roles),
};
}
这个例子表明,传输类型不是简单地把领域模型中的Date换成string,它要求明确所有可能被JSON破坏的结构。如果你在传输类型中依然写着Date,TypeScript不会报错,但水合后的数据其实是字符串,类型系统就失去了意义。因此,定义传输类型是水合安全的第一步。
用映射类型自动推导可序列化类型
手动为每个领域模型编写DTO虽然直观,但模型一多就容易遗漏。TypeScript的映射类型和条件类型可以帮我们自动生成传输类型。核心思路是:遍历对象属性,遇到Date就替换为string,遇到Map替换为数组,遇到Set替换为数组,遇到函数或undefined则剔除或改成可空。下面是一个简化版的Serialize工具类型:
type Serialize<T> = {
[K in keyof T]: T[K] extends Date
? string
: T[K] extends Map<infer MK, infer MV>
? Array<[MK, MV]>
: T[K] extends Set<infer SV>
? SV[]
: T[K] extends Function
? never
: T[K] extends object
? Serialize<T[K]>
: T[K];
};
// 使用
interface Post {
title: string;
publishedAt: Date;
tags: Set<string>;
metadata: Map<string, number>;
}
type PostDTO = Serialize<Post>;
// 结果:{ title: string; publishedAt: string; tags: string[]; metadata: Array<[string, number]> }
这类工具类型的优势在于,当领域模型新增字段时,传输类型会自动更新,减少手工同步。但需要注意,这个Serialize类型只处理了浅层的Date、Map、Set,嵌套对象通过递归处理,数组中的Date则没有覆盖,需要额外补充数组的处理规则。实际项目中,可以借助递归条件类型完善。还有一个陷阱:可选属性和null值在JSON中的表现不同,JSON.stringify会把undefined省略,但null保留。如果你希望客户端类型保持可选性,需要在Serialize中保留可选标记,这需要使用更复杂的映射方式,比如同态映射类型。
自动推导虽然方便,但不应完全依赖它作为运行时保证。编译期类型在打包后就被擦除,水合数据是否符合预期仍需要运行时验证。不过,将Serialize类型作为基础,可以显著减少类型定义重复,并让服务端与客户端共享同一套类型推导逻辑,从源头减少类型漂移。
客户端水合后必须做运行时类型恢复
即使传输类型定义得再精确,客户端拿到的仍然只是普通对象。如果你需要Date实例来调用getFullYear等方法,就必须在客户端执行反序列化转换。这个步骤通常放在应用入口或状态初始化阶段。一个做法是编写针对传输类型的恢复函数,例如:
function hydrateUser(dto: UserDTO): User {
return {
id: dto.id,
name: dto.name,
createdAt: new Date(dto.createdAt),
roles: new Set(dto.roles),
};
}
// 在客户端初始化时调用
const initialUser = hydrateUser(window.__INITIAL_STATE__.user);
这里的hydrateUser函数将字符串转换回Date,将数组转回Set。如果服务端与客户端代码分离,这个函数需要手动维护。更健壮的方式是使用运行时校验库(如Zod、io-ts)来同时完成校验和转换。Zod的schema可以声明输出类型,从而让TypeScript推断出正确的客户端类型。例如:
import { z } from 'zod';
const UserSchema = z.object({
id: z.string(),
name: z.string(),
createdAt: z.string().datetime().transform((value) => new Date(value)),
roles: z.array(z.string()).transform((value) => new Set(value)),
});
type User = z.infer<typeof UserSchema>;
const user = UserSchema.parse(window.__INITIAL_STATE__.user);
// user.createdAt 是 Date,user.roles 是 Set<string>
使用Zod这类库,可以把传输类型、运行时校验和客户端领域类型三者合一。它的transform能力让你在解析阶段就把字符串转成Date,并且输出类型被TypeScript正确推断。这比手动编写hydrate函数更安全,因为解析失败会抛错,还能提供明确的错误信息。但要权衡的是,Zod会引入额外的运行时依赖和包体积,对于轻量项目,手动转换配合类型守卫也可以接受。关键是,客户端不能默认JSON解析结果已经符合领域模型,必须有一层显式转换。
区分SSR初始状态与后续API请求的数据类型
SSR水合只发生在首屏,客户端接管后,后续数据请求通常由fetch或axios发起,返回的数据也是JSON。很多人误以为水合阶段的问题只出现在初始状态,其实后续API请求同样需要区分传输类型和客户端类型。如果你在API层直接返回领域模型,TypeScript也会给出错误的类型假设。比较一致的做法是:所有跨网络边界的数据都使用传输类型,API函数返回Promise<DTO>,然后由客户端仓库层调用转换函数得到领域模型。
以React Query或SWR为例,查询函数返回的数据类型应该定义为DTO,然后再通过select或二次处理转换为客户端模型。例如,一个获取文章的API返回PostDTO,其中publishedAt是string,客户端组件如果需要显示相对时间或格式化日期,就应当在数据层完成Date转换。这样类型定义才能在各个层次保持一致。如果你没有做这层区分,组件里到处使用new Date(post.publishedAt)虽然能工作,但类型上post.publishedAt是string,每次使用都要手动转换,既容易遗漏也失去类型保护。
更进一步,可以设计一个通用的API类型包装器,比如:
type ApiResponse<T> = {
data: T;
error: string | null;
};
// 领域模型
type Post = {
id: string;
title: string;
publishedAt: Date;
};
// 传输类型
type PostDTO = Serialize<Post>;
// API层返回传输类型
async function fetchPost(id: string): Promise<ApiResponse<PostDTO>> {
const res = await fetch(`/api/posts/${id}`);
return res.json();
}
这个模式把序列化边界固化在API层,客户端在拿到PostDTO后统一调用hydratePost转换为Post。这样无论首屏水合还是后续请求,数据类型定义都遵循同一套规则。总结起来,支持SSR水合过程的客户端数据类型,不应只是把interface复制两份,而应从序列化边界、自动推导、运行时验证和架构分层四个角度共同解决。TypeScript能提供强大的类型工具,但真正可靠的水合需要你在代码中明确每一步转换。
TypeScriptSSR水合客户端数据类型修改时间:2026-10-05 19:59:52