整体架构设计
由于业务并不复杂,我们可以将它简单划分为几个组件。使用 Context 足够应对这个场景,而不需要额外导入状态管理库来增加复杂性。
Wallet 是根组件,内部维护了很多 state,以 Context 的方式将数据和操作注入到子组件。
Connect 负责连接钱包和断开连接。
Details 负责显示钱包信息,是纯展示型组件。
Transfer 负责向其他账户进行转账。
Loading 负责渲染加载动画,是纯展示型组件。
创建上下文
type IWalletCtx = { walletProvider: any; setWalletProvider: (walletProvider: any) => void; msgIsOpen: boolean; setMsgIsOpen: (msgIsOpen: boolean) => void; msg: string; setMsg: (msg: string) => void; account: string; setAccount: (account: string) => void; networkName: string; setNetworkName: (networkName: string) => void; balance: string; setBalance: (balance: string) => void; showMessage: (message: string) => void; refresh: boolean; setRefresh: (refresh: boolean) => void; }; const WalletCtx = createContext<IWalletCtx>({} as IWalletCtx);
通过初始化一个对象,然后断言为 IWalletCtx 的方式,可以避免在使用 WalletCtx 时添加是否为 undefined 或 null 的判断。因为我们一定会注入数据。
Loading 组件
Loading 作为纯展示型组件,是最简单的组件。SVG 的代码是直接从 tailwindcss 文档中搬运过来的。仅仅是添加了一个 size 属性,用来展示不同大小的尺寸。
function Loading({ size = "md" }: { size?: "sm" | "md" | "lg" | "xl" }) { const sizes = { sm: "h-3 w-3", md: "h-5 w-5", lg: "h-7 w-7", xl: "h-9 w-9", }; return ( <svg className={`animate-spin -ml-1 mr-3 ${sizes[size]} text-black`} xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" > <circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" ></circle> <path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" ></path> </svg> ); }
Wallet 组件
在 Wallet 组件中创建这些 state,并注入到 context 中。
export default function Wallet() { const [walletProvider, setWalletProvider] = useState<any>(null); const [msgIsOpen, setMsgIsOpen] = useState(false); const [msg, setMsg] = useState(""); const [account, setAccount] = useState<string>(""); const [networkName, setNetworkName] = useState<string>(""); const [balance, setBalance] = useState<string>(""); const [refresh, setRefresh] = useState<boolean>(false); useEffect(() => { setWalletProvider(new ethers.providers.Web3Provider(window.ethereum)); }, []); const showMessage = (message: string) => { setMsg(message); setMsgIsOpen(true); setTimeout(() => { setMsg(""); setMsgIsOpen(false); }, 2000); }; return ( <WalletCtx.Provider value={{ walletProvider, setWalletProvider, msgIsOpen, setMsgIsOpen, msg, setMsg, account, setAccount, networkName, setNetworkName, balance, setBalance, showMessage, refresh, setRefresh, }} > <div className="flex flex-col gap-4 p-4"> <Dialog open={msgIsOpen} as={"div"} onClose={() => setMsgIsOpen(false)}> <div className="fixed flex justify-center items-center w-full top-2"> <Dialog.Panel className="inline-flex flex-col bg-green-400 text-slate-600 p-4 shadow-xl rounded-3xl"> <Dialog.Title>{msg}</Dialog.Title> </Dialog.Panel> </div> </Dialog> <Connect /> <Details /> <Transfer /> </div> </WalletCtx.Provider> ); }
Wallet 组件基本上没有什么逻辑,它的主要作用有三个:
- 向 context 注入数据。
- 创建 ethers.provider。
- 使用 Dialog 组件作为全局消息提示。
Connect 组件
在 styles/globals.css 中添加按钮样式。
@layer components { .btn { @apply bg-black text-white py-2 px-4 rounded-3xl; } }
在 index.tsx 中编写逻辑。
function Connect() { const { walletProvider, account, setAccount, setNetworkName, setBalance, showMessage, refresh, } = useContext(WalletCtx); const refreshBalance = useCallback(async () => { if (!walletProvider || !account) return; const balance = await walletProvider.getBalance(account); setBalance(ethers.utils.formatEther(balance)); }, [setBalance, walletProvider, account]); useEffect(() => { refreshBalance(); }, [refresh, refreshBalance]); const connectToMetamask = async () => { try { await window.ethereum.enable(); const accounts = await walletProvider.send("eth_requestAccounts", []); const network = await walletProvider.getNetwork(); const balance = await walletProvider.getBalance(accounts[0]); setAccount(accounts[0]); setNetworkName(network.name); setBalance(ethers.utils.formatEther(balance)); } catch (error) { console.log(error); showMessage("failed to connect to metamask"); } }; const disconnect = async () => { setAccount(""); }; if (!account) { return ( <div className="flex justify-end p-4"> {walletProvider ? ( <button className="btn" onClick={connectToMetamask}> connect to metamask </button> ) : ( <Loading /> )} </div> ); } return ( <div className="flex justify-end items-center gap-2"> <h1 className="text-end">Hello, {account}</h1> <button className="btn" onClick={disconnect}> disconnect </button> </div> ); }
我们连接钱包后会获取 3 个重要的信息:钱包账户地址、连接的网络和余额。
分别通过 walletProvider.listAccounts()、walletProvider.getNetwork() 和 walletProvider.getBalance(accounts[0]) 来获取,需要注意它们都是异步操作。
Details 组件
Details 作为纯展示型组件没有什么逻辑,主要是一些样式。
function Details() { const { account, networkName, balance } = useContext(WalletCtx); if (!account) { return null; } return ( <div className="flex flex-col gap-4 w-full bg-slate-800 text-white p-4 rounded-md"> <div className="flex justify-between"> <div className="text-2xl font-thin">balance</div> <div>network: {networkName}</div> </div> <div className="flex items-end gap-2"> <div className="text-2xl">{balance}</div> <div>ETH</div> </div> </div> ); }
现在我们看一下 Connect 和 Details 组件一起使用的效果。
Transfer 组件
Transfer 的功能比较简单,它在 UI 上仅仅包含两个输入框和一个 send 按钮。
两个输入框分别可以输入 to 和 value,表示转账的钱包地址和转账金额。
function Transfer() { const { walletProvider, account, showMessage, refresh, setRefresh } = useContext(WalletCtx); const [to, setTo] = useState<string>(""); const [amount, setAmount] = useState<string>(""); const [transferring, setTransferring] = useState<boolean>(false); const transfer = async () => { try { const value = ethers.utils.parseEther(amount); const signer = walletProvider.getSigner(); const tx = { to, value, }; setTransferring(true); const receipt = await signer.sendTransaction(tx); await receipt.wait(); setTo(""); setAmount(""); showMessage("successfully transferred"); } catch (error) { console.log(error); showMessage("failed to transfer"); } finally { setTransferring(false); setRefresh(!refresh); } }; if (!account) { return null; } return ( <div className="flex flex-col gap-4 mt-4"> <div className="font-bold text-4xl">Transfer</div> {transferring ? ( <div className="flex flex-col items-center gap-4"> <div className="text-3xl">transferring...</div> <Loading size="xl" /> </div> ) : ( <div className="flex flex-col gap-2"> <input className="input" value={to} onInput={(e: any) => setTo(e.target.value)} type="text" placeholder="address" /> <input className="input" value={amount} onInput={(e: any) => setAmount(e.target.value)} type="number" placeholder="amount" /> <button className="btn" onClick={transfer}> send </button> </div> )} </div> ); }
转账是通过 signer.sendTransaction 方法进行的,它会返回收据对象 receipt。
在转账时使用到了 ethers.utils.parseEther,因为 value 默认的单位是 wei,它非常小,10 的 18 次方才是一个 ehter。在 JS 中需要使用 BigInt 类型表示,并不方便操作,而我们更喜欢用 ether 来描述货币。所以这个 API 可以帮我们转换货币单位。
需要注意,在测试时需要选择 goerli 网或者其他测试网,否则会浪费 gas 费。
你至少要有两个钱包账户,这样可以从一个钱包账户转到另一个钱包账户。
下面我们来测试一下转账。
这时 MetaMask 钱包会弹出来签名界面。
点击确认后,需要等待一段时间上链,大约 1 分钟,或者更久。
转账成功后,当前账户的余额会刷新。
完成
现在一个简单的加密钱包就完成了。通过这个项目的学习,相信你已经学会了 ethers.js 常规 API 的使用。
线上地址:web3-examples.vercel.app/
我一直在深入研究 Web3 的最新趋势,相信这些趋势会使我们的生活变得更好。
如果你对 Web3 感兴趣,欢迎联系我,一起打造一个更公平的世界吧。
同时也可以关注我的 Web3 专栏,我会持续更新更多 Web3 相关内容。