在本次开发之前,笔者对网络选择按钮中各按钮颜色的实现进行了修改,摒弃了自定义Theme这种实现,改成了直接给图标设置颜色。这里作一下说明,具体代码不再列出。另外,前几次开发中提到的Provider实质上是指Context,特此更正说明。
在上一次开发中,我们拼接了钱包主界面并且能够实时正确显示用户的余额。这次,我们来完成钱包主界面的发送ETH功能。让我们先登录钱包并切换到Kovan测试网: 你也可以先切换到kovan测试网再登录,都是相同的。进入到主界面后会自动获取用户余额,在获取余额之前,发送按钮是置灰的,不可点击。点击发送按钮,我们计划显示一个这样的简单页面:
在这里仍然可以切换网络,同样,切换后会自动获取用户余额,未获取前发送不可点击。点击取消按钮可以退回到钱包主界面。让我们输入一个账号地址并且点击发送,如果验证通过,会出现一个确认框:
用户可再次检查一下是否输入有误,点击确定后,在短时间的loading之后会出现如下界面:
该界面会显示本次转账的简要信息,也提供了在EtherScan上查看详情的链接。注:测试网的交易也是可以在EtherScan查看的。经过短时间的等待后,交易状态会自动变更为已完成,任何时候点击返回按钮可以直接返回到主界面。注意:本钱包开发计划中是没有保存交易记录这个功能的,所以这里点击返回后就看不到这次的交易信息了。
提示
在国内无法直接访问EtherScan,你可能需要一个梯子。不过如果是主网交易,也可以在EtherScan.CN查看,这个不需要梯子。这里给出EtherScan.CN链接:https://cn.etherscan.com/
二、React状态提升 在前一次的开发中,我们在钱包主界面实时获取和更新用户的账户余额。可以看到,本次开发中,在发送表单界面也有同样的需求,因为用户切换网络之后必须获取对应网络的账户余额并且更新。这样一来,在两个地方都需要共享余额及对余额的修改。按照React的设计原则,需要进行状态提升,将原来在钱包主界面中实现的对余额的实时更新提升到这两个界面的公共父组件(元素)中去。这里为了减少复杂度,我们把用户余额从GlobalProvider.js
中分离出来,把它和更新余额的实现放在一个专门的Context中去。先删除相应的代码,然后再新建src\contexts\BalancesProvider.js
,代码如下:
/**
* 本文件用来实时更新用户的余额
*/
import React, { createContext, useEffect, useContext, useReducer, useMemo, useCallback } from 'react'
import { ethers } from 'ethers'
import { safeAccess } from '../utils'
import {useGlobal} from './GlobalProvider'
const UPDATE='UPDATE'
const BalancesProvider = createContext()
function useBalancesContext() {
return useContext(BalancesProvider)
}
function reducer(state,{type,payload}) {
switch (type) {
case UPDATE:{
const {address,network,value} = payload
return {
...state,
[address]:{
...(safeAccess(state,[address]) || {}),
[network]:{
value
}
}
}
}
default:{
throw Error(`Unexpected action type in BalancesContext reducer: '${type}'.`)
}
}
}
export default function Provider({children}) {
const [state, dispatch] = useReducer(reducer, {})
const {wallet,network} = useGlobal()
const update = useCallback((address,network,value) => {
dispatch({type:UPDATE, payload:{address,network,value}})
},[])
//刷新每个账号在每个网络下的余额
useEffect(()=>{
if(wallet) {
const {address} = wallet
let stale = false
const provider = ethers.getDefaultProvider(network)
provider.on(address, value => {
if(!stale ){
update(address,network,value)
}
});
return ()=>{
stale = true
provider.removeAllListeners(address)
}
}
},[wallet,network,update])
return (
[state, { update }], [state, update])}>
{children}
)
}
export function useBalance(address,network) {
const [state,] = useBalancesContext()
const {value} = safeAccess(state,[address,network]) || {}
return value
}
这个代码记录了用户账号在各个网络(主网和测试网)中的ETH余额,并且实时更新。当然,只有切换到某网络才会实时更新该网络的账号余额,不是当前选中的网络并不会实时更新。
Context的作用我在前面的开发中有提及,稍后会接着介绍,这里先讲这段代码中三个关键点:
useReducer
的用法。useState
的替代方案。它接收一个形如(state, action) => newState
的reducer
,并返回当前的state
以及与其配套的dispatch
方法。一般在Context中useReducer
使用的比较多,详情见:https://zh-hans.reactjs.org/docs/hooks-reference.html#usereducer- 钱包的实时更新方法。在
useEffect
中利用ethers
库通过监听用户余额变化事件实现,注意它的依赖项和返回函数。useEffect
的详细用法 :https://zh-hans.reactjs.org/docs/hooks-effect.html - 最后导出的
useBalance
是一个获取用户余额的自定义hook。它有两个参数,分别是账号地址和网络,这就意味着它支持多账号多网络余额的获取和更新,虽然目前在我们的开发计划中钱包是单账号钱包。
我们借助发送按钮点击后的逻辑实现来介绍React的数据流向。React不同于Vue,它的数据是自上而下单向流动的,每个组件只能更改它自己的数据(状态),而Vue是双向流动的。React这样设计是故意为之的,一方面减少复杂度,另一方面可以快速定位问题所在,因为状态只能被拥有它的组件所更改。而双向流动也有它的好处,这里谁优谁劣不作比较,只是介绍一下React的设计。
新建src\views\SendEther.jsx
,代码如下:
/**
* 本文件用来实现钱包主界面发送ETH功能
*/
import React,{useState} from 'react';
import { withRouter } from "react-router";
import SendEtherForm from './SendEtherForm'
import TransactionInfo from './TransactionInfo'
//交易状态
const BEGIN = 'begin'
const PENDING = 'pending'
const values_init = {
status:BEGIN,
tx:null, //交易HASH
td:null //交易结果
}
function SendEther({history}) {
const [values,setValues] = useState(values_init)
//交易发送之后转到交易信息页面
const sendOver = tx => {
setValues({
status:PENDING,
tx,
td:null
})
}
//返回主界面
const resverseBack = () => {
history.push('/detail')
}
const {status,tx} = values
if(status === BEGIN){
return (
)
}else if(status === PENDING) {
return (
)
}else {
return null
}
}
export default withRouter(SendEther)
注意这段代码:,它将组件的数据(状态)
tx
通过TransactionInfo
组件的属性tx
进行了传递,也就是数据从上向下传递是通过属性进行的,而属性是不可更改的。因此如果传递的数据发生了变化,肯定是原组件进行了修改。
然而我们有时又希望子组件在某些操作后能修改父组件的数据(状态),怎么办?让我们来看这句代码:,它将一个修改父组件状态的方法
sendOver
作来一个回调函数通过属性sendCallback
传递给了组件SendEtherForm
。这样组件SendEtherForm
在ETH转账交易发出后执行sendCallback
回调,便会调用父组件对应的sendOver
方法来修改父组件的状态。状态修改后,父组件就会重新渲染从而显示交易信息界面,而不再是显示转账表单界面。
在React中,不管是传递状态还是反向修改状态,必须将父组件的状态或者修改方法通过属性一级一级往下传,这样在组件树层次比较多的时候,会造成一些中间组件拥有这些并无太大意义的属性。为了避免这种情况,我们可以使用Context。Context提供了一个无需为每层组件手动添加 props,就能在组件树间进行数据传递的方法。但是Context在使用前必须初始化,一般在组件树的最高层进行初始化。
Context的详细教程:https://zh-hans.reactjs.org/docs/context.html
四、实现ETH转账在以太坊中,ETH转账也是一个交易,而交易的一般描述为:外部账号(用户,非合约)发起一个交易(创建一个交易对象),然后用私钥将该交易签名并且发送,并等待以太坊上的矿工进行打包执行和发布到所有节点。这中间如果包含改变以太坊状态的操作则会消耗gas。同时你还需要设置一个gas价格,消耗的gas数量乘于gas价格就是你这次交易的手续费,会从你的余额中扣除。gas价格的多少也决定了交易的速度,你出的价格高你就是VIP通道,你出的价格低就没有矿工理你,你就只能慢慢等或者根本无法打包。我们也可以随交易同时发送ETH,它也从你的余额中扣除。如果执行的过程中出错(或者gas不够),你的交易就会失败,所有造成的改变被重置,发送的ETH会被退回,但手续费会根据出错的实际情况部分消耗或者完全消耗。如果矿工执行了交易并且发布到所有节点,你的交易就成功了,就永久保存在以太坊上了。
在具体的实现中,执行转账我们得先构建一个交易对象。在JavaScript中一个交易对象当然也就是一个普通的对象,比如{}
,它有以下几个可选属性:
- to 代表调用的地址,转账时就是接收ETH的地址
- gasLimit 本次交易消耗的最大gas,一般来说ETH转账设置成23000即可,最小不能小于21000
- gasPrice 你愿意为你的gas出的价格,当然是你付给别人的价格
- nonce 这次交易的编号,编号对每个地址来说都是自动增长的,代表已经完成的交易数量。你可以设定成一个正在pending的交易的nonce来加速或者覆盖该交易,一般情况下缺省即可。
- data 随交易发送的数据, 注意发送数据会收手续费,数据越大手续费越多。转账时没有特殊需要缺省即可。
- value 随交易发送的ETH数量。如果是转账,就是转账的ETH数量。
- chainId 交易网络的ID,防止使用了错误的网络交易。一般缺省即可。
上面七项属性都是可选项,意即可以省略,当然不能全部省略。
在src\views\SendEtherForm\index.js
中,我们构建交易对象的代码块为:
let transaction = {
to:_address,
value:utils.parseEther("" + eth_amount),
gasLimit:GAS_LIMIT,
gasPrice:utils.parseUnits("" + gas_price,'gwei'),
chainId:getChainIdByNetwork(_network)
}
可以看到和上面七个属性相比,我们没有nonce和data,我们只转ETH,没有交易数据,所以没有data。nonce让它自己决定就好。
签名并且发送交易的代码块为:
//签名并发送交易
let provider = ethers.getDefaultProvider(network)
let tx_wallet = wallet.connect(provider)
let sendPromise = tx_wallet.sendTransaction(transaction);
setOpen(false)
setCircleOpen(true)
sendPromise.then(tx => {
setCircleOpen(false)
if(sendCallback){
sendCallback(tx)
}
}).catch(err =>{
setCircleOpen(false)
return showSnackbar("ETH发送失败,请检查你的余额")
})
前两行代码将我们的钱包和要交易的网络进行了链接,第三行代码我们使用钱包对象的sendTransaction
方法来签名并发送交易,注意它返回一个promise。交易发送后我们会得到一个交易对象tx
,它包含交易的哈希、nonce、接收者等信息。可以看到,我们将该交易对象tx
通过回调函数传递给了父组件(数据流中的反向改变数据)。
交易对象tx
有一个wait()
方法,它用来等待交易的执行结果(矿工打包执行并发布的结果),它也返回一个pormise,我们来看一下它的用法 ,在src\views\TransactionInfo\index.js
中,代码块为:
useEffect(()=>{
if(tx){
tx.wait().then(td => {
setState({
pendingOver:true,
td
})
}).catch(err => {})
}
},[tx])
tx.wait()
返回一个包含交易结果的td
,我们可以用它来显示一些执行信息,比如转账是否成功等。注意,tx.wait()
的执行时长是未定的,视网络拥堵情况和矿工的选择。交易在签名发送后而又未返回结果时,交易的状态是pending,字面意思就是悬而未决的。交易结果返回后状态要么是成功的,要么是失败的。
在我们的交易信息界面还使用了三个进度条来动态表示pending,有兴趣的读者可以看一下进度条的用法。最后放一个转账完成的界面: 这是点击界面上 EtherScan上查看 超链接后EtherScan上的结果:
本次开发我们主要完成了更新用户账户余额的状态提升和用户转账功能的实现。同时也介绍了以太坊上交易的一般流程和交易对象的构建方法。最后对交易签名发送和执行的返回结果进行了简要介绍。具体的UI代码没有介绍,大家可以直接查看或者下载码云git仓库上的代码。
下次开发计划实现账号的导出功能。
本钱包码云git仓库地址: => https://gitee.com/TianCaoJiangLin/khwallet
恳请大家留言指正或者提出宝贵意见、建议。