· Nacho Coll · Tutorials  · 14 分钟阅读

IPFS + React:构建去中心化文件上传组件

构建一个 React 拖放文件上传组件,将文件固定到 IPFS。完整教程,使用签名令牌进行安全的浏览器上传。

构建一个 React 拖放文件上传组件,将文件固定到 IPFS。完整教程,使用签名令牌进行安全的浏览器上传。

使用 React 和 IPFS 构建去中心化应用从未如此简单。在这个全面的教程中,您将学习如何创建一个生产就绪的文件上传组件,直接从浏览器将文件固定到 IPFS。我们将使用 IPFS.NINJA 的签名令牌来确保上传安全,而无需在前端代码中暴露 API 密钥。

到本指南结束时,您将拥有一个功能齐全的拖放组件,具有上传进度、错误处理和使用 TanStack Query 的正确状态管理。

IPFS Ninja

为什么要构建一个 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.';
};

测试您的组件

要测试上传功能:

  1. 启动开发服务器:npm start
  2. 将一些文件拖到上传区域
  3. 检查文件是否已上传并显示 CID
  4. 通过单击外部链接图标验证 IPFS URL 是否有效
  5. 测试错误场景(大文件、网络问题等)

部署考虑因素

部署您的 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 配置

确保您的后端允许来自前端域的请求。

性能优化

为了更好的大文件或多上传性能:

  1. 使用 Web Workers 进行 base64 转换
  2. 实现上传队列 以限制并发上传
  3. 添加文件压缩 用于上传前的图像
  4. 正确缓存上传令牌 以避免不必要的请求

与其他解决方案比较

我们的 IPFS.NINJA 方法相对于替代方案有几个优势。如果您正在评估不同的 IPFS 固定服务,请查看我们在 IPFS.NINJA vs Pinata 中的全面比较和我们的最佳 IPFS 固定服务指南。

为了更广泛地理解 IPFS 上传方法,请查看我们的 IPFS 上传 API 教程如何将文件上传到 IPFS指南。

下一步

您现在拥有一个完全功能的 React IPFS 上传组件!以下是一些扩展它的方法:

  • 添加文件预览功能
  • 实现批量操作(删除、分享)
  • 创建上传分析和指标
  • 添加与应用认证系统的集成
  • 构建文件夹组织功能
  • 实现文件搜索和过滤

React 的组件架构和 IPFS 的去中心化存储的结合为构建有弹性、用户友好的应用程序创造了强大的可能性。

准备好开始固定了吗? 创建免费帐户 — 50 个文件,1 GB 存储,2 GB 带宽/月。无需信用卡。

返回博客