Server Actions是Next.js提供的一项强大能力,它允许我们在客户端组件中直接调用运行在服务端的异步函数,省去了手写API路由的步骤。但这项便利背后有一条硬性约束:所有传给Server Action的参数以及它的返回值,都必须能够被序列化后跨网络边界传输。一旦你尝试把一个Date对象、一个类实例或者一个包含函数的对象传进去,TypeScript的编译期检查或者React的运行时检查就会直接报错。这篇文章就来彻底讲清楚这套序列化约束背后的原理,以及在实际项目中如何设计类型定义来优雅地满足它。

为什么Server Actions对参数类型有严格的序列化限制
Server Actions本质上不是一个普通的函数调用,而是一次远程过程调用(RPC)。当你在客户端组件里调用一个标记了'use server'的函数时,React会把这个调用的参数通过HTTP请求发送到服务端,服务端执行完毕后再把返回值序列化回客户端。既然数据要经过网络传输,就意味着参数不能是任意合法的JavaScript值。
React针对Server Actions内置了一套序列化协议,这套协议支持的范围比JSON.stringify更广一些,但仍然有明确边界。可以直接传递的类型包括:原始类型(string、number、boolean、null、undefined、bigint)、Plain Object(通过对象字面量或Object.create(null)创建的普通对象)、数组、Map、Set、Date、以及一些特殊情况如Promise、Symbol(注册过的)。而不能传递的类型包括:类实例(比如new UserModel()产生的对象)、函数、DOM元素、Error的子类实例(部分版本支持)等。
TypeScript层面,Next.js通过React的类型定义对Server Action的参数做了静态检查。典型报错类似这样:Argument of type 'UserModel' is not assignable to parameter of type 'string | number | ... ,后面跟着一长串可序列化类型的联合定义。理解了这套机制,你就明白为什么解决办法不是简单地as any绕过去,而是要重新设计数据的传输形态。
四种实用的类型定义解决方案
方案一:定义纯数据传输对象DTO
最推荐的做法是在Server Action边界处定义专门的DTO(Data Transfer Object)类型,只包含可序列化的字段。服务端内部继续使用功能丰富的类或复杂对象,但在边界处做一次显式的转换:
// 服务端内部使用的领域模型,包含方法
class UserModel {
constructor(
public id: string,
public name: string,
public createdAt: Date,
) {}
getDisplayName(): string {
return this.name.toUpperCase();
}
}
// 定义可序列化的DTO类型,只保留数据字段
interface UserDTO {
id: string;
name: string;
// Date本身是可序列化的,这里也可以转成ISO字符串更保险
createdAt: string;
}
// Server Action的参数和返回值都用DTO类型
export async function getUser(id: string): Promise<UserDTO> {
'use server';
const user = await db.findUser(id); // 返回UserModel实例
// 边界处显式转换,剥离方法和原型链
return {
id: user.id,
name: user.name,
createdAt: user.createdAt.toISOString(),
};
}这种方案的优点是边界清晰、类型安全,DTO类型本身就起到了契约文档的作用。缺点是当模型字段很多时,手写转换函数比较繁琐,可以借助工具函数或库如class-transformer的plainToInstance和instanceToPlain来简化双向转换。
方案二:利用结构化克隆友好的类型
如果你希望尽量少写转换代码,可以在设计数据结构时就避免使用类实例。比如把方法从对象中剥离,用独立的函数去处理纯数据。函数式风格的数据建模天然与序列化兼容:
// 用接口描述数据,把行为写成独立函数
interface Product {
id: string;
price: number;
tags: string[];
}
// 行为与数据分离,函数不需要跨边界传输
function formatPrice(product: Product): string {
return `¥${(product.price / 100).toFixed(2)}`;
}
export async function createProduct(input: Product): Promise<Product> {
'use server';
// input可以直接在服务端使用,无需任何转换
const created = await db.products.create({ data: input });
return created;
}这种函数式的数据建模方式让序列化问题从源头上消失。数据就是数据,行为就是行为,两者不混在一起,跨边界传输时自然畅通无阻。
方案三:用类型工具约束可序列化性
为了在团队协作中防止有人不小心传入不可序列化的类型,可以自定义一个递归的条件类型,用它来约束Server Action的参数:
// 简化版的可序列化类型定义
type Serializable =
| string | number | boolean | null | undefined | bigint
| Date
| Serializable[]
| { [key: string]: Serializable }
| Map<Serializable, Serializable>
| Set<Serializable>;
// 用它约束Server Action参数
export async function updateSettings(
payload: Serializable
): Promise<void> {
'use server';
// 编译期就能拦住类实例、函数等非法类型
await db.settings.update(payload as Record<string, unknown>);
}这个自定义类型会递归检查对象的所有属性,一旦某个属性是函数或类实例,编译期就会报错。它比Next.js内置的报错信息更直观,也能复用在表单提交、缓存键等任何需要序列化保证的场景。
方案四:借助第三方库扩展序列化能力
某些场景下确实需要传递复杂对象,比如包含正则表达式、Date嵌套很深的第三方库返回值。这时可以引入superjson这类库,在边界处先做编码,到达对端后再解码:
import superjson from 'superjson';
export async function submitReport(rawData: string): Promise<string> {
'use server';
// 客户端先用superjson把复杂对象编码成字符串
const data = superjson.parse<ReportData>(rawData);
// ...业务处理
const result = await analyze(data);
// 返回值也编码后传回
return superjson.stringify(result);
}
// 客户端调用侧
async function handleSubmit(report: ReportData) {
const encoded = superjson.stringify(report);
const response = await submitReport(encoded);
return superjson.parse<AnalysisResult>(response);
}superjson支持Date、RegExp、undefined、NaN等JSON无法处理的类型。代价是多了一层编解码的性能开销,且边界处的类型信息需要靠泛型手动维护,适合作为兜底手段而非默认选择。
设计客户端与服务端之间的类型契约
解决序列化问题只是第一步,更重要的工程实践是建立清晰的类型契约。建议把Server Action的参数和返回值类型统一定义在一个共享文件中,比如types/actions.ts,客户端组件和服务端Action都从这里导入,避免两边类型定义漂移。
另一个常见陷阱是Next.js的缓存机制与序列化的交互。被cache包裹或参与路由缓存的返回值同样要满足序列化约束。如果你的Server Action返回了一个带有循环引用的对象,即使TypeScript没报错,运行时序列化也会失败并抛出难以定位的错误。因此在契约设计阶段就应该规定:所有跨边界的数据结构必须是树形的,不允许环状引用。
最后提醒一点,不要用as any或@ts-ignore来压制序列化类型报错。编译期被绕过的错误只会在运行时以更难排查的方式爆发,比如服务端收到一个丢失了原型链的空对象。正确面对类型约束,把它当作免费的设计评审——编译器在提醒你,这段数据需要一次显式的边界转换。养成在Server Action边界做数据转换的习惯,配合DTO模式和共享类型契约,你的Next.js全栈项目就能在类型安全和运行稳定之间取得很好的平衡。
TypeScript序列化Next.js Server Actions类型定义修改时间:2026-09-04 12:08:43