NextJS 14 Server Actions接收文件返回文件报错问题咨询
问题
使用NextJS 14构建应用时,尝试通过Server Actions实现一个接收两个Excel文件、返回处理后文件的表单,触发了以下错误:
Error: Only plain objects, and a few built-ins, can be passed to Client Components from Server Components. Classes or null prototypes are not supported
相关代码如下:
CMSForm.tsx
'use client' import { useTranslations } from 'next-intl' import { useFormState } from 'react-dom' import { transformExcel, State } from './transformExcel' export default function CMSForm() { const t = useTranslations('tools.cms') const initialState: State = { message: null, errors: {} } const [state, dispatch] = useFormState(transformExcel, initialState) return ( <form action={dispatch} className='flex flex-col justify-center p-4 gap-4 border rounded bg-white dark:bg-zinc-900/50' > <fieldset className='flex gap-4 items-start justify-center'> <div className='flex flex-col'> <label htmlFor='file' className='font-semibold'> {t('fileToUpload')} </label> <input type='file' id='file' name='file' className='p-2' aria-describedby='file-error' accept='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' /> <div id='file-error' aria-live='polite' aria-atomic='true'> {state?.errors?.file && state.errors.file.map((error: string) => ( <p className='mt-2 text-sm text-red-500' key={error}> {error} </p> ))} </div> </div> <div className='flex flex-col'> <label htmlFor='template' className='font-semibold'> {t('outputTemplate')} </label> <input type='file' id='template' name='template' className='p-2' aria-describedby='template-error' accept='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' /> <div id='template-error' aria-live='polite' aria-atomic='true'> {state?.errors?.template && state.errors.template.map((error: string) => ( <p className='mt-2 text-sm text-red-500' key={error}> {error} </p> ))} </div> </div> </fieldset> <button type='submit' className='w-56 mx-auto bg-blue-500 text-white py-2 rounded'> {t('convertToCMS')} </button> </form> ) }
服务器动作代码(transformExcel.ts)
'use server' import { z } from 'zod' import { generateCMSFile } from './CMSExcelService' const MAX_FILE_SIZE = 5000000 const ACCEPTED_FILE_TYPE = 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' const TextExtractionSchema = z.object({ file: z .instanceof(File) .refine((file) => file?.size <= MAX_FILE_SIZE, { message: 'Please upload a file smaller than 5MB.' }) .refine((file) => file.type === ACCEPTED_FILE_TYPE, { message: 'Only .xlsx format are supported.' }), template: z .instanceof(File) .refine((file) => file?.size <= MAX_FILE_SIZE, { message: 'Please upload a file smaller than 5MB.' }) .refine((file) => file.type === ACCEPTED_FILE_TYPE, { message: 'Only .xlsx format are supported.' }) }) export type State = { errors?: { file?: string[] template?: string[] } message?: string | null } export async function transformExcel(_prevState: State | undefined, formData: FormData) { const validatedFields = TextExtractionSchema.safeParse({ file: formData.get('file'), template: formData.get('template') }) if (!validatedFields.success) { return { errors: validatedFields.error.flatten().fieldErrors, message: 'Missing Fields. Failed to Create Invoice.' } } const { file, template } = validatedFields.data try { const transformedFile = await generateCMSFile({ excel: file, template }) const fileName = transformedFile.headers['content-disposition']?.split('filename=')[1] const downloadFile = new Blob([transformedFile.data]) // 此处尝试返回文件时触发错误 } catch (error) { return { message: 'Failed to transform excel file.' } } }
解决方案
错误原因
Server Actions的返回值会在Server Component和Client Component之间传递,NextJS仅支持传递纯对象、基础数据类型(字符串/数字/布尔值),Blob、File这类带原型的类实例不在允许范围内,这就是报错的核心原因。
推荐方案:改用Route Handler
Route Handler可以直接控制HTTP响应头,适合返回文件流,是处理文件下载场景的最优解,步骤如下:
1. 创建API路由
新建app/api/cms-transform/route.ts,处理文件上传、验证、生成和返回:
import { z } from 'zod' import { generateCMSFile } from '@/services/CMSExcelService' import { NextResponse } from 'next/server' const MAX_FILE_SIZE = 5000000 const ACCEPTED_FILE_TYPE = 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' const TextExtractionSchema = z.object({ file: z .instanceof(File) .refine(file => file.size <= MAX_FILE_SIZE, '文件不能超过5MB') .refine(file => file.type === ACCEPTED_FILE_TYPE, '仅支持.xlsx格式'), template: z .instanceof(File) .refine(file => file.size <= MAX_FILE_SIZE, '文件不能超过5MB') .refine(file => file.type === ACCEPTED_FILE_TYPE, '仅支持.xlsx格式'), }) export async function POST(request: Request) { try { const formData = await request.formData() const validated = TextExtractionSchema.safeParse({ file: formData.get('file'), template: formData.get('template'), }) if (!validated.success) { return NextResponse.json( { errors: validated.error.flatten().fieldErrors, message: '验证失败' }, { status: 400 } ) } const { file, template } = validated.data const transformedFile = await generateCMSFile({ excel: file, template }) const fileName = transformedFile.headers['content-disposition']?.split('filename=')[1] || 'transformed.xlsx' // 直接返回文件流,设置下载响应头 return new NextResponse(transformedFile.data, { headers: { 'Content-Disposition': `attachment; filename="${fileName}"`, 'Content-Type': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', }, }) } catch (error) { return NextResponse.json({ message: '文件处理失败' }, { status: 500 }) } }
2. 修改客户端表单组件
去掉useFormState,改用手动处理表单提交和下载:
'use client' import { useTranslations } from 'next-intl' import { useState } from 'react' type ErrorState = { file?: string[] template?: string[] } export default function CMSForm() { const t = useTranslations('tools.cms') const [errors, setErrors] = useState<ErrorState>({}) const [message, setMessage] = useState<string | null>(null) const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => { e.preventDefault() setErrors({}) setMessage(null) const formData = new FormData(e.currentTarget) const response = await fetch('/api/cms-transform', { method: 'POST', body: formData, }) if (!response.ok) { const data = await response.json() setErrors(data.errors || {}) setMessage(data.message || '请求失败') return } // 触发浏览器下载 const blob = await response.blob() const url = window.URL.createObjectURL(blob) const a = document.createElement('a') a.href = url const contentDisposition = response.headers.get('Content-Disposition') const fileName = contentDisposition?.split('filename=')[1] || 'transformed.xlsx' a.download = fileName document.body.appendChild(a) a.click() window.URL.revokeObjectURL(url) document.body.removeChild(a) setMessage('文件下载成功') } return ( <form onSubmit={handleSubmit} className='flex flex-col justify-center p-4 gap-4 border rounded bg-white dark:bg-zinc-900/50' > <fieldset className='flex gap-4 items-start justify-center'> <div className='flex flex-col'> <label htmlFor='file' className='font-semibold'> {t('fileToUpload')} </label> <input type='file' id='file' name='file' className='p-2' aria-describedby='file-error' accept='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' /> <div id='file-error' aria-live='polite' aria-atomic='true'> {errors.file && errors.file.map((error: string) => ( <p className='mt-2 text-sm text-red-500' key={error}> {error} </p> ))} </div> </div> <div className='flex flex-col'> <label htmlFor='template' className='font-semibold'> {t('outputTemplate')} </label> <input type='file' id='template' name='template' className='p-2' aria-describedby='template-error' accept='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' /> <div id='template-error' aria-live='polite' aria-atomic='true'> {errors.template && errors.template.map((error: string) => ( <p className='mt-2 text-sm text-red-500' key={error}> {error} </p> ))} </div> </div> </fieldset> {message && <p className='text-center text-sm'>{message}</p>} <button type='submit' className='w-56 mx-auto bg-blue-500 text-white py-2 rounded'> {t('convertToCMS')} </button> </form> ) }
备选方案:Server Actions返回临时下载链接
如果一定要保留Server Actions,可以在服务器生成文件后保存到临时目录(或云存储),返回包含下载链接的纯对象,客户端收到后触发下载,最后清理临时文件(需额外处理)。但此方案需要维护临时文件生命周期,不如Route Handler直接高效。
内容的提问来源于stack exchange,提问作者Aw3same
相关产品推荐
相关产品推荐

