首页 / 资讯中心 / 文章详情

从投票DApp入门区块链开发:Solidity合约与Web3全流程实践

从投票DApp入门区块链开发:Solidity合约与Web3全流程实践 ★ FEATURED ARTICLE
最近总有人问我同一个问题区块链开发入门究竟先做点什么好我的答案一直很固定——先写一个投票 DApp哪怕它看起来很不起眼。因为投票场景足够简单却能逼着你把一条完整的 Web3 开发链路全部走一遍从 Solidity 合约编写、本地模拟链测试再到钱包连接和前端交互最后部署到测试网。这篇文章就把我这次“快速学习”的完整过程拆开来讲包括每一步的选型理由、代码逻辑、踩坑记录以及你会用到的调试思路。它适合刚学完 Solidity 基础语法、想尽快做出一个能连钱包跑通的完整项目的开发者也适合想快速建立 DApp 全局认知的读者。1. 为什么拿“投票”当练手项目——选型逻辑与项目边界1.1 一个投票场景能覆盖 DApp 的全部关键节点很多新手入门时会纠结第一个项目是做代币还是 NFT但这两类项目都需要额外的标准规范知识。ERC-20 要理解 transfer、approve、allowance 这些授权模型的细节NFT 则涉及到 tokenId 的生成和元数据存储。对一个只想快速建立“去中心化应用”整体感知的人来说这些标准反而是噪音。投票 DApp 不一样它天然就是一组极简的业务规则“有哪些候选人”“谁投了谁”“怎么防止重复投票”“最后谁赢了”。落到区块链上这四句话分别对应了合约部署、状态读写、权限校验和链上查询恰好是 DApp 的所有核心环节。整个项目跑通后你掌握的实际上是可迁移的经验。下一次不管做什么类型的合约你都得回答同样的问题状态存在哪里、谁能改状态、怎样验证调用者的身份、前端如何监听链上变化。投票项目的每一个设计决策都能直接平移到更大的应用里。其实一个完整的 DApp 从用户角度看就三件事用钱包完成身份认证、向合约发起一笔交易、等待交易确认后看到结果更新。投票场景把这三件事压缩到了最少的代码量学习效率最高。1.2 技术栈的选择一切向“最少新概念”看齐这个项目的技术选型如下合约语言Solidity版本 0.8.x开发框架Hardhat配合官方 toolbox 插件本地链Hardhat 内置的运行节点前端纯 HTML 原生 JavaScript ethers.js 库钱包浏览器钱包插件测试阶段用它的开发网络账户你可能会问为什么不用 Truffle 或者 Foundry。我的判断标准其实很实际换成 Truffle你多学一层配置但不解决任何投票项目的痛点Foundry 更偏向高效的测试和 Solidity 脚本但快速学习阶段Hardhat 的调试工具和插件生态对新手更友好。尤其是 Hardhat Console 和console.log调试能力能把你在合约里“看不见的状态”直接打到终端上学习效率高出一大截。前端不用任何脚手架和 npm 构建工具这也是故意的。如果你用 React就得多理解一个 Hooks 依赖数组的问题但这个问题和区块链没有任何关系。纯 HTML 页面加一个脚本文件直接双击或者用一个极简静态服务器就能跑起来你唯一要专注于理解的就是浏览器里的钱包对象和以太坊 JSON-RPC 交互这对建立心智模型非常重要。提示如果你是第一次接触 Hardhat不需要急着理解它的所有插件机制。只需要知道它承担三件事管理合约编译、启动模拟区块链、帮你写部署与交互脚本。剩下的边用边学即可。2. 合约设计状态、权限与业务规则的最小实现2.1 三个状态变量为什么这样设计而不是别的方式投票合约的核心可以化简成三个数据// SPDX-License-Identifier: MIT pragma solidity ^0.8.17; contract SimpleBallot { struct Candidate { string name; uint voteCount; } Candidate[] public candidates; mapping(address bool) public hasVoted; }第一件事候选人为什么要用动态数组存结构体而不是单独建两个数组分别存名字和票数因为这二者天生是强关联的。把它们放在同一个结构体里后续遍历找赢家时candidates[i].voteCount一次就能取到完整数据不存在“两个数组长度不一致”这类状态隐患。第二件事为什么已投票的人用mapping(address bool)而不是另一个数组因为“某个地址有没有投过票”是一个天然的键值查询。mapping 查询时间复杂度为 O(1)而且在 Solidity 中mapping 的默认值就是 false不需要额外的初始化逻辑。如果要记录“谁投了谁”以便审计可以再额外加一个mapping(address uint) public votedCandidate;但最小场景里一个布尔映射已经够用。第三件事为什么候选人名单不是合约里写死的常量这个涉及到部署程序。把候选人作为constructor(string[] memory names)参数传入合约开发者就能在部署时灵活决定选项。你甚至不需要考虑合约部署后如何修改名单因为在一个最简单的投票场景里部署前定稿本来就是一个合理约束。2.2 “防重复投票”为什么是安全底线如果一段合约代码允许一个地址反复投票那整个投票系统的结果就没有任何意义。攻击者只要写一个循环反复调用投票函数一次交易就能刷出几千票。所以require(!hasVoted[msg.sender], already voted);是这套逻辑里最不能砍掉的一行。要注意这里的防重复投票依赖于msg.sender也就是发起交易的地址。这有一个天然局限一个用户可以创建无数个新钱包地址从而绕过限制。这在所有公链投票场景都会遇到属于“女巫攻击”。正规的链上投票通常还会叠加身份凭证比如持有某种不可转移的凭证代币或者绑定现实身份。但在学习用的最小项目中按地址去重是完全合理的第一步。2.3 核心函数投票、查赢家、统计总数接下来是完整合约逻辑我加了事件和查询函数。event VoteCast(address indexed voter, uint candidateId); constructor(string[] memory names) { for (uint i 0; i names.length; i) { candidates.push(Candidate(names[i], 0)); } } function vote(uint candidateId) external { require(candidateId candidates.length, invalid candidate id); require(!hasVoted[msg.sender], already voted); hasVoted[msg.sender] true; candidates[candidateId].voteCount 1; emit VoteCast(msg.sender, candidateId); } function winner() public view returns (string memory) { uint winningVoteCount 0; uint winnerIndex 0; for (uint i 0; i candidates.length; i) { if (candidates[i].voteCount winningVoteCount) { winningVoteCount candidates[i].voteCount; winnerIndex i; } } return candidates[winnerIndex].name; } function totalVotes() external view returns (uint) { uint count; for (uint i 0; i candidates.length; i) { count candidates[i].voteCount; } return count; }vote函数前面有一个输入校验candidateId candidates.length。这个 check 必须放在所有逻辑之前否则传一个超大的索引合约会让整个过程回滚白白浪费一笔交易的 gas。Solidity 里的 revert 会撤销所有状态改动所以即使hasVoted已经写入也会一并回退这是以太坊事务原子性带来的保护。winner是一个view函数它不发送交易只读取链上状态。最简单的求赢家方式就是遍历数组并记录最大值。候选人数量很小的时候完全够用。如果候选人能到成千上万的规模就需要引入更复杂的数据结构但对练手项目完全是多余的。totalVotes作为统计辅助函数主要用于前端页面上显示“当前总票数”也能间接验证每个人是否只投了一票。它可以被winner替代但单独写一个让读取语义更清楚。第二层设计思考是事件VoteCast看似多余其实前端交互非常依赖它。如果没有事件前端每次投票结束后必须重新轮询所有候选人数据才能知道链上发生了什么变化。而通过监听VoteCast可以实时捕捉到“哪个地址投了哪个候选人”让交互体验更接近现代应用。同时事件在打包时用indexed标记了voter这样外部程序可以按投票人地址进行高效检索。提示在学习阶段你可以显式触发 revert观察什么情况下交易会失败。比如用一个已经投过票的钱包再次调用vote浏览器钱包会弹出“交易预计将失败”的提示这正是防重复逻辑在前端的直接体现。3. 本地模拟链部署前的完整闭环验证3.1 Hardhat 节点为什么先别急着接测试网写代码之前先确认环境一个干净的 Hardhat 项目只需要npm init和安装两个包hardhat与nomicfoundation/hardhat-toolbox。初始化完成后在hardhat.config.js里指定编译器版本。require(nomicfoundation/hardhat-toolbox); module.exports { solidity: 0.8.17, };然后直接启动内置节点npx hardhat node这条命令会在本地启动一个模拟以太坊节点监听 8545 端口。它会一次性打印出 20 个开发账户和对应的私钥每个账户预置了充裕的测试 ETH。这些账户只存在于这个本地节点进程中重启后就消失所以你在测试网被骗到账户私钥也无所谓。为什么要先用本地节点而不是立刻部署到 Sepolia 这类测试网因为本地区块间隔短、无需等待、费用为零。当你还在反复调试合约逻辑的时候节约的时间非常可观。测试网的每个操作都要等真实区块出块一次简单部署可能就得等十几秒如果合约里有 bug你每次修复都要重复这个等待流程严重影响学习节奏。3.2 部署脚本把合约放到链上的标准动作Hardhat 项目里部署脚本通常放在scripts/deploy.js。下面是一个最简部署脚本const { ethers } require(hardhat); async function main() { const names [Alice, Bob, Carol]; const SimpleBallot await ethers.getContractFactory(SimpleBallot); const ballot await SimpleBallot.deploy(names); await ballot.waitForDeployment(); console.log(SimpleBallot deployed to:, ballot.target); } main().catch((error) { console.error(error); process.exitCode 1; });注意 Hardhat 不同版本中Contract.getContractFactory返回格式略有差异。如果你用的是 Hardhat 2.x 早期版本可能还需要await ballot.deployed()来做等待新一点的 Toolbox 版本则推荐waitForDeployment()。这类 API 变化很容易卡住新手遇到报错时优先查看当前版本插件的文档而不是照抄网上的旧教程。本地节点跑起来后另外开一个终端执行部署npx hardhat run scripts/deploy.js由于没加--network参数Hardhat 会默认部署到内置的临时开发网络。这个网络和npx hardhat node启动的持久节点不是同一个东西。临时开发网络在脚本结束后就销毁所以你拿到合约地址后如果立刻想测试最好还是手动先启动节点让它在后台保持运行。3.3 用两个不同账户验证投票计数部署成功只是万里长征第一步。真正验证逻辑是否可靠需要模拟多个用户各自投票。你可以用 Hardhat 提供的getSigners()拿到部署者之外的账户再通过connect切换调用者身份const hre require(hardhat); async function main() { const [deployer, voter1, voter2] await hre.ethers.getSigners(); const ballot await hre.ethers.getContractAt(SimpleBallot, 0x部署地址); const tx await ballot.connect(voter1).vote(0); await tx.wait(); console.log(voter1 voted); const tx2 await ballot.connect(voter2).vote(1); await tx2.wait(); console.log(voter2 voted); for (let i 0; i 3; i) { const c await ballot.candidates(i); console.log(${c.name}: ${c.voteCount.toString()}); } console.log(winner:, await ballot.winner()); }这段脚本模拟了两个不同地址分别投给不同候选人的完整过程。通过connect(voter1)让合约以 voter1 的身份发起调用这和前端里contract.connect(signer)是同一套逻辑。执行完脚本你应该看到 Alice 和 Bob 各一票而 winner 会因为票数相同返回数组里先出现的那个名字这说明 winner 函数只取首个最大值。建议你再补一个“坏路径”测试让 voter1 第二次调用vote观察脚本抛出的异常信息。Solidity 0.8 内置的require会附带already voted字符串你在脚本的 error.message 或交易回执中能看到对应的 revert 原因。这一步能帮助你理解链上校验在真实调用中如何抛出失败而不是等到前端上线后才发现问题。4. 前端接入连接钱包、读取链上数据、发起交易4.1 为什么可以在无构建工具的情况下写 DApp你把前端想象成一个没有服务器的特殊静态网站核心逻辑是浏览器里注入的钱包对象提供了以太坊网络的访问入口前端拿到这个入口后就能直接和合约交互。这里没有“后端 API”因为链本身就是一个后端。用纯 HTML 加一个script标签引入 ethers.js是最直接演示这个原理的方式。script srchttps://cdn.jsdelivr.net/npm/ethers5.7.2/dist/ethers.umd.min.js/script很多教程会引导你直接上 React 和 web3 库但在快速学习这个目标下我强烈建议从最朴素的前端开始。因为你不需要理解任何构建工具的细节只要一个浏览器和一个静态服务器。后端开发经验再少的人也能直接把精力放在“钱包连接”这个核心概念上。4.2 连接钱包window.ethereum 是什么浏览器钱包插件会通过注入脚本向每个页面提供window.ethereum对象。它是浏览器和以太坊节点之间的桥梁遵循 EIP-1193 协议。前端可以通过它发起 JSON-RPC 请求比如请求账户列表、发送交易、切换网络。第一件要做的事是检测这个对象是否存在。如果没有提示用户安装浏览器钱包扩展。如果存在调用eth_requestAccounts才能触发钱包弹窗让用户授权 DApp 读取地址。const provider new ethers.providers.Web3Provider(window.ethereum); async function connect() { await window.ethereum.request({ method: eth_requestAccounts }); const signer provider.getSigner(); const address await signer.getAddress(); document.getElementById(account).innerText 已连接 address; }这里的provider和signer是两个关键角色。provider 是只读视角类似数据库连接用来查询链上状态signer 是签名视角只有签名过的交易才会被网络接受。调用getSigner()后得到的对象封装了当前钱包账户的私钥管理能力DApp 本身永远碰不到用户私钥。提示如果打开页面时钱包弹出的是盲签名或权限请求你需要在浏览器钱包的设置里手动连接网站。开发和测试阶段建议使用单独的浏览器配置文件避免日常浏览器的缓存和 Cookie 干扰连接状态。4.3 读取候选人列表与实时票数拿到合约地址和 ABI 之后合约调用就变成了一件非常直观的事。ABI 相当于合约的“接口说明书”。它由编译过程自动生成在本地项目里位于artifacts/build-info的子文件中。可以让脚本输出合约 ABI然后拷贝到前端脚本里。const contractAddress 0x你的部署地址; const abi [...]; // 从 artifacts 中复制 const contract new ethers.Contract(contractAddress, abi, provider); async function loadCandidates() { const container document.getElementById(candidates); container.innerHTML ; for (let i 0; ; i) { let info; try { info await contract.candidates(i); } catch (e) { break; } const button document.createElement(button); button.innerText ${info.name}${info.voteCount.toString()} 票; button.onclick () vote(i); container.appendChild(button); } }注意candidates是public变量Solidity 编译器会自动生成一个同名 getter用contract.candidates(i)就能取到第 i 个候选人的信息。我没有通过单独的函数获取候选人总数而是采用循环尝试读取直到抛错这会在候选人数组很短时简单有效但更规范一点还是写一个candidateCount()函数。当你试验到多个候选人时这种方式也能训练你处理 Solidity 数组边界条件。4.4 发起投票并刷新 UI真正改变链上状态的操作必须经过签名和交易广播。前端代码只需要两步用 signer 连接合约然后调用投票函数。调用完vote(i)返回的是一个交易对象需要等待tx.wait()获得回执才能确定交易已上链。async function vote(index) { const signer provider.getSigner(); const signedContract contract.connect(signer); const tx await signedContract.vote(index); await tx.wait(); await loadCandidates(); }这里有个细节值得展开同样是vote这个函数用 provider 连接时你只能调用view函数而用 signer 连接时你才能发送交易。因为真正的选票写入需要私钥签名这是链上身份认证的基本逻辑。tx.wait()有几个阶段值得观察刚返回的 transaction hash 代表交易已经被内存池接收如果 hash 出现但一直 pending多半是 gas 设置问题或当前网络不对。如果希望页面“实时”跟随其他用户的投票变化可以用合约事件监听contract.on(VoteCast, function (voter, candidateId) { console.log(${voter} 投了 ${candidateId}); loadCandidates(); });事件监听适合开发调试和实时展示但它强依赖网络的 WebSocket 连接。测试环境下 MetaMask 的本地节点通常已支持事件推送这里能跑通换到某些公共节点服务商时可能需要切换providers.JsonRpcProvider这样的高级配置属于后续优化的范畴。5. 测试网部署配置、私钥保护与验证5.1 基础设施准备节点 URL、测试币和账户私钥本地模拟链只能证明逻辑正确要让别人真正通过钱包访问你的 DApp你需要把合约部署到一个公共测试网。测试网是真实区块链的复制品有区块生成、有浏览器可查、有多个节点共同参与唯一和主网不同的是代币没有实际经济价值。第一步是去节点服务商申请一个免费项目的 HTTP 端点也就是 RPC URL。这类服务商通常会让你创建项目、选择目标测试网然后生成一个类似https://sepolia.infura.io/v3/xxx的地址。这些地址你将在 Hardhat 配置文件中使用相当于你的开发机器到以太坊节点的入口。第二步是领取测试币。测试网的水龙头会按一定规则向你的地址发送少量测试 ETH每笔部署和投票交易都需要消耗测试 ETH 作为 gas。千万不要在未领到测试币时直接部署否则会得到类似“insufficient funds”的报错这是新手最常撞到的第一堵墙。5.2 配置 Hardhat同时防止私钥泄露测试网部署的hardhat.config.js一般像下面这样require(nomicfoundation/hardhat-toolbox); require(dotenv).config(); module.exports { solidity: 0.8.17, networks: { sepolia: { url: process.env.SEPOLIA_RPC_URL, accounts: [process.env.PRIVATE_KEY], }, }, };这里有两条铁律第一私钥永远不能直接写在代码里第二环境变量文件绝不能提交到 Git 仓库。我见过太多人为了赶进度把私钥粘贴在 config 文件里再顺手 push 到公开仓库结果被自动化脚本扫走所有资产。开发阶段用的账户最好是专用的测试账户主网上的钱包私钥绝不能出现在这个文件里。使用dotenv插件可以让 Hardhat 自动读取.env文件。在.env里写SEPOLIA_RPC_URL你的HTTP端点 PRIVATE_KEY你的测试账户私钥还要在.gitignore里确认忽略.env。如果项目仓库本身是公开的哪怕只是半分钟私钥也可能被机器人抓取所以养成习惯非常重要。提示测试网部署比本地部署多一个--network参数。如果漏了Hardhat 会默认部署到本地开发网络。在我自己的经验里至少有两次急着部署测试网却忘了加参数导致前端连接时怎么都找不到合约最后发现合约还躺在本地的内存里。5.3 部署到测试网并认真检查交易执行部署npx hardhat run scripts/deploy.js --network sepolia部署完成后记下合约地址然后打开测试网浏览器把地址粘贴到搜索框你会看到这笔部署交易的所有信息区块高度、交易的 from 和 to、Gas used、合约代码等。这是一个很好的习惯——永远用区块浏览器来验证交易是否真的执行成功不要只靠命令行输出。命令行说成功不代表交易回执里一切正常尤其在测试网可能出现区块延迟或者前端轮询过早的问题。测试网部署成功后把你的前端配置里的contractAddress改成测试网上的地址然后在浏览器钱包里切换网络到同一个测试网。投票交易会进入待处理状态通常几秒钟后出块确认页面应该能正确显示票数更新。前置条件都是公开信息不需要注册任何服务时你也可以直接用浏览器钱包内置的测试网水龙头领取测试币。这取决于钱包版本和地区的可用性自行判断即可。6. 避坑清单与下一步扩展方向6.1 我按真实踩坑顺序整理的高频问题第一类问题出在“网络不匹配”。本地 DApp 在测试网部署后前端如果不切换网络MetaMask 会提示“调用合约地址出错”。我当时的处理方式是在前端连接钱包后检测networkVersion如果和合约所在网络不一致就主动调用钱包的wallet_switchEthereumChain这个方法属于标准协议可以放心使用。第二类是“交易一直 pending”。这在本地节点不常见但在测试网经常遇到。可能原因只有两个gas price 设置过低或者 nonce 被之前的一笔 pending 交易卡住。MetaMask 里有个“重置账户”功能可以把本地 nonce 缓存清掉。但重置前一定要确认钱包里没有还靠区块确认的交易否则会出现撞 nonce 的混乱。第三类是“前端读取不到数据”。排除网络问题后最常见原因是 ABI 没更新。合约代码改了之后重新部署但前端还复制着旧的 ABI或者合约地址指向旧部署。我的习惯是每次重新部署后立刻把artifacts里新生成的 ABI 文件复制到前端然后验证一下地址。这听起来琐碎但非常容易漏。第四类比较隐蔽合约里winner()数组遍历时如果存在并列票数你会得到靠前的候选人。这不算 bug但在真实产品中需要明确处理并列规则。如果不想让并列出现就得引入更严格的“平分加赛”机制或者直接允许并列投票结果。很多真实的链上投票工具都公开说明这一设计决策而不是让读者自行猜测。6.2 这个项目还能往哪些方向扩展如果你已经完整走通了上面的流程考虑给项目加以下功能给候选人增加“竞选宣言”绑定到 IPFS 或简单字符串给投票设置时间窗口用block.timestamp限制开始与结束时间加一个管理员角色允许管理员增删候选人采用“委托投票”机制让支持者把票权转移到自己信任的人手里或者引入“二次方投票”计算权重限制巨鲸影响。其中“时间窗口”最贴近真实需求。合约可以改成uint public voteStart; uint public voteEnd; function vote(uint candidateId) external { require(block.timestamp voteStart, not started); require(block.timestamp voteEnd, voting closed); ... }这个实现让前端 UI 能动态显示“投票未开始”“投票进行中”“投票已结束”三个状态也多层面对 timeout 场景做了验证。推荐下一个练手项目就是给投票合约加上时间锁并把剩余时间显示在页面上。不过这里提醒一句区块链时间戳由出块节点决定不是绝对精确的。时间窗口只能控制的“区块层级”精度。对于社区投票、提案表决这类场景足够但如果要精确到秒的实时竞拍还需要额外的链下协调工具。6.3 关于部署后真实使用场景的思考我在把投票 DApp 部署到测试网并让几个朋友实际体验后才发现“连接钱包”本身对普通用户仍然是一道门槛。有的浏览器没有安装钱包扩展有的安装了却不知道如何切换测试网还有的因为网络慢导致页面长时间白屏。你想把这个项目做得“像产品”就得处理这些现实问题。我的建议是核心功能先跑通不要一开始就做花哨的加载动画。把精力和时间放到“链上状态变化能否正确驱动界面变化”这个核心命题上。等你有了二次或三次迭代再去考虑错误提示、加载骨架、移动端适配这些工程化问题。DApp 的界面不仅是一个 UI 层它不断在回答一个问题——我怎么证明我看到的数据就是链上的真实数据。想明白这一点比写再多组件都更值钱。当你把这个投票项目完整跑通一遍再回头看以太坊官方文档里的那些概念你会发现自己理解得比当初快得多。因为你不是在看抽象名词而是在看已经亲手踩过一遍的系统。区块链开发入门最大的障碍从来不是语法而是心智模型交易广播、区块确认、签名授权、事件回溯。一个简单的投票 DApp 恰好把这一整套模型串起来了这是它作为学习项目最大的价值。
阅读完成 · 觉得有帮助?
咨询建站