边学边用--使用React下的Material UI框架开发一个简单的仿MetaMask的网页版以太坊钱包(一)

一、前言

        区块链经历了长时期的低谷,最近又火了起来。目前国家层面也在积极推动,各地政府也在加大投入,并且估计央行今年就要发行数字货币了。提到区块链,不能不提比特币和以太坊,区块链诞生于比特币,比特币就是区块链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 编写网络选择按钮组件

        网络选择按钮的作用是让用户自行选择钱包连接的以太坊网络,比如是主网还是测试网还是本机私有节点。因此,我们需要对网络有一个定义,这里有一点基础工作要做:

  1. 新建src/constants/index.js,在里面写上如下内容:
export const NET_WORKS_NAME = [
    '网络',
    '以太坊主网络',
    'Ropsten测试网络',
    'Rinkeby 测试网络',
    'Kovan 测试网络',
    'Localhost 8545'
];

export const NET_WORKS = [
    'network',
    'homestead',
    'ropsten',
    'rinkeby',
    'kovan',
    'localhost'
]

这里主要是定义网络的名称和对应的中文名,注意数组的第一项目–网络–只是一个标题,并不是真正的网络名称(放在这是为了简化一下页面的编写处理)。

  1. 新建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中primarysecondary对应的颜色。这里是官方文档说明: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.jsApp.css等新创建React 工程时的无用文件。最终工程的目录结构如下:
在这里插入图片描述

6.3 安装相应的库

        这其中用到了一些第三方或者不在Material UI主目录里的库,我们可以直接运行npm start,看提示什么模块找不到就安装对应的库。反复运行npm start直至最后页面能够显示如下画面:
在这里插入图片描述
        你也可以修改根目录下public/index.html,更改页面的标题,顺便替换一下favicon.ico为你自己的logo。

        好了,本次的学习到此结束了。下一章计划做一个导入界面并实现路由功能。

        我是边学边做边写,因此里面肯定有很多写的不合理的地方式甚至错误的地方肯请大家留言指正或者提出改进意见。

        码云地址:=> https://gitee.com/TianCaoJiangLin/khwallet

发布了15 篇原创文章 · 获赞 0 · 访问量 537

猜你喜欢

转载自blog.csdn.net/weixin_39430411/article/details/104066018
今日推荐