前一篇文章我们学习了React中路由的使用并创建了一个导入界面;这一次我们学习钱包的具体实现和登录界面的开发,并且通过钱包将登录界面、创建钱包和导入钱包这三个UI串连起来。
一、Provider的进一步应用在前面的学习中,我们使用了Provider来实现消息条的全局显示和网络切换选择,这次我们计划使用Provider来实现更多功能。Provider是一种非常实用的工具,它除了能提供全局共享的变量和方法外,还可以使用同步的模式来写异步操作,并且也不需要async/await。
在前面内容中,由于内容较少,我们专门写了一个Network.js
来使用Provider处理网络切换选择。随着开发内容的增加,我们需要全局共享的变量或者方法会越来越多。这些变量有些是保存在本地存储的,比如私钥、简要账户信息等;有些变量只需要保存在内存中即可,比如是否登录,当前选择的网络,当前登录的钱包等。因此,我们将Provider进行了整合,使用一个GlobalProvider.js
来保存那些不需要本地存储的全局变量,使用StorageProvider.js
来保存需要本地存储的内存变量。
先删除src/contexts/Network.js
,然后新建src/contexts/GlobalProvider.js
,代码如下:
/**
* 本文件用来全局获取和更新不需要本地存储的全局变量(内存中)
*/
import React, { createContext, useContext, useReducer, useMemo, useCallback } from 'react'
const UPDATE='UPDATE'
const GlobalProvider = createContext()
function useGlobalContext() {
return useContext(GlobalProvider)
}
//todo 全局变量随着开发逐渐增加
const global_init = {
network:"homestead",
isLogin:false,
wallet:null
}
function reducer(state,{type,payload}) {
switch (type) {
case UPDATE:{
return { ...state,...payload }
}
default:{
throw Error(`Unexpected action type in GlobalContext reducer: '${type}'.`)
}
}
}
export default function Provider({children}) {
const [state, dispatch] = useReducer(reducer, global_init)
const update = useCallback( payload => {
dispatch({ type: UPDATE, payload})
}, [])
return (
[state,{update}], [state, update])}>
{children}
)
}
export function useGlobal() {
const [state,] = useGlobalContext()
return state
}
export function useUpdateGlobal() {
const [,{update}] = useGlobalContext()
return update
}
接着我们再新建src/contexts/StorageProvider.js
,代码如下:
/**
* 本文件用来全局获取和更新本地存储
* 存储内容随着开发逐渐增加
*/
import React, { useState,useEffect,createContext,useMemo,useContext, useCallback } from 'react'
import {reactLocalStorage} from 'reactjs-localstorage';
//需要在.env.local等文件中设置REACT_APP_APPKEY,代表本APP的key或者ID
const appKey = process.env.REACT_APP_APPKEY;
//创建上下文环境,固定用法
const StorageContext = createContext()
function useStorageContext() {
return useContext(StorageContext)
}
//定义一个provider
export default function Provider({ children }) {
//内存中保留一份缓存,不用每次从本地存储读取
const [data, setData] = useState(null)
//存储更新的同时也更新内存缓存
const update = useCallback( _data => {
reactLocalStorage.setObject(appKey,_data)
setData(_data)
},[])
//provider返回值,注意update包装在一个对象中,直接当作数组元素返回有时会出问题
return (
[data,{update}], [data, update])}>
{children}
)
}
/**
* 获取本地存储的hook,这里先返回一个undefined,读取本地存储后再更新这个返回值
*/
export function useStorage() {
const [data,{update}] = useStorageContext();
useEffect(()=>{
if(!data) {
let _data = reactLocalStorage.getObject(appKey);
//还未保存过数据为{}或者[]时
if( !_data.length) {
update([])
}else{
update(_data)
}
}
},[data,update])
return data
}
//更新存储的hook
export function useUpdateStorage() {
const [,{update}] = useStorageContext();
return update
}
大家注意看一下这个useStorage()
的用法,它是一个hook,首先返回一个未定义的值,然后再读取本地存储的值后进行更新。通常更新值都是异步的(这里恰好是同步的),值更新以后所有使用这个值的子元素都会重新渲染来使用最新的值,这对一些需要定时更新的全局共享的值非常有用。比如我们在一个交易所里,可以用它来定时更新ETH的价格等,更新后所有界面自动使用最新的价格。
那么有的人可能会问为什么不使用async/await
来直接获取最新的值,而是先返回一个值然后再更新呢。这是因为它是一个hook,虽然hook内部可以使用Promise,但是hook只能应用在函数组件最顶层,它只能用同步的方法调用,所以这里不能使用async/await
。
我们使用reactjs-localstorage
这个库来实现React中的本地存储,让我们先安装它:
npm install reactjs-localstorage
它读取或者设置本地存储时,需要提供一个appId之类的字符串,它的用法如下:
import {reactLocalStorage} from 'reactjs-localstorage';
reactLocalStorage.set('var', true);
reactLocalStorage.get('var', true);
reactLocalStorage.setObject('var', {'test': 'test'});
reactLocalStorage.getObject('var');
reactLocalStorage.remove('var');
reactLocalStorage.clear();
其实它保存的是K/V对(键/值对),这里的'var'
相当于一个key,需要注意的是,使用setObject
时保存的数据也可以是一个数组,因为在JavaScript中,数组也是对象。另外当使用getObject
时,如果对应的键不存在,它返回的是一个空对象{}
。注意,空对象在直接应用于逻辑判断时是返回true
的。
另外我们将有关的自定义环境变量集中设置,在项目根目录下新建.env.local
,内容如下:
REACT_APP_APPKEY = 'KHWallet2019'
REACT_APP_PASSWORD_LENGTH = 1
第一个就是本地存储的key,改成你自己的,第二个是限定密码最小长度,这里为了简单,设置成了1。
注意,在React中,自定义的环境变量必须以REACT_APP_
开头。有好几个地方可以设置自定义环境变量:
.env # 在所有的环境中被载入
.env.local # 在所有的环境中被载入,但会被 git 忽略
.env.[mode] # 只在指定的模式中被载入
.env.[mode].local # 只在指定的模式中被载入,但会被 git 忽略
这些环境变量在React中通过代码process.env.REACT_APP_NOT_SECRET_CODE
来获取,注意不要存放私钥、密码等。
让我们修改src/index.js
,加入这次新建的两个Provider的初始化,修改完成后的代码片断如下:
function AllProvider() {
return (
)
}
ReactDOM.render(,document.getElementById('root'));
因为篇幅关系,这里只列出代码片断,完整代码请大家直接在文章结尾的码云链接上去看。
四、使用AES加密我们的钱包私钥需要加密后保存在客户端,本次开发使用了AES加密,先安装它:
npm install crypto
然后新建src/utils/index.js
,代码片断如下:
import crypto from 'crypto'
export function aesEncrypt(data,key) {
let cipher = crypto.createCipher('aes192', key);
let crypted = cipher.update(data, 'utf8', 'hex');
crypted += cipher.final('hex');
return crypted;
}
export function aesDecrypt(encrypted, key) {
let decipher = crypto.createDecipher('aes192', key);
let decrypted = decipher.update(encrypted, 'hex', 'utf8');
decrypted += decipher.final('utf8');
return decrypted;
}
上面分别对应加密和解密两个方法,key
就是密码。
创建src/views/SignIn.js
,里面的代码如下:
import React, {useState} from 'react';
import {makeStyles} from '@material-ui/core/styles';
import { Link } from "react-router-dom";
import {useSimpleSnackbar} from 'contexts/SimpleSnackbar.jsx';
import TextField from '@material-ui/core/TextField';
import Avatar from '@material-ui/core/Avatar';
import Button from '@material-ui/core/Button';
import LockIcon from '@material-ui/icons/LockOutlined';
import Typography from '@material-ui/core/Typography';
import FormControl from '@material-ui/core/FormControl';
import {ethers} from 'ethers';
import {useStorage} from 'contexts/StorageProvider'
import {useUpdateGlobal} from 'contexts/GlobalProvider.js'
import { withRouter } from "react-router";
import {aesDecrypt} from 'utils'
const useStyles = makeStyles(theme => ({
avatar: {
margin: theme.spacing(1),
backgroundColor: theme.palette.secondary.main
},
form: {
width: '100%', // Fix IE 11 issue.
marginTop: theme.spacing(3),
textAlign: 'center'
},
submit: {
fontSize: 20,
width: "50%",
marginTop: theme.spacing(6)
},
import: {
fontSize: 18,
textDecoration:"none",
color:"#f44336",
margin: theme.spacing(6),
},
wallet: {
textAlign: "center",
marginTop: theme.spacing(3),
fontSize: 20
},
container: {
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
margin: theme.spacing(3)
}
}));
function SignIn({history}) {
const classes = useStyles();
const showSnackbar = useSimpleSnackbar()
const [password, setPassword] = useState('')
const storage = useStorage()
const updateGlobal = useUpdateGlobal()
const updatePassword = e => {
let _password = e.target.value;
setPassword(_password)
};
const onSubmit = e => {
e.preventDefault();
if(storage && storage.length > 0) {
let _crypt = storage[0].crypt;
try{
let privateKey = aesDecrypt(_crypt,password)
let wallet = new ethers.Wallet(privateKey)
let options = {
isLogin:true,
wallet,
}
updateGlobal(options)
history.push('/detail')
}catch(err) {
showSnackbar("密码错误",'error')
}
}
}
return (
KHWallet,简单安全易用的
以太坊钱包
登录
重置密码/导入新账号
)
}
export default withRouter(SignIn);
让我们着重来看这段代码:
const onSubmit = e => {
e.preventDefault();
if(storage && storage.length > 0) {
let _crypt = storage[0].crypt;
try{
let privateKey = aesDecrypt(_crypt,password)
let wallet = new ethers.Wallet(privateKey)
let options = {
isLogin:true,
wallet,
}
updateGlobal(options)
history.push('/detail')
}catch(err) {
showSnackbar("密码错误",'error')
}
}
}
从代码中可以看到,我们使用了ethers这个库通过解密后的私钥创建了一个钱包,并保存在内存中,钱包建立之后会跳转到钱包主界面/detail
。让我们先安装它:
npm install ethers
ethers
是一个很棒的用来进行以太坊上钱包管理和各种交互的库,有了它就不需要web3.js
了,它几乎能满足你关于以太坊的一切需求,所以向大家极力推荐。这里是它的文档:https://docs.ethers.io/ethers.js/html/,一定要多读几遍,多读几遍,多读几遍!
让我们建立一个最简单的钱包主页面来完成这个逻辑,这个页面只显示钱包地址,新建src/views/WalletDetail.jsx
,代码如下:
import React from 'react';
import {makeStyles} from '@material-ui/core/styles';
import {useGlobal} from 'contexts/GlobalProvider'
const useStyles = makeStyles(theme => ({
container: {
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
margin: theme.spacing(3)
}
}));
function WalletDetail() {
const classes = useStyles();
let {wallet} = useGlobal();
return (
{wallet.address}
)
}
export default WalletDetail
随着开发内容的增多,我们需要集中处理路由。由于路由可以在客户端由用户直接访问,所以在路由导航前需要进行相应的判断,比如没有账号就不能使用登录界面,只能使用创建或者导入界面等。
其实也可以不用路由而使用一个全局变量来控制显示哪个页面,但是我们主要是学习React和Material UI,所以就采用了一个复杂的方式使用路由来进行控制。
新建src/layouts/Routes/index.js
,代码如下:
import React,{Suspense,lazy} from 'react';
import {useStorage} from 'contexts/StorageProvider';
import {useGlobal} from 'contexts/GlobalProvider';
import {withRouter} from "react-router";
import { Route, Switch,Redirect } from "react-router-dom";
const ImportWallet = lazy(() => import('views/ImportWallet'));
const CreateWallet = lazy(() => import('views/CreateWallet'));
const SignInWallet = lazy(() => import('views/SignIn'));
const WalletDetail = lazy(() => import('views/WalletDetail'));
function SwitchRoute({history,path}) {
history.push(path)
return null
}
function Admin({history}) {
const storage = useStorage()
const {isLogin} = useGlobal()
const hasAccount = storage && storage.length !== 0 ;
if(!storage) {
return null
}
return (
{hasAccount ? : }
{hasAccount ? : }
{hasAccount
? (isLogin ? : )
: }
)
}
export default withRouter(Admin)
可以看到,我们将页面的延迟导入和路由导航都放在这个页面进行统一处理,并且通过hasAccout
和isLogin
两个变量来控制路由导航。注意我们使用了一个很小的函数组件SwitchRoute
来进行实际跳转。可能有人会问,为什么我们不直接将history.push(path)
写在Route
下面,而还要写一个新的函数组件,那是因为Route
下面只能是节点,必须有渲染。
接着我们再修改/src/views/Main.jsx
,导入上面的文件:
import Routes from 'layouts/Routes';
再修改路由导航处的代码:
该代码将所有访问转到Routes.js
也就是我们上面的路由处理文件进行处理。
这次学习还有一些其它修改,比如网络选择按钮中代码的修改(因为现在没有Network.js
了),这里就不再列出了。同时我们对导入钱包和新建钱包界面的密码输入框增加了个显/隐按钮,用来显示或者隐藏按钮,如下图所示: 注意:
我在这里遇到了一点小问题,这里显/隐按钮的颜色是紫色,这是我自定义Theme中secondary
的颜色。然而在Material UI标准theme下secondary
应该显示红色才对。这里我并未使用自定义theme,并且把颜色定义往上移一层就可以正确显示了。为了突出问题,这里仍然把它显示成紫色(也顺眼一点)。这个问题有待进一步研究,有兴趣的读者可以等下看完整的代码,这里先放上代码片断。
这个问题哪位读者知道症结所在,还请留言或者私信告知。
由于篇幅限制,这里不再列出所有修改过的地方,比如钱包导入和钱包登录逻辑的实现,请大家下载源码后自己研究一下。
八、创建一个以太坊账号 好了,我们以太坊钱包的第一阶段已经可用了。先将/src/.env.local
中对应环境变量设置好,然后运行 npm start
,你就会看到一个创建账号的界面了。如果提示缺少了某个库,请先npm install 库名
来安装。 还没有以太坊账号的小伙伴们还在等什么,马上输入你的密码创建一个吧。创建之后就会跳到还未开发的钱包主页面 -_- 。点击刷新之后,你就会看到创建页面变成登录页面了:
输入你创建钱包(导入钱包)时的密码就可以登录了。
注意:
需要注意的是:这个钱包的密钥加密保存在本地存储中,如果你清除了本地存储,也就把它清除掉了,所以你的账号就丢了,谁也无法找回了。以后我们会增加导出账号的功能,让你能对钱包私钥做一个备份,但是目前,还是别轻易清除本地存储才好。
吐槽一下:CSDN将我们的登录信息也保存在本地存储中,如果你在写文章时为了测试钱包不小心清除了本地存储(或者其它别的原因),对不起,它会认为你没有登录。你再点击保存草稿或者发布文章都不可行,意味着你以前未保存的内容会丢失。不过,还是可以挽救的,你还可以点击导出按钮,将整个文章导出为一个MarkDown文件,这样重新登录后再点击导入,导入刚才的md文件即可。
下一次计划开发钱包主页面的部分功能。
本次学习完成后的码云地址: => https://gitee.com/TianCaoJiangLin/khwallet
退请大家留言指出错误或者提出改进意见