前言
本篇主要记录学习Vue并实际参与完结web3门户项目的经验和走过的弯路。拖了这么久才来还债,说项目忙那是借口,还是因为个人懒!从自学到实战Vue实际中间就1周的学习熟悉时间,学习不够深就会造成基础不稳,多次推翻重来的情况,从架子搭设到实际页面功能都存在这种情况,说来真是惭愧。最终,算是圆满完工吧。
一、项目框架
1.打包方式
vue新建项目打包方式分2种(其他的方式暂未学习):
1.使用webpack工具
学习时参照了bilibili教学老师的打包方式,也就是上篇文章(自学Vue开发Dapp去中心化钱包(二))介绍的,之后按照这个新建项目开始开发web3门户。
命令如下:
vue init webpack 项目名称
项目结构如下:
2.使用vue-cli工具
命令如下:
vue create 项目名称
项目结构如下:
总结:就学习而言,webpack打包方式新手比较适合,多数参数都能接触到,然对项目而言,再经过学习和调查后发现多数快速搭建大家用的是vue-cli工具。最终web3门户这个项目我使用了vue-cli这种打包方式的项目,结构很明朗。
2.vuex组件
store的结构上篇文章(自学Vue开发Dapp去中心化钱包(二))也介绍过,这里对文章中store的模块化重新做了优化,使其更符合“模块化”这个概念。
结构如下:
这里myStore,user,settings相当于3个不同的模块,存储3组不同的信息分别对应web3相关参数、用户相关参数、系统配置项相关参数。
注:Vuex持久化插件vuex-persistedstate这里主要是为了解决刷新后数据消失的问题,持久化缓存一些全局变量。使用时注意createPersistedState里面应该是模块的参数,比如myStore.account,是myStore模块下的参数account。
myStroe.js
import * as ethers from "ethers";
import {getWethAddress} from "@/config/contracts";
import {getEth_chainId} from "@/methods/common";
const state = {
//provider对象
provider: {},
//合约对象
contracts: {},
//签名对象
signer: {},
//小狐狸钱包的账户address
account: '',
//以太坊网络ID:0x5
net: '',
//gas费,后续可能要用
gasPrice: 0,
//钱包余额
balance: '0.0',
//作为是否链接登录到小狐狸钱包的标志
isConnectWallet: false,
//绑卡列表数据,用于下拉框
accountList: [],
//交易计数,用于发生交易时同步刷新交易记录列表
tradeCounter: 0,
}
const mutations = {
saveProviderStore: (state, provider) => {
state.provider = provider;
},
saveContractsStore: (state, contracts) => {
state.contracts = contracts;
},
saveAccountStore: (state, account) => {
state.account = account;
},
saveBalanceStore: (state, balance) => {
state.balance = balance;
},
saveNetStore: (state, net) => {
state.net = net;
},
saveGasPriceStore: (state, gasPrice) => {
state.gasPrice = gasPrice;
},
saveIsConnectWallet: (state, isConnectWallet) => {
state.isConnectWallet = isConnectWallet;
},
saveSigner: (state, signer) => {
state.signer = signer;
},
saveAccountList: (state, accountList) => {
state.accountList = accountList;
},
saveTradeCounter: (state, tradeCounter) => {
state.tradeCounter = tradeCounter;
},
}
const actions = {
// 触发保存方法
SET_PROVIDER: ({ commit }, payload) => {
commit('saveProviderStore', payload);
},
SET_CONTRACTS: ({ commit }, payload) => {
commit('saveContractsStore', payload);
},
SET_ACCOUNT: ({ commit }, payload) => {
commit('saveAccountStore', payload);
},
SET_BALANCE: ({ commit }, payload) => {
commit('saveBalanceStore', payload);
},
SET_NET: ({ commit }, payload) => {
commit('saveNetStore', payload);
},
SET_GAS_PRICE: ({ commit }, payload) => {
commit('saveGasPriceStore', payload);
},
SET_IS_CONNECT_WALLET: ({ commit }, payload) => {
commit('saveIsConnectWallet', payload);
},
SET_SIGNER: ({ commit }, payload) => {
commit('saveSigner', payload);
},
SET_ACCOUNT_LIST: ({ commit }, payload) => {
commit('saveAccountList', payload);
},
SET_TRADE_COUNTER: ({ commit }, payload) => {
commit('saveTradeCounter', payload);
},
async connectWallet({ dispatch }) {
let web3Provider;
if (window.ethereum) {
web3Provider = window.ethereum;
try {
//通过
const addressArray = await web3Provider.request({
method: "eth_requestAccounts",
});
let address = addressArray[0];
const obj = {
status: "👆🏽 Write a message in the text-field above.",
address: address,
};
let chainId = await getEth_chainId();
dispatch("setProvider",{address,chainId});
dispatch("addWalletListener");
return obj;
} catch (err) {
return {
address: "",
status: "😥 " + err.message,
};
}
} else {
return {
address: "",
status: (
<span>
<p>
{" "}
🦊{" "}
<a target="_blank" href={`https://metamask.io/download.html`}>
You must install Metamask, a virtual Ethereum wallet, in your
browser.
</a>
</p>
</span>
),
};
}
},
async getCurrentWalletConnected ({ dispatch }) {
let web3Provider;
if (window.ethereum) {
web3Provider = window.ethereum;
try {
const addressArray = await web3Provider.request({
method: "eth_accounts",
});
if (addressArray.length > 0) {
let address = addressArray[0];
//请求chain写在这里,防止beforeEach时参数还未放入store中
let chainId = await getEth_chainId();
//vuex dispatch多个参数时使用object对象传递
dispatch("setProvider",{address,chainId});
dispatch("addWalletListener");
return {
address: addressArray[0],
status: "👆🏽 Write a message in the text-field above.",
};
} else {
return {
address: "",
status: "🦊 Connect to Metamask using the top right button.",
};
}
} catch (err) {
return {
address: "",
status: "😥 " + err.message,
};
}
} else {
return {
address: "",
status: (
<span>
<p>
{" "}
🦊{" "}
<a target="_blank" href={`https://metamask.io/download.html`}>
You must install Metamask, a virtual Ethereum wallet, in your
browser.
</a>
</p>
</span>
),
};
}
},
setProvider({commit},data) {
let web3Provider;
if (window.ethereum) {
web3Provider = window.ethereum;
const provider = new ethers.providers.Web3Provider(web3Provider);
const signer = provider.getSigner();
const contractABI = require("@/config/constants/contract-abi.json");
const wethAddress = getWethAddress();
const daiContract = new ethers.Contract(wethAddress, contractABI, provider);
//先改变isConnectWallet值,后改变account值
commit('saveNetStore', data.chainId);
commit('saveIsConnectWallet', true);
commit('saveAccountStore', data.address);
commit('saveProviderStore', provider);
commit('saveContractsStore', daiContract);
commit('saveSigner', signer);
//监听区块
/*provider.on("block", (blockNumber) => {
// Emitted on every block change
console.log("blockNumber: " + blockNumber);
})*/
}
},
addWalletListener({commit}) {
let web3Provider;
if (window.ethereum) {
web3Provider = window.ethereum;
web3Provider.on('accountsChanged', accounts => {
//断开链接后,初始化一些值
if(accounts.length===0){
commit('saveIsConnectWallet', false);
commit('saveProviderStore', {});
commit('saveContractsStore', {});
commit('saveSigner', {});
commit('saveBalanceStore', '0.0');
commit('saveAccountList', []);
}else{
//先改变isConnectWallet值,后改变account值
commit('saveIsConnectWallet', true);
}
commit('saveAccountStore', accounts[0]);
});
web3Provider.on('chainChanged', (chainId) => {
commit('saveNetStore', chainId);
});
}
},
}
export default {
state,
mutations,
actions
}
getter.js
// 获取最终的状态信息
const getters = {
provider: state => state.myStore.provider,
contracts: state => state.myStore.contracts,
signer: state => state.myStore.signer,
account: state => state.myStore.account,
net: state => state.myStore.net,
gasPrice: state => state.myStore.gasPrice,
isConnectWallet: state => state.myStore.isConnectWallet,
accountList: state => state.myStore.accountList,
tradeCounter: state => state.myStore.tradeCounter,
token: state => state.user.token,
avatar: state => state.user.avatar,
name: state => state.user.name,
mrspFlag: state => state.user.mrspFlag,
roles: state => state.user.roles,
permissions: state => state.user.permissions,
defaultDecimalPalces: state => state.settings.defaultDecimalPalces,
tokenName: state => state.settings.tokenName,
legalTender: state => state.settings.legalTender,
legalDecimalPalces: state => state.settings.legalDecimalPalces,
}
export default getters
index.js
import Vue from 'vue';
import Vuex from 'vuex';
import myStore from '@/store/modules/myStore';
import user from "@/store/modules/user";
import settings from '@/store/modules/settings';
import getters from '@/store/getters';
import createPersistedState from 'vuex-persistedstate';
Vue.use(Vuex);
const store = new Vuex.Store({
modules: {
myStore,user,settings,
},
getters,
plugins: [
createPersistedState({
paths: ['myStore.isConnectWallet', 'myStore.account', 'myStore.net']
}),
],
});
export default store
二、实战经验
1.router
由于项目做了改版,存在多级子路由,这里路由路径要注意的是子路由带/和不带/是有区别的。
比如:
{
path:'/home',
meta: {authRequired: true},
component: Home,
children: [
{path:'/', redirect: 'wallet'},
{
path:'wallet',
component: Wallet,
children: [
{path:'/', redirect: 'balances'},
{
path:'balances',
component:Balances
},{
path:'transfer',
component: Transfer
},{
path:'swap',
component: Swap
},{
path:'receive',
component: Receive
}]
},
]
}
如果这里的path:'balances'改为path:'/balances',子路由前面加/ ,加上/就不会拼接上父级路由的path路径,地址则为http://localhost:8080/#/balances,这样就造成点击菜单时没法联动,点击父菜单子菜单也不会切换。
完整的index.js
import Vue from 'vue'
import Router from 'vue-router'
import Login from '@/components/Login'
import Home from '@/components/Home'
import Wallet from '@/components/pages/Wallet'
import Balances from "@/views/wallet/Balances";
import Transfer from "@/views/wallet/Transfer";
import Receive from "@/views/wallet/Receive";
import Swap from "@/views/wallet/Swap";
import Bridge from '@/components/pages/Bridge'
import Deposit from "@/views/bridge/Deposit";
import Withdraw from "@/views/bridge/Withdraw";
import Card from '@/components/pages/Card'
import Bongloy from "@/views/card/Bongloy";
Vue.use(Router);
const originalPush = Router.prototype.push
Router.prototype.push = function push (location) {
return originalPush.call(this, location).catch(err => err)
}
let routes =[
{path: "*", redirect: "/"},
{
path:'/',
name:"login",
component: Login,
},
{
path:'/home',
meta: {authRequired: true},
component: Home,
children: [
{path:'/', redirect: 'wallet'},
{
path:'wallet',
component: Wallet,
children: [
{path:'/', redirect: 'balances'},
{
path:'balances',
component:Balances
},{
path:'transfer',
component: Transfer
},{
path:'swap',
component: Swap
},{
path:'receive',
component: Receive
}]
},{
path:'bridge',
component: Bridge,
children: [
{path:'/', redirect: 'deposit'},
{
path:'deposit',
component:Deposit
},{
path:'withdraw',
component: Withdraw
}]
},{
path:'card',
component: Card,
children: [
{path:'/', redirect: 'bongloy'},
{
path:'bongloy',
component:Bongloy
}]
}
]
},
];
export default new Router({
mode: 'history', // 去掉url中的#
routes:routes
})
菜单跳转时path
<router-link to="balances">{{$t(item.navname)}}</router-link>
效果:
router里面的meta: {authRequired: true} 这个authRequired参数是做拦截路由的,当请求的路由时,验证是否需要登录认证。
需要再main.js里增加如下代码:
//拦截路由,当请求的路由时,验证是否需要登录认证,并验证当前是否已连接小狐狸且网络是0x5通道,如果不是则进入登录页面;
//authRequired是router中自定义的参数
router.beforeEach((to, from, next) => {
if (to.matched.some(res => res.meta.authRequired)) { // 验证是否需要登陆
if (store.getters.account&&store.getters.net===getChainId()) { // 查询本地存储信息是否已经登陆且通道正确
next();
} else {
//未登录则跳转至login页面
next({path: '/', });
}
} else {
next();
}
})
效果如下
2.父子方法调用
父页面调用子页面方法用this.$refs
父页面
...
<--引入的子页面-->
<my-temp-page ref="myTempPageRef" >
...
methods:{
initEdit(row){
this.$refs.myTempPageRef.handleUpdate(row);
},
},
子页面
myTempPage.vue
methods:{
handleUpdate(){
//TODO dosomething
},
},
子页面调用父页面方法用this.$emit()
父页面
...
<Success @toBack="onNotifyBack"/>
...
methods:{
onNotifyBack(){
//dosomething
},
},
子页面
success.vue
...
<button @click="toBack" class="reset-button" variant="outlined" data-testid="transaction-receipt-reset">{{ $t('lang.swap41') }}</button>
...
methods:{
toBack(){
this.$emit("toBack");
},
},
3.store的使用
页面使用语法糖获取store属性
computed: {
...mapState({
balance: state => state.myStore.balance,
address: state => state.myStore.account,
}),
},
...mapState是语法糖。
取值时注意不能是state.account,因为vuex结构修改成多个模块,取值时要加上定义的模块,比如state.myStore.account、state.user.email等等
页面对store属性变更
这时这里的SET_TRADE_COUNTER方法名前不加模块名
this.$store.dispatch('SET_TRADE_COUNTER', this.tradeCounter+1);
页面调用store定义的方法
同样的方法名前不加模块名
this.$store.dispatch('connectWallet').then((res) => {
//TODO
});
在user(其他)模块中使用另外一个模块myStore里的方法
使用dispatch,参数中增加{root: true}
user.js
...
methodName({ dispatch }) {
...
commit('SET_EMAIL', res.data.email)//调用自己模块更新属性方法
dispatch('SET_ACCOUNT', 参数值,{root: true});//调用myStore里的更新account属性的方法
}
4.监听数据变化
vue监听某个值变化使用watch。
如下是监听store某个属性的变化,需是有变化时才会监听到。
computed: {
storeTradeCounter(){
return this.$store.getters.tradeCounter;//获取属性
}
},
...
watch:{
//监听有交易发生时,刷新列表
storeTradeCounter (newValue,oldValue) {
//交易发送时试试修改store里的绑卡余额及钱包余额
//dosomething
},
},
5.input框监听
监听输入框只能输入2位小数的数字,其他均无法输入
...
<input v-model="formData.amount" type="text" name="amount" placeholder="0.00"
@input="handleAmountInput(formData.amount)">
...
methods:{
handleAmountInput(value) {
//大于等于0,且只能输入2位小数
let val=value.replace(/^\D*([0-9]\d*\.?\d{0,6})?.*$/,'$1');
if(val==null||val==undefined||val==''){
val=''
}
this.formData.amount = val;
},
}
6.vue生成二维码
引入vue-qr
npm install vue-qr --save
使用
import VueQr from 'vue-qr'
...
components:{
VueQr,
},
...
<vue-qr
:text="this.account"
:size="148"
logoSrc=""
:logoScale="0.2">
</vue-qr>
7.小狐狸3d logo
下载小狐狸钱包3d logo资源
本人在github上和其他网站均找了许久,最后融合到项目整了几次,总算总结出来具体哪些文件可用,并且好用的。资源如下:
Metamask小狐狸3d Logo
代码中使用
将metamask-logo放入utils下,
package.json文件中引入这2个
"gl-mat4": "1.1.4",
"gl-vec3": "1.0.3"
然后npm install
使用
...
<div id="logo-container" class="meta-mask-fox mr-2 h-10 w-auto md:h-16" ></div>
...
data(){
return {
viewer: null,
}
},
mounted () {
//加載3D小狐狸logo
const ModelViewer = require('@/utils/metamask-logo');
this.viewer = ModelViewer({
// Dictates whether width & height are px or multiplied
pxNotRatio: true,
width: 60,
height: 60,
// To make the face follow the mouse.
followMouse: true,
// head should slowly drift (overrides lookAt)
slowDrift: false,
});
var container = document.getElementById('logo-container');
container.appendChild(this.viewer.container);
},
destroyed() {
if(this.viewer!==null){
this.viewer.setFollowMouse(true);
this.viewer.stopAnimation();
}
},
效果
三、记录用到的方
1.金额格式化(千分位)
效果是:9775格式化为9,775.500000
/**
* @description 格式化金额
* @param number:要格式化的数字
* @param decimals:保留几位小数 默认0位
* @param decPoint:小数点符号 默认.
* @param thousandsSep:千分位符号 默认为,
*/
export const formatMoney = (number, decimals = 0, decPoint = '.', thousandsSep = ',') => {
number = (number + '').replace(/[^0-9+-Ee.]/g, '')
let n = !isFinite(+number) ? 0 : +number
let prec = !isFinite(+decimals) ? 0 : Math.abs(decimals)
let sep = (typeof thousandsSep === 'undefined') ? ',' : thousandsSep
let dec = (typeof decPoint === 'undefined') ? '.' : decPoint
let s = ''
let toFixedFix = function (n, prec) {
let k = Math.pow(10, prec)
return '' + Math.ceil(n * k) / k
}
s = (prec ? toFixedFix(n, prec) : '' + Math.round(n)).split('.')
let re = /(-?\d+)(\d{3})/
while (re.test(s[0])) {
s[0] = s[0].replace(re, '$1' + sep + '$2')
}
if ((s[1] || '').length < prec) {
s[1] = s[1] || ''
s[1] += new Array(prec - s[1].length + 1).join('0')
}
return s.join(dec)
}
//去除千分位中的‘,'
export const delcommafy = function (num) {
if (!num) return num;
num = num.toString();
num = num.replace(/,/gi, "");
return Number(num);
};
使用方法
import {formatMoney} from "@/utils/fixednumber";
...
formatMoney(‘9775’, 6);//格式化成小数点6位带千分位的货币金额9,775.500000
...
2.校验
// utils.js
// 全局函数
export function validateMobile(str) {
// 检查手机号码格式
return /^((13[0-9])|(14[5-9])|(15([0-3]|[5-9]))|(16[6-7])|(17[1-8])|(18[0-9])|(19[1|3])|(19[5|6])|(19[8|9]))\d{8}$/.test(
str,
);
}
export function validateEmail(str) {
// 检查邮箱格式
return /^([A-Za-z0-9_\-.])+@([A-Za-z0-9_\-.])+\.([A-Za-z]{2,4})$/.test(str);
}
export function validateMoney(str) {
// 检查金额格式
return /^([1-9]\d*(\.\d{1,2})?|([0](\.([0][1-9]|[1-9]\d{0,1}))))$/.test(str);
}
export function validateBonMoney(str) {
// 检查金额格式
return /^([1-9]\d*(\.\d{1,6})?|([0](\.([0][1-9]|[1-9]\d{0,1}))))$/.test(str);
}
export function validatePhone(str) {
// 检查电话格式
return /^(0\d{2,4}-)?\d{8}$/.test(str);
}
export function validateQQ(str) {
// 检查QQ格式
return /^[1-9][0-9]{4,}$/.test(str);
}
// 检查验证码格式
export function validateSmsCode(str) {
return /^\d4$/.test(str);
}
// 校验 URL
export function validURL(url) {
const reg =
/^(https?|ftp):\/\/([a-zA-Z0-9.-]+(:[a-zA-Z0-9.&%$-]+)*@)*((25[0-5]|2[0-4][0-9]|1[0-9]{2}|[1-9][0-9]?)(\.(25[0-5]|2[0-4][0-9]|1[0-9]{2}|[1-9]?[0-9])){3}|([a-zA-Z0-9-]+\.)*[a-zA-Z0-9-]+\.(com|edu|gov|int|mil|net|org|biz|arpa|info|name|pro|aero|coop|museum|[a-zA-Z]{2}))(:[0-9]+)*(\/($|[a-zA-Z0-9.,?'\\+&%$#=~_-]+))*$/
return reg.test(url)
}
// 校验特殊字符
export function specialCharacter(str) {
const reg = new RegExp(
// eslint-disable-next-line quotes
"[`~!@#$^&*()=|{}':;',\\[\\]<>《》/?~!@#¥……&*()——|{}【】‘;:”“'。,、? ]"
)
return reg.test(str)
}
/**
* @param value
* 测试密码是否满足条件,包括四种类型
* 密码6-20位,必须包含大写字母,小写字母,数字及特殊字符
*/
export function validPassword(value) {
const num = /^.*[0-9]+.*/
const low = /^.*[a-z]+.*/
const up = /^.*[A-Z]+.*/
const spe = /^.*[^a-zA-Z0-9]+.*/
const passLength = value.length > 5 && value.length < 21
return num.test(value) && low.test(value) && up.test(value) && spe.test(value) && passLength
}
3.复制到粘贴板
export function copyToClipboard(content) {
if (window.clipboardData) {
window.clipboardData.setData('text', content);
} else {
(function (content) {
document.oncopy = function (e) {
e.clipboardData.setData('text', content);
e.preventDefault();
document.oncopy = null;
}
})(content);
document.execCommand('Copy');
}
};