You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

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

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.07.04 01:54:53