一、前言
區塊鏈經歷了長時期的低谷,最近又火了起來。目前國家層面也在積極推動,各地政府也在加大投入,並且估計央行今年就要發行數字貨幣了。提到區塊鏈,不能不提比特幣和以太坊,區塊鏈誕生於比特幣,比特幣就是區塊鏈1.0。以太坊將區塊鏈發揚光大,是爲區塊鏈2.0。然而我們想要和以太坊交互,就必須有一個以太坊錢包。以太坊錢包有很多重,分爲手機端和桌面端。手機上的錢包有很多很多,這裏就不列舉了;但桌面上可用的錢包卻很少,使用的最多的是Chrome瀏覽器裏的MetaMask插件。不過使用Chrome的插件需要去翻牆去應用商店下載,有很多人又不想或者無法翻牆。要是有一個不用翻牆就直接在電腦上簡單使用的錢包就好了。正好筆者正在學習Material UI,爲了加深學習效果,就計劃使用Material UI開發一個最簡單的仿MetaMask的以太坊錢包,當然不是瀏覽器插件而是網頁版(網頁版也可以是在手機上使用的)。
這個網頁版錢包其實我在2018年剛接觸React和Material UI時就寫了一個很低級版本的,很是醜陋。如今又把它翻了出來,準備重新寫一下,主要是爲了學習Material UI。
本文中幾乎所有UI相關的代碼都直接複製於Material UI官方文檔中的組件示例。由於筆者還在學習中,對一些UI的用法也不是很清楚,另外對CSS也不怎麼熟,因此代碼肯定有寫的不好需要精簡優化的地方,還懇請大家指正並提出寶貴意見。
二、錢包功能設計
MetaMask的功能很強大,支持多網絡多用戶,有歷史記錄查詢、交易加速、ERC20代幣列表等等。我們的目的是做一個最簡單的網頁版錢包,因此只實現最基本的功能,其它的功能慢慢拓展。我們的錢包基本思想是私鑰加密後存在本地,每次使用時需要提供密碼來解密。因此,作爲一個學習型的產品,不可將大量數字資產置於這個錢包管理的賬戶中。
我們的錢包只是一個單用戶錢包。它計劃的功能包括:支持多網絡(主網和測試網)、支持用戶新建、導入錢包,支持用戶登錄與導出賬戶功能。支持添加ERC20代幣到列表功能,支持轉移ERC20代幣、ETH轉賬、簽名交易併發送到以太坊。
目前並沒有計劃的功能:交易歷史記錄、多用戶管理、交易加速等。
PS:前段時間有幾天MetaMask轉移ropsten測試網ETH總是失敗,於是我就使用了自己古老版本的錢包進行了轉賬,可見開發完成了總是會有用的。另外,錢包開發完了後可以作爲網頁中iframe的內嵌頁面,再進行適當改進後可以用到無需MetaMask的DAPP上。
錢包按照先拼接UI頁面,再編寫邏輯的方式進行開發。由於本次連載是邊開發邊寫,而開發主要是利用空閒時間,所以請大家多一點耐心,多一點等待,錢包肯定會開發完成的。最後全部完成的工程照例發到碼雲上供大家下載改進。
三、本次任務
本次任務主要是開發一個用戶新建錢包時的界面,假定我的錢包叫 KHWallet,那麼完成後的頁面應該如下圖所示:
桌面端和手機端的適配簡單處理,先完成主要功能。
四、準備工作
這個項目中會用到消息條(SnackBar),因此需要使用我上一篇文章提到過的notistack用例。文章地址爲 => Material UI框架下SnackBar(消息條)的高級用例–notistack
本工程是在那個工程的基礎上直接接着開發的。因此建議未看過的讀者先行閱讀一下。另外本着偷懶偷到底的原則,我又將原notistack中的enqueueSnackbar
函數包裝了一下,額外寫了一個Provider
,在src/contexts
目錄下新建SimpleSnackbar.jsx
,代碼如下:
import React, { createContext, useContext } from 'react'
import { useSnackbar } from 'notistack';
const SnackbarContext = createContext()
export function useSimpleSnackbar() {
return useContext(SnackbarContext)
}
const VARIANTS = [
'default',
'success',
'error',
'warning',
'info'
]
export default function Provider({children}) {
const { enqueueSnackbar } = useSnackbar();
const showSnackbar = (message,variant='default',closeNotification) => {
// variant could be success, error, warning, info, or default
if(VARIANTS.indexOf(variant) === -1) {
variant='default';
}
let options = {
variant
}
if(closeNotification) {
options['onClose'] = closeNotification
}
enqueueSnackbar(message, options);
};
return (<SnackbarContext.Provider value={showSnackbar}>
{children}
</SnackbarContext.Provider>)
}
這裏showSnackbar
就是我們顯示消息條的方法了,由於本項目裏基本用不上關閉時的回調函數,所以平常只需要使用showSnackbar("This is a message","success")
即可。
另外,需要將該Provider置於notistack的SnackbarProvider
之下,所以我們修改一下src\contexts\NotistackWrapper.js
,在上面增加一條導入語句導入新建的Provider,最後return時也要修改。完成後的代碼如下,修改的地方就是和SimpleSnackbarProvider
相關的地方,大家可以搜索一下然後看下代碼的修改:
//本JS進行一些notistack的常用設置
import React from 'react';
import { SnackbarProvider } from 'notistack';
import { isMobile } from 'react-device-detect';
import IconButton from '@material-ui/core/IconButton';
import CloseIcon from '@material-ui/icons/Close';
import SimpleSnackbarProvider from './SimpleSnackbar.jsx';
/**
* 顯示的消息條的最大數量,如果超過,會關掉最先打開的然後再顯示新的,是一個隊列
* 如果只想顯示1個,設置爲1,3是默認值
*/
const MAX_SNACKBAR = 3
//設置自動隱藏時間,默認值是5秒
const AUTO_HIDE_DURATION = 3000
//設置消息條位置,默認值爲底部左邊
const POSITION = {
vertical: 'bottom',
horizontal: 'left'
}
export default function NotistackWrapper({children}) {
const notistackRef = React.createRef();
const onClickDismiss = key => () => {
notistackRef.current.closeSnackbar(key);
}
return (
<SnackbarProvider
maxSnack={MAX_SNACKBAR}
autoHideDuration={AUTO_HIDE_DURATION}
anchorOrigin={POSITION}
dense={isMobile}
ref={notistackRef}
action={(key) => (
<IconButton key="close" aria-label="Close" color="inherit" onClick={onClickDismiss(key)}>
<CloseIcon style={{fontSize:"20px"}}/>
</IconButton>
)}
>
<SimpleSnackbarProvider>
{children}
</SimpleSnackbarProvider>
</SnackbarProvider>
)
}
這裏稍微講一下Provider,Provider就像是全局變量和全局方法,提供給所有子元素使用。當Provider的值發生變化時,所有使用這個值的子元素都會自動重新渲染以使用最新的值。Provider使用之前必須初始化,一個項目可以有很多Provider,它們通常以一個嵌套一個的形式進行集體初始化。
爲了簡化導入語句,我們需要在工程根目錄下建立一個jsconfig.json
,內容如下:
{
"compilerOptions": {
"baseUrl": "src",
"paths": {
"*": ["src/*"]
}
}
}
它的作用是去掉導入時的相對路徑,直接使用絕對路徑的簡化版本,比如:import NetworkProvider from 'contexts/Network.js'
。
五、頁面設計與編寫
從前面截圖中我們可以看到該頁面主要分兩部分:一個WalletBar,一個WalletBody。而WalletBar部分又包括左邊的Logo和右邊的網絡選擇按鈕。因此我們可以將Logo和網絡選擇按鈕分別設計成一個組件,再一組合就得到了WalletBar。而WalletBody部分主要是一個Form,用來讓用戶設置密碼的。
5.1 編寫Logo組件
讓我們分別在/src
目錄下分別建立components\assets
目錄和components\Logo
目錄,assets目錄裏放置錢包的Logo圖片 ,Logo目錄下新建index.js
,代碼如下:
import React from 'react';
import Typography from '@material-ui/core/Typography';
import {makeStyles} from '@material-ui/core/styles';
import WalletIcon from 'components/assets/wallet.png';
const useStyles = makeStyles(theme => ({
icon: {
width: 50,
height: 50
},
grow: {
marginTop: theme.spacing(-0.5),
fontSize: 15
}
}));
export default function Logo() {
const classes = useStyles();
return (<div>
<img src={WalletIcon} alt="KHWallet" className={classes.icon}/>
<Typography className={classes.grow}>
KHWallet
</Typography>
</div>)
}
從代碼中我們可以看到,將圖片設置成了50*50大小,並且將下面的文字KHWallet
使用marginTop: theme.spacing(-0.5),
稍微向上移了一點,是爲了看上去緊湊一些。
5.2 編寫網絡選擇按鈕組件
網絡選擇按鈕的作用是讓用戶自行選擇錢包連接的以太坊網絡,比如是主網還是測試網還是本機私有節點。因此,我們需要對網絡有一個定義,這裏有一點基礎工作要做:
- 新建
src/constants/index.js
,在裏面寫上如下內容:
export const NET_WORKS_NAME = [
'網絡',
'以太坊主網絡',
'Ropsten測試網絡',
'Rinkeby 測試網絡',
'Kovan 測試網絡',
'Localhost 8545'
];
export const NET_WORKS = [
'network',
'homestead',
'ropsten',
'rinkeby',
'kovan',
'localhost'
]
這裏主要是定義網絡的名稱和對應的中文名,注意數組的第一項目–網絡–只是一個標題,並不是真正的網絡名稱(放在這是爲了簡化一下頁面的編寫處理)。
- 新建
src/contexts\Network.js
,代碼如下:
/**
* 本文件用來全局獲取和改變network
*/
import React, { useState,createContext, useContext, useMemo } from 'react'
const NetworkContext = createContext()
function useNetworkContext() {
return useContext(NetworkContext)
}
export default function Provider({ children }) {
const [network, setNetwork] = useState("homestead")
return (
<NetworkContext.Provider value={useMemo(() => [network, setNetwork], [network, setNetwork])}>
{children}
</NetworkContext.Provider>
)
}
export function useUpdateNetwork() {
const [,setNetwork] = useNetworkContext()
return setNetwork
}
export function useNetwork() {
const [network,] = useNetworkContext()
return network
}
該文件用來獲取當前網絡和改變當前網絡,它也是一個Provider,因此需要初始化,這個晚一點再進行。
好了,基礎工作差不多做完了,讓我們新建src\components\MenuBtn\index.js
,代碼如下:
import React, {useState, useRef, useEffect} from 'react';
import { makeStyles,withStyles,createMuiTheme } from '@material-ui/core/styles';
import { ThemeProvider } from '@material-ui/styles';
import MenuItem from '@material-ui/core/MenuItem';
import { purple,green } from '@material-ui/core/colors';
import Menu from '@material-ui/core/Menu';
import DownIcon from '@material-ui/icons/KeyboardArrowDown';
import CircleIcon from '@material-ui/icons/FiberManualRecord';
import IconButton from '@material-ui/core/IconButton';
import Divider from '@material-ui/core/Divider';
import DoneIcon from '@material-ui/icons/Done';
import { isMobile } from 'react-device-detect';
import { NET_WORKS,NET_WORKS_NAME } from '../../constants';
import {useUpdateNetwork} from 'contexts/Network.js'
const useStyles = makeStyles(theme => ({
root: {
display: 'flex',
},
btnIcon:{
fontSize: 15,
height:40,
fontWeight: "solid",
border: 2,
borderRadius: 25,
borderStyle: "solid",
borderColor: "black"
},
btnContext:{
marginTop: theme.spacing(-0.7),
},
btnText:{
position:'relative',
top:theme.spacing(-1),
},
}));
const StyledMenu = withStyles({
paper: {
border: '1px solid #d3d4d5',
//此處是將背景設置爲黑色,此時菜單裏的面文字要設置成白色
// backgroundColor: '#111111ee',
},
})(props => (
<Menu
elevation={0}
getContentAnchorEl={null}
anchorOrigin={{
vertical: 'bottom',
horizontal: 'center',
}}
transformOrigin={{
vertical: 'top',
horizontal: 'center',
}}
{...props}
/>
));
const StyledMenuItem = withStyles(theme => ({
root: {
//這一段是設置菜單獲取焦點時的顏色,暫時不用
// '&:focus': {
// backgroundColor: "#b2dfdb",
// // '& .MuiListItemIcon-root, & .MuiListItemText-primary': {
// // color: theme.palette.common.white,
// // backgroundColor:theme.palette.common.white,
// // },
// },
marginTop:theme.spacing(isMobile ? 0 : 1)
},
}))(MenuItem);
const colorType = {
'homestead':'primary',
'ropsten':'secondary',
'rinkeby':'action',
'kovan':'error',
'localhost':'inherit',
}
const custom_theme = createMuiTheme({
palette: {
primary:{
main:green[500]
},
secondary:{
main:purple[500],
},
},
});
function MenuBtn() {
const classes = useStyles();
const [open, setOpen] = useState(false);
const [selectedIndex,setSelectedIndex] = useState(1)
const anchorRef = useRef(null);
const prevOpen = useRef(open);
const updateNetwork = useUpdateNetwork()
const handleToggle = () => {
setOpen(prevOpen => !prevOpen);
};
const handleSelected = key => () => {
if(selectedIndex === key) {
return;
}
setSelectedIndex(key)
setOpen(false);
updateNetwork(NET_WORKS[key])
};
const handleClose = event => {
if (anchorRef.current && anchorRef.current.contains(event.target)) {
return;
}
setOpen(false);
};
function handleListKeyDown(event) {
if (event.key === 'Tab') {
event.preventDefault();
setOpen(false);
}
}
useEffect(()=>{
if (prevOpen.current === true && open === false) {
anchorRef.current.focus();
}
prevOpen.current = open;
},[open])
function showMenuItem() {
return NET_WORKS_NAME.map((net,key)=> showOneItem(net,key))
}
function showOneItem(net,key) {
let menuPos = key === 0 ? {textAlign:'center'} : {textAlign:"left"}
let _color = colorType[NET_WORKS[key]];
return (
<StyledMenuItem
key={net}
disabled={key===0}
selected={key===selectedIndex}
onClick={handleSelected(key)}
>
{key !== 0 && <DoneIcon color='primary' visibility={selectedIndex === key ? "show" : "hidden"}/>}
{key !== 0 && <CircleIcon color={_color}/>}
<div style={{width:"100%",...menuPos}}>
{net}
{key===0 && <Divider/>}
</div>
</StyledMenuItem>
)
}
return (
<div className={classes.root}>
<IconButton
className={classes.btnIcon}
ref={anchorRef}
aria-controls={open ? 'menu-list-grow' : undefined}
aria-haspopup="true"
onClick={handleToggle}
>
<div className={classes.btnContext}>
<CircleIcon color={colorType[NET_WORKS[selectedIndex]]} />
<span className={classes.btnText}>
{NET_WORKS_NAME[selectedIndex]}
</span>
<DownIcon color={colorType[NET_WORKS[selectedIndex]]}/>
</div>
</IconButton>
<ThemeProvider theme={custom_theme}>
<StyledMenu
id="customized-menu"
anchorEl={anchorRef.current}
keepMounted
open={open}
onClose={handleClose}
onKeyDown={handleListKeyDown}
>
{showMenuItem()}
</StyledMenu>
</ThemeProvider>
</div>
)
}
export default MenuBtn
這段代碼中,我們主要是學習Material UI中菜單的使用和元素的樣式。下面只介紹一些重點:
- 菜單
Menu
需要有一個anchorEl
屬性來錨定它的位置,一般是錨定在按鈕上。它用一個open
屬性來控制是否顯示。並且也需要有一個點擊任意位置關閉處理的onClose
方法。見StyledMenu
組件。 - 菜單項
MenuItem
是具體的菜單內容,它一般使用數組的map
方法來構建,因爲是列表,所以必須有一個key
屬性。通常還有選中的判斷和點擊的處理,見showOneItem
方法。 - 在Material UI中,可以使用預定義樣式,也可以將樣式和組件綁在一起變成一個新組件,或者使用自定義樣式。大家仔細看這麼一句代碼:
import { makeStyles,withStyles,createMuiTheme } from '@material-ui/core/styles';
,這裏面三種樣式的使用本代碼裏均涉及到。其中withStyle
用來產生一個hook,它使用預定義的樣式,這個是Material UI中使用的最多的。而makeStyle
的用法和styled-components
的使用類似,這裏給出styled-components
的鏈接,大家有空看一下。https://styled-components.com/。而createMuiTheme
主要是自定義調色版來改變Material UI中primary
和secondary
對應的顏色。這裏是官方文檔說明:https://material-ui.com/zh/customization/palette/。大家可以仔細看一下這三種用法,注意Material UI中的樣式和屬性名稱和CSS中的名稱有細微的區別,採用的是小駝峯命名,比如borderRadius
。
好了,我們現在有Logo和網絡選擇按鈕了,讓我們把它組合在一起。
5.3 編寫WalletBar組件
新建/src/components/WalletBar/index.js
,代碼如下:
import React from 'react';
import {makeStyles} from '@material-ui/core/styles';
import AppBar from '@material-ui/core/AppBar';
import Toolbar from '@material-ui/core/Toolbar';
import Logo from 'components/Logo'
import MenuBtn from 'components/MenuBtn'
const useStyles = makeStyles(theme => ({
root: {
flexGrow: 1
},
container: {
display: 'flex',
justifyContent: 'space-between'
}
}));
export default function WalletBar() {
const classes = useStyles();
return (<div className={classes.root}>
<AppBar position="static" color='default'>
<Toolbar className={classes.container}>
<Logo/>
<MenuBtn/>
</Toolbar>
</AppBar>
</div>);
}
可以看到,我們的WalletBar其實就是一個AppBar
,然後把Logo和網絡選擇按鈕放了進去。
5.4 編寫WalletBody
新建src/views/CreateWallet.js
,因爲這個頁面是創建錢包,所以我們這樣取名,代碼如下:
import React, {useState} from 'react';
import {makeStyles} from '@material-ui/core/styles';
import Avatar from '@material-ui/core/Avatar';
import AddIcon from '@material-ui/icons/PersonAdd';
import Button from '@material-ui/core/Button';
import FormControl from '@material-ui/core/FormControl';
import Typography from '@material-ui/core/Typography';
import Link from '@material-ui/core/Link';
import {useSimpleSnackbar} from 'contexts/SimpleSnackbar.jsx';
import TextField from '@material-ui/core/TextField';
const minLength = 12;
const useStyles = makeStyles(theme => ({
avatar: {
margin: theme.spacing(1),
backgroundColor: theme.palette.secondary.main
},
title: {
marginTop: theme.spacing(1),
fontSize: 20
},
form: {
width: '100%', // Fix IE 11 issue.
marginTop: theme.spacing(1),
textAlign: 'center'
},
submit: {
fontSize: 20,
width: "50%",
marginTop: theme.spacing(5)
},
import: {
margin: theme.spacing(4),
color: "#FF5722",
fontSize: 18,
textDecoration: "none"
},
wallet: {
textAlign: "center",
fontSize: 18
},
container: {
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
margin: theme.spacing(3)
}
}));
function CreateWallet() {
const classes = useStyles();
const [password, setPassword] = useState('')
const [confirmPassword, setConfirmPassword] = useState('')
const showSnackbar = useSimpleSnackbar()
const updatePassword = e => {
let _password = e.target.value;
setPassword(_password)
};
const updateConfirmPassword = e => {
let _confirmPassword = e.target.value;
setConfirmPassword(_confirmPassword)
}
const onSubmit = e => {
e.preventDefault();
if (password !== confirmPassword) {
return showSnackbar("前後兩次密碼不一致", "error");
}
if (password.length < minLength) {
return showSnackbar("密碼至少12位", "error");
}
}
return (<div className={classes.container}>
<Avatar className={classes.avatar}>
<AddIcon/>
</Avatar>
<Typography className={classes.title}>
創建一個新賬號
</Typography>
<form className={classes.form} onSubmit={onSubmit}>
<FormControl margin="normal" fullWidth>
<TextField id="standard-password-input"
label="設置密碼"
required
type="password"
autoComplete="current-password"
value={password}
onChange={updatePassword}/>
</FormControl>
<FormControl margin="normal" fullWidth>
<TextField id="confirm-password-input"
label="再次輸入密碼"
required
type="password"
autoComplete="current-password"
value={confirmPassword}
onChange={updateConfirmPassword}/>
</FormControl>
<Button type='submit' variant="contained" color="primary" className={classes.submit}>
創建
</Button>
</form>
<Link href="_blank" className={classes.import}>導入已有賬號</Link>
<div className={classes.wallet}>
<Typography color='secondary'>
KHWallet,簡單安全易用的
</Typography>
<Typography color='secondary'>
以太坊錢包
</Typography>
</div>
</div>)
}
export default CreateWallet
代碼的主要內容就是一個表單,其中的輸入框用到了受控組件,一般在React中,都使用受控組件。具體新建功能和導入功能均未實現,只是做了一個簡單的密碼判斷。
5.5 組合成主頁面
下面我們將Bar和Body組合起來,新建src\views\Main.jsx
,代碼如下:
import React from 'react';
import Grid from '@material-ui/core/Grid';
import {makeStyles} from '@material-ui/core/styles';
import WalletBar from 'components/WalletBar';
import CreateWallet from './CreateWallet';
import Paper from '@material-ui/core/Paper';
import { isMobile } from 'react-device-detect';
const useStyles = makeStyles(theme => ({
root: {
marginTop: theme.spacing(isMobile ? 8 :10),
display: "flex",
justifyContent: "center"
}
}));
export default function Main() {
const classes = useStyles();
return (<div className={classes.root}>
<Grid item xs={12} sm={12} md={3}>
<Paper style={{
height: 600,
mixHeight: 600
}}>
<WalletBar/>
<CreateWallet/>
</Paper>
</Grid>
</div>)
}
從代碼中可以看出,我們主要使用Grid來進行桌面和移動端的適配,並且將錢包高度設置成了600。這裏簡要介紹一下Grid,注意這行代碼:<Grid item xs={12} sm={12} md={3}>
。Grid將屏幕分成了12列,然後每個item佔其中幾例。這裏我們整個錢包就是唯一一個item,它在屏幕較大時佔3列md={3}
,如果屏幕較小時就佔滿整個屏幕(12列)xs={12} sm={12}
。注意我們將根容器設置成了Flex容器來將錢包居中。
我們的主頁面已經編寫完成了,但是我們還得有一些收尾工作。
六、收尾工作
6.1 主頁面替換
替換根目錄下index.js
中的主頁面,並且將Network.js
中的Provider也在此初始化,修改完成後的代碼如下:
import React from 'react';
import ReactDOM from 'react-dom';
import './index.css';
import Main from 'views/Main.jsx';
import * as serviceWorker from './serviceWorker';
import NotistackWrapper from 'contexts/NotistackWrapper.js'
import NetworkProvider from 'contexts/Network.js'
function AllProvider() {
return (
<NotistackWrapper>
<NetworkProvider>
<Main />
</NetworkProvider>
</NotistackWrapper>
)
}
ReactDOM.render(<AllProvider />,document.getElementById('root'));
// If you want your app to work offline and load faster, you can change
// unregister() to register() below. Note this comes with some pitfalls.
// Learn more about service workers: https://bit.ly/CRA-PWA
serviceWorker.unregister();
6.2 刪除多餘的文件
刪除根目錄下原App.js
、App.css
等新創建React 工程時的無用文件。最終工程的目錄結構如下:
6.3 安裝相應的庫
這其中用到了一些第三方或者不在Material UI主目錄裏的庫,我們可以直接運行npm start
,看提示什麼模塊找不到就安裝對應的庫。反覆運行npm start
直至最後頁面能夠顯示如下畫面:
你也可以修改根目錄下public/index.html
,更改頁面的標題,順便替換一下favicon.ico爲你自己的logo。
好了,本次的學習到此結束了。下一章計劃做一個導入界面並實現路由功能。
我是邊學邊做邊寫,因此裏面肯定有很多寫的不合理的地方式甚至錯誤的地方肯請大家留言指正或者提出改進意見。