现在网络上已经有很多Web3应用教程的资源,可是作为新手往往还是很容易一头雾水。因为现有的技术栈实在太多了,同时大部分教程会默认读者对教程中所使用的技术很熟悉。于是即使是获取余额这样的简单功能,不同教程会给出完全不同的实现方法。实在是很不友好。
我作为一个只会一点c++/python的初学者,之前基本没有接触过前端代码,妥妥的初学者。整理本文一方面希望能给别的初学者些微帮助,同时也算是记录一下,免得自己过段时间忘记了如何操作。所以本文尽量不涉及DApp技术栈的底层细节,而是着眼于先写出一个可用的东西。想要在这个领域继续深入的读者,自然会再去自行寻找更多的技术知识来提高。
本文部分例子来自于《Web3 Tutorial: build DApp with Web3-React and SWR》。
特别注意:因为本人完全不掌握前端技能,所以代码中的页面展示部分存在诸多不合理与需优化的地方。但这些都不是重点,本文的目标就是先让程序跑起来。
本文全部代码(不包含各种依赖库)已上传至Github
https://github.com/IrStar/hello-web3
包管理:npm
IDE:VS Code
前端页面:Next.js + TypeScript
UI库:NextUI
Provider: web3-react
wallet: MetaMask
合约环境: hardhat
建立文件夹/hello-web3作为我们的项目目录。通常来说,我们需要开发两部分内容:前端页面、链上合约。有些情况下可能还需要服务端,在本文先不涉及。为了方便,我们在本文会使用两个不同目录来保存相应内容。
我们使用Next.js + NextUI + TypeScript来进行前端页面开发。由于typescript是javascript的超集,web3-react也是基于typescript来编写,所以为了便于 照抄 学习其代码,我们也选择使用typescript。
cd ./hello-web3
npx create-next-app webapp --typescript
...
cd webapp
这时会自动创建一个名为/webapp的文件夹,我们会在其中存放前端部分的代码。为了看起来比较整洁,我们使用src作为root路径。(Next.js关于src文件夹的说明)
mkdir src
mv pages src/pages
mv styles src/styles
这里还需要一步操作,打开tsconfig.json文件,
// 在"compilerOptions"中添加:
"baseUrl": "./src",
然后运行 npm run dev,浏览器会自动打开一个窗口,也可以自己打开访问http://localhost:3000 显示Next.js的欢迎页面。

我们使用NextUI作为我们的UI库。
npm i @nextui-org/react
在/src目录下新建/components目录,并在其中新建文件layout.tsx
import Head from 'next/head'
import React, { ReactNode } from 'react'
import styles from 'styles/layout.module.css'
type Props = {
children: ReactNode
}
export default function Layout(props: Props) {
return (
<>
<Head>
<title>Hello Web3 World.</title>
</Head>
<main className={styles.main}>{props.children}</main>
</>
)
}
相应地,在/src/styles目录下新建文件layout.module.css,内容非常简单
.main {
height: calc(100vh - 64px);
background-color: white;
padding: 0rem 8rem;
flex: 1;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
}
接下来修改/src/pages/_app.tsx
import type { AppProps } from 'next/app'
import Layout from 'components/layout'
export default function MyApp({ Component, pageProps }: AppProps) {
return (
<Layout>
<Component {...pageProps} />
</Layout>
)
}
最后回来修改/src/pages/index.tsx
import type { NextPage } from 'next'
import { Grid, Card, Text, Button} from "@nextui-org/react";
interface Props {
title: string,
content: string
}
const HomeCard = (props:Props) => {
return (
<>
<Card hoverable bordered shadow={false} css={{my: "10px"}}>
<Text h4> {props.title} </Text>
<Card.Footer>
<Text> {props.content} </Text>
</Card.Footer>
</Card>
</>
)
}
const Home: NextPage = () => {
return (
<>
<Grid.Container justify="flex-start">
<Text h2 css={{my: "1rem", px: "5px"}}>
第一个 Web3 DApp
</Text>
<HomeCard title='本文目标' content='让DApp跑起来' />
</Grid.Container>
</>
)
}
export default Home
刷新浏览器窗口,可以看到新的页面。

目前web3-react有stable V6和beta V8两个版本,我们在这里使用V6版本。回到/webapp文件夹,使用npm安装web3-react
npm i @web3-react/core @web3-react/injected-connector ethers
接下来修改/src/pages/_app.tsx,首先增加两个import
import { Web3ReactProvider } from '@web3-react/core'
import { Web3Provider } from '@ethersproject/providers'
增加getLibrary函数
function getLibrary(provider: any): Web3Provider {
return new Web3Provider(provider)
}
然后对MyApp函数进行修改
export default function MyApp({ Component, pageProps }: AppProps) {
return (
<Web3ReactProvider getLibrary={getLibrary}>
<Layout>
<Component {...pageProps} />
</Layout>
</Web3ReactProvider>
)
}
接下来我们以连接MetaMask为例,在/src/components目录中新建一个connectMetaMask.tsx文件。如果要连接其它的钱包,使用web3-react提供的useWeb3React也可以很方便地实现,具体方法可以参考这篇文章。
import { useEffect } from 'react'
import { Grid, Card, Button, Text} from '@nextui-org/react'
import { useWeb3React } from '@web3-react/core'
import { Web3Provider } from '@ethersproject/providers'
import { UserRejectedRequestError } from '@web3-react/injected-connector'
import { injectedConnector } from 'utils/connectors'
import { formatAddress } from 'utils/helpers'
const ConnectMetaMask = () => {
const { account, chainId, active, library, connector,
activate, deactivate, setError } = useWeb3React<Web3Provider>()
const onClickConnect = () => {
activate(injectedConnector,(error) => {
if (error instanceof UserRejectedRequestError) {
// ignore user rejected error
console.log("user refused")
} else {
setError(error)
}
}, false)
}
const onClickDisconnect = () => {
deactivate()
}
useEffect(() => {
console.log(chainId, account, active, library, connector)
})
return (
<>
<Card bordered shadow={false} css={{my: "10px"}}>
{active && typeof account === 'string'
? <Grid><Button onPress={onClickDisconnect} >
Account: {formatAddress(account,4)}
</Button>
<Text>ChainID: {chainId} connected</Text>
</Grid>
: <Grid><Button onPress={onClickConnect}>
Connect MetaMask
</Button>
<Text> not connected </Text>
</Grid>
}
</Card>
</>
)
}
export default ConnectMetaMask
注意:本文使用的是web-react的V6版本。当使用V8版本时,const { library } = useWeb3React()语句中的library需要修改为provider
这里用到了两个功能函数。在
/src目录下新建utils文件夹,并新建connectors.tsx和helpers.tsx两个文件。其中connector.tsx列出了当前支持的链ID,也可以再手动添加新的链ID。这里要注意的是,我们稍后会使用hardhat来进行合约开发和本地链的部署,所使用的链ID是31337,而在MetaMask中默认的本地链ID是1337,所以一定要记得修改,不然会导致连接失败。
import { InjectedConnector } from "@web3-react/injected-connector"; export const injectedConnector = new InjectedConnector({ supportedChainIds: [ 1, // mainnet 3, // ropsten 4, // rinkeby 5, // goerli 42, // kovan 31337, // localhost ] })
helpers中提供了将40位地址只显示前后各4位的方法。export function formatAddress(value: string, length: number = 4) { return `${value.substring(0, length + 2)}...${value.substring(value.length - length)}` }记得将我们刚刚新建的
connectMetaMask引入到index.tsx中,然后刷新页面。import ConnectMetaMask from 'components/connectMetaMask' ... <ConnectMetaMask />可以看到我们的页面已经具备了与MetaMask连接的能力,并显示当前的链。
未连接状态 连接了以太坊主链 作为一个入门教程,我们只在本地链上操作,先不去访问真实链上的数据,所以就不需要Relay Network了。未来真实上链的时候,可以考虑Infura或者Alchemy。我个人使用Alchemy,主要是因为个人感觉它的免费方案更实惠。还有Moralis提供了直接访问区块链状态的功能,避免反复调用第三方网络的API,不过我对这个功能的需求不是很强,所以暂时还没有尝试。
我们首先回到
/hello-web3,创建/chain文件夹,并进行初始化。我们将把合约相关的代码放在这个目录里。mkdir chain cd chain npm init -y接下来我们需要安装hardhat作为本地的Solidity合约开发环境,并创建起始项目。
npm i hardhat创建起始项目
这里会出现几个选项。使用箭头上下移动,选择Create an advanced sample project that uses TypeScript 其实选择另外几个选项也是可以的,不过我懒得手动安装工具包,就让hardhat自己看着来了
一路回车。hardhat会自动安装一些工具包,等待安装完成后即可。hardhat会自动生成一个样例合约,一个部署脚本和一个测试脚本。有兴趣的同学可以试试。我们先把它们删除,再利用OpenZeppelin编写一个简单的ERC20合约,那么先安装OpenZeppelin
npm i @openzeppelin/contracts接下来,在
/chain/contracts目录中新建HelloToken.sol//SPDX-License-Identifier: MIT pragma solidity ^0.8.0; import "@openzeppelin/contracts/token/ERC20/ERC20.sol"; contract HelloToken is ERC20 { constructor(uint supply) ERC20("HelloToken", "HLT") { _mint(msg.sender, supply); } }该合约定义了一种叫做HelloToken的ERC20代币,简写符号为HLT,当有人在链上部署该合约时,会给部署者发放若干数量的代币。
我们快速写一个单元测试脚本
/chain/test/HelloToken.test.tsimport { expect } from "chai"; import { ethers } from "hardhat"; describe("HelloToken Initial Supply", function () { it("Should have the correct initial supply", async function () { const initialSupply = ethers.utils.parseEther('10000.0') const HelloToken = await ethers.getContractFactory("HelloToken"); const token = await HelloToken.deploy(initialSupply); await token.deployed(); expect(await token.totalSupply()).to.equal(initialSupply); }); });再写一个简单的部署脚本
/chain/scripts/HelloToken_deploy.tsimport { ethers } from "hardhat"; async function main() { const initialSupply = ethers.utils.parseEther('10000.0') const HelloToken = await ethers.getContractFactory("HelloToken"); const token = await HelloToken.deploy(initialSupply); await token.deployed(); console.log("HelloToken deployed to:", token.address); } main().catch((error) => { console.error(error); process.exitCode = 1; });这里需要注意一点,hardhat的本地测试链有in-process和stand-alone两种模式。当没有指定网络时,默认使用in-process模式。但是由于in-process模式并不具有独立的链ID,所以大部分情况下我们需要使用的是stand-alone模式。为了启动stand-alone模式的本地测试链,首先打开一个新的终端窗口并导航到当前的
/chain目录中,然后运行稍等一下,就能看到本地测试链启动成功,并创建了20个账号地址与它们的私钥,每个地址上有10000个ETH。
共创建20个账号地址与相应私钥。注意由于这些地址的私钥是公开的,所以任何人都可以操作主链上的这些地址。千万不要在主链上给这些地址转账!有兴趣的同学可以去Etherscan上面看看主链上的这几个地址里面的存款和交易记录,被坑过的人不少啊! 回到之前的终端窗口中,来运行单元测试。
npx hardhat test --network localhost注意,如果只输入
npx hardhat test而忽略掉后面的--network localhost,则使用的就是in-process模式。那么继续,在本地测试链上部署我们的ERC20合约。
npx hardhat run scripts/HelloToken_deploy.ts --network localhost部署成功后,通过部署脚本打印出了合约的部署地址。
这里显示的就是合约在本地测试链上的部署地址 一定要注意的是,每次重新部署都可能让合约地址发生变化,所以在与合约交互时一定要注意传递的合约地址是否正确。有兴趣的同学也可以尝试通过hardhat提供的控制台来与合约进行交互,这里就省略了。
当我们部署合约时,可以看到前面执行了
npx hardhat node的控制台窗口中一直有信息输出,包括在链上执行的只读指令和出块信息。持续一段时间之后,可能会导致我们无法查看之前创建的账号地址与私钥。这时可以在另一个终端窗口中执行npx hardhat accounts,来查看当前测试链上的账号地址信息。为了读取合约,我们需要让前端知道合约都提供了哪些可用方法。回到
/hello-web3/webapp/src目录,新建/abi文件夹,并在其中新建HelloTokenABI.tsx文件。export const HelloTokenABI = [ // Read-Only Functions "function balanceOf(address owner) view returns (uint)", "function totalSupply() view returns (uint)", "function symbol() view returns (string)", ];我们使用了合约中提供的三个只读方法。其中
balanceOf返回指定地址中的代币数量,totalSupply返回代币的总发行数量,symbol返回代币的简写符号。在
/src/components目录中新建HelloToken.tsximport React, { useEffect,useState } from 'react' import { Card, Text } from "@nextui-org/react" import {HelloTokenABI as abi} from 'abi/HelloTokenABI' import { useWeb3React } from '@web3-react/core' import { Web3Provider } from '@ethersproject/providers' import {Contract} from "@ethersproject/contracts"; import { formatEther } from "@ethersproject/units" import { parseEther }from "@ethersproject/units" interface Props { addressContract: string } export default function HelloToken(props:Props){ const addressContract = props.addressContract const [symbol,setSymbol]= useState<string>("") const [totalSupply,setTotalSupply]=useState<string>() const [balance,setBalance]=useState<string|undefined>(undefined) const { account, active, library } = useWeb3React<Web3Provider>() useEffect( () => { if(!(active && account && library)) return const contract = new Contract(addressContract, abi, library); library.getCode(addressContract).then((result:string)=>{ //check whether it is a contract if(result === '0x') return contract.symbol().then((result:string) => { setSymbol(result) }).catch('error', console.error) contract.totalSupply().then((result:string) => { setTotalSupply(formatEther(result)) }).catch('error', console.error) }) //called only when changed to active },[active]) useEffect(()=>{ if(!(active && account && library)) { setBalance(undefined) return } const contract = new Contract(addressContract, abi, library); library.getCode(addressContract).then((result:string)=>{ //check whether it is a contract if(result === '0x') return contract.balanceOf(account).then((result:string) => { setBalance(parseFloat(formatEther(result)).toFixed(2)) }).catch('error', console.error) }) },[active,account]) return ( <> <Card hoverable bordered shadow={false} css={{my: "10px"}}> <Text><b>HelloToken合约地址</b>: {addressContract}</Text> <Text><b>Token总发行量</b>: {totalSupply} {symbol}</Text> <Text><b>当前钱包中的HelloToken数量</b>: {balance} {symbol}</Text> </Card> </> ) }这里同样注意,当将web-react从V6版本更新到V8版本后,原有的const { library } = useWeb3React()需要将library修改为provider
相应还要修改
/src/pages/index.tsximport HelloToken from 'components/HelloToken'; ... const addressContract = '0x5FbDB2315678afecb367f032d93F642f64180aa3' ... <HelloToken addressContract={addressContract} />注意,这里我们赋给addressContract的值,就是前面所得到的合约地址。如果得到的合约地址变化了,这里也一定要修改成实际的地址。另外,需要将MetaMask的网络切换到 Localhost 8545也就是本地测试链。
连接钱包后,即可显示当前钱包地址中拥有的HelloToken代币数量 刚才我们只使用了合约当中的只读方法,也就是只能读取链上数据,并不能对数据做出修改。那么现在我们来尝试对链上数据进行修改操作。
首先还是在
/src/abi/HelloTokenABI.tsx文件中,增加合约所提供的写入链上状态数据的方法。这里准备使用合约中的transfer方法,该方法实现了转账功能,会将调用者地址中的若干数量的代币,发送至目标地址中。"function transfer(address to, uint amount) returns (bool)",然后我们修改
/src/components/HelloToken.tsx文件。首先增加两个hooks,用于设置目标地址和待发送数量const [toAddress, setToAddress]=useState<string>("") const [amount,setAmount]=useState<string>('100')然后增加一个异步函数用于完成实际的转账功能
async function onTransfer(event:React.FormEvent) { event.preventDefault() if(!(active && account && library)) return // new contract instance with **signer** const contract = new Contract(addressContract, abi, library.getSigner()); contract.transfer(toAddress,parseEther(amount)).catch('error', console.error) }注意在实例化contract时,与上一节中的方法相比,最后一个参数从library变成了library.getSigner(),这是因为该操作会改变链上状态数据,所以需要调用者进行签名。当然,还需要支付gas
然后增加需要在页面上显示的内容,相应也要记得引入NextUI库的Input和Button组件
<Card hoverable bordered shadow={false} css={{my: "10px"}}> <form onSubmit={onTransfer}> <Input label="Amount" type="number" css={{mr: "10px"}} required onChange={(e) => setAmount(e.target.value)}/> <Input label="To Address" type="text" css={{ml: "10px"}} required onChange={(e) => setToAddress(e.target.value)} /> <Button type="submit" disabled={!account} css={{mt: "20px"}}> Transfer </Button> </form> </Card>刷新浏览器页面。首先连接钱包,然后填入准备转账的代币数量,这里以1000个为例,再从测试链的20个钱包地址中随便选一个地址作为目标地址。点击Transfer发送,MetaMask会弹出来要你签名确认,几秒钟后转账成功!
两点需要注意。第一点是发送代币数量不要多于当前钱包的拥有数量,否则合约执行会发生错误,而我们目前还没有处理链上发出的错误。第二点是不要使用当前钱包地址作为转账目标地址,否则合约执行同样会发生错误。
MetaMask提示的交易信息,确认无误后点击确认 耐心等待几秒钟,就能看到交易已经成功了 不过虽然转账成功了,但是页面上显示的当前账户金额却没有变化。这是因为我们的useEffect hook并没有被触发。我们接下来就解决这个问题。
由于链上的合约执行结束后并不会向链外返回数据,所以即使链上状态数据发生了变化,在链外也无法得知。为了解决这个问题,合约会在链上状态数据发生变化时发送事件广播。在链外对合约的事件广播进行监听,就可以确定链上发生了什么事情。例如对于ERC20合约,在transfer执行成功后会发送Transfer事件广播。我们首先修改
/src/abi/HelloTokenABI.tsx文件,告知前端页面可能会接收到的事件类型。"event Transfer(address indexed from, address indexed to, uint amount)",然后修改
/src/components/HelloToken.tsx文件,增加对Transfer事件的监听。我们之前有一个useEffect hook用于更新账户持币数量,现在需要在其中增加两个监听器,一个用于监听当前地址向外发送代币,另一个用于监听当前地址收到代币。useEffect(()=>{ if(!(active && account && library)) { setBalance(undefined) return } const contract = new Contract(addressContract, abi, library); library.getCode(addressContract).then((result:string)=>{ //check whether it is a contract if(result === '0x') return balanceOf(contract, account) // listen for changes on an Ethereum address console.log(`listening for Transfer...`) const fromMe = contract.filters.Transfer(account, null) contract.on(fromMe, (from, to, amount, event) => { console.log('Transfer|sent', { from, to, amount, event }) balanceOf(contract, account) }) const toMe = contract.filters.Transfer(null, account) contract.on(toMe, (from, to, amount, event) => { console.log('Transfer|received', { from, to, amount, event }) balanceOf(contract, account) }) // remove listener when the component is unmounted return () => { contract.removeAllListeners(fromMe) contract.removeAllListeners(toMe) } }) },[active,account])我们将查询当前地址代币余额的方法封装为了
balanceOf方法。async function balanceOf(contract:Contract, account:any) { contract.balanceOf(account).then((result:string) => { setBalance(parseFloat(formatEther(result)).toFixed(2)) }).catch('error', console.error) }刷新浏览器页面,连接钱包,发送代币。耐心等待几秒钟,链上交易完成后,页面自动刷新成功。
我们再发送1000个代币,成功后页面自动刷新,显示当前钱包中只剩下了8000个HelloToken 大功告成。
当然,这个DApp还非常简单,缺少实际生产环境中必须具备的很多功能。比如当链上合约抛出revert错误时如何处理、不同钱包的切换等等。合约提供的功能也非常简单。现有代码还存在着大量的优化空间。不过这毕竟是一个开始。









