· Nacho Coll · Tutorials · 14 分钟阅读
IPFS + React:构建去中心化文件上传组件
构建一个 React 拖放文件上传组件,将文件固定到 IPFS。完整教程,使用签名令牌进行安全的浏览器上传。

使用 React 和 IPFS 构建去中心化应用从未如此简单。在这个全面的教程中,您将学习如何创建一个生产就绪的文件上传组件,直接从浏览器将文件固定到 IPFS。我们将使用 IPFS.NINJA 的签名令牌来确保上传安全,而无需在前端代码中暴露 API 密钥。
到本指南结束时,您将拥有一个功能齐全的拖放组件,具有上传进度、错误处理和使用 TanStack Query 的正确状态管理。

为什么要构建一个 React IPFS 上传组件?
像 AWS S3 这样的传统云存储服务会集中您的数据并产生供应商锁定。使用 IPFS(星际文件系统),您的文件分布在对等网络中,使其更具弹性和抗审查能力。
这种方法的主要优势:
- 去中心化存储:文件分布在多个节点上
- 内容寻址:每个文件都获得一个唯一的 CID(内容标识符)
- 不可变链接:一旦上传,文件无法在不更改 CID 的情况下被修改
- 全球可访问性:可以从任何 IPFS 网关访问文件
- 安全上传:使用签名令牌而不是暴露 API 密钥
如果您是 IPFS 概念的新手,请在深入代码之前查看我们关于 IPFS pinning 是什么 的指南。
前提条件
在开始之前,请确保您具备:
- 基本的 React 知识
- 已安装 Node.js 16+
- 一个 IPFS.NINJA 账户(在此注册)
- 了解现代 React 模式(hooks、async/await)
项目设置
首先,让我们创建一个新的 React 项目并安装所需的依赖项:
npx create-react-app ipfs-upload-demo
cd ipfs-upload-demo
npm install @tanstack/react-query react-dropzone lucide-react我们将使用这些库:
- @tanstack/react-query:用于状态管理和缓存
- react-dropzone:用于拖放功能
- lucide-react:用于图标
理解签名令牌
在构建组件之前,理解为什么我们使用签名令牌而不是 API 密钥至关重要:
// ❌ 永远不要这样做 - 暴露您的 API 密钥
const response = await fetch('https://api.ipfs.ninja/upload/new', {
headers: {
'X-Api-Key': 'bws_your_secret_key_here' // 这在浏览器中可见!
}
});
// ✅ 使用签名令牌
const response = await fetch('https://api.ipfs.ninja/upload/new', {
headers: {
'Authorization': `Signed ${signedToken}` // 在客户端使用是安全的
}
});签名令牌是您在服务器端生成并传递到前端的有时间限制的凭证。它们不能用于访问您的账户设置或其他敏感操作。
创建后端令牌端点
首先,创建一个简单的后端端点来生成签名令牌。这可以是 Next.js API 路由、Express 端点或无服务器函数:
// api/upload-token.js (Next.js API 路由示例)
export default async function handler(req, res) {
if (req.method !== 'POST') {
return res.status(405).json({ error: 'Method not allowed' });
}
try {
const response = await fetch('https://api.ipfs.ninja/upload/tokens', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Api-Key': process.env.IPFS_NINJA_API_KEY // 仅限服务器端!
},
body: JSON.stringify({
expiresIn: '1h',
maxUploads: 10,
description: 'React upload component token'
})
});
const data = await response.json();
if (!response.ok) {
throw new Error(data.error || 'Token generation failed');
}
res.json({ token: data.token });
} catch (error) {
console.error('Token generation error:', error);
res.status(500).json({ error: 'Failed to generate upload token' });
}
}构建上传 Hook
让我们创建一个自定义 hook,使用 TanStack Query 管理上传逻辑:
// hooks/useIPFSUpload.js
import { useMutation, useQueryClient } from '@tanstack/react-query';
const uploadToIPFS = async ({ file, token }) => {
// 将文件转换为 base64 以进行 JSON 上传
const base64Content = await new Promise((resolve) => {
const reader = new FileReader();
reader.onload = () => resolve(reader.result.split(',')[1]);
reader.readAsDataURL(file);
});
const response = await fetch('https://api.ipfs.ninja/upload/new', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Signed ${token}`
},
body: JSON.stringify({
content: base64Content,
description: `Uploaded via React: ${file.name}`,
metadata: {
originalName: file.name,
size: file.size,
type: file.type,
uploadedAt: new Date().toISOString()
}
})
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.error || 'Upload failed');
}
const data = await response.json();
return {
...data,
originalFile: {
name: file.name,
size: file.size,
type: file.type
}
};
};
export const useIPFSUpload = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: uploadToIPFS,
onSuccess: (data) => {
// 缓存上传的文件数据
queryClient.setQueryData(['uploads'], (oldData = []) => [
...oldData,
data
]);
}
});
};获取上传令牌
创建另一个 hook 来从您的后端获取签名令牌:
// hooks/useUploadToken.js
import { useQuery } from '@tanstack/react-query';
const fetchUploadToken = async () => {
const response = await fetch('/api/upload-token', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
}
});
if (!response.ok) {
throw new Error('Failed to fetch upload token');
}
const data = await response.json();
return data.token;
};
export const useUploadToken = () => {
return useQuery({
queryKey: ['upload-token'],
queryFn: fetchUploadToken,
staleTime: 30 * 60 * 1000, // 30 分钟
retry: 3
});
};创建上传组件
现在让我们构建主要的上传组件,具有拖放功能:
// components/IPFSUploader.jsx
import React, { useCallback, useState } from 'react';
import { useDropzone } from 'react-dropzone';
import { Upload, CheckCircle, XCircle, Loader2, Copy, ExternalLink } from 'lucide-react';
import { useIPFSUpload } from '../hooks/useIPFSUpload';
import { useUploadToken } from '../hooks/useUploadToken';
const IPFSUploader = () => {
const [uploadedFiles, setUploadedFiles] = useState([]);
const uploadMutation = useIPFSUpload();
const { data: token, isLoading: isTokenLoading, error: tokenError } = useUploadToken();
const onDrop = useCallback(async (acceptedFiles) => {
if (!token) {
console.error('No upload token available');
return;
}
for (const file of acceptedFiles) {
try {
const result = await uploadMutation.mutateAsync({ file, token });
setUploadedFiles(prev => [...prev, result]);
} catch (error) {
console.error('Upload failed:', error);
}
}
}, [token, uploadMutation]);
const { getRootProps, getInputProps, isDragActive } = useDropzone({
onDrop,
maxSize: 10 * 1024 * 1024, // 10MB 限制
multiple: true
});
const copyToClipboard = (text) => {
navigator.clipboard.writeText(text);
};
const formatFileSize = (bytes) => {
if (bytes === 0) return '0 Bytes';
const k = 1024;
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
};
if (isTokenLoading) {
return (
<div className="flex items-center justify-center p-8">
<Loader2 className="w-6 h-6 animate-spin" />
<span className="ml-2">Initializing uploader...</span>
</div>
);
}
if (tokenError) {
return (
<div className="p-4 bg-red-50 border border-red-200 rounded-lg">
<div className="flex items-center">
<XCircle className="w-5 h-5 text-red-500" />
<span className="ml-2 text-red-700">Failed to initialize uploader</span>
</div>
</div>
);
}
return (
<div className="max-w-2xl mx-auto p-6">
{/* 上传放置区 */}
<div
{...getRootProps()}
className={`
border-2 border-dashed rounded-lg p-8 text-center cursor-pointer transition-colors
${isDragActive
? 'border-blue-400 bg-blue-50'
: 'border-gray-300 hover:border-gray-400'
}
`}
>
<input {...getInputProps()} />
<Upload className="w-12 h-12 mx-auto text-gray-400 mb-4" />
{isDragActive ? (
<p className="text-blue-600">Drop the files here...</p>
) : (
<div>
<p className="text-gray-600 mb-2">
Drag & drop files here, or click to select
</p>
<p className="text-sm text-gray-500">
Max file size: 10MB
</p>
</div>
)}
</div>
{/* 上传进度 */}
{uploadMutation.isPending && (
<div className="mt-4 p-4 bg-blue-50 border border-blue-200 rounded-lg">
<div className="flex items-center">
<Loader2 className="w-5 h-5 animate-spin text-blue-500" />
<span className="ml-2 text-blue-700">Uploading to IPFS...</span>
</div>
</div>
)}
{/* 上传错误 */}
{uploadMutation.isError && (
<div className="mt-4 p-4 bg-red-50 border border-red-200 rounded-lg">
<div className="flex items-center">
<XCircle className="w-5 h-5 text-red-500" />
<span className="ml-2 text-red-700">
{uploadMutation.error?.message || 'Upload failed'}
</span>
</div>
</div>
)}
{/* 已上传文件列表 */}
{uploadedFiles.length > 0 && (
<div className="mt-6">
<h3 className="text-lg font-semibold mb-4">Uploaded Files</h3>
<div className="space-y-3">
{uploadedFiles.map((file, index) => (
<div key={index} className="p-4 bg-green-50 border border-green-200 rounded-lg">
<div className="flex items-start justify-between">
<div className="flex items-start space-x-3">
<CheckCircle className="w-5 h-5 text-green-500 mt-0.5" />
<div>
<p className="font-medium text-gray-900">
{file.originalFile.name}
</p>
<p className="text-sm text-gray-500">
{formatFileSize(file.originalFile.size)} • {file.sizeMB}MB on IPFS
</p>
<div className="mt-2">
<p className="text-xs text-gray-600">CID:</p>
<div className="flex items-center space-x-2 mt-1">
<code className="text-xs bg-gray-100 px-2 py-1 rounded">
{file.cid}
</code>
<button
onClick={() => copyToClipboard(file.cid)}
className="text-gray-400 hover:text-gray-600"
>
<Copy className="w-4 h-4" />
</button>
</div>
</div>
</div>
</div>
<a
href={file.uris.url}
target="_blank"
rel="noopener noreferrer"
className="text-blue-500 hover:text-blue-700"
>
<ExternalLink className="w-5 h-5" />
</a>
</div>
</div>
))}
</div>
</div>
)}
</div>
);
};
export default IPFSUploader;设置 App 组件
最后,用 TanStack Query 提供者包装您的应用:
// App.js
import React from 'react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import IPFSUploader from './components/IPFSUploader';
import './App.css';
const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: 1,
staleTime: 5 * 60 * 1000, // 5 分钟
},
},
});
function App() {
return (
<QueryClientProvider client={queryClient}>
<div className="App">
<header className="bg-white shadow-sm">
<div className="max-w-4xl mx-auto px-4 py-6">
<h1 className="text-3xl font-bold text-gray-900">
IPFS File Uploader
</h1>
<p className="text-gray-600 mt-2">
Upload files to IPFS with drag-and-drop simplicity
</p>
</div>
</header>
<main className="max-w-4xl mx-auto px-4 py-8">
<IPFSUploader />
</main>
</div>
</QueryClientProvider>
);
}
export default App;添加 CSS 样式
添加一些基本样式让组件看起来更精美:
/* App.css */
.App {
min-height: 100vh;
background-color: #f9fafb;
}
/* 确保正确的文件输入样式 */
input[type="file"] {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}高级功能和优化
文件类型验证
使用文件类型限制增强 dropzone:
const { getRootProps, getInputProps, isDragActive, fileRejections } = useDropzone({
onDrop,
maxSize: 10 * 1024 * 1024,
accept: {
'image/*': ['.png', '.jpg', '.jpeg', '.gif', '.webp'],
'application/pdf': ['.pdf'],
'text/plain': ['.txt'],
},
multiple: true
});
// 显示文件被拒绝的错误
{fileRejections.length > 0 && (
<div className="mt-4 p-4 bg-yellow-50 border border-yellow-200 rounded-lg">
<p className="text-yellow-800 font-medium">Some files were rejected:</p>
<ul className="mt-2 text-sm text-yellow-700">
{fileRejections.map(({ file, errors }) => (
<li key={file.path}>
{file.path}: {errors.map(e => e.message).join(', ')}
</li>
))}
</ul>
</div>
)}上传进度跟踪
为了更好的用户体验,您可能希望跟踪单个文件的上传进度。虽然 IPFS.NINJA API 不直接支持进度回调,但您可以模拟进度或显示每个文件的上传状态:
const [uploadingFiles, setUploadingFiles] = useState(new Set());
const onDrop = useCallback(async (acceptedFiles) => {
if (!token) return;
for (const file of acceptedFiles) {
const fileId = `${file.name}-${file.size}`;
setUploadingFiles(prev => new Set([...prev, fileId]));
try {
const result = await uploadMutation.mutateAsync({ file, token });
setUploadedFiles(prev => [...prev, result]);
} catch (error) {
console.error('Upload failed:', error);
} finally {
setUploadingFiles(prev => {
const next = new Set(prev);
next.delete(fileId);
return next;
});
}
}
}, [token, uploadMutation]);错误处理最佳实践
为生产就绪的组件实施全面的错误处理:
const getErrorMessage = (error) => {
if (error.message.includes('401')) {
return 'Upload token expired. Please refresh the page.';
}
if (error.message.includes('413')) {
return 'File too large. Maximum size is 10MB.';
}
if (error.message.includes('429')) {
return 'Too many uploads. Please wait a moment and try again.';
}
return error.message || 'Upload failed. Please try again.';
};测试您的组件
要测试上传功能:
- 启动开发服务器:
npm start - 将一些文件拖到上传区域
- 检查文件是否已上传并显示 CID
- 通过单击外部链接图标验证 IPFS URL 是否有效
- 测试错误场景(大文件、网络问题等)
部署考虑因素
部署您的 React IPFS 上传器时:
环境变量
安全地存储您的 IPFS.NINJA API 密钥:
# .env.local(用于 Next.js)
IPFS_NINJA_API_KEY=bws_your_api_key_here令牌刷新策略
为长期运行的会话实施自动令牌刷新:
export const useUploadToken = () => {
return useQuery({
queryKey: ['upload-token'],
queryFn: fetchUploadToken,
staleTime: 25 * 60 * 1000, // 25 分钟(令牌在 30 分钟后过期)
refetchOnWindowFocus: true,
retry: 3
});
};CORS 配置
确保您的后端允许来自前端域的请求。
性能优化
为了更好的大文件或多上传性能:
- 使用 Web Workers 进行 base64 转换
- 实现上传队列 以限制并发上传
- 添加文件压缩 用于上传前的图像
- 正确缓存上传令牌 以避免不必要的请求
与其他解决方案比较
我们的 IPFS.NINJA 方法相对于替代方案有几个优势。如果您正在评估不同的 IPFS 固定服务,请查看我们在 IPFS.NINJA vs Pinata 中的全面比较和我们的最佳 IPFS 固定服务指南。
为了更广泛地理解 IPFS 上传方法,请查看我们的 IPFS 上传 API 教程和如何将文件上传到 IPFS指南。
下一步
您现在拥有一个完全功能的 React IPFS 上传组件!以下是一些扩展它的方法:
- 添加文件预览功能
- 实现批量操作(删除、分享)
- 创建上传分析和指标
- 添加与应用认证系统的集成
- 构建文件夹组织功能
- 实现文件搜索和过滤
React 的组件架构和 IPFS 的去中心化存储的结合为构建有弹性、用户友好的应用程序创造了强大的可能性。
准备好开始固定了吗? 创建免费帐户 — 50 个文件,1 GB 存储,2 GB 带宽/月。无需信用卡。
