react Next.js oss上传 上传阿里云

一、上传功能的整体流程

整个上传流程可以分为前端选择文件、服务端获取 OSS 配置、服务端上传文件、返回文件地址四个步骤。

简单流程如下:

复制代码
用户选择文件

-> 前端生成本地预览

-> 点击上传

-> 调用服务端 uploadToOSS 方法

-> 服务端调用 getOss 获取 OSS 临时凭证

-> 服务端使用 ali-oss 上传文件

-> 上传成功后返回 OSS 文件访问地址

-> 前端展示上传结果

代码在最下面!代码在最下面!代码在最下面!

二、为什么使用 Server Action 上传

在 Next.js 中,如果上传方法文件顶部添加:

javascript 复制代码
'use server'

说明这个文件中的方法会在服务端执行。

前端组件虽然可以调用这个方法,但实际上传逻辑不会运行在浏览器中,而是由 Next.js 服务端处理。

这样做的好处是:

  • OSS 密钥不会暴露到浏览器

  • 上传逻辑集中在服务端,安全性更高

  • 可以在服务端统一校验文件类型和大小

  • 可以在服务端统一处理错误和日志

  • 前端代码更简单,只需要关心文件选择和结果展示

三、前端上传页面主要负责什么

前端页面一般只负责用户交互,不直接持有 OSS 密钥。

主要职责包括:

  • 选择文件 :通过 input type="file" 获取用户选择的图片或视频

  • 文件校验:判断文件类型是否符合要求,例如只允许图片或视频

  • 本地预览 :使用 URL.createObjectURL(file) 生成临时预览地址

  • 上传触发:点击按钮后调用服务端上传方法

  • 状态展示:显示上传中、上传成功、上传失败等状态

  • 结果展示:上传成功后展示 OSS 文件地址

前端不会直接操作 OSS 永久密钥,这样可以避免敏感信息泄露。

四、服务端上传方法主要负责什么

服务端上传方法负责真正的 OSS 上传逻辑。

主要职责包括:

  • 接收前端传来的 File 文件对象

  • 校验文件是否为空

  • 校验文件类型是否合法

  • 调用接口获取 OSS 临时凭证

  • 校验 OSS 配置是否完整

  • 创建 OSS 客户端

  • 生成唯一文件名

  • 拼接 OSS 存储路径

  • 将 File 转换为 Buffer

  • 调用 OSS SDK 上传

  • 返回最终文件访问地址

服务端上传成功后,会返回类似这样的结果:url就是上传到阿里云的文件地址

javascript 复制代码
{
  success: true,
  url: "https://xxx.oss-cn-xxx.aliyuncs.com/path/file.png"
}

五、OSS 临时凭证的作用

上传前,服务端会先调用

getOss 获取 OSS 配置。

通常配置中会包含:

  • region:OSS 区域

  • bucket:OSS 存储桶名称

  • AccessKeyId:临时访问 Key

  • AccessKeySecret:临时访问 Secret

  • SecurityToken:STS 临时 Token

  • keyPrefix:上传目录前缀

  • url:文件访问域名

这些信息用于创建 OSS 客户端,并完成文件上传。

这里推荐使用 STS 临时凭证,而不是长期有效的 AccessKey。

原因是:

  • 临时凭证有效期短

  • 权限可以限制到指定 bucket 或目录

  • 安全性更高

  • 即使泄露,风险也更可控


六、文件名为什么要重新生成

上传文件时,不建议直接使用用户原始文件名。

一般会重新生成文件名,例如:时间戳_随机字符串.扩展名

这样做有几个好处:

  • 避免文件名重复导致覆盖

  • 避免中文、空格、特殊字符造成路径问题

  • 便于按时间排查上传记录

  • 减少用户原始文件名泄露风险

最终 OSS 路径通常是:keyPrefix + fileName

例如:uploads/20260523_abc123.png

七、为什么要把 File 转成 Buffer

在服务端使用 ali-oss 上传时,一般需要传入可被 Node.js 处理的数据类型。前端传来的 File 对象可以通过:file.arrayBuffer()转换成 ArrayBuffer,再通过:Buffer.from()

转换成 Node.js 的 Buffer

最终上传给 OSS 的就是这个 Buffer。


八、服务端上传和客户端直传的区别

服务端上传

服务端上传的流程是:浏览器 -> Next.js 服务端 -> 阿里云 OSS

优点:

  • 安全性高

  • 密钥不暴露

  • 逻辑统一

  • 适合管理后台、头像、Logo、二维码等中小文件上传

缺点:

  • 文件会经过自己的服务器

  • 服务器带宽和内存压力更大

  • 大文件可能受到 Server Action 上传大小限制


客户端直传

客户端直传的流程是:浏览器 -> 阿里云 OSS

前端先向后端请求临时 STS 凭证,然后浏览器使用临时凭证直接上传 OSS。

优点:

  • 文件不经过业务服务器

  • 上传速度更快

  • 服务器压力更小

  • 更适合大视频、大文件上传

缺点:

  • 前端会拿到临时凭证

  • 必须严格限制 STS 权限

  • 需要控制凭证过期时间和上传目录

  • 前端逻辑相对复杂


九、什么时候需要配置 bodySizeLimit

如果使用的是 Next.js Server Action 上传文件,大文件会先传到 Next.js 服务端。

Next.js 对 Server Action 的请求体大小有限制。

如果上传大图片或视频,可能出现类似:Body exceeded 1 MB limit

这时需要在 next.config.ts 中配置:experimental.serverActions.bodySizeLimit

例如设置为:200mb

适用场景:

  • 上传视频

  • 上传大图

  • 文件超过默认 Server Action 限制

  • 调用 Server Action 时直接传 File 对象

如果只是上传 Logo、二维码、小图标,一般文件较小,可能不需要额外配置。


示例代码

1、page.tsx,我这里定义的是客户端组件,在界面最顶部加上了'use client'
javascript 复制代码
'use client'

import React, { useEffect, useState } from 'react';
import styles from '@/app/[lang]/[domain]/modelRun/styles/VideoGenerator.module.css';
import { uploadToOSS } from '@/utils/aliyOssUpload';
import { useToast } from '@/components/Toast/ToastContainer';

const VideoGenerator: React.FC = () => {
    // 选择的文件对象,支持图片或视频
    const [file, setFile] = useState<File | null>(null);

    // 文件本地预览地址,用于上传前预览
    const [previewUrl, setPreviewUrl] = useState('');

    // 上传成功后 OSS 返回的文件访问地址
    const [ossUrl, setOssUrl] = useState('');

    // 当前文件类型,用于决定预览时显示 img 还是 video
    const [fileType, setFileType] = useState<'image' | 'video' | null>(null);

    // 上传中状态,用于禁用按钮和展示上传中
    const [isUploading, setIsUploading] = useState(false);

    // Toast 提示
    const toast = useToast();

    /**
     * 处理文件选择
     *
     * 功能:
     * 1. 校验文件是否存在
     * 2. 校验文件类型是否为图片或视频
     * 3. 清理旧的本地预览 URL
     * 4. 保存新的文件对象
     * 5. 创建新的本地预览 URL
     */
    const handleFileChange = (selectedFile: File | null) => {
        if (!selectedFile) {
            return;
        }

        const isImage = selectedFile.type.startsWith('image/');
        const isVideo = selectedFile.type.startsWith('video/');

        if (!isImage && !isVideo) {
            toast.error('只支持图片或视频文件');
            return;
        }

        if (previewUrl && previewUrl.startsWith('blob:')) {
            URL.revokeObjectURL(previewUrl);
        }

        setFile(selectedFile);
        setPreviewUrl(URL.createObjectURL(selectedFile));
        setOssUrl('');
        setFileType(isImage ? 'image' : 'video');
    };

    /**
     * 清除当前选择的文件
     *
     * 功能:
     * 1. 释放本地预览 URL
     * 2. 清空文件对象
     * 3. 清空 OSS 地址
     * 4. 清空文件类型
     * 5. 重置 input value,保证可以重复选择同一个文件
     */
    const handleClearFile = () => {
        if (previewUrl && previewUrl.startsWith('blob:')) {
            URL.revokeObjectURL(previewUrl);
        }

        setFile(null);
        setPreviewUrl('');
        setOssUrl('');
        setFileType(null);

        const input = document.getElementById('oss-upload-input') as HTMLInputElement;
        if (input) {
            input.value = '';
        }
    };

    /**
     * 上传文件到 OSS
     *
     * 功能:
     * 1. 判断是否已经选择文件
     * 2. 调用 uploadToOSS Server Action
     * 3. 上传成功后保存 OSS 地址
     * 4. 上传失败时提示错误信息
     */
    const handleUpload = async () => {
        if (!file) {
            toast.error('请先选择文件');
            return;
        }

        setIsUploading(true);

        try {
            const result = await uploadToOSS(file);

            if (result.success && result.url) {
                setOssUrl(result.url);
                toast.success('上传成功');
            } else {
                toast.error(result.error || '上传失败');
            }
        } catch (error) {
            console.error('上传异常:', error);
            toast.error(error instanceof Error ? error.message : '上传失败');
        } finally {
            setIsUploading(false);
        }
    };

    /**
     * 组件卸载时释放本地预览 URL
     *
     * 作用:
     * - URL.createObjectURL 会占用浏览器内存
     * - 组件卸载时需要 revoke,避免内存泄漏
     */
    useEffect(() => {
        return () => {
            if (previewUrl && previewUrl.startsWith('blob:')) {
                URL.revokeObjectURL(previewUrl);
            }
        };
    }, [previewUrl]);

    return (
        <div className={styles.container}>
            <div className={styles.inputSection}>
                <div className={styles.sectionHeader}>
                    <h3 className={styles.sectionTitle}>OSS 上传测试</h3>
                </div>

                <div className={styles.formContent}>
                    <div className={styles.formGroup}>
                        <label className={styles.label}>选择图片或视频</label>

                        <div className={styles.uploadSection}>
                            {file && previewUrl ? (
                                <div className={styles.imagePreviewContainer}>
                                    {fileType === 'image' ? (
                                        <img
                                            src={previewUrl}
                                            alt="文件预览"
                                            className={styles.imagePreview}
                                        />
                                    ) : (
                                        <video
                                            src={previewUrl}
                                            className={styles.imagePreview}
                                            controls
                                            style={{
                                                maxHeight: '200px',
                                                width: '100%',
                                                objectFit: 'contain',
                                            }}
                                        />
                                    )}

                                    <div className={styles.imageActions}>
                                        <span className={styles.fileName}>
                                            {file.name}
                                        </span>

                                        <button
                                            type="button"
                                            className={styles.clearButton}
                                            onClick={handleClearFile}
                                            title="清除文件"
                                        >
                                            ✕
                                        </button>
                                    </div>
                                </div>
                            ) : (
                                <div className={styles.uploadBox}>
                                    <input
                                        id="oss-upload-input"
                                        type="file"
                                        accept="image/*,video/*"
                                        className={styles.fileInput}
                                        onChange={(e) => handleFileChange(e.target.files?.[0] || null)}
                                    />

                                    <label htmlFor="oss-upload-input" className={styles.uploadLabel}>
                                        <svg
                                            className={styles.uploadIconSvg}
                                            width="48"
                                            height="48"
                                            viewBox="0 0 24 24"
                                            fill="none"
                                            xmlns="http://www.w3.org/2000/svg"
                                        >
                                            <path
                                                d="M12 4L12 16M12 4L8 8M12 4L16 8"
                                                stroke="currentColor"
                                                strokeWidth="2"
                                                strokeLinecap="round"
                                                strokeLinejoin="round"
                                            />
                                            <path
                                                d="M4 17V19C4 20.1046 4.89543 21 6 21H18C19.1046 21 20 20.1046 20 19V17"
                                                stroke="currentColor"
                                                strokeWidth="2"
                                                strokeLinecap="round"
                                            />
                                        </svg>

                                        <span className={styles.uploadMainText}>
                                            点击选择文件
                                        </span>

                                        <span className={styles.uploadSubText}>
                                            支持图片或视频
                                        </span>
                                    </label>
                                </div>
                            )}
                        </div>
                    </div>

                    <button
                        className={styles.submitButton}
                        onClick={handleUpload}
                        disabled={isUploading || !file}
                    >
                        <span className={styles.buttonIcon}>▶</span>
                        {isUploading ? '上传中...' : '上传到 OSS'}
                    </button>

                    {ossUrl && (
                        <div className={styles.formGroup}>
                            <label className={styles.label}>OSS 文件地址</label>

                            <textarea
                                className={styles.textarea}
                                value={ossUrl}
                                readOnly
                            />

                            <a
                                href={ossUrl}
                                target="_blank"
                                rel="noopener noreferrer"
                                style={{
                                    color: '#635bff',
                                    marginTop: '8px',
                                    display: 'inline-block',
                                }}
                            >
                                打开文件
                            </a>
                        </div>
                    )}
                </div>
            </div>
        </div>
    );
};

export default VideoGenerator;
2、上传实现js文件:aliOssUpload.ts
javascript 复制代码
'use server'

import { getOss } from '@/api/common';
import OSS from 'ali-oss';

export interface UploadResult {
    // 是否上传成功
    success: boolean;
    // 上传成功后返回的 OSS 文件访问地址
    url?: string;
    // 上传失败时返回的错误信息
    error?: string;
}
/**
 * 上传单个文件到阿里云 OSS
 *
 * 执行环境:
 * - 由于文件顶部声明了 'use server'
 * - 所以该方法会作为 Server Action 在服务端执行
 * - OSS 临时密钥不会暴露到浏览器端
 *
 * 上传流程:
 * 1. 校验文件是否有效
 * 2. 校验文件类型是否为图片或视频
 * 3. 调用 getOss 获取 OSS 临时上传配置
 * 4. 校验 OSS 配置是否完整
 * 5. 创建 ali-oss 客户端
 * 6. 生成唯一文件名和 OSS 存储路径
 * 7. 将 File 转为 Buffer
 * 8. 上传到 OSS
 * 9. 拼接并返回文件访问地址
 *
 * @param file 要上传的文件对象
 * @param userId 用户 ID,可选,当前方法中暂未使用
 * @param sourceType 资源类型,可选,当前方法中暂未使用
 * @returns 上传结果,成功时包含 url,失败时包含 error
 */
export async function uploadToOSS(
    file: File,
    userId?: string,
    sourceType?: string
): Promise<UploadResult> {
    try {
        // 校验文件对象是否存在,以及文件大小是否有效
        if (!file || file.size === 0) {
            return { success: false, error: '无效的文件对象' };
        }
        // 限制只允许上传图片或视频文件
        if (!file.type.startsWith('image/') && !file.type.startsWith('video/')) {
            return { success: false, error: '只支持图片或视频文件' };
        }
        // 开始获取 OSS 上传所需的临时配置
        console.log('[OSS] 准备调用 getOss');
        // 调用后端接口获取 OSS 临时凭证、bucket、region、上传目录和访问域名
        const response = await getOss();

        // 打印 OSS 配置接口返回的关键信息,方便排查配置问题
        console.log('[OSS] getOss response:', {
            code: response?.code,
            msg: response?.msg,
            bucket: response?.data?.bucket,
        });

        // 判断 OSS 配置接口是否返回成功
        if (response.code !== 0 || !response.data) {
            console.error('[OSS] 获取配置失败:', response);
            return {
                success: false,
                error: response.msg || '获取 OSS 配置失败',
            };
        }

        // OSS 配置信息,包含 region、bucket、临时密钥、上传目录、访问域名等
        const ossConfig = response.data as any;

        // 校验 OSS 上传必须字段是否完整
        const missing = ['region', 'bucket', 'AccessKeyId', 'AccessKeySecret', 'SecurityToken', 'keyPrefix', 'url']
            .find(k => !ossConfig[k]);

        // 如果缺少必要字段,直接返回错误
        if (missing) {
            console.error(`[OSS] 配置缺失: ${missing}`);
            return { success: false, error: `OSS ${missing} 未设置` };
        }

        // 创建 OSS 客户端实例,使用的是 STS 临时凭证
        const client = new OSS({
            region: ossConfig.region,
            accessKeyId: ossConfig.AccessKeyId,
            accessKeySecret: ossConfig.AccessKeySecret,
            stsToken: ossConfig.SecurityToken,
            bucket: ossConfig.bucket,
            secure: true,
        });

        // 获取原文件扩展名,例如 .png、.jpg、.mp4
        const ext = file.name.includes('.')
            ? file.name.substring(file.name.lastIndexOf('.'))
            : '';

        // 生成唯一文件名,避免覆盖 OSS 上已有文件
        const fileName = `${Date.now()}_${Math.random().toString(36).slice(2, 8)}${ext}`;
        // 拼接 OSS 存储路径,keyPrefix 是后端返回的上传目录前缀
        const filePath = `${ossConfig.keyPrefix || ''}${fileName}`;
        // 将浏览器传来的 File 对象转换为 Node.js Buffer,供 ali-oss 服务端上传使用
        const buffer = Buffer.from(await file.arrayBuffer());
        // 上传文件到 OSS,并设置文件 Content-Type
        const result = await client.put(filePath, buffer, {
            headers: { 'Content-Type': file.type },
        });

        // 打印上传成功后的 OSS 对象名称
        console.log('[OSS] 上传成功:', result.name);

        // 去掉访问域名末尾的 /,避免最终 URL 出现双斜杠
        const baseUrl = String(ossConfig.url).replace(/\/$/, '');

        // 返回完整文件访问地址
        return {
            success: true,
            url: `${baseUrl}/${filePath}`,
        };
    } catch (error) {
        // 捕获上传过程中的异常,例如 OSS SDK 报错、网络异常等
        console.error('[OSS] 上传异常:', error);
        return {
            success: false,
            error: error instanceof Error ? error.message : '上传失败',
        };
    }
}

/**
 * 批量上传文件到阿里云 OSS
 *
 * 上传方式:
 * - 内部复用 uploadToOSS
 * - 使用 Promise.all 并发上传多个文件
 *
 * @param files 要上传的文件数组
 * @param userId 用户 ID,可选,会继续传递给 uploadToOSS
 * @param sourceType 资源类型,可选,会继续传递给 uploadToOSS
 * @returns 每个文件对应的上传结果数组
 */
export async function uploadMultipleToOSS(
    files: File[],
    userId?: string,
    sourceType?: string
): Promise<UploadResult[]> {
    // 并发上传所有文件,并返回每个文件的上传结果
    return Promise.all(files.map(f => uploadToOSS(f, userId, sourceType)));
}
3、如果需要在nextjs的服务端上传大文件,则需要在根目录文件next.config.ts内加入以下配置:
javascript 复制代码
 // 上传阿里云文件限制大小
    experimental: {
        serverActions: {
            bodySizeLimit: '200mb',
        },
    },

注意:如果在aliOssUpload.js中的顶部加入:'use server'则上传调用在服务端进行上传,否则在客户端上传

如果使用 use server,上传逻辑会运行在服务端,安全性更高,适合中小文件上传。如果需要上传大文件,可以通过调整 bodySizeLimit 临时解决,但从架构上看,客户端直传 OSS 会更合适。

相关推荐
kyriewen1 天前
写组件文档写到吐?我用AI自动生成Storybook,同事以后直接抄
前端·javascript·面试
五点六六六1 天前
你敢信这是非Native页面写出来的渐变效果吗🌝(底层原理解析
前端·javascript·面试
吃西瓜的年年1 天前
TypeScript
javascript·ubuntu·typescript
熊猫_豆豆1 天前
一个模拟四轴飞行器在随机气流扰动下悬停飞行的交互式3D仿真网页,包含飞行器建模与PID控制算法
javascript·3d·html·四轴无人机模拟飞行
来恩10031 天前
jQuery选择器
前端·javascript·jquery
前端繁华如梦1 天前
树上挂苹果还是挂玻璃球?Three.js 程序化果实的完整实现指南
前端·javascript
CDwenhuohuo1 天前
优惠券组件直接用 uview plus
前端·javascript·vue.js
川冰ICE1 天前
TypeScript装饰器与元编程实战
前端·javascript·typescript