ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Vue3+Web3.js区块链DApp实战:从架构设计到合约部署

Vue3+Web3.js区块链DApp实战:从架构设计到合约部署

简介:《区块链应用开发:Vue3+Webjs的DApp实战》是一份聚焦Vue3与Web3.js的DApp开发实战教程,面向具有一定前端基础、希望切入区块链领域的开发者,系统解决从以太坊智能合约到去中心化应用集成的核心问题。全书共94页,单份PDF文件,压缩包约4.46MB,支持目录章节跳转与阅读器左侧大纲定位,文档内容完整、图表函数显示正常。目前已有90人学习下载。内容从区块链基础与DApp架构出发,依次涵盖Vue3组合式API、Web3.js数据读取与交易发送、Solidity合约开发部署、MetaMask钱包对接、安全机制、性能优化、测试部署策略,并以去中心化投票系统为例串联全流程;还展望多链生态、去中心化身份、DeFi 2.0等前沿方向。章节划分清晰,尤其适合需要快速搭建Vue3+Web3.js项目并理解智能合约交互的开发者按需查阅。请注意,文档仅供学习参考,请勿用于商业用途。

1. 从 Vue3 到链上:这份 DApp 实战资料值不值得跟

说实话,DApp 开发最折腾人的不是写合约,而是前端跟链之间那层说不清的边界。我在重构一个投票原型时,光是在“连接钱包 → 拿账户 → 发交易 → 收事件”这条链上就翻了三次车。这份 94 页的《区块链应用开发:Vue3+Web3.js 的 DApp 实战》正好把这条链路拆干净了——从区块链基础、Vue3 的组合式 API,到 Web3.js 的账户与合约交互,再到 Solidity 安全、Hardhat 部署和一个完整的去中心化投票系统案例,文档支持目录跳转,也能当手册按章节查。适合刚会 Vue 3 想碰链的前端,也适合后端转全栈拿来做技术选型对照。整份内容没有只讲概念,每一章都能落到能跑的代码上。

2. DApp 分层架构与组合式 API:四层边界和一组可复用代码

2.1 四层架构:为什么不愿让前端直接调合约

资料第一章就反复强调一件事:不要因为合约能跑,就把所有逻辑堆在前端。一个正经 DApp 应该拆成四层,而不是“页面 + 合约”两层。按资料里的分层思路,我整理成下面这个边界表:

层级职责常见技术边界约束
用户界面层渲染、交互、表单校验Vue3、Tailwind、TypeScript不直接操作 RPC,不持有私钥
应用逻辑层状态管理、业务规则、缓存Pinia、组合式函数不感知链上细节,可测试
区块链交互层连接网络、账户、交易、事件封装Web3.js / Ethers.js统一单位换算和错误处理
智能合约层业务核心数据与规则Solidity、Hardhat只暴露 ABI,权限收敛

为什么这么拆?我踩过的坑是:最初把合约调用直接写在组件里,后来合约加了一个参数,前端所有调用点都要改;更麻烦的是,测试时想换一条测试链,每个组件都要跟着改 Provider。把交互层抽出来之后,换链只改一处配置,前端组件根本不知道后面是本地网络还是测试网。资料里提到的“最小权限原则”和“避免外部调用”,本质也是逼着开发者把合约当边界,而不是当数据库。

另一个容易被忽略的点是状态管理。Vue3 的组合式 API 很适合做 DApp 的状态封装,但很多人还在用 Vue2 时代的 mixin,导致多个组件混入同一个 web3 实例时出现响应式失效。资料里的做法是:用组合式函数管理连接状态,用 Pinia 存跨页面共享的账户和链 ID。

2.2 组合式 API 落地:把 Web3 封装成 useBlockchain

资料给出了 setup 函数、生命周期钩子和依赖注入的基本写法,真正能直接抄的是下面这个组合式函数。它把连接钱包、读取余额、监听账户变化这三件事收敛到一个方法集合里:

// src/composables/useBlockchain.js import { ref, onMounted } from 'vue' import Web3 from 'web3' export function useBlockchain() { const account = ref('') const balance = ref('0') const chainId = ref('') const web3 = ref(null) async function connectWallet() { if (!window.ethereum) { throw new Error('未检测到浏览器钱包插件,请先安装 MetaMask') } const accounts = await window.ethereum.request({ method: 'eth_requestAccounts' }) account.value = accounts[0] web3.value = new Web3(window.ethereum) await refreshChainState() } async function refreshChainState() { if (!web3.value || !account.value) return chainId.value = await web3.value.eth.getChainId() const wei = await web3.value.eth.getBalance(account.value) balance.value = web3.value.utils.fromWei(wei, 'ether') } function onAccountsChanged(callback) { if (!window.ethereum) return window.ethereum.on('accountsChanged', async (accounts) => { account.value = accounts[0] || '' await refreshChainState() callback && callback(accounts) }) } onMounted(() => { // 插件注入过地址时,直接预填显示 if (window.ethereum?.selectedAddress) { account.value = window.ethereum.selectedAddress } }) return { account, balance, chainId, web3, connectWallet, refreshChainState, onAccountsChanged } }

逻辑说明:web3用ref包了一层,因为初始化时可能是null,只有用户授权后才赋值;refreshChainState里把余额从 wei 转成 ether,是为了让前端模板直接显示带小数点的数,避免自己去拼接精度。onMounted里处理selectedAddress,是常见做法——钱包插件刷新后不会自动重新授权,但会保留上一次会话的地址,这样用户刷新页面不用再点一次连接按钮。

在组件里用起来很简短:

// src/views/Dashboard.vue <script setup> import { useBlockchain } from '@/composables/useBlockchain' import { onMounted } from 'vue' const { account, balance, chainId, connectWallet, refreshChainState, onAccountsChanged } = useBlockchain() onMounted(() => { onAccountsChanged(() => { refreshChainState() }) }) </script> <template> <div v-if="account"> <p>账户:{{ account }}</p> <p>余额:{{ balance }} ETH</p> <p>链 ID:{{ chainId }}</p> </div> <button v-else @click="connectWallet">连接钱包</button> </template>

这里有个参数陷阱:onAccountsChanged的回调在钱包插件里是接收整个账户数组的,如果你只关心当前账户,一定要取accounts[0]。资料里强调“账户状态管理”时也点了这一点。以前有同学直接把整个数组赋给account,结果地址栏显示成[0x...],签名交易时直接报错。组合式函数的好处就是把这类细节收敛在一处,组件里永远拿到的都是一维账户字符串。

3. Web3.js 集成 Vue3:从初始化连接走到交易上链

3.1 环境初始化:Vite 工程与三种 Provider 选型

资料第三章把 Web3.js 的安装和初始化放在很靠前的位置,我的习惯也是先把空项目跑起来再谈合约。创建一个 Vue3 + Vite 项目:

npm create vue@latest my-dapp cd my-dapp npm install web3

版本这里要留个心眼:Web3.js 目前有 1.x 和 4.x 两个大版本,API 不完全兼容。资料里的商法主要以 1.x 为准,我下面也按 1.x 写,如果你装到 4.x,web3.eth.sendTransaction的回填方式会有差异。创建完工程后,Provider 选型决定你连接哪条链:

// 方式一:浏览器钱包注入 const web3 = new Web3(window.ethereum) // 方式二:本地开发节点(Ganache / Hardhat network) const web3 = new Web3('http://localhost:8545') // 方式三:公共RPC服务(测试网/主网) const web3 = new Web3('https://your-rpc-endpoint.example.com')

参数说明:方式一最省事,但要求用户先安装钱包插件并授权;方式二适合跑测试,本地节点区块时间快,gas 费用可忽略,但链 ID 和主网不同;方式三要换成你实际申请或自建的 RPC 地址,注意很多公共 RPC 服务有频率限制,生产环境不建议直接暴露在前端。我一般是开发阶段用方式二,联调阶段用方式三的测试网,主网永远不放在前端源码里。

硬编码 Provider 地址等于把切换网络的动作变成改代码,所以资料里推荐的“插件化”思路值得借鉴——把初始化动作放到一个 Vue 插件里,让所有组件共享同一个web3实例。

3.2 交易链路:从授权到回执的完整封装

资料里最实用的部分是把“发送交易”封装成一个可以复用的流程,而不是每写一个页面就调一次eth_sendTransaction。下面是我按资料思路整理的一个函数:

async function sendEther(toAddress, amountInEther) { if (!web3.value) throw new Error('web3 未初始化') const from = account.value // 动态获取实时 gasPrice,避免前端写死 const gasPrice = await web3.value.eth.getGasPrice() const tx = { from, to: toAddress, value: web3.value.utils.toWei(amountInEther, 'ether'), gasPrice, gas: 21000, // 纯转账固定 21000,合约调用要另估 nonce: await web3.value.eth.getTransactionCount(from, 'pending') } const receipt = await web3.value.eth.sendTransaction(tx) return receipt.transactionHash }

逻辑说明:toWei的第二个参数ether决定了精度换算,前端输入的是带小数点的字符串,函数内部统一转成 wei。nonce显式设置了pending状态,这样连续发两笔交易时不会因为 nonce 冲突而互相覆盖。gas 固定 21000 只适用于普通 ETH 转账,如果to是合约地址,gas 要单独用estimateGas估算,否则容易踩到“gas required exceeds allowance”。

交易发出后不要急着刷新页面,要先等回执。资料里给了查询回执的写法:

const hash = await sendEther('0xYourAddress', '0.1') // 轮询回执,网络不稳定时最多等 10 次 let receipt = null for (let i = 0; i < 10; i++) { receipt = await web3.value.eth.getTransactionReceipt(hash) if (receipt && receipt.status === '1') { console.log('已确认,消耗 gas 为', receipt.gasUsed) break } await new Promise(resolve => setTimeout(resolve, 3000)) } if (!receipt) { throw new Error('等待回执超时,请通过区块浏览器查询 hash') }

getTransactionReceipt在交易还在 pending 时返回null,所以必须轮询。receipt.status === '1'表示成功,'0'表示 revert。这里要注意:很多人在轮询里忘了判断status,只判断 receipt 非空,结果拿到一条失败的交易还继续往下执行。资料里虽然没有长篇展开,但“处理异步操作与错误”这一节值得反复看,我后来每次发交易都是先 estimateGas,再 send,最后验 status,三件套一个不能少。

4. Solidity 合约与 Hardhat 测试:部署上链前的四道关卡

4.1 合约设计:状态变量、事件与权限

资料第四章从 Solidity 基础语法讲起,再到设计模式,最后落到测试部署。我按它的节奏,以一个最精简的投票合约为例。这个例子几乎覆盖了 DApp 开发里 80% 的合约写法:

// SPDX-License-Identifier: MIT pragma solidity ^0.8.0; contract Voting { struct Proposal { string name; uint256 voteCount; } mapping(address => bool) public hasVoted; Proposal[] public proposals; bool public votingOpen = true; event Voted(address indexed voter, uint256 indexed proposalId); constructor(string[] memory names) { for (uint256 i = 0; i < names.length; i++) { proposals.push(Proposal(names[i], 0)); } } function vote(uint256 proposalId) external { require(votingOpen, "Voting is closed"); require(!hasVoted[msg.sender], "Already voted"); require(proposalId < proposals.length, "Invalid proposal"); hasVoted[msg.sender] = true; proposals[proposalId].voteCount++; emit Voted(msg.sender, proposalId); } function getProposal(uint256 id) external view returns (string memory, uint256) { return (proposals[id].name, proposals[id].voteCount); } }

逻辑说明:mapping(address => bool) hasVoted是防重复投票的关键,vote函数里三个require分别处理投票关闭、重复投票、提案越界。事件Voted里两个参数都标了indexed,前端就可以按投票者或提案 ID 做过滤。getProposal用view声明,意思是它不消耗 gas,前端可以直接调用,不需要用户签名。

参数说明:external和public的区别要拎清——external仅允许外部调用,合约内部不能用;vote不需要内部调用,所以用external省一点 gas。string memory里的memory表示这个是内存拷贝,不是引用存储数组,如果不声明,Solidity 会默认尝试用storage,编译就会报错。资料里强调的状态变量最小化,在这个合约里就是尽量把votingOpen做成本来就固定值的布尔变量,而不是每个函数都去链上读一堆配置。

4.2 Hardhat 测试与部署:本地模拟到 ABI 交付

合约写出来不算完,资料里的重点在测试与部署。我一般用 Hardhat 而不是 Remix 跑整个流程,因为测试和部署可以用脚本控制。先看配置:

// hardhat.config.js module.exports = { solidity: '0.8.24', networks: { local: { url: 'http://127.0.0.1:8545', chainId: 31337 } } }

这段配置里最容易坑人的是chainId。如果你同时开着 Ganache,默认 chainId 是 1337,而 Hardhat 本地网络是 31337,前端连接时如果链 ID 不匹配,MetaMask 会一直提示“wrong network”。资料里专门讲了用chainChanged事件去适配网络,但前提是合约层配置要先对。

部署脚本要写成可重复执行的:

// scripts/deploy.js const { ethers } = require('hardhat') async function main() { const Voting = await ethers.getContractFactory('Voting') const voting = await Voting.deploy(['Alice', 'Bob', 'Carol']) await voting.waitForDeployment() const address = voting.target console.log('Voting deployed to:', address) } main().catch(console.error)

注意waitForDeployment是 Hardhat 2.x 以后的方法,旧版本写的是deployed()。部署成功后,合约地址要填进前端的配置里,同时把编译产物里的abi文件拷贝到前端src/abis/目录。很多人部署完只拿地址,忘了拿 ABI,前端new web3.eth.Contract(abi, address)没有 ABI 根本没法实例化。

测试用例是最后一道关:

const { expect } = require('chai') describe('Voting', function () { it('should record a vote', async function () { const [voter] = await ethers.getSigners() const Voting = await ethers.getContractFactory('Voting') const voting = await Voting.deploy(['Alice']) await voting.vote(0) const proposal = await voting.getProposal(0) expect(proposal[1]).to.equal(1) }) it('should reject duplicate vote', async function () { const [voter] = await ethers.getSigners() const Voting = await ethers.getContractFactory('Voting') const voting = await Voting.deploy(['Alice']) await voting.vote(0) await expect(voting.vote(0)).to.be.revertedWith('Already voted') }) })

这里面最值得抄的是第二个案例:用await expect(...).to.be.revertedWith('Already voted')来验证前置条件是否生效。资料里提到的“常见安全漏洞”,在这个投票场景里就是“重复投票”这个业务漏洞,如果合约没有hasVoted这个映射,前端怎么禁止都没用,因为用户可以自己调 RPC 直接给合约发交易。测试驱动的好处,就是逼你在写前端之前先把这些边界想清楚。

5. 避坑指南:连接钱包、单位换算与事件监听的四个教训

5.1 连接失败:环境检测与错误码

现象:用户点“连接钱包”按钮没反应,控制台报Cannot read properties of undefined (reading 'request');或者点了授权但前端一直停在“等待确认”。

原因:浏览器没有安装任何钱包插件,window.ethereum不存在;还有一种情况是插件装了,但用户点了拒绝授权,Promise reject 的错误码是 4001,代码里没区分就直接当成网络错误。

解决:连接前必须做环境检测,并对 4001 单独提示。我后来固定用下面这段:

async function connect() { if (!window.ethereum) { alert('请先安装浏览器钱包插件') return } try { const accounts = await window.ethereum.request({ method: 'eth_requestAccounts' }) return accounts[0] } catch (err) { if (err.code === 4001) { console.error('用户拒绝了授权') } else { console.error('连接失败', err) } } }

注意:eth_requestAccounts只能在用户点击事件的调用栈里触发,页面加载时自动调会被插件拦截。我在写单页应用时经常犯这个错,以为刷新后能自动连上,实际必须等交互事件。

5.2 单位与精度:两个 18 位的小数坑

现象:转账输入0.1,点击确认后 MetaMask 弹出的是0.1 ETH,但到账的余额不对,或者提示insufficient funds。

原因:web3.eth.sendTransaction的value参数单位是 wei,0.1换算成 wei 等于0.1 * 10^18。如果直接把 input 的值塞进value,实际发送的是0.1 wei,约等于没有;反过来,读余额时如果没有从 wei 转成 ether,前端会显示一长串数字。

解决:所有进链的金额用toWei(字符串, 'ether'),所有出链的金额用fromWei(字符串, 'ether'),而且第一个参数必须是字符串,不能是浮点数:

const valueWei = web3.utils.toWei('0.1', 'ether') // 正确,输出 100000000000000000 // const badWei = web3.utils.toWei(0.1, 'ether') // 某些版本会因浮点精度丢位

如果用户输入0.123456789123456789123,超出 18 位小数,toWei会直接抛错。前端要先做小数位数校验,这是很多同学忽略的点。

5.3 切换网络后监听断流与事件丢失

现象:合约事件监听刚加载时还能收到Voted事件,用户在小狐狸里手动切换到另一条链之后,页面再也收不到新事件,有时还直接报错。

原因:创建 Web3 实例的时候,Provider 绑定的是当时正在连接的链。切换网络后,旧实例虽然还在,但发送的请求全部对到旧链,等于断流。资料里提到“实时事件监听”时会让你订阅chainChanged,但很多人只写了提示,没有重新初始化合约。

解决:在chainChanged回调里重建 web3、重建合约对象、重新订阅事件:

window.ethereum.on('chainChanged', (chainId) => { web3.value = new Web3(window.ethereum) contract.value = new web3.value.eth.Contract(abi, contractAddress) subscribeToEvents() // 把之前的 filter 全部取消再重新订阅 })

还有一个容易被忽略的:chainChanged回调参数是十六进制字符串,比如0x1是主网,0xaa36a7是常见的测试网。判断chainId.value时不要直接用数字1去比较,要转成十进制再看。

5.4 写方法一直 pending:没估 gas 和状态不同步

现象:调用vote(0)之后,前端loading状态一直转圈,交易在区块浏览器里又能查到成功回执。

原因:写方法send的gas参数没有手动估,或者低估了;另外,前端在send之后没有主动调用读取函数刷新状态,导致界面和链上脱节。

解决:发送前先estimateGas,发送后等回执再刷新;如果是多用户场景,还要监听合约事件来触发全局刷新:

const gas = await contract.methods.vote(0).estimateGas({ from: account.value }) const receipt = await contract.methods.vote(0).send({ from: account.value, gas }) if (receipt.status === '1') { await refreshProposals() }

这里最容易踩的是“点击两次”:第一次交易还在 pending,用户又点了第二次,等于提交了两笔交易,而且 nonce 相同会互相覆盖。我后来在按钮上加了pending状态,交易 hash 没回来之前禁用按钮,再从pending状态转回来。

6. 投票系统收尾:合约闭环与上线自检清单

6.1 投票系统闭环:状态机与前端联动

把前面几章凑到一起,一个可用的投票系统大概是这个状态机:合约处于votingOpen时,前端允许用户连接钱包并投票;用户投票成功后,合约触发Voted事件,前端监听事件后刷新提案列表;投票关闭后,合约把votingOpen置为false,前端读取状态并禁用按钮。

这里有个关键设计:不要把“投票是否成功”的判断放在前端,而是以合约回执的status为准。前端可以做乐观更新来提升体验,但最终链上确认后必须用事件结果覆盖。资料里第九章案例也是这么处理的,先写合约,再写组件,最后才是 UI 细节。如果你要验证自己的能力能不能拼起一套完整链路,最有效的练习就是把概率系统的合约、部署脚本、前端事件监听三件套白手起家各写一遍。

6.2 上线前的自检清单

检查项方法常见失败信号
合约测试通过npx hardhat test任何 revert 报错
本地链 ID 一致对比 hardhat config 与前端配置MetaMask 提示 wrong network
余额单位转换输入 0.1,查看 MetaMask 确认金额金额变成极小数值
事件监听稳定切换网络后再回到原链事件不再触发
交易失败回滚故意重复投票页面状态与链上不一致
ABI 版本与合约匹配对比编译产物和前端文件方法名 undefined

最后一条是我从投一个正式合约时换来的教训:合约改了一个函数名,前端 ABI 文件没同步更新,结果调用一直报method is not a function。因为 ABI 是编译产物,不是手写的,每次改合约后必须重新拷贝。从那以后,我每次做 DApp 原型,都强制自己先走一遍“连接 → 授权 → 读链上状态 → 发一笔交易 → 监听回执”的完整流程,五步全通才开始写业务组件。少一步,后面都得回来补课。希望帮到你。

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

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进