跳到主要内容

初识探索 Web 3.0

· 阅读需 19 分钟
Yancy Qi
Front End Engineer

学习探索什么是 Web 3.0

通过建立一个计数器体验一下 Web 3.0 的开发流程

使用到的框架和语言:

  • HardHat(以太坊 Ethereum 本地开发环境)

  • Solidity 建立智能合约

  • React.js 建立前端网站

  • Ethers.js 简易的 Web3 框架

第一步:创建名为 “hello-web3” 的文件夹

第二步:初始化项目

打开终端,输入命令:

npx hardhat init

选项默认即可

运行命令安装依赖:

npm install --save-dev "hardhat@^2.8.4" "@nomiclabs/hardhat-waffle@^2.0.0" "ethereum-waffle@^3.0.0" "chai@^4.2.0" "@nomiclabs/hardhat-ethers@^2.0.0" "ethers@^5.0.0"

HardHat 是一套完善的 Ethereum 本地开发套件,我们可以通过它来进行智能合约的测试和部署

使用 VSCode 打开项目

先将 contracts 和 scripts 目录下的文件移除,从零开始编写第一个智能合约

第三步:从零开始编写第一个智能合约

在 contracts 目录下建立一个名为 ” Counter.sol “ 文件

信息

.sol 是 Solidity 的副档名

合约一开始都会有一句版权声明,例如: // SPDX-License-Identifier: UNLICENSED

然后是定义 solidity 的版本号,例如:pragma solidity ^0.8.0;

再载入 console.log() 的套件:import "hardhat/console.sol";

提示

其实 Solidity 与 JavaScript 很多方面都很相似

定义合约使用 contract 关键字

定义一个 state 变量,名为 counts,用来记住计数器的数字

定义一个 constructor 将它初始化为 0

添加一个 function add() 使 counts 加 1

不需要使用 this 即可引用到 counts

再加入一个 function getCounts() public view returns (unit) {}

其中的 view 的意思是说这个函数只返回资料,并不涉及到交易

可以先简单理解为呼叫这个函数是不用花钱的

至此,这份简单的智能合约就写好了

接下来是测试的部分

在 scripts 目录下新增一个名为 run.js 的文件

定义一个函数 main,注意要加上 async 关键词

因为函数内执行的很多语句都需要等待,

所以一般习惯使用 async / await 的模式去编写

先将 Counter 这份智能合约获取回来,

然后 await Counter.deploy() 将合约部署到区块链上

由于上链需要时间,所以还会有一句 await counter.deployed()

代表确保智能合约完成部署到区块链上之后,

使用 console.log 将合约地址输出一下,然后执行 main()

在成功执行的时候输出 success 并且正常退出,

发生错误的话就输出错误讯息,并且退出

为什么这里的 hre 不需要载入?

hre 是 HardHat Runtime Environment 的简写,

由于稍后我们会使用 hardhat 来执行这个文件,

所以 hre 是会在环境中直接植入,毋须专门去引用它

切换到终端中,使用 hardhat 运行它

执行:npx hardhat run scripts/run.js

合约会先进行编译再执行,完成运行后会显示合约地址

警告

执行过程中可能会遇到问题:

对此问题有两个解决方法:

① 尝试卸载 Node.js 17+ 版本并重新安装 Node.js 16+ 版本

② 打开终端输入:

适用于 Linux & Mac OS (windows git bash):

export NODE_OPTIONS=--openssl-legacy-provider

适用于 Windows 命令提示符:

set NODE_OPTIONS=--openssl-legacy-provider

再执行一次,会发现由于并没有通过节点执行合约,合约地址依旧相同

但在正式部署的时候,合约地址每次都会不一样的

回到 VSCode,会看到新建了一个 artifacts 的目录,

里面有很多在刚才编译时生成的文件,

其中比较重要的是 Counter.json 文件,

里面有 Counter 这份智能合约编译后的编码以及合约内的函数名称等

接着用另一种方式进行本机测试

打开一个新的终端,运行 npx hardhat node

这样会运行一个本机的节点,并且会提供 20 个钱包地址及私匙

特别提醒

这些私匙也是确确实实可以在真实环境中使用的,

但是由于这些私匙都被公开了的,

所以除了测试用途,请勿在正式环境中使用

可见每个钱包都有 10000 个 ETH,这足够我们进行任何测试了

然后再使用 run 指令运行合约,

这次加上 --network localhost 的设定,

就会将合约在本机的节点上运行了

运行后,可以在节点的终端中看到有一份新的智能合约成功部署了

并且花费了一些 Gas,即是手续费

再试着部署一次,这次会发现合约的地址就不同了

所以要记住一个概念:合约是不能更新的,只能部署一份新的合约

至此,合约就成功部署并且运行了

接着试执行以下刚才我们定义的两个函数

首先,运行合约内的 getCounts() 获取 counts 的值

通过 console.log() 输出

然后运行合约内的 add() 函数,

同样将最新的 counts 输出,在输出的内容中标示一下编号

再来一次,前后总共获取 3 次,增加 2 次

运行一下,可以看到输出了 3 次结果

符合我们的预期

回到节点的终端上,可以看到:

第一笔记录是部署智能合约,花费了一些 Gas 费用

然后是 Contract Call 执行 getCounts() 函数

再来是执行 add() 函数

需要注意的是,执行 add() 就很不同了,

它是一个 Transaction,即是交易,需要花费一些 Gas 费用

我们可以留意一下,getCounts() 是不需要付费的,而 add() 需要

这是由于 getCounts() 只是读取 counts 的值,

add() 需要改动 counts 的值,所以需要支付一些上链的费用

第四步:建立一个 React.js 的项目

通过 create-react-app 建立一个名为 react-hello-web3 的项目

npx create-react-app react-hello-web3

然后再安装 TailwindCSS

npm install -D tailwindcss postcss autoprefixer

npx tailwindcss init

运行 npm run start 启动本地测试服务器

运行成功,回到 VSCode,修改一下 App.js 的样式

确保 Tailwind CSS 已经成功安装

function App() {
return (
<div className='text-5xl text-center text-blue-800'>
<h1>Hello, Web3!</h1>
</div>
);
}

接下来建立一下页面的结构:分别有

一个大标题:Hello, Web3!

一个表情符号:👏

加上一个数字,代表 Hello 了多少次

再加一个按钮,点击它的话就会将数字加 1

然后载入 useState 和 useEffect

使用 React Hooks 来管理 state

定义 count 和 setCount 的 state,初始化为 0

然后用 count 将数字替换掉

下一步需要判断用户是否已经使用钱包登入

定义 account 和 setAccount,初始化为 null

然后判断一下,如果未登入的话,就显示 Connect Wallet 的按钮,

登入了就显示数量和加 1 的按钮

接下来是钱包的安装

第五步:安装钱包

目前其中一个主流的钱包是 MetaMask

信息

如果你使用 Safari 浏览器,请注意:

Safari 不支持安装这些钱包,

可以切换到 Chrome 浏览器

MetaMask 官方网站:https://metamask.io/

以 Chrome 浏览器的版本为例:

点击后会跳转到官方的商店中去下载插件

https://chrome.google.com/webstore/detail/metamask/nkbihfbeogaeaoehlefnkodbefgpgknn

安装完成后,建立一个新的钱包,设置一个解锁密码

如果忘记解锁密码,可以重新设置

关键是不要把私匙遗失

不过自从 BIP39 的提案后,可以使用一种叫助记词的方式去导入你的钱包

它会给你 12 个英文生词,只要按他的顺序输入这 12 个英文生词,就能将钱包回复

而一套助记词又可以生成多组钱包地址,等于保管着多条私匙的意思,

所以必须好好保管

由于只是做本地的开发测试,属于可以用完即弃的,

所以就不去记录这些助记词了

完成建立,可以看到目前有 0 个 ETH

然后运行本机的节点 npx hardhat node

将其中一组的私匙复制一下

然后在 MetaMask 的 Import Account

将这个测试用的钱包通过私匙回复

可以看到,这个地址在正式环境中竟然会有资产

危险

这些是已经公开了的钱包私匙,

私匙有别于密码,是不能更改的,

所以除了测试以外,不要作其他用途

千万记得不要在正式环境中使用

然后在 Networks 中

在 Localhost 这个本机测试网络中

将 Chain ID 从 1337 改为 31337

这只需要做一次,符合 HardHat 的设定

然后当切换到 Localhost 这个网络时,

钱包中就会显示目前有 10000 个 ETH 了

现在我们再试试运行一下 npx hardhat run scripts/run.js --network localhost

节点终端上显示有对应的交易

然后切换到钱包上一看,目前就扣掉了一点 ETH 了,剩余 9999.9998 ETH

现在回到 React 项目中,将登入钱包,显示 counts 和 加 1 这些功能都做进网页中

第六步:完善 React 项目

首先安装一个名为 ethers 的 JavaScript 库 npm install ethers

ethers 是一个简单版的 Web3 框架,方便我们调用 Web3 的 API

在 App.js 中载入它

然后定义一个函数 checkIfWalletIsConnected 检查钱包是否已经登入网站

先从 window 物件中获取 ethereum 物件

这个物件是由 MetaMask 植入的,

所以如果不存在的话,就代表没有安装 MetaMask

然后尝试执行它的 API eth_accounts

如果是已经授权了的话,就会返回钱包地址

所以如果有地址的话,就通过 setAccount() 将它储存起来

通过 useEffect() 加上空白数组的方式,在页面加载时执行一次

这就等于 React 的 componentDidMount() 的方法

const checkIfWalletIsConnected = async () => {
try {
const { ethereum } = window

if (ethereum) {
console.log(`MetaMask is available`)
} else {
console.log(`Please install metamask`)
}

const accounts = await ethereum.request({
method: "eth_accounts"
})

if (accounts.length !== 0) {
const account = accounts[0]
console.log(`Found account with address`, account)
setAccount(account)
} else {
console.log(`No authorized account found`)
}
} catch (err) {
console.error(err)
}
}

useEffect(() => {
checkIfWalletIsConnected()
}, [])

然后在 Connect Wallet 按钮上加入 onClick 事件,执行使用钱包登入的逻辑

定义 connectWallet 函数,同样先判断 ethereum 物件是否可用

然后执行 API 方法 eth_requestAccounts

这样就会询问用户是否授权我们这个网站去获取钱包地址

有的话就通过 setAccount() 记下来

  const connectWallet = async () => {
try {
const { ethereum } = window

if (!ethereum) {
alert(`Please install MetaMask`)
return
}

const accounts = await ethereum.request({
method: "eth_requestAccounts"
})
console.log(accounts[0])

setAccount(accounts[0])
} catch (err) {
console.error(err)
}
}

点一下按钮,会弹出 MetaMask 的授权视窗

我们会发现是可以多选的,所以为什么在代码中 accounts 是一个数组

点击 Connect 的话,就可以获取到钱包地址了

也可以在 MetaMask 中断开连接,

这样网站就会回复到未登入的状态了

登录完成 Connect Wallet 按钮隐藏,显示数字和 Say Hi 按钮

修改一下 App.js ,使得在页面上显示一下登入了的钱包地址

由于地址太长,所以只显示地址的首 4 位和尾 4 位

在视觉上就更加美观了

<h3 className='mt-12 text-center text-3xl text-bold text-white'>
Logged in as {" "}
<strong>
{`${account.substring(0, 4)}...${account.substring(account.length - 4)}`}
</strong>
</h3>

接下来处理 Say Hi 按钮的逻辑

在按钮上加入 onClick 事件以及对应的处理函数

先判断 ethereum 物件是否可用

这次有一点不同,因为要执行合约内的函数,

所以要先经由 ethereum 物件去获取 Provider

然后再经由 Provider 获取 Signer

Signer 就是执行合约的签名方

再来就是建立一个合约的实体,要求三个参数:

Ⅰ 智能合约的地址

Ⅱ 合约的 ABI 即 HardHat 编译后的 JSON 文件

Ⅲ Singer 即是谁在执行合约

先定义一下 contractAddress,然后将刚刚部署在节点上的合约地址填上去

回到智能合约的项目中,将 Counter.json 的内容复制一下

在 React 项目中,建立一个名为 utils 的目录

建立 Counter.json 文件,并将内容贴上

App.js 中载入它的内容

试试执行合约的 add() 函数,在前面加上 await 关键词

因为这个动作必须等待它完成

import abi from "./utils/Counter.json"

const contractAddress = "0x5fbdb2315678afecb367f032d93f642f64180aa3"
const contractABI = abi.abi

...

const hi = async () => {
try {
const { ethereum } = window

if (ethereum) {
const provider = new ethers.providers.Web3Provider(ethereum)
const signer = provider.getSigner()
const CounterContract = new ethers.Contract(contractAddress, contractABI, signer)

await CounterContract.add()
}
} catch (err) {
console.error(err)
}
}

点击 Say Hi 按钮,神奇的事情出现了

会看到一个交易的画面,下面列出所需要的费用,同意的话就确认

现在执行 add 函数,由于会变更 counts 的值的关系

所以要更改它需要支付一定的上链费用

目前我们还未将最新的 counts 值获取回来,

所以定义一个新的函数 getCounts()

检查 ethereum 物件是否可用这些逻辑都是大同小异

不同的地方是,这次是执行 getCounts() 函数

然后将获取到的值通过 setCount() 更新到 state 中

在检查了钱包是否登入以后,再执行 getCounts()

useEffect(() => {
checkIfWalletIsConnected().then(() => {
getCounts()
})
}, [])

...

const getCounts = async () => {
try {
const { ethereum } = window

if (!ethereum) {
alert(`Ethereum object is not available`)
return
}

const provider = new ethers.providers.Web3Provider(ethereum)
const signer = provider.getSigner()
const CounterContract = new ethers.Contract(contractAddress, contractABI, signer)

const counts = await CounterContract.getCounts()
setCount(counts)
} catch (err) {
console.error(err)
}
}

刷新一下,可以看到画面白了,有一些错误

提示 BigNumber,原因是 getCounts() 返回的数字

打印一下 counts 可以发现 长度非常大,

所以在 JavaScript 中返回了一个物件

通过 toNumber() 转换一下即可

可以看到目前的数字是 2

试试在 add() 之后就执行 getCounts() 将最新的值获取回来

在执行 add() 之后会返回一个 Transaction

在这里要执行

let tx = await CounterContract.add()
await tx.wait()
await getCounts()

等待这个 add() 里面修改 counts 的动作完成上链后,才去获取新的值

测试一下,这次就正常了

最后,做一些 UI / UX 上的优化

第七步:优化 UI / UX

由于上链的动作一般都需要等待,

所以最好有一个 Loading 的提示

加入 isLoading 和 setIsLoading 的 state 设定值

const [isLoading, setIsLoading] = useState(false)

修改一下 HTML 结构

判断如果 isLoading 是 true 的时候,显示一个 Loading 图标

再在适当的位置,将 isLoading 设定为对应的值

参考 B站 UP主 CodingStartup起码课 视频 BV1ES4y1r7DL