☰
IPFS与Solidity去中心化文件存储:React+Web3.js全栈实践
2026/10/29 5:52:46 网站建设 项目流程

简介:这是一份面向区块链与分布式存储初学者及进阶开发者的实战型项目源码,完整呈现了一个类似 Dropbox 的去中心化文件存储平台:文件经 IPFS 分块哈希后,将哈希值与权限规则写入以太坊智能合约,前端通过 Web3.js 与合约交互,实现上传、下载、分享与访问控制。资源包共 26 个文件,压缩后约 1.23MB,以 10 个 JavaScript 文件构成前端与测试主体,另含 2 个 Solidity 合约、4 个 JSON 配置与依赖清单、2 个 png 图标、1 个 css 样式及 html、md 说明等辅助文件,目录按 contracts、migrations、src/components、test 等模块划分,结构规整、便于按需检索。目前已有 335 人学习。读者可据此掌握 Solidity 合约编写与 Truffle 部署流程、Web3.js 调用合约读写状态的方法,以及 React 组件化界面与 IPFS 存储层的衔接思路,并理解付费上传、权限设定等业务逻辑的落地方式,是打通区块链与分布式文件系统技能的完整参考案例。

1. 从一次上传卡住说起:这套 Dropbox 克隆到底能不能跑起来

上个月帮一个做内部素材库的朋友看需求,他们最怕的不是存储贵,而是“文件到底有没有被人悄悄换过”。这套用 Solidity 写索引、用 IPFS 存内容、用 React 加 Web3.js 拼前端的去中心化文件存储平台,正好能解决这个问题:链上只留元数据和内容标识,文件本体走内容寻址,任何人拿到同一个哈希都能独立校验。它本质上是一个 Dropbox 克隆,只是把“谁有权限读”这件事从中心服务器挪到了合约里。JavaScript 在这套东西里承担了最重的一段——前端交互、链上交易、IPFS 读写全靠它串起来。适合两类人:想找一个区块链真能跑通的全栈练习项目的开发者,以及手里有“多方可信共享”诉求、又不想自建一整套权限后台的小团队。下面从架构一路拆到能跑。

2. 四层分工拆解:React 界面、Web3.js 通道、Solidity 账本与 IPFS 仓库

2.1 四个角色各管什么,为什么要这么切

刚接触这个方向的人常有一个误解:以为“去中心化存储”就是把文件写进区块链。真按这个思路做,一个几百 KB 的文件光 calldata 就要消耗掉几个数量级的 gas,而且链上数据全节点都要永久保存,成本高得离谱。正确的切法是四层各司其职,每一层只做自己最擅长的事。

层承担什么明确不该承担什么出问题时的表现
React 组件层文件选择、列表渲染、下载触发、状态机私钥管理、权限判断页面白屏、按钮无响应
Web3.js 通道层账户获取、交易签名、回执解析业务规则交易长时间 pending
Solidity 合约层文件索引、所有者、共享关系文件正文、大字符串部署或调用 gas 超限
IPFS 节点层内容寻址、去重、块分发访问控制、权限仲裁CID 取不到、网关 404

这个分工背后有一个关键判断:权限必须在链上,因为链上状态是全网可验证的;内容必须在链下,因为内容寻址本身就能证明完整性。两者合起来的语义是“合约说这个 CID 属于你,而 CID 本身能证明内容没被改过”,这比传统网盘“服务器说有就有”的可信度高一个层级。

代价也很明显。链上写一次就是一次交易,要等出块、要付 gas、失败还得重发。所以这个项目适合“文件数量不大、但每次共享都要可追溯”的场景,不适合当日常同步盘用。我在评估这类项目时习惯先问一句:你一周新增多少文件?超过几百个,就得考虑把索引做成批量写入或者改用链下签名加链上锚定的混合方案。

2.2 依赖与环境:本地链、IPFS 守护进程、前端脚手架

跑通这套东西需要三个进程同时在线:一条本地链、一个 IPFS 守护进程、一个前端 dev server。少一个都跑不起来,而且三者之间的端口和 CORS 关系是最容易出问题的地方。

# 终端 A:本地链,出块快、账户余额可随意重置 npx hardhat node --hostname 127.0.0.1 --port 8545 # 终端 B:IPFS 守护进程 ipfs init # 首次执行,生成仓库 ipfs config Addresses.API /ip4/127.0.0.1/tcp/5001 ipfs config Addresses.Gateway /ip4/127.0.0.1/tcp/8080 ipfs config --json API.HTTPHeaders.Access-Control-Allow-Origin '["http://localhost:5173"]' ipfs daemon

这段命令里有两个参数值得单独说。Addresses.API是浏览器端ipfs-http-client要连的 HTTP RPC 地址,默认端口 5001;Addresses.Gateway是下载时拼 URL 用的只读网关,默认 8080。这两个端口在前端代码里会各出现一次,必须和守护进程配置严格对齐,否则会出现“上传成功但下载 404”这种看起来毫无道理的故障。第三行的 CORS 配置是给前端开发服务器放行,端口要和你实际跑的 dev server 一致,Vite 默认 5173,其他脚手架可能是 3000。

前端这边我一般用 Vite 起,不用 create-react-app,原因后面避坑那章会展开讲(简单说是 node polyfill 的问题):

npm create vite@latest dstorage-ui -- --template react cd dstorage-ui npm install web3 ipfs-http-client

web3提供eth.Contract和交易签名封装,ipfs-http-client提供create()工厂和add()方法。这两个包在浏览器里跑都需要打包工具做一层兼容处理,装完之后先别急着写业务,把npm run dev跑起来确认没报错再往下走。

提示:本地链每次重启都会重新部署合约,合约地址会变。前端如果硬编码地址,重启一次就得改一次代码,这是新手最常踩的第一个坑。

2.3 合约接口先定,前端状态机才有锚点

在写任何一行 React 之前,先把合约对外暴露的方法和事件列成表,这一步能省掉后面大量的返工。前端本质上是这个接口的状态机投影,接口没定就写页面,等于闭着眼睛搭积木。

方法 / 事件参数是否写链用途
uploadcid, name, fileType, size是文件写入 IPFS 后登记索引
shareid, to是把某个文件授权给指定地址
removeid是软删除,保留事件痕迹
myFiles无否当前地址名下的文件数组
sharedFiles无否别人共享给当前地址的文件数组
FileUploadedid, owner, cid, name事件上传成功后可回放
FileSharedid, from, to事件共享关系变更
FileDeletedid, owner事件删除留痕

事件这一列不能省。view方法只能告诉你“现在是什么状态”,拿不到“曾经发生过什么”,而在这个项目里,“谁在什么时候把文件共享给了谁”恰恰是最需要审计的部分。把事件设计好,后面做历史记录、做增量刷新、做对账,全都有现成的数据源。

3. 合约先落地:用 struct 与 mapping 把文件索引写进链上

3.1 数据结构:为什么是 struct 加双层 mapping,而不是一张全局大数组

最直觉的写法是搞一个全局File[] files,谁上传就 push 进去,读的时候遍历一遍按 owner 过滤。这个写法在本地测试链上跑得挺欢,一旦文件数上百,前端myFiles()就会开始卡——因为合约返回的是整个数组,gas 不受影响(view 调用不花 gas),但 RPC 响应体积和浏览器反序列化成本会线性膨胀。

我一般用 struct 存单条记录,用两层 mapping 做归属索引:

// SPDX-License-Identifier: MIT pragma solidity ^0.8.17; contract DStorage { struct File { uint256 id; // 全局自增,唯一 string cid; // IPFS 内容标识,CIDv1 字符串 string name; // 原始文件名,仅用于展示 string fileType; // MIME,下载时决定如何渲染 uint256 size; // 字节数,前端做进度和限额判断 uint256 uploadedAt; // 区块时间戳 address owner; // 上传者地址 } uint256 private _nextId = 1; mapping(uint256 => File) private _files; // id -> 记录 mapping(address => uint256[]) private _owned; // 地址 -> 自己的 id 列表 mapping(address => uint256[]) private _sharedWith; // 地址 -> 被共享的 id 列表 }

_files负责按 id 取详情,两个数组 mapping 负责按地址取列表。这样myFiles()只需要遍历_owned[msg.sender]里那几十个 id,再逐个_files[id]取详情,复杂度跟“这个用户有多少文件”挂钩,而不是跟“全网有多少文件”挂钩。

cid用string而不是bytes32,是因为 CIDv1 的 base32 编码长度会超过 32 字节,硬塞进bytes32会截断。代价是存储更贵,但索引本来就该短,一条记录几十字节,可以接受。uploadedAt用block.timestamp而不是前端传进来的时间,避免客户端伪造。

3.2 上传、共享、删除三个核心方法的实现

event FileUploaded(uint256 indexed id, address indexed owner, string cid, string name); event FileShared(uint256 indexed id, address indexed from, address indexed to); event FileDeleted(uint256 indexed id, address indexed owner); modifier onlyOwner(uint256 id) { require(_files[id].owner == msg.sender, "NOT_OWNER"); _; } function upload( string calldata cid, string calldata name, string calldata fileType, uint256 size ) external returns (uint256 id) { require(bytes(cid).length > 0, "EMPTY_CID"); require(size > 0, "EMPTY_FILE"); id = _nextId++; _files[id] = File(id, cid, name, fileType, size, block.timestamp, msg.sender); _owned[msg.sender].push(id); emit FileUploaded(id, msg.sender, cid, name); } function share(uint256 id, address to) external onlyOwner(id) { require(to != address(0) && to != msg.sender, "BAD_TARGET"); _sharedWith[to].push(id); emit FileShared(id, msg.sender, to); } function remove(uint256 id) external onlyOwner(id) { delete _files[id]; emit FileDeleted(id, msg.sender); }

upload的参数全部用calldata,因为只读不写内存,能省一次拷贝。returns (uint256 id)把新 id 直接返回给前端,省掉一次额外查询。onlyOwner修饰器把权限判断抽出来,share和remove复用,减少重复的require。

remove用的是软删除:只delete _files[id],不动_owned和_sharedWith里的 id。这么做有两个考虑。一是从动态数组中间删元素要挪位置,gas 不可控;二是保留 id 痕迹,配合FileDeleted事件可以做完整的生命周期审计。代价是读取时必须跳过已被删除的记录——判断条件就是_files[id].owner == address(0)。

share这里有个设计取舍。当前实现是“被共享者持有一个 id 列表”,共享关系只记在接收方那边,发送方查不到“我把哪些文件共享出去了”。如果你需要双向可查,再加一个mapping(uint256 => address[]) private _sharedTo即可,但每次共享多一次 push,gas 会涨一截。我倾向于按需加,别一上来就全都要。

3.3 读方法与编译部署:从 solc 产物到前端可用的合约地址

两个读方法要绕开已删除的记录:

function myFiles() external view returns (File[] memory list) { uint256[] storage ids = _owned[msg.sender]; uint256 count = 0; for (uint256 i = 0; i < ids.length; i++) { if (_files[ids[i]].owner != address(0)) count++; } list = new File[](count); uint256 k = 0; for (uint256 i = 0; i < ids.length; i++) { if (_files[ids[i]].owner != address(0)) { list[k++] = _files[ids[i]]; } } }

两遍循环看着笨,但第一遍先算出准确长度、第二遍填值,比用动态数组 push 更省 gas,也更符合 Solidity 里“memory 数组长度不可变”的约束。sharedFiles()结构完全一样,只把_owned换成_sharedWith。

部署这一步的关键不是命令本身,而是产物文件里那个networks字段:

npx hardhat compile npx hardhat run scripts/deploy.js --network localhost

编译产物artifacts/contracts/DStorage.sol/DStorage.json里,abi是接口描述,networks是按 chainId 索引的部署地址表。Ganache 默认 chainId 1337,Hardhat 默认 31337,两条链上部署的地址互不相同,前端必须按当前连接的 chainId 去查对应的地址,不能写死。这就是 2.2 里那条提示的具体落地方式。

deploy.js里我一般会把地址打印出来并且顺手写一份到前端目录:

const fs = require("fs"); const path = require("path"); async function main() { const DStorage = await ethers.getContractFactory("DStorage"); const contract = await DStorage.deploy(); await contract.waitForDeployment(); const address = await contract.getAddress(); console.log("DStorage deployed to:", address); const artifactPath = path.join(__dirname, "../artifacts/contracts/DStorage.sol/DStorage.json"); const artifact = JSON.parse(fs.readFileSync(artifactPath, "utf8")); fs.writeFileSync( path.join(__dirname, "../frontend/src/contracts/DStorage.json"), JSON.stringify({ abi: artifact.abi, networks: artifact.networks }, null, 2) ); }

这段脚本干的事就一件:把abi和networks两个字段摘出来,落成前端可以直接import的 JSON。前端不直接读artifacts目录,是因为那个目录通常不进版本库,打包时也不该被带上。只搬需要的字段,体积小、耦合低。

4. 前端接链与内容寻址:Web3.js 签名交易、ipfs-http-client 写入的全流程

4.1 建立连接:从 window.ethereum 到合约实例

浏览器里的钱包注入window.ethereum,Web3.js 拿它当 provider,合约实例再挂到这条链上。三步看起来简单,实际最容易漏的是“网络和地址的对应关系”。

import Web3 from "web3"; import Artifact from "./contracts/DStorage.json"; export async function connect() { if (!window.ethereum) throw new Error("NO_WALLET"); await window.ethereum.request({ method: "eth_requestAccounts" }); const web3 = new Web3(window.ethereum); const accounts = await web3.eth.getAccounts(); const chainId = await web3.eth.getChainId(); const deployed = Artifact.networks[String(chainId)]; if (!deployed) { throw new Error(`CONTRACT_NOT_DEPLOYED_ON_CHAIN_${chainId}`); } const contract = new web3.eth.Contract(Artifact.abi, deployed.address); return { web3, account: accounts[0], contract, chainId }; }

eth_requestAccounts是唯一会弹钱包授权框的一步,其余都是静默读。getChainId()返回的是数字,而networks对象的 key 是字符串,所以String(chainId)这个转换不能省——少了它,Artifact.networks[5777]永远返回undefined,然后你会收到一个“合约没部署”的报错,实际合约部署得好好的。

监听账户和网络切换同样必要。用户切了网络,contract实例还指着旧链的地址,后续每笔交易都会失败:

window.ethereum.on("accountsChanged", (list) => { if (list.length === 0) return; window.dispatchEvent(new CustomEvent("wallet:account", { detail: list[0] })); }); window.ethereum.on("chainChanged", () => { window.location.reload(); });

账户切换只更新状态,网络切换直接刷新页面。这么处理不优雅,但足够稳——网络变了之后合约地址、余额、链上状态全变,与其逐项重置,不如让应用重新走一遍初始化流程。

4.2 上传两步走:先 add 到 IPFS 拿 CID,再发交易写链

上传是这个项目里唯一一个“两个系统都要写”的操作,顺序不能反。必须先把文件交给 IPFS 拿到 CID,再把 CID 写进合约。反过来做的话,合约里会先出现一条指向不存在内容的记录。

import { create } from "ipfs-http-client"; const ipfs = create({ url: "http://127.0.0.1:5001/api/v0" }); export async function uploadFile(file, contract, account, onProgress) { // 第一步:写入 IPFS,拿到内容标识 const added = await ipfs.add( { path: file.name, content: file }, { progress: (p) => onProgress?.(p) } ); const cid = added.cid.toString(); // 第二步:把 CID 和元数据登记到链上 const receipt = await contract.methods .upload(cid, file.name, file.type || "application/octet-stream", file.size) .send({ from: account }) .on("transactionHash", (h) => console.log("tx:", h)); return { cid, txHash: receipt.transactionHash }; }

create({ url })里的地址必须和ipfs config Addresses.API完全一致,协议、IP、端口、路径四段都不能差。add的第一个参数用{ path, content }而不是裸File对象,是因为带path时 IPFS 会把文件名也编码进目录结构,同一份内容用不同文件名上传会得到不同 CID,这对“同名文件覆盖”的场景反而是好事。传裸 Blob 则会走纯内容哈希,忽略文件名。

progress回调给的是{ loaded, total }字节数,前端拿来画进度条。注意这个进度只覆盖 IPFS 写入那一段,第二段的链上交易是另一个耗时过程,两者要分开显示,否则用户会看到进度条卡在 100% 但按钮一直转圈。

.send({ from })不指定gas,让 Web3.js 走eth_estimateGas自动估算。手写gas: 500000看着稳妥,实际是给自己挖坑——不同 CID 长度差别不小,写死一个值要么浪费,要么在文件名特别长的时候直接 out of gas。

4.3 读取与下载:从 CID 拼网关、处理大文件与 MIME

读取分两条路:列表走合约,内容走网关。

export function ipfsUrl(cid) { return `http://127.0.0.1:8080/ipfs/${cid}`; } export async function downloadFile(cid, name) { const res = await fetch(ipfsUrl(cid)); if (!res.ok) throw new Error(`IPFS_FETCH_FAILED_${res.status}`); const blob = await res.blob(); const url = URL.createObjectURL(blob); const a = document.createElement("a"); a.href = url; a.download = name; a.click(); URL.revokeObjectURL(url); }

ipfsUrl拼的是网关地址,不是 API 地址。这两个端口在 4.2 和 4.3 各用一次,都在 2.2 的配置里定义过,改一处就得同步改另一处。URL.createObjectURL生成的临时地址必须在点击后立刻revoke,否则每下载一次就泄漏一份内存,连续下十几个大文件浏览器就开始卡。

MIME 类型这一块容易被忽略。合约里存了fileType,但<a download>的下载行为不看这个字段,浏览器只按Content-Type决定是预览还是下载。如果你的网关返回的是application/octet-stream,那所有文件都会变成下载;如果返回的是image/png,浏览器会直接在新标签页打开。想强制下载,就别依赖网关的 Content-Type,老老实实用上面这段 blob 加download属性的写法。

5. 避坑与排查:五个让上传、共享、下载同时翻车的真问题

5.1 现象:前端一 import ipfs-http-client 就白屏,控制台报 Buffer is not defined

原因:这个包内部依赖 Node 的Buffer、process、stream等核心模块,浏览器里没有这些全局对象。create-react-app 5 之后的版本不再自动注入 polyfill,Vite 也默认不做这件事。项目能npm install成功,但一跑就炸。

解决:换 Vite 的话在vite.config.js里显式注入,或者在入口文件最顶部手写全局兜底。

// vite.config.js import { defineConfig } from "vite"; import react from "@vitejs/plugin-react"; export default defineConfig({ plugins: [react()], define: { global: "globalThis", // 部分依赖仍在用 global "process.env": {}, // 避免 process is not defined }, resolve: { alias: { buffer: "buffer" }, }, optimizeDeps: { esbuildOptions: { define: { global: "globalThis" } }, }, });

再在main.jsx里补一句import { Buffer } from "buffer"; window.Buffer = Buffer;。这两步做完还报错,就看报错栈里第一个包名,缺哪个补哪个,别一次性把所有 polyfill 都塞进去——体积会翻倍。

5.2 现象:上传第一次成功,第二次开始报 Failed to fetch

原因:IPFS 守护进程的 CORS 白名单只放行了特定来源。第一次可能是从 curl 或者别的端口测的,前端 dev server 换了端口(比如 Vite 自动从 5173 跳到 5174)之后就不在白名单里了。

解决:把API.HTTPHeaders.Access-Control-Allow-Origin配成["*"]只用于本地开发,或者每次换端口后重新执行一遍ipfs config并重启 daemon。生产环境不要用*,改成具体域名。排查时先看 daemon 终端有没有打请求日志,没打就是网络层没通,打了但报错才是 CORS。

5.3 现象:合约调用报 contract.methods.upload is not a function

原因:Artifact.networks[chainId]取到了undefined,new web3.eth.Contract(abi, undefined)创建出来的实例方法全空。最常见的触发场景是本地链重启——合约地址变了,但前端还在用上一次构建的 JSON。

解决:把 3.3 的部署脚本固定成“每次部署都覆盖前端 JSON”,并且connect()里对deployed做非空判断,直接抛带 chainId 的错误信息。看到这个错误第一反应不是改代码,是去看本地链当前 chainId 和 JSON 里的networkskey 对不对得上。

5.4 现象:共享出去的文件,对方登录后列表里没有;或者自己删掉的文件还挂在列表上

原因:remove是软删除,_files[id].owner变成address(0),但 id 还留在_owned[msg.sender]里。前端如果只遍历 id 列表、不检查 owner,就会把已删记录渲染成一条空白项。共享那边类似:如果sharedFiles()忘了过滤owner == address(0),接收方会看到一条已删除的文件。

解决:所有从数组 mapping 取列表的地方,统一过一遍_files[id].owner != address(0)。更彻底的做法是前端渲染前再做一次Array.filter(f => f.owner !== "0x0000000000000000000000000000000000000000"),双保险。这类 bug 不会报错,只会让界面数据莫名其妙,排查成本比报错高得多。

5.5 现象:交易一直 pending,或者报 nonce too low

原因:在本地链上连续快速点击上传,前一笔交易还没打包,第二笔用了同一个 nonce;或者中途重启过链,钱包里缓存的 nonce 比链上实际值大。

解决:前端加一个“交易进行中”的禁用态,用receipt回来再解锁,别靠setTimeout猜。

const [busy, setBusy] = useState(false); async function handleUpload(file) { if (busy) return; setBusy(true); try { await uploadFile(file, contract, account); await refreshList(); } finally { setBusy(false); // 无论成功失败都要解锁 } }

链重启后 nonce 对不上,最省事的处理是在钱包里重置账户活动记录,或者换一个测试账户。写代码层面能做的是捕获nonce too low这类错误并提示用户,而不是静默重试——重试很可能又撞上同一个 nonce。

6. 进阶验证:用事件回放与 CID 校验把 Demo 变成可信存储层

列表刷新这一块,我后来改成了事件回放优先、view兜底。原因很实际:myFiles()每次都要全量拉一遍,文件多了之后 RPC 响应会明显变慢;而事件是增量的,只取上次区块之后的日志就行。

export async function loadByEvents(contract, account, fromBlock = 0) { const logs = await contract.getPastEvents("FileUploaded", { fromBlock, filter: { owner: account }, }); // 用 id 去重,后写入的覆盖先写入的 const map = new Map(); for (const e of logs) { map.set(e.returnValues.id, { id: e.returnValues.id, cid: e.returnValues.cid, name: e.returnValues.name, owner: e.returnValues.owner, }); } return [...map.values()]; }

getPastEvents的filter只对indexed参数生效,所以FileUploaded里的id和owner都标了indexed,cid和name没标——这两个字段本来也不需要按条件筛。fromBlock传 0 会扫全链,本地链无所谓,真实链上要传一个合理的起始块,否则 RPC 节点会直接拒绝。

第二个验证动作是下载后的 CID 复算。这一步看着多余,实际是整个“可信存储”承诺的落点:只要本地能重新算出一模一样的 CID,就证明网关返回的字节和当初上传的字节完全一致。

export async function verifyCid(file, expectedCid) { const added = await ipfs.add({ path: file.name, content: file }); const actual = added.cid.toString(); return { ok: actual === expectedCid, actual }; }

注意add的参数必须和上传时保持同样的结构——带path就两边都带,不带就两边都不带,否则算出来的 CID 天然对不上,会误判成内容被篡改。这个细节我吃过一次亏:上传时用了{ path, content },校验时图省事传了裸 Blob,结果所有文件都报校验失败,查了半天才发现是自己两边不一致。

如果你要在这个基础上再做一层,建议加一个mapping(uint256 => bytes32) private _contentHash,把裸内容哈希单独存一份,校验时直接比哈希而不是重算 CID,速度快很多,也不依赖 IPFS 客户端的编码细节。合约改一行,前端少一次网络往返,值。

这套东西我从第一次跑通到现在,最大的教训是把它当成“能跑的 Demo”和当成“能用的存储层”之间隔着的不是功能,而是一致性——IPFS 的 CID、合约里的记录、前端的列表,三份数据任何时候对不上,用户看到的都是“文件丢了”。从那以后我每次改合约结构,都强制走一遍“清空本地链 → 重新部署 → 重新上传三类文件(小文本、图片、超过 10MB 的压缩包)→ 下载并校验 CID”的流程,宁可多花十分钟,也不要在演示现场对着空白列表解释。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询