IPFS + React:构建去中心化文件上传组件——完整教程

构建一个将文件上传并固定到IPFS的React拖放上传组件。本教程包含使用签名令牌实现安全浏览器上传的完整代码,并使用TanStack Query和React Dropzone构建生产就绪的组件。

Nacho Coll作者: 更新时间: 14 分钟阅读
构建一个将文件上传并固定到IPFS的React拖放上传组件。本教程包含使用签名令牌实现安全浏览器上传的完整代码,并使用TanStack Query和React Dropzone构建生产就绪的组件。

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

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

IPFS Ninja

第一步:安装依赖项并初始化 React 应用#

像 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 带宽/月。无需信用卡。

See also: IPFS CIDs explained.

Nacho Coll

关于作者

Founder & Engineer at IPFS.NINJA

Nacho founded IPFS.NINJA to make content-addressed storage feel as simple as an S3 PUT — a single API call, a permanent CID, no wallets or peer discovery to reason about. Writes about IPFS internals, decentralized storage patterns, and the pinning-service landscape from the operator side of the wire.

返回博客

相关文章