ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

区块链投票Dapp开发实战:Truffle+Solidity+React三端集成指南

区块链投票Dapp开发实战:Truffle+Solidity+React三端集成指南 简介智能合约投票系统Dapp是一份面向区块链初学者的完整实践资源聚焦Truffle、React与Solidity三者的结合演示如何从零构建一个基于以太坊的去中心化投票应用。资源围绕合约层、部署层与前端层展开Solidity定义投票选项、权限与计票规则Truffle提供编译、部署和自动化测试环境React搭建用户交互界面并通过Web3.js与链上合约通信覆盖合约编写、环境配置、部署上线到前端调用的完整链路适合正在学习智能合约开发或需要课程作业参考的人群。压缩包整体大小约6.59MB内容大致包含Solidity合约源码、Truffle迁移脚本、单元测试文件、配置文件以及React前端应用等模块结构清晰便于按目录定位练习。已有1544人学习下载具备一定的参考热度。通过此资源读者可以掌握Solidity语法、Truffle工程化操作、Web3.js调用智能合约等关键技能理解投票上链后透明防篡改的实现原理并获得一个可直接运行和二次开发的Dapp原型。1. 从一份编程作业说起智能合约投票系统Dapp到底在做什么你可能刚拿到这样一份作业用 truffle 写合约、用 solidity 实现投票逻辑、再用 react 搭一个前端页面最终交付一个能跑起来的 Dapp。这个题目看起来是三条技术栈的拼盘但真正动手时会发现难点不在写合约也不在写 react而在把三方捏合在一起的状态管理——前端怎么知道合约部署在哪条链、交易确认后界面如何刷新、migrate 之后为什么拿不到新地址。这套系统解决的是「如何让浏览器里的普通人通过 MetaMask 参与链上投票」的问题适合正在修区块链课程的学生也适合想快速走通一个最小 Dapp 的开发者。做完你会得到一个完整的 C 端应用候选人列表、投票按钮、实时票数以及一个不可篡改的计票结果。2. 先把技术栈捋清truffle、solidity、react 各自在该系统中的角色2.1 为什么作业环境里选 truffle 而不是 hardhat常见做法是选 truffle。它自带编译、迁移、测试和部署脚本一条truffle migrate就能把合约发布到本地开发链并且配合 ganache 使用非常顺手。hardhat 最近更流行但它的插件体系需要额外配置hardhat-toolbox对作业来说多了一层不确定性。truffle 的迁移脚本更像一个「项目入口」从编译到部署再到调用测试网所有步骤都在同一个目录下完成出错时更容易定位是合约问题还是配置问题。如果你更看重社区里的作业模板truffle 也明显占优。truffle unbox react会生成一个包含 client 目录的前后端一体项目直接就给出了 react 读取合约实例的标准写法。相比之下hardhat 的模板往往只覆盖合约侧react 前端需要自己从零接线。我一般会建议除非你已经非常熟悉 hardhat否则作业场景优先选 truffle。下表是我在实际对比后总结出的选型要点对比项trufflehardhat迁移脚本内置 migrations 目录按序号执行需要自己写 deploy scriptreact 模板unbox react 直接可用需要额外找模板或手搭与 ganache 集成自动获取网络地址需要配置 network 的 url出错信息相对直接定位到文件有时堆栈很深2.2 solidity 合约里的投票数据结构与事件设计一个最小投票合约核心是三个要素候选人、投票人、计票状态。候选人我一般用一个 struct 存名字和票数再用数组维护顺序投票人用一个mapping(address bool)防止重复投票计票状态通过candidates[uint].voteCount体现。事件设计很容易被忽略但它是 react 前端能否实时刷新的关键。你需要在每次投票成功时抛出Voted(address voter, uint candidateId)事件让前端订阅这个事件并重新读取票数。如果不设计事件前端只能靠轮询或者手动刷新按钮体验上就差了很多。pragma solidity ^0.8.0; contract Voting { struct Candidate { string name; uint voteCount; } Candidate[] public candidates; mapping(address bool) public voters; address public owner; event Voted(address indexed voter, uint indexed candidateId); constructor(string[] memory _names) { owner msg.sender; for (uint i 0; i _names.length; i) { candidates.push(Candidate({name: _names[i], voteCount: 0})); } } }这个合约只做了数据结构和构造函数。candidates是 publicsolidity 会自动生成查询函数candidates(i)可以返回第 i 个候选人的 name 和 voteCount。voters的 mapping 默认值是 false所以不需要额外初始化。owner 保存部署者后续添加候选人时要用到。2.3 react 前端与智能合约的交互方式react 本身连不上区块链中间需要一层 web3 桥。常见做法是用truffle-contract包装 ABI 和地址然后交由 web3 实例发起交易。还有一个方案是用 web3.js 直接生成合约实例但那时你需要手动处理contract.json里的地址字段比 truffle-contract 多了一步。我偏好 truffle-contract因为它能直接读取truffle migrate写入 artifact 文件里的networks字段自动选择正确的部署地址。前提是 contract 实例要和当前 web3 的 provider 绑定否则它默认出现的地址是 undefined。下面这段代码是任何 react 组件里都绕不开的初始化。import VotingContract from ../abis/Voting.json; import web3 from web3; const getContract async () { const provider window.ethereum; const web3Instance new web3(provider); const networkId await web3Instance.eth.net.getId(); const deployedNetwork VotingContract.networks[networkId]; if (!deployedNetwork) { throw new Error(合约尚未部署到当前网络); } const instance new web3Instance.eth.Contract( VotingContract.abi, deployedNetwork.address ); return instance; };这里最关键的是deployedNetwork的查找。很多新手直接写VotingContract.networks[5777]一旦换成 ganache 的其他端口或者部署到测试网就失效。通过networkId动态获取是最稳的写法。注意 provider 要优先使用window.ethereum它同时适配 MetaMask 和大部分浏览器钱包。2.4 开发环境搭建node、truffle、ganache 的版本搭配版本搭配直接决定你是否能顺利跑通。truffle 5.x 要求 node 版本不能太老solidity 0.8.x 又能适配 truffle 5.4 以上。我目前比较稳的组合是node 16.20.2 truffle 5.11.5 ganache 7.9.2 solidity 0.8.19。如果你用 node 18部分依赖会报 OpenSSL 错误需要加NODE_OPTIONS--openssl-legacy-provider不建议这样折腾。nvm install 16.20.2 nvm use 16.20.2 npm install -g truffle5.11.5 npm install -g ganache如果你不想全局装 ganache也可以用ganache-cli它是 ganache 的轻量版功能完全一致。安装完后再建项目目录执行truffle init然后truffle unbox react可以把 react 模板也拉进来。注意truffle unbox会覆盖当前目录建议先建一个空目录再操作。检查版本是否对得上用truffle version看 solidity 版本用node -v看 node 版本两者不冲突就可以继续。3. 写一个能跑的投票合约核心函数与权限控制3.1 合约的数据结构与状态变量数据结构在上一章已经给出雏形现在要把权限控制和投票函数补全。我建议把管理员和普通用户彻底分开只有 owner 能添加候选人只有未投票的地址能投票并且投票后直接标记杜绝重复投。状态变量我习惯这样分块第一块是候选人相关第二块是投票人相关第三块是事件。在Candidate数组外再添加一个candidateCount变量虽然candidates.length也能拿到数量但在视图函数里多用变量可以省去一些类型转换的麻烦。contract Voting { struct Candidate { string name; uint voteCount; } Candidate[] public candidates; mapping(address bool) public voters; address public owner; uint public candidateCount; event Voted(address indexed voter, uint indexed candidateId); constructor(string[] memory _names) { owner msg.sender; for (uint i 0; i _names.length; i) { candidates.push(Candidate({name: _names[i], voteCount: 0})); } candidateCount _names.length; } }这里candidateCount在初始化时赋值后续addCandidate函数里也要同步更新。public变量的 getter 会自动生成前端可以用candidateCount()直接拿到候选人数量然后循环调用candidates(i)。相比之下如果你在 react 里直接调用candidateCount读取返回值是一个 BigNumber 对象记得调用.toString()。3.2 投票与候选人管理的函数实现投票函数的核心是require(voters[msg.sender] false, already voted)这一行同时承受了两个业务规则不能重复投票且智能合约无法阻止无投票权的地址参与所以必须由前端或白名单机制来控制谁有资格投票。本作业最简方案是允许所有地址投一票满足课程验收即可。function vote(uint _candidateId) external { require(_candidateId 0 _candidateId candidateCount, invalid candidate); require(!voters[msg.sender], already voted); voters[msg.sender] true; candidates[_candidateId].voteCount 1; emit Voted(msg.sender, _candidateId); } function addCandidate(string memory _name) external onlyOwner { candidates.push(Candidate({name: _name, voteCount: 0})); candidateCount 1; } modifier onlyOwner() { require(msg.sender owner, not owner); _; }第一行require保证了候选人 ID 在有效范围内防止前端传一个越界值导致异常。第二行是防重复投票的关键。如果作业想做得更完善可以再增加一个投票截止时间变量比如uint public votingDeadline在构造函数里设置并在vote函数里检查block.timestamp votingDeadline。但大多数作业没这个要求这里不做过多的设计。3.3 迁移脚本把合约部署到开发链迁移脚本是 truffle 的固定入口文件名以数字开头代表执行顺序。常见做法是创建一个2_deploy_contracts.js在里面传入候选人名单。注意构造函数参数是 string 数组solidity 里对应的是string[] memory所以在 JavaScript 里需要传一个普通数组。const Voting artifacts.require(Voting); module.exports function (deployer) { const candidateNames [Alice, Bob, Charlie]; deployer.deploy(Voting, candidateNames); };部署后 truffle 会把合约地址和 ABI 写入build/contracts/Voting.json。如果你在过程中修改了合约代码需要重新执行truffle migrate --reset否则 truffle 检测不到变化不会重新部署。--reset会强制重新编译并部署代价是链上会生成一份新合约旧地址作废前端一定要读取最新的 artifact。3.4 用 truffle test 验证投票逻辑测试不是作业硬性要求但能让你在写前端前就确认合约没有逻辑漏洞。truffle 内置了 Mocha 测试框架测试文件放在test/目录用truffle test运行。const Voting artifacts.require(Voting); contract(Voting, (accounts) { let voting; before(async () { voting await Voting.new([Alice, Bob]); }); it(should start with two candidates, async () { const count await voting.candidateCount(); assert.equal(count.toString(), 2); }); it(should allow a user to vote once, async () { await voting.vote(0, { from: accounts[1] }); const candidate await voting.candidates(0); assert.equal(candidate.voteCount.toString(), 1); }); it(should reject double voting, async () { try { await voting.vote(0, { from: accounts[1] }); assert.fail(should have thrown); } catch (error) { assert(error.message.includes(already voted)); } }); });第一行Voting.new([Alice, Bob])相当于直接把合约当参数传入部署truffle 会生成一个测试专用的实例和migrate部署的互不干扰。测试三个场景初始候选人数、投一票后票数变化、重复投票被拒绝。第二个测试里的{ from: accounts[1] }很重要默认发送者是accounts[0]如果不换地址第三个测试会立刻失败。4. 用 React 搭前端从连接钱包到实时刷新票数4.1 项目布局truffle unbox react 与手工集成的取舍truffle 官方提供了truffle unbox react模板它的前端放在client/目录下并且预先引入了一个简单的合约读取示例。这个模板最大的优势是省去了 react 与 truffle 之间的构建配置webpack 已经帮你把合约 ABI 的 json 文件处理好了。缺点是模板里的依赖版本可能比较老react 可能还是 16.8 的早期版本但作业完全够用。如果你不想用模板手工集成需要额外做三件事把build/contracts目录暴露给 webpack确保Voting.json能被 import配置环境变量区分开发链地址处理 web3 与 MetaMask 的窗口事件。这些工作量足以让你从下午忙到晚上所以除非作业明确要求手写配置否则直接用模板更划算。truffle unbox react cd client npm install web3模板里默认没有 web3 依赖需要自己装。同时你要确认client/package.json里的react版本是否和你系统里的 node 兼容如果出现编译错误大多依赖react-scripts的版本可以尝试降到 4.x。4.2 拿到合约实例的关键代码在 react 组件里拿到合约实例我一般会封装成一个自定义函数避免每个组件都重复绑定 provider。这一步是作业里最容易出错的地方因为Voting.json里networks字段依赖于最近一次的truffle migrate如果你改了合约但没有重新部署前端会一直拿到旧地址。import Voting from ../abis/Voting.json; import Web3 from web3; export async function getVotingContract() { const provider window.ethereum; if (!provider) { throw new Error(请安装 MetaMask); } await provider.request({ method: eth_requestAccounts }); const web3 new Web3(provider); const networkId await web3.eth.net.getId(); const networkData Voting.networks[networkId]; if (!networkData) { throw new Error(合约未部署到当前网络请先 truffle migrate); } return new web3.eth.Contract(Voting.abi, networkData.address); }这里特意调用了eth_requestAccounts它会让 MetaMask 弹出授权窗口只有用户授权后你才能拿到地址。如果你在页面初始化的useEffect里直接调浏览器会拦截弹窗所以最好通过按钮触发授权。Voting.networks[networkId]的键是数字 IDganache 默认是 1337但如果你修改过配置文件一定要以配置文件里的network_id为准。4.3 候选人列表与投票按钮的实现拿到合约实例后前端需要循环读取候选人列表。因为 solidity 的公共数组访问器每次只能返回一个元素所以前端要写一个 for 循环依次调用candidates(i)。注意返回值里 name 是字符串voteCount 是 BigNumber渲染时需要.toString()。import React, { useState, useEffect } from react; import { getVotingContract } from ../utils/web3; function VotingPage() { const [candidates, setCandidates] useState([]); const [account, setAccount] useState(); const [contract, setContract] useState(null); const loadCandidates async (instance) { const count await instance.methods.candidateCount().call(); const list []; for (let i 0; i count; i) { const c await instance.methods.candidates(i).call(); list.push({ name: c.name, voteCount: c.voteCount.toString() }); } setCandidates(list); }; const handleConnect async () { const instance await getVotingContract(); setContract(instance); const accounts await window.ethereum.request({ method: eth_accounts }); setAccount(accounts[0]); await loadCandidates(instance); }; const handleVote async (id) { await contract.methods.vote(id).send({ from: account }); await loadCandidates(contract); }; return ( div {!account button onClick{handleConnect}连接钱包/button} ul {candidates.map((c, index) ( li key{index} {c.name} - 票数 {c.voteCount} button onClick{() handleVote(index)} disabled{!account}投票/button /li ))} /ul /div ); } export default VotingPage;这个组件里最核心的是loadCandidates它从candidateCount()拿到人数再逐个读取候选人最终拼出带票数数组。handleVote在交易确认后重新加载列表是一种最直观但不那么优雅的方案——先看效果再引入事件监听优化。4.4 事件监听让票数实时刷新的最佳实践手动刷新有个问题如果用户开了两个窗口一个窗口投完票另一个窗口不会自动更新。更好的做法是监听合约的Voted事件并在回调里重新加载列表。在 react 里结合useEffect使用注意清理监听器否则容易造成内存泄漏和多次加载。useEffect(() { if (!contract) return; const voteEvent contract.events.Voted(); voteEvent.on(data, () loadCandidates(contract)); voteEvent.on(error, (err) console.error(err)); return () { voteEvent.removeAllListeners(data); voteEvent.removeAllListeners(error); }; }, [contract]);事件监听里的voteEvent是可以用removeAllListeners清理的子订阅这是 web3.js 对合约事件的封装。设计事件回调时你可以从事件参数里拿到candidateId然后只更新那一个候选人的票数而不是整个列表。作业里求稳直接全量刷新也没事性能不会成为瓶颈。5. 避坑指南truffle react 联调时的 5 个常见问题5.1 合约地址拿到 undefined每一次 migrate 后都要重新获取现象前端调用getVotingContract()时报错提示deployedNetwork未找到或者instance.methods.candidateCount()返回 undefined。原因Voting.json里的networks字段只在truffle migrate时写入。如果你先跑过一次 migrate然后重启了 ganache再修改合约紧接着truffle migrate又失败那么 JSON 里的地址还是旧链上的地址和当前链的 networkId 对不上。另一个常见原因是你把build/contracts文件拷贝给前端时不小心带上了错误的版本。解决在每次前端启动前删除build目录并重新执行truffle migrate --reset。如果用的是truffle unbox react模板注意client目录启动时会直接引用src/abis/下的 JSON 文件你要确认这个文件是后来从build/contracts拷贝过来的而不是模板自带的旧合约。最稳妥的流程是先执行truffle migrate --reset再把build/contracts/Voting.json复制到client/src/abis/最后启动 react。5.2 React 中投票后页面不刷新事件监听没用好现象点击投票MetaMask 已经确认交易链上票数也变了但页面上的数字不变刷新页面后又变了。原因最常见的是你在handleVote里没有重新加载数据或者加载时使用的合约实例和当前页面绑定的不是同一个。另一个原因是你监听事件的方式不对——如果直接在组件里contract.events.Voted()但没保存返回值react 重新渲染时又会创建新的监听器旧监听器断掉且回调失效。解决先确认loadCandidates在交易回执之后被调用同时检查你传入的from地址是否是授权账户。事件监听推荐写成useEffect依赖数组里包含contract变量这样一旦合约实例变化就会重新订阅。如果还是不行可以在回调里打印日志看事件是否真的触发。注意 ganache 的events.Voted()返回的是一个 EventEmitter你需要监听它的data事件不要写成await contract.events.Voted()那样只会拿到一个订阅对象。5.3 MetaMask 扣费后报错 Nonce too high现象MetaMask 弹窗确认交易后链上迟迟不出现随后报错Nonce too high, expected nonce X but got Y。原因这是本地链重置引发的典型问题。你跑了ganache --deterministicMetaMask 缓存了之前的交易 nonce随后重启 ganache链上 nonce 归零而 MetaMask 还在从上次的交易序号继续就会一直报错。尤其在作业过程中反复 migrate、重置网络很容易触发。解决在 MetaMask 里对当前账户执行「设置 - 高级 - 重置账户」这会清空本地 nonce 缓存。如果重置后仍然报错检查 MetaMask 当前连接的网络是否真的与 ganache 端口一致。另一个根治方法是每次truffle migrate前把本地合约地址和 MetaMask 的 network 重新对上避免在多个本地链之间来回切换。5.4 truffle migrate 时 Gas estimation failed 的三种内幕现象truffle migrate在部署阶段报错Gas estimation failed有时会提示exceeds block gas limit有时直接无下文。原因第一种是构造函数参数写错。比如 solidity 合约构造函数要求string[]你却在 JavaScript 里传了一个字符串truffle 会把字符串当成数组处理导致参数解码失败。第二种是合约构造函数里做了require判断比如require(_names.length 0)而你在部署时传了空数组gas 预估阶段直接 revert。第三种是 solidity 版本和编译配置不匹配比如 pragma 写^0.8.0但 truffle-config 里却指定了 0.5.0 的编译器导致字节码生成异常。解决先手动跑truffle compile --all确认编译通过。然后检查构造函数参数类型是否和合约定义一致特别留意字符串数组。如果合约里有复杂的require逻辑可以在部署脚本里先打印参数验证。最后如果实在找不出原因在部署脚本的deployer.deploy里显式加gas: 6721975但这个数字要小于当前链的区块 gas 上限ganache 默认是 6721975刚好卡在边缘。5.5 Node 版本和 truffle 的兼容性坑现象truffle命令不存在或者运行truffle migrate时崩溃报TypeError: Cannot read properties of undefined (reading toString)。原因truffle 5.x 的依赖中存在web3旧版本它们在 node 18 及以上环境会触发 Buffer 相关 API 的兼容问题。node 20 尤为严重经常在加载 ganache 或解析 JSON 时直接挂掉。很多同学的作业最终都倒在环境搭建这一步。解决使用 nvm 切换 node 版本固定到 16.x。具体命令是nvm install 16.20.2 nvm use 16.20.2然后全局重装 truffle 和 ganache。如果你已经用 node 18 创建了项目切完 node 版本后最好删除node_modules和package-lock.json重新安装否则旧的二进制依赖仍然指向不兼容的编译版本。另外macOS 用户如果开了系统自带的安全权限truffle 命令可能无法执行需要到「系统设置 - 隐私与安全性」里允许运行。6. 让作业不止于能跑验证思路与两个细节优化如果只是想交作业前面五章已经够用但要做到「演示流畅、能回答老师追问」我建议再花一小时做两件事。第一利用useEffect的清理函数处理合约事件监听这正好可以展示你对 react 生命周期函数的理解。第二验证投票结果真的不可篡改——在 ganache 里随便选一个投票交易点击查看它的输入数据再对比兄弟节点的状态。你的合约里没有管理员改票函数链上数据就不会被单方面修改。关于 react 生命周期函数我举一个具体的优化点不要把loadCandidates直接写进useEffect没依赖数组的版本里那样会造成每个状态变化都重新调用而且容易出现竞态。正确的做法是useEffect(() { if (contract account) { loadCandidates(contract); } return () { setCandidates([]); }; }, [contract, account]);依赖数组里放contract和account这样只有合约实例和账户变化时才重新加载数据。清理函数里把 candidate 清空能在组件卸载时避免对已卸载组件的setState警告。这是作业里值得加分的细节。另一个提升是结合react图表的可视化。作业要求只用按钮展示票数但你可以用react-chartjs-2画一个柱状图把票数映射成高度。实现上只需要在组件返回 JSX 时把candidates数组传给图表组件代码量不大视觉冲击力很强。我在自己带的新人项目里就是这么做的老师追问时你可以说这是对原始数据的「诚实展示」——只做了聚合没做任何中心化处理。最后说一个我的习惯坚持在写 react 前先用truffle console把合约所有函数手动调一遍。命令行里看到的返回值、错误信息是比任何前端调试器都准确的真相。我会在建好合约后运行truffle console然后依次执行Voting.deployed()、.candidateCount()、.vote(0, {from: acct})确认每一步输出再去写前端。这样当页面报错时我能立刻区分是合约问题还是 react 问题。希望这个习惯能帮你在作业里少走一晚上弯路。本文还有配套的精品资源点击获取
返回列表