JavaScript/TypeScript 前端实现文件上传到 MinIO 完整指南

JavaScript/TypeScript 前端实现文件上传到 MinIO 完整指南

以往前端实现文件上传到服务端,常用方案为 HTTP 上传或 FTP 上传,但这两种方式均存在明显短板:HTTP 上传易受网络波动影响,可靠性较差;FTP 配置复杂且安全性不足。随着对象存储服务(Object Storage Service, OSS)的普及,这一问题得到了有效解决。
对象存储(基于对象的存储)是一种专为海量非结构化数据设计的存储架构。与传统存储不同,它将数据封装为独立对象,捆绑元数据和唯一标识符,便于快速查找与访问。OSS 提供与平台无关的 RESTful API 接口,支持在任意应用、任意时间、任意地点存储和访问各类数据。

目前主流的开源 OSS 方案包括 MinIOCeph。其中 MinIO 凭借轻量、易用、兼容 S3 接口等优势,使用率持续攀升,成为开源对象存储的首选方案之一。本文将详细介绍如何基于 JavaScript/TypeScript 前端实现文件上传到 MinIO。

一、什么是 MinIO?

官方定义:MinIO 是基于 Apache License v2.0 开源协议,采用 Golang 开发的对象存储服务。

它完全兼容亚马逊 S3 云存储服务接口,特别适合存储图片、视频、日志文件、备份数据、容器/虚拟机镜像等大容量非结构化数据,支持单个对象文件从几 KB 到 5T 的大小范围。

MinIO 具备轻量特性,可轻松与 NodeJS、Redis、MySQL 等应用集成。同时,它通过纠删码(erasure code)和校验和(checksum)保障数据安全——即使丢失一半数量(N/2)的硬盘,仍可完整恢复数据。

二、本地 Docker 部署 MinIO 测试服务

通过 Docker 可快速部署 MinIO 测试环境,步骤如下:

# 拉取最新版 MinIO 镜像 docker pull bitnami/minio:latest # 启动 MinIO 容器# 注意:MINIO_ROOT_USER 至少 3 个字符,MINIO_ROOT_PASSWORD 至少 8 个字符# 首次运行后服务可能自动关闭,手动重启容器即可正常使用 docker run -itd \ --name minio-server \ -p 9000:9000 \# API 端口 -p 9001:9001 \# 控制台端口 --env MINIO_SERVER_URL="http://127.0.0.1:9000"\ --env MINIO_BROWSER_REDIRECT_URL="http://127.0.0.1:9001"\ --env MINIO_ROOT_USER="root"\ --env MINIO_ROOT_PASSWORD="123456789"\ --env MINIO_DEFAULT_BUCKETS='images'\# 自动创建名为 images 的存储桶 --env MINIO_FORCE_NEW_KEYS="yes"\ --env BITNAMI_DEBUG=true \ bitnami/minio:latest 

三、TypeScript 实现文件上传的核心方案

前端基于 TypeScript 上传文件到 MinIO,核心有三种 HTTP 请求方案可选,分别适配不同开发场景:

  1. XMLHttpRequest:传统方案,兼容性好,支持进度监听等细粒度控制
  2. Fetch API:现代浏览器原生支持,基于 Promise,语法更简洁
  3. Axios:第三方 HTTP 库,支持拦截器、取消请求等增强功能,生态完善

3.1 XMLHttpRequest 实现

functionxhrUploadFile(file: File, url:string){const xhr =newXMLHttpRequest(); xhr.open('PUT', url,true); xhr.send(file); xhr.onload=()=>{if(xhr.status ===200){console.log(`${file.name} 上传成功`);}else{console.error(`${file.name} 上传失败`);}};}

3.2 Fetch API 实现

functionfetchUploadFile(file: File, url:string){fetch(url,{ method:'PUT', body: file,}).then((response)=>{console.log(`${file.name} 上传成功`, response);}).catch((error)=>{console.error(`${file.name} 上传失败`, error);});}

3.3 Axios 实现

functionaxiosUploadFile(file: File, url:string){const instance = axios.create(); instance .put(url, file,{ headers:{'Content-Type': file.type,// 需指定文件真实 Content-Type},}).then(function(response){console.log(`${file.name} 上传成功`, response);}).catch(function(error){console.error(`${file.name} 上传失败`, error);});}

四、MinIO 上传 API 选型:安全优先

MinIO 提供 4 种核心上传 API,需根据安全性和使用场景选择:

  1. putObject:从流上传
  2. fPutObject:从文件上传
  3. PresignedPutObject:生成临时 PUT 预签名 URL,用于前端上传
  4. PresignedPostPolicy:生成临时 POST 预签名 URL,用于前端上传

关键选型说明:

使用 putObject 和 fPutObject 时,需在前端暴露 MinIO 的访问密钥(Access Key/Secret Key),存在严重安全隐患;且 MinIO 官方 JavaScript 客户端未针对浏览器环境适配,因此不推荐前端直接使用这两种方案。

PresignedPutObject 和 PresignedPostPolicy 方案通过服务端生成临时预签名 URL,前端仅需使用该临时 URL 上传文件,无需暴露核心密钥,安全性极高,因此本文重点讲解这两种方案。

MinIO 官方关于预签名 URL 上传的详细说明:Upload Files Using Pre-signed URLs

五、前后端完整实现

整体架构:前端通过调用后端接口获取 MinIO 预签名 URL,再通过该 URL 直接将文件上传到 MinIO。其中后端采用 Go + Gin 框架实现,负责 MinIO 客户端封装和预签名 URL 生成。

5.1 Go 后端实现

首先封装 MinIO 客户端,统一管理连接和预签名 URL 生成逻辑:

package minio import("context""log""net/url""time""github.com/minio/minio-go/v7""github.com/minio/minio-go/v7/pkg/credentials")const( defaultExpiryTime = time.Second *24*60*60// 1 day endpoint string="localhost:9000" accessKeyID string="root" secretAccessKey string="123456789" useSSL bool=false)type Client struct{ cli *minio.Client }funcNewMinioClient()*Client { cli, err := minio.New(endpoint,&minio.Options{ Creds: credentials.NewStaticV4(accessKeyID, secretAccessKey,""), Secure: useSSL,})if err !=nil{ log.Fatalln(err)}return&Client{ cli: cli,}}func(c *Client)PostPresignedUrl(ctx context.Context, bucketName, objectName string)(string,map[string]string,error){ expiry := defaultExpiryTime policy := minio.NewPostPolicy()_= policy.SetBucket(bucketName)_= policy.SetKey(objectName)_= policy.SetExpires(time.Now().UTC().Add(expiry)) presignedURL, formData, err := c.cli.PresignedPostPolicy(ctx, policy)if err !=nil{ log.Fatalln(err)return"",map[string]string{}, err }return presignedURL.String(), formData,nil}func(c *Client)PutPresignedUrl(ctx context.Context, bucketName, objectName string)(string,error){ expiry := defaultExpiryTime presignedURL, err := c.cli.PresignedPutObject(ctx, bucketName, objectName, expiry)if err !=nil{ log.Fatalln(err)return"", err }return presignedURL.String(),nil}

然后实现 HTTP 接口,对外提供预签名 URL 获取服务:

package http import("context""github.com/gin-contrib/cors""github.com/gin-gonic/gin""main/minio""net/http")type Response struct{ Code int`json:"code"` Msg string`json:"msg"` Data interface{}`json:"data"`}funcResponseJSON(c *gin.Context, httpCode, errCode int, msg string, data interface{}){ c.JSON(httpCode, Response{ Code: errCode, Msg: msg, Data: data,})return}type Server struct{ srv *gin.Engine minioClient *minio.Client }funcNewHttpServer()*Server { srv :=&Server{ srv: gin.New(), minioClient: minio.NewMinioClient(),} srv.init()return srv }func(s *Server)init(){ s.srv.Use( gin.Logger(), gin.Recovery(), cors.Default(),) s.registerRouter()}func(s *Server)registerRouter(){ s.srv.GET("/presignedPutUrl/:filename", s.handlePutPresignedUrl) s.srv.GET("/presignedPostUrl/:filename", s.handlePostPresignedUrl)}func(s *Server)handlePutPresignedUrl(c *gin.Context){ fileName := c.Param("filename") presignedURL, err := s.minioClient.PutPresignedUrl(context.Background(),"images", fileName)if err !=nil{ c.String(500,"get presigned url failed")return}type ResponseData struct{ Url string`json:"url"`}var resp ResponseData resp.Url = presignedURL ResponseJSON(c, http.StatusOK,200,"", resp)}func(s *Server)handlePostPresignedUrl(c *gin.Context){ fileName := c.Param("filename") presignedURL, formData, err := s.minioClient.PostPresignedUrl(context.Background(),"images", fileName)if err !=nil{ c.String(500,"get presigned url failed")return}type ResponseData struct{ Url string`json:"url"` FormData map[string]string`json:"formData"`}var resp ResponseData resp.Url = presignedURL resp.FormData = formData ResponseJSON(c, http.StatusOK,200,"", resp)}func(s *Server)Run(){// Listen and serve on 0.0.0.0:8080_= s.srv.Run(":8080")}

5.2 前端 PUT 方式上传实现

封装三种 PUT 上传方案(XMLHttpRequest/Fetch/Axios),并通过后端接口获取预签名 URL:

import axios from'axios';exportclassPutFile{staticxhr(file: File, url:string){const xhr =newXMLHttpRequest(); xhr.open('PUT', url,true); xhr.send(file); xhr.onload=()=>{if(xhr.status ===200|| xhr.status ===204){console.log(`[${xhr.status}] ${file.name} 上传成功`);}else{console.error(`[${xhr.status}] ${file.name} 上传失败`);}};}staticfetch(file: File, url:string){fetch(url,{ method:'PUT', body: file,}).then((response)=>{console.log(`${file.name} 上传成功`, response);}).catch((error)=>{console.error(`${file.name} 上传失败`, error);});}staticaxios(file: File, url:string){ axios .put(url, file,{ headers:{'Content-Type': file.type,},}).then(function(response){console.log(`${file.name} 上传成功`, response);}).catch(function(error){console.error(`${file.name} 上传失败`, error);});}}exportfunctionretrievePutUrl(file: File,cb:(file: File, url:string)=>void){const url =`http://localhost:8080/presignedPutUrl/${file.name}`; axios.get(url).then(function(response){cb(file, response.data.data.url);}).catch(function(error){console.error(error);});}exportfunctionxhrPutFile(file?: File){console.log('XhrPutFile', file);if(file){retrievePutUrl(file,(file, url)=>{ PutFile.xhr(file, url);});}}exportfunctionfetchPutFile(file?: File){console.log('FetchPutFile', file);if(file){retrievePutUrl(file,(file, url)=>{ PutFile.fetch(file, url);});}}exportfunctionaxiosPutFile(file?: File){console.log('AxiosPutFile', file);if(file){retrievePutUrl(file,(file, url)=>{ PutFile.axios(file, url);});}}

5.3 前端 POST 方式上传实现

POST 方式需携带后端返回的表单数据,封装三种上传方案:

import axios from'axios';exportclassPostFile{staticxhr(file: File, url:string, data: object){const formData =newFormData(); Object.entries(data).forEach(([k, v])=>{ formData.append(k, v);}); formData.append('file', file);const xhr =newXMLHttpRequest(); xhr.open('POST', url,true); xhr.send(formData); xhr.onload=()=>{if(xhr.status ===200|| xhr.status ===204){console.log(`[${xhr.status}] ${file.name} 上传成功`);}else{console.error(`[${xhr.status}] ${file.name} 上传失败`);}};}staticfetch(file: File, url:string, data: object){const formData =newFormData(); Object.entries(data).forEach(([k, v])=>{ formData.append(k, v);}); formData.append('file', file);fetch(url,{ method:'POST', body: formData,}).then((response)=>{console.log(`${file.name} 上传成功`, response);}).catch((error)=>{console.error(`${file.name} 上传失败`, error);});}staticaxios(file: File, url:string, data: object){const formData =newFormData(); Object.entries(data).forEach(([k, v])=>{ formData.append(k, v);}); formData.append('file', file); axios.post( url, formData,{ headers:{'Content-Type':'multipart/form-data',},}).then(function(response){console.log(`${file.name} 上传成功`, response);}).catch(function(error){console.error(`${file.name} 上传失败`, error);});}}exportfunctionretrievePostUrl(file: File,cb:(file: File, url:string, data: object)=>void){const url =`http://localhost:8080/presignedPostUrl/${file.name}`; axios.get(url).then(function(response){cb(file, response.data.data.url, response.data.data.formData);}).catch(function(error){console.error(error);});}exportfunctionxhrPostFile(file?: File){console.log('xhrPostFile', file);if(file){retrievePostUrl(file,(file: File, url:string, data: object)=>{ PostFile.xhr(file, url, data);});}}exportfunctionfetchPostFile(file?: File){console.log('fetchPostFile', file);if(file){retrievePostUrl(file,(file: File, url:string, data: object)=>{ PostFile.fetch(file, url, data);});}}exportfunctionaxiosPostFile(file?: File){console.log('axiosPostFile', file);if(file){retrievePostUrl(file,(file: File, url:string, data: object)=>{ PostFile.axios(file, url, data);});}}

六、实战踩坑指南

在实现过程中,容易遇到以下问题,整理解决方案如下:

6.1 PresignedPutObject 必须用 PUT 方法

PresignedPutObject 生成的预签名 URL 仅支持 PUT 方法,若使用 POST 方法上传会直接失败。需严格匹配 API 定义的请求方法。

6.2 PUT 上传无需构造 FormData

部分开发者会习惯性构造 FormData 上传文件,但 PresignedPutObject 方案不支持这种方式——FormData 会导致请求体包含额外的协议数据(如 ------WebKitFormBoundary 分隔符),MinIO 无法正确解析文件内容。

正确做法:直接将 File 对象作为请求体发送,无需封装 FormData。

6.3 Axios 上传需手动指定 Content-Type

XMLHttpRequest 和 Fetch API 会自动根据文件类型设置正确的 Content-Type,但 Axios 不会。若未手动指定 Content-Type: file.type,MinIO 会将文件 Content-Type 设为 Axios 默认的 application/x-www-form-urlencoded,导致文件无法正常预览。

6.4 POST 上传时 file 表单域必须在最后

使用 PresignedPostPolicy 方案时,FormData 中的 file 字段必须放在所有表单数据的最后一位。否则会报以下错误:

The body of your POST request is not well-formed multipart/form-data # 或 The name of the uploaded key is missing 

原因:MinIO 对 POST 表单数据的解析顺序有严格要求,file 字段需作为最后一个参数提交。

6.5 403 错误:主机名不匹配

PUT 上传时出现 403 错误,大概率是预签名 URL 中的主机名与 MinIO 服务的主机名不匹配。核心原因:

MinIO 预签名 URL 会将主机名(host)纳入签名验证范围(对应 X-Amz-SignedHeaders: host)。若后端连接 MinIO 使用的 endpoint(如 localhost:9000)与前端实际访问的 MinIO 地址(如 192.168.1.100:9000)不一致,会导致签名验证失败。

解决方案:

  1. 后端连接 MinIO 时,使用前端可访问的地址(如外网 IP 或域名)作为 endpoint;
  2. 通过环境变量 MINIO_SERVER_URLMINIO_BROWSER_REDIRECT_URL 绑定 MinIO 服务的域名/IP:MINIO_SERVER_URL:指定 API 服务地址(默认 9000 端口);
  3. MINIO_BROWSER_REDIRECT_URL:指定控制台地址(默认 9001 端口);
  4. 注意:必须添加 http://https:// 前缀,例如 http://minio.example.com:9000

Docker 部署时,可通过 --env 参数注入这两个环境变量(参考本文第二部分的 Docker 启动命令)。

七、示例代码仓库

本文完整示例代码已上传至 Github 和 Gitee,包含后端 Go + Gin 实现,以及前端 React、Vue 两种框架的上传示例(支持进度条、多文件上传等扩展功能):

Read more

用 Codex + GitHub Spec-Kit 做一次“规格驱动开发”实战

用 Codex + GitHub Spec-Kit 做一次“规格驱动开发”实战

* 用 Codex + GitHub Spec-Kit 做一次“规格驱动开发”实战 * 1) 初始化:把 spec-kit 工作区真正建起来(多种方式) * 方式 A:uvx 一次性运行(推荐) * 方式 B:uv tool install(全局安装 specify) * 方式 C:pipx 安装(Python 工具常用法) * 2) 初始化后,正确的目录结构长什么样( * 3) 在 Codex 里跑 speckit:统一输入规则(非常重要) * 4) 标准流水线:Constitution → Spec → Plan → Tasks → Implement * Step 1:

By Ne0inhk
VSCode Github Copilot使用OpenAI兼容的自定义模型方法

VSCode Github Copilot使用OpenAI兼容的自定义模型方法

背景 VSCode 1.105.0发布了,但是用户最期待的Copilot功能却没更新!!! (Github Copilot Chat 中使用OpenAI兼容的自定义模型。) 🔥官方也关闭了Issue,并且做了回复,并表示未来也不会更新这个功能: “实际上,这个功能在可预见的未来只面向内部人员开放,作为一种“高级”实验功能。是否实现特定模型提供者的功能,我们交由扩展作者自行决定。仅限内部人员使用可以让我们快速推进,并提供一种可能并非始终百分之百完善,但能够持续改进并快速修复 bug 的体验。如果这个功能对你很重要,我建议切换到内部版本 insider。” 🤗 官方解决方案:安装VSCode扩展支持 你们完全不用担心只需要在 VS Code 中安装扩展:OAI Compatible Provider for Copilot 通过任何兼容 OpenAI 的提供商驱动的 GitHub Copilot Chat,使用前沿开源大模型,如 Kimi K2、DeepSeek

By Ne0inhk
使用 VS Code 将项目代码上传到 Gitee 的完整指南

使用 VS Code 将项目代码上传到 Gitee 的完整指南

在现代软件开发流程中,版本控制是不可或缺的一环。 Gitee(码云)作为国内领先的代码托管平台,为开发者提供了稳定、快速的 Git 服务。 本文将详细介绍如何使用 Visual Studio Code(VS Code)将本地项目代码上传至 Gitee 仓库,涵盖从环境配置、初始化仓库到推送代码的完整流程。 一、准备工作 1. 安装必要工具 * Git:确保你的系统已安装 Git。 可通过终端运行 git --version  或 git -v 验证是否安装成功。 * VS Code:下载并安装 Visual Studio Code。 * Gitee 账号:前往 Gitee 官网 注册账号(如尚未注册)。 2. 安装 VS

By Ne0inhk
使用Git将代码从远程仓库拉取到本地(详细图解、简单易懂)

使用Git将代码从远程仓库拉取到本地(详细图解、简单易懂)

目录 一、前言 二、全流程 一、前言 本博客主要记录一下使用Git将代码从远程仓库拉取到本地的全流程,使用Git拉取代码在学校内多同学合作开发项目或者是实习拉取公司代码等场景都很常见,单纯记录希望对你有帮助 二、全流程 首先在你想要存放代码的位置新建一个文件夹并改名 进入刚刚创建的空文件中,右键然后点击显示更多选项 然后点击Git Bash Here 然后就会出现如图所示的命令行窗口 此时先不用管命令行窗口,找到你要远程仓库所在的平台(我这里以Gitee演示),如图点击克隆/下载按钮 HTTPS下方就是远程仓库的url地址,只要有远程仓库的url地址,只需要在刚刚的命令行窗口打上git clone在将url地址复制在后面再回车即可(Gitee下面的提示也给了,直接复制带git clone的命令就行,没有的话就自己敲git clone) 复制到命令行窗口之后,等待片刻即可 然后点开刚刚创建的文件夹就可以看到拉取下来的代码了,后续用IDEA打开该文件就可以在本地进行开发了

By Ne0inhk