金融科技管理平台

2023年9月开始跟随博士参加了研创赛,最终年底拿了研创赛国银,虽然成绩不错,但项目搭建本身个人没吃透。代码的编写,由于本人当时还未系统完成学习,很多功能都是依靠组件库依葫芦画瓢,细节与优化并不完美。

于是今天决定记录一下,将其中重点功能进行叙述,此笔记仅展示项目的部分代码(非个人项目)。

一、环境搭建

使用vite搭建(https://www.vitejs.net/guide/#scaffolding-your-first-vite-project)

1
npm init vite@latest

然后自己取个项目名字,选择所需开发配套(我这里选择vue、JS即可)。

接着进入项目,安装所需依赖,即可运行。

  1. 为了后期管理方便,在src下新建router(路由文件)、utils(公共文件)、views(页面组件文件)、api(地址管理文件)、components(公共组件文件)及asserts。

  2. 下载所需依赖包:

    1
    npm i axios vue-router vuex
  3. 路由与main.js文件关联。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    import { createRouter, createWebHashHistory } from "vue-router";
    import Login from '../views/Login.vue';

    const routes = [
    {
    path: '/',
    component: Login
    }
    ]

    const router = createRouter({
    history: createWebHashHistory(),
    routes
    })

    export default router;

    main.js引用路由

    1
    2
    3
    import router from './router'

    createApp(App).use(router).mount('#app')
  4. store与main.js文件关联

    vuex:集中式管理状态容器,可以实现任意组件间通信!

    在store文件夹下新建文件index.js,创建大仓库。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    import { createStore } from "vuex";
    import admin from './admin'

    export default createStore({
    // 存储数据
    state:{

    },
    //计算属性
    getters: {

    },
    mutations: {

    },
    actions: {

    },
    modules: {
    admin
    }
    })

    main.js与引用路由方法一样,引入状态管理store。

  5. vite相关配置

    这个本质其实就是开发环境与生产环境配置。

    由于我是使用vite搭建环境,所以使用此方法的朋友需要知道vite环境变量和相关模式。至于为何使用vite,就一个字:快!

    • vite在 import.meta.env 对象上暴露环境变量;
    • 只有以 VITE_ 为前缀的变量才会暴露给经过vite处理的代码(防止泄露到客户端);

    实际操作中,在自己根目录下新建 .env.development 与 .env.production 文件,在里面配置端口号和接口地址等信息。接着在vite.config.js文件引入loadEnv函数,配置修改如下:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    export default defineConfig(({command, mode}) => {
    const env = loadEnv(mode, process.cwd(), '')

    return {
    plugins: [
    vue()
    ],
    server: {
    host: '0.0.0.0',
    port: env.VITE_APP_PORT,
    proxy: {
    '/api': {
    target: env.VITE_APP_API_BASEURL,
    changeOrigin: true,
    rewrite: (path) => path.replace(/^\/api/, ''),
    },
    }
    }
    }
    });

    注意,Vite默认不加载.env文件,所以要使用loadEnv函数来指定加载,具体配置可以参考vite官方文档。

  6. axios使用

    1
    npm install axios --save

    在官网里面我们可以看到其具体使用方法,直接导入后,axios.get…,这种使用方式虽然简单,但不易于维护,所以一般都需要封装。

    我在utils文件夹下新建一个request的JS文件来封装这个库,下面这些代码可以参考axios官网,包括拦截器等。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    import axios from "axios";

    export function request(config){
    const instance = axios.create({
    // `baseURL` 将自动加在 `url` 前面
    baseURL: import.meta.env.VITE_APP_API_BASEURL,
    //指定请求超时的毫秒数
    timeout: 5000,
    });

    // 添加请求拦截器
    instance.interceptors.request.use(function (config) {
    // 在发送请求之前做些什么(如插入token)
    return config;
    }, function (error) {
    // 对请求错误做些什么
    return Promise.reject(error);
    });

    // 添加响应拦截器
    instance.interceptors.response.use(function (response) {
    // 2xx 范围内的状态码都会触发该函数。
    // 对响应数据做点什么
    return response;
    }, function (error) {
    // 超出 2xx 范围的状态码都会触发该函数。
    // 对响应错误做点什么
    if(error.response){
    if(error.status == 500){
    alert('服务器内部发送错误!')
    }
    }
    return Promise.reject(error);
    });

    // 发送一个真正的请求
    return instance(config)
    }

    那当我们需要向多个服务器发送多个请求,那只需在此新建多个实例,按需配置对应的拦截器就行,非常方便!

    使用时,直接引用request即可,不过我这里由于功能太复杂,页面太多,我在views文件夹每个页面下各建了一个管理接口方法的文件(service.js),真正规范的应该是在页面同级目录下新建service文件夹,对应管理。

  7. element-plus引入

    1
    npm install element-plus

    main.js引入

    1
    2
    3
    import ElementPlus from 'element-plus'
    import 'element-plus/dist/index.css'
    createApp(App).use(router).use(store).use(ElementPlus).mount('#app')

二、页面开发

本次页面开发所用组件库如下:

2.1 登录注册

考虑到登录注册所需信息不一样的情况,先使用Tabs组件进行包裹,再使用form表单。

我这里只展示登录初始写法吧(注册一样的),一些细节问题要着重注意,我放在3.2进行叙述。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
<el-tabs v-model="activeName" class="demo-tabs">
<el-tab-pane label="账号登录" name="first">
<el-form :model="form" label-width="120px">
<el-form-item label="用户名字">
<el-input v-model="userName" />
</el-form-item>
<el-form-item label="手机号码">
<el-input v-model="phone"/>
</el-form-item>
<el-form-item label="用户密码">
<el-input v-model="pwd" type="password" />
</el-form-item>
<el-form-item label="权限密码">
<el-input v-model="topPwd" type="password" />
</el-form-item>

<el-form-item>
<el-button type="primary" @click="onSubmit">登录</el-button>
<el-button>取消</el-button>
</el-form-item>
</el-form>
</el-tab-pane>
</el-tabs>

同时使用reactive动态接收登录信息。

1
2
3
4
5
6
7
const form = reactive({
userName: "",
phone: "",
pwd: "",
topPwd: ""
});
const {userName, phone, pwd, topPwd} = toRefs(form);

我在第一章第6部分已经对axios进行了封装,我这里因为接口太多,我直接在login.vue建立同级的service.js文件,进行请求发送管理。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
import api from '../../api';
import { request } from "../../utils/request";

export function login(data){
if(data.topPwd){
const params = {
"name": data?.userName,
"telephone": data?.phone,
"password": data?.pwd,
"platformPassword": data?.topPwd
}
return request({
url: api.admin.Login,
method: 'POST',
data: params
})
}
}

这里就是封装了登录请求的方法,这里注意的点在于我把所有接口信息全部重命名在api文件夹下的admin.js下。

1
export const Login = '/login';

然后再在index.js文件下统一暴露,方便对url地址进行管理。

1
2
3
4
import * as admin from './admin'
export default{
admin
}

接着我们可以直接对登录请求所获信息进行操作。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
const onSubmit = () => {
const data = {...form};
login(data).then(res => {
console.log(res)
if(res.data.code == 200){
// 登录成功
ElMessage.success("登录成功");
// 信息存入缓存
token.setStore("type", res?.data.type);
}else{
ElMessage.warning(res?.data.msg);
}
}).catch(err => {
console.log(err);
ElMessage.error("登录失败,请重试!");
});
if(token.getStore("type")){
// 跳转欢迎页
router.push("/home");
}
}

我们在实际浏览过程中,会需要将一些个人信息存入浏览器缓存,以便快捷登录、功能权限设置等。

此系统需要将登录的个人信息中的权限类型存入缓存,我在utils公共文件下新建currentToken.js,对缓存信息写入、获取、清空的操作进行设置。

1
2
3
4
5
6
7
8
9
10
11
12
window._ = {
setStore: (name, content) => {
window.localStorage.setItem(name, content);
},
getStore: (name) => {
return window.localStorage.getItem(name);
},
clear: () => {
window.localStorage.clear();
},
};
export default window._;

于是我们每次引用token,使用相关方法即可操作缓存信息。

此时我们已经完成了登录操作版块!

2.2 路由配置

注册登录完成后,信息存入浏览器缓存,接着应该跳转到首页,我们在构建首页前,需要配置各个页面路由。

我在第一章第3部分完成了路由与main.js的关联,这里只需要将所需新建的页面写入就行。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
const routes = [
{
path: '/',
component: () => import('../views/Login/Login.vue'),
},
{
path: '/home',
component: Home,
children: [
{
path: '',
component: () => import('../views/Welcome/index.vue'),
meta: { title: "欢迎"},
},
{
path: 'connect',
component: () => import('../views/Connect/index.vue'),
meta: { title: "联邦学习"},
},
{
path: 'digit',
component: () => import('../views/Digit/index.vue'),
meta: { title: "协调存储"},
},
{
path: 'template',
component: () => import('../views/Template/index.vue'),
meta: { title: "合约模板"},
},
{
path: 'welcome',
component: () => import('../views/Welcome/index.vue'),
meta: { title: "欢迎"},
},
{
path: 'opera',
children: [
{
path: 'index',
component: () => import('../views/Opera/index.vue'),
meta: { title: "网络拓扑"},
},
{
path: 'create',
component: () => import('../views/Opera/Create.vue'),
meta: { title: "网络新建"},
},
],
meta: { title: "网络运维"},
},
],
meta: { title: "系统"},
},
]

这里有人可能会发现我并未像之前导入Home组件一样一次性导入其他页面组件。我使用了懒加载的方式,匹配到对应路径才会动态导入,这是目前比较推荐的方式,性能更好。

接着就可以进行路由的相关操作,如路由跳转useRouter。

1
2
3
4
5
6
7
8
9
import { useRouter } from "vue-router";
const router = useRouter();
const onSubmit = () => {
...
if(token.getStore("type")){
// 跳转欢迎页
router.push("/home");
}
}

这里我们需要掌握一些基础信息,路由有两种模式:hash与history。

  • 其中hash的url带#,hash出现在url中,但不出现在HTTP请求中,改变hash,不会重新加载页面。hash路由又称前端路由,是SPA的标配。

  • history模式是传统的路由分发模式,输入一个URL时,服务器会接收这个请求,并解析这个URL,然后做出相应的逻辑处理。如果没有相应的路由或资源,返回404。

前端路由本质:为 SPA 中的各个视图匹配一个唯一标识。这意味着用户前进、后退触发的新内容,都会映射到不同的 URL 上去。(拦截用户的刷新操作,感知 URL 的变化)

2.3 基础模块

在构建各页面前,得将基础模块构建完毕,包括侧面导航栏、面包屑等。

侧面导航栏我直接在组件库menu里面选择了collapse折叠面板。

具体实现过程如下,参考element-plus组件库中的menu组件,自己选择喜欢的样式,然后选中对应代码复制即可。我这里在views文件夹下新建Home文件夹,将代码copy到index.vue下。

(考虑到代码过多,我直接截取部分,样式部分我就不展示了)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
<aside class="menu">
<!-- 左侧菜单栏 -->
<el-radio-group v-model="isCollapse" style="margin-bottom: 20px">
<el-radio-button :label="false">展开</el-radio-button>
<el-radio-button :label="true">折叠</el-radio-button>
</el-radio-group>

<el-menu
default-active="1"
class="el-menu-vertical-demo"
:collapse="isCollapse"
>
<el-menu-item index="1">
<el-icon><House /></el-icon>
<template #title><router-link to="/home/welcome">欢迎</router-link></template>
</el-menu-item>
<el-menu-item index="2">
<el-icon><icon-menu /></el-icon>
<template #title><router-link to="/home/connect">联邦学习</router-link></template>
</el-menu-item>
<el-sub-menu index="3">
<template #title>
<el-icon><icon-menu /></el-icon>
<span>碳金融</span>
</template>
<el-sub-menu index="3-1">
<template #title><span>碳审核</span></template>
<el-menu-item index="3-1-1">新建</el-menu-item>
<el-menu-item index="3-1-2">查询</el-menu-item>
</el-sub-menu>
<el-sub-menu index="3-2">
<template #title><span>碳交易</span></template>
<el-menu-item index="3-2-1">开户</el-menu-item>
<el-menu-item index="3-2-2">查询</el-menu-item>
<el-menu-item index="3-2-3">转账</el-menu-item>
<el-menu-item index="3-2-4">记录</el-menu-item>
</el-sub-menu>
</el-sub-menu>
</el-menu>
</aside>

这里我结合多种组件的特性,主要包括菜单的可折叠(collapse)、多级菜单(sub-menu与menu-item)。

我这里设置菜单默认是展开的。

1
2
import { ref } from 'vue'
const isCollapse = ref(false);

接着根据菜单栏的对应页面,创建对应的页面组件,同时配置对应的路由(参考2.2),我这里菜单栏的页面跳转使用的是router-link。

这里值得注意的是在路由配置中,/ 的使用得极其慎重,在children中,一般不以 / 开头。原因在于 / 代表的是绝对路径,不以 / 开头则是相对路径。

接着是数据展示,直接使用router-view即可,与router-link进行配对,我们布局设置是左侧菜单栏,右侧数据展示的传统配置。

1
2
3
4
<main class="content">
<!-- 数据展示 -->
<router-view></router-view>
</main>

现在大家只需根据个人情况在对应的页面填充即可,但是大家可能还会用到面包屑,所以我进行简要描述一下。

在路由配置中,我其实已经给每个路由meta中设置了对应的title,此目的在于面包屑展示的前提是获取跳转的页面的数据。

然后我们在components下新建Breadcrumb文件,route的matched到的是一个数组,里面存的就是获取到的路由对象,面包屑展示的就是每个路由对象(item)的meta的title属性。

1
2
3
4
5
<el-breadcrumb :separator-icon="ArrowRight">
<el-breadcrumb-item v-for="(item, index) in $route.matched" :key="index" :to="{ path: item.path }">
{{ item.meta.title }}
</el-breadcrumb-item>
</el-breadcrumb>

这里我们发现面包屑应该是个全局组件,那我们在main.js里面导入组件,同时注册全局组件。

1
2
3
4
5
6
7
8
// 导入面包屑组件
import Breadcrumb from './components/Breadcrumb.vue';

// 注册全局组件
app
.component('Breadcrumb', Breadcrumb)

app.use(router).use(store).use(ElementPlus).mount('#app')

然后哪个页面使用,直接使用即可。

1
2
 <!-- 使用面包屑组件 -->
<Breadcrumb></Breadcrumb>

三、常见细节

3.1 样式配置

虽然大部分样式都是根据个人情况设置,但仍然存在一些必需的样式,我这里将自己所认为的配置进行举例。

在Home中,配置的样式其实是会影响所有页面的,那直接在这里配置数据的展示情况、菜单栏的属性都是必须的。

让整个页面的展示使用flex布局,同时考虑到我做的是网页端,还需设置最小宽度。菜单栏需要设置固定宽度、最小高度及加上盒子尺寸。

1
2
3
4
5
6
7
8
.container{
display: flex;
min-width: 1280px;
margin: 0 auto;
}
.menu{
box-sizing: border-box;
}

3.2 双token登录

讲述一下登录逻辑吧,使用的是双token的无感刷新及重新认证思想。不使用基于cookie的session的原因:在特定时间有大量用户访问服务器的时候,服务端可能需要存储大量sessionID,而假如存在多台服务器,一台存储的服务器超载还需转移,同时集群的情况下,负载均衡会造成登录不成功,还会造成跨域相关问题。

  1. 输入相关信息(用户名、密码、类型密码),服务端使用jwt去配置双token(accessToken、refreshToken);
  2. 前端使用storage去存储token;
  3. 每次请求前,使用请求拦截器对请求头配置Authorization字段;
  4. 服务端收到请求,进行响应,此时还需配置响应拦截器,这里主要判断accessToken是否失效(code是否为4003)进行操作。
    1. 短token失效,暂存过期请求,长token请求;
    2. 长token有效,服务端重新发送短token及更新的长token,前端再调用过期请求与新短token进行请求;
    3. 长token无效,重新登录。

这部分具体代码涉及前后端,go不是很熟悉,等我不再面试,去公司仓库找一下。

3.3 图表使用

这里我使用的是echarts,大家有其他喜欢的也可以,本人使用echarts原因主要胜在其“悠久”。

大家直接去它模板库选择自己合适的就行(https://echarts.apache.org/examples/en/index.html),我这里比如在联邦学习版块训练数据时需要展示各阶段训练数据,所以直接采用了基础的折线图。

使用前先安装相关库,注意这是vue3。

1
npm install echarts vue-echarts

接着我使用了全局引入。

1
2
3
4
5
6
7
//引入ECharts
import VueECharts from 'vue-echarts'
import 'echarts'

// 注册全局组件
app
.component('v-chart', VueECharts)

虽然折线图只在平台一个地方使用,但是为了后续的模块化管理,我还是和面包屑一样单独做成一个组件。

这里我使用了vue3新的组件数据传递方式defineProps,用于在子组件中定义接收哪些父组件的props。当父组件(联邦学习版块)的props发生变化时,子组件(折线图)也会随之响应。

在子组件声明该组件需要接收的props,它需要传递一个包含props字段的对象,每个字段表示该props的默认值和类型等信息。同时我定义了一个可以修改数据的方法。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
<template>
<v-chart autoresize ref="charts" />
</template>

<script setup>
import {defineProps, ref, onMounted, defineExpose} from 'vue'

const charts = ref()
const props = defineProps({
option: {
type: Object
}
})
const setOption = (option) => {
charts.value.setOption({
tooltip: {
trigger: 'axis',
axisPointer: {
type: 'shadow'
}
},
grid: {
left: '3%',
right: '4%',
bottom: '3%',
containLabel: true
},
xAxis: [
{
type: 'category',
data: option.colChartsName,
axisTick: {
alignWithLabel: true
}
}
],
yAxis: [
{
type: 'value',
}
],
series: [
{
// name: option.RoundNumber,
type: 'line',
data: option.colChartsData
}
]
})
}
onMounted(() => {
setOption(props.option)
})
//导出方法给父组件使用
defineExpose({
setOption
})
</script>

这里需要在main.js里面导入组件,同时注册全局组件。

1
2
3
4
5
6
// 导入折线图
import ELine from './components/ELine.vue';

// 注册全局组件
app
.component('ELine', ELine)

接着直接在父组件使用即可。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
<template>
...
<ELine :option="option" ref="charts"></ELine>
...
</template>

<script setup>
import { ref } from 'vue';
let roundNum = new Array();
let acc = new Array();
const charts = ref();
const option = ref({
colChartsName: [],
colChartsData: [],
colUnitName: ""
});

const refresh = () => {
roundNum = [];
acc.splice(0);
getResult().then((res) => {
for(let i = 0; i < res?.data.result.length; i++){
roundNum.push(res.data.result[i].RoundNumber);
acc.push(res.data.result[i].Accuracy * 100);
}
option.value.colChartsName = roundNum;
option.value.colChartsData = acc;
//调用子组件的方法
charts.value.setOption(option.value)
});
}
</script>

3.4 粘贴复制

我们经常在一些地方见到对某些信息的展示与copy功能,此平台在合约模板版块对各种合约的代码提供copy功能。主要流程是用户点击按钮,展示相关代码,用户可以选择copy,成功复制后右上角出现信息弹窗。我在这里简述一下关键实现步骤。

首先各合约简要信息使用el-card进行展示,点击按钮后,信息弹出使用el-dialog。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
<template>
...
<!-- dialogVisible用于控制弹窗的关闭与否 -->
<el-dialog
v-model="dialogVisible"
title="Code"
width="90%"
:before-close="handleClose"
>
<span>{{msgCode}}</span>
<template #footer>
<span class="dialog-footer">
<el-button @click="dialogVisible = false">取消</el-button>
<el-button plain type="primary" @click="copyCode">
复制
</el-button>
</span>
</template>
</el-dialog>
...
</template>

其中,这个copyCode方法很重要,完成对信息的copy,具体解释看我博客相关对应文档,里面有对逐行代码分析。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
const copyCode = () => {
dialogVisible.value = false;
const textarea = document.createElement("textarea");
textarea.value = msgCode.value;
document.body.appendChild(textarea);
textarea.select();
document.execCommand('copy')
document.body.removeChild(textarea);
ElNotification({
title: 'Info',
message: '已成功复制到粘贴板',
type: 'info',
})
}

ElNotification是element-plus的一个组件。

3.5 首屏优化

首屏速度是用户体验的最关键一环,而首屏速度最大决定因素是资源的加载速度。

资源加载速度 = 资源大小 + 网速。

所以我们要考虑资源大小部分的影响方面:

  1. 压缩
  2. 部分代码切割出来做异步加载
  3. 代码尽量精简

这里我们主要针对的是第二个。

3.5.1 页面加载

因此我们需要知道页面加载的方式有哪些:

  1. prefetch加载

    优先加载没有标记prefetch的link资源。

    用户体验感很好,但是会浪费一定的带宽。

  2. script加载

    充分按需引入,用到再加载,不用永不加载,充分节省带宽,但是因为切换要等待,我认为体验感不好,没有采用这种。但也介绍一下吧,关闭全局prefetch。

    1
    2
    3
    4
    5
    module.exports = {
    chainWebpack: config => {
    config.plugins.delete('prefetch')
    }
    }

    此时已经全局关闭,但是如果你要部分使用此功能,在对应部分:

    1
    webpackPrefetch: true

    优化经验:首先使用按需引入的版本,接着在组件mounted阶段再引入库或者使用时引入,我按照我自己项目的操作来举个例子。

    因为我的UI库使用的是element-plus,在合约模板这个版块,我只要用到2个组件,就可以只引用这两个。

    1
    import { ElMessageBox, ElNotification } from 'element-plus';

    当然这个例子不是很恰当,因为官方网站提供了按需引入的方法。但是注意这种方法很有必要,因为在Vite项目中,Tree-shaking是默认启用的。

  3. 页面闪动

    初始化时代码还没有解析的情况下,可能会出现花屏现象,当然出现时间极其短暂,但也需要解决。

    1
    [v-cloak] { display: none;}
3.5.2 图片懒加载

这里我手动造了轮子,基于浏览器的原生构造函数交叉观察器intersectionObserve实现。

具体使用这里进行介绍一下,初始化接收两个参数,一个是回调函数,对观察的img对象数组entries进行操作,一旦进入可视区域,替换src,另一个是options,包含被观察的元素root、rootMagin等。具体代码见我“交叉观察器”的文章。

3.6 组件缓存

当我们来回切换页面时,该页面可能需要不断向后台请求数据,从而渲染页面,而多次渲染会带来性能问题。因此我们需要进行页面状态的保存,这时候我们需要区分页面组件是否卸载。

比如我登录的时候,登录的组件在我登录后,会卸载掉,但是我登录后,我采用的是SPA,Home组件并不会卸载。而我在网络运维\网络拓扑页面访问时,每次都需要获取所有的网络信息,如果不进行缓存,非常影响用户体验。

解决上述问题的方案其实很简单,在Home中对数据展示外层套一个即可,但是vue-router的版本一般是4.x以上的都改用了新的写法,用slot插槽进行配合。

1
2
3
4
5
6
7
8
<main class="content">
<!-- 数据展示 -->
<router-view v-slot="{ Component }">
<keep-alive>
<component :is="Component" />
</keep-alive>
</router-view>
</main>

3.7 前进后退

由于单页应用在一个页面中显示所有的内容,所以不能使用浏览器的前进后退功能,所有的页面切换需要自己建立堆栈管理。不过项目当时未有这个需求,所以暂时没做。

3.8 SEO

因为VUE框架一般都是使用SPA,因此在SEO上由突然弱势(SEO条件之一是需要在MPA上),因此只能针对这个做尽可能的优化,但是没有彻底的解决方案。

  • 预渲染

    • 使用prerender-spa-plugin,解决页面单一情况;

    • 使用vue-meta-info,解决title、描述、关键字;

    缺点:不适合众多页面需要SEO的需求;title、描述、关键字只能是静态的。

  • SSR

    • 使用nuxt.js扩展
    • 使用asyncData请求数据
    • 具体打包过程:首先npm run build,接着把四个文件(.nuxt、nuxt.config.js、package.json、static)拷贝到服务器,有node环境的服务器安装依赖运行npm run start,最后nginx配置。

四、对应处理

4.1 滚动处理

在网络运维/网络拓扑版块,首先要展示所有网络信息,后针对单条信息查询节点、状态信息渲染不同的模块。但是网络信息数据量常常有几百条,而每个网络对应的节点等又是几十条信息,测试的时候发现有两个瑕疵:一是渲染速度下降,内容展示慢(平均渲染区间1.72~1.8s);二是如果有操作(编辑、删除等),操作卡顿,渲染更新也慢。虽然1s多还可以,但是我们为了比赛现场展示效果,还是需要进行处理。

针对这个问题,网上大部分解决方案是写组件,用插槽插入。但是这改变原有父子结构,而且我本身功能已经完成了,再次修改,工作量极大,同时封装成组件,自己要写的代码还是很多。这时候可以发现自定义指令是个好的解决方案,现来阐述一下具体细节。

先讲一下思路吧,解决这个无限滚动,有两种方案:

  1. 触底,获取滚动元素后,进行滚动监听,监听的是触底(首次加载K条数据,每次触底,再多获取K条数据),数据形式为0K,02K,0~3K。此方法初次渲染确实无卡顿,但是随着往下滚动,速度会越来越慢。
  2. 动态截取,随着滚动,实时计算截取点(start、over),始终保证数据在一个固定值。每次都是渲染固定量的数据,保证渲染质量。

首先新建文件v-myscroll文件,用以存放自定义指令,当然也可以直接在main.js直接写,我觉得不美观。start、over计算涉及以下几个值:scrrollTop视口到滚动顶部的高度、clientHeight窗口的高度、scrollHeight可滚动的内容总高度。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
import { ref } from 'vue'
let num = ref(0);
let start = ref(0);
let over = ref(15);

export default {
install(app) {
app.directive('myscroll', {
//完成初始化工作(其实就是指令用在table上会调用一次),其中binding参数是指令接收的数据
mounted(el, binding) {
//获取滚动元素,并做滚动监听
let target = el.querySelector('.el-scrollbar__wrap');
target.addEventListener('scroll', () => {
//这加减5的含义是为了做缓冲,滚动更加流畅
start.value = Math.ceil(Math.max(target.scrollTop / 39.67 - 5, 0))
over.value = Math.ceil(Math.min((target.scrollTop + target.clientHeight) / 39.67 + 5, num));
})
},
updated(el, binding) {
let target = el.querySelector('.el-scrollbar__wrap');
//这里针对的是table
const _table = target.querySelector('table');
num = binding.value; //自定义指令接收到的数据
//通过padding 将盒子撑起来, 上面用paddingTop
_table.style.paddingTop = start.value * 39.67 + 'px';
//over之后数据量要先撑开,但不去加载,用paddingBottom设置完成为真实的高度
_table.style.paddingBottom = (num - over.value) * 39.67 + 'px';
}
})
}
}
export {
start, over
}

接着全局导入。

1
2
3
4
//引入myscroll
import myscroll from './utils/v-myscroll' ;

app.use(myscroll)

最后使用即可。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
<el-table v-myscroll="num" :data="tableData.slice(start, over)" :border=true>
<el-table-column label="networkname" prop="networkname" />
<el-table-column label="channelname" prop="channelname" />
<el-table-column align="right">
<template #default="scope">
<el-button
size="small"
type="danger"
@click="handleDelete(scope.row)"
>
删除
</el-button>
</template>
</el-table-column>
</el-table>


// 获取所有网络信息
let tableData = ref([]);
let num = ref(0);
queryAllNetwork(type).then((res) => {
tableData.value = res?.data;
num.value = tableData.value.length;
});

本身写到这里就可以结束了,但是最近新接触了一个知识点,我觉得非常有趣,也记录一下吧。

当遇到海量数据,可以使用fragment+animationFrame处理。具体过程是先设置切片数值,然后调用document.createFragment(),接着在fragment上面添加数据,接着循环此方法步骤,通过window.requestAnimationFrame(fn)来完成。

4.2 文件操作

在协同存储这个功能版块遇到了一个复杂的需求,图片的上传。要求是输入密钥、选择身份后,可以与多份文件上传,且完成文件压缩、预览等任务。

简单说一下思路吧,这文件的操作必然封装成组件,因为需要与其他信息一块传递,所以涉及父子组件通信。子组件内部需要对文件选择按钮进行二次修饰,完成上传文件信息的传递。同时预览之前需要预留一块空间,一方面为了美观,另一方面减少来回预览对DOM的影响,这里需要使用插槽。

首先新建Upload组件,展示一下子组件关键代码,里面关于文件压缩、预览功能可以查看博客的相关文章(文件上传及相关操作),里面进行了代码详细解释。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
<template>
<div class="comStyle">
<label class="label-but">
<span class="span-but">选择文件</span>
<input id="input-but" type="file" @change="fileChange" multiple />
</label>

<el-skeleton style="width: 21vw" :loading="loading" animated>
<template #template>
<el-skeleton-item variant="image" />
</template>
<template #default>
<el-card
:body-style="{ padding: '0px', marginBottom: '-10px' }"
>
<img ref="img1" :src="imgbase64" />
</el-card>
</template>
</el-skeleton>
</div>
</template>

<script setup>
import { ref, defineExpose, defineEmits } from 'vue';
import { ElNotification } from 'element-plus';

const emits = defineEmits(["clickDigit"]);

let _fileObj = ref({});
let imgList = ref([]);

const loading = ref(true);
let imgbase64 = ref("");
// 获取图片的真实DOM
let img1 = ref(null);

const fileChange = function(e){
let file = e.target.files[0];
let fr = new FileReader();
fr.readAsDataURL(file);
fr.onload = function(){
imgbase64.value = fr.result;
loading.value = false;
setTimeout(() => {
let pressCanvas = document.createElement("canvas");
pressCanvas.width = img1.value.width;
pressCanvas.height = img1.value.height;
let ctx = pressCanvas.getContext("2d");
ctx.drawImage(img1.value, 0, 0, img1.value.width, img1.value.height);
pressCanvas.toBlob((blob) => {
let _formData = new FormData();
_formData.append("file", blob);
imgList.value.push(_formData);
}, "image/jpeg", 0.6)
emits("clickDigit", imgList);
})
}
</script>

这里可能需要对预览进行一下阐述,这里可以发现运用了两个插槽,插槽名为template的渲染 skeleton 模板的内容,而名为default是真正渲染的DOM,两者通过loading这个Boolean变量控制。

接着全局引用,在协调存储(Digit)进行调用,将传过来的数据赋值给表单中_fileObj,我这里数据格式应该为[“[Object FormData]”, “[Object FormData]”]。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
<template>
...
<el-form :inline="true" :model="formStore" class="demo-form-inline">
...
<el-form-item>
<Upload @clickDigit="clickDigit"/>
</el-form-item>
...
</el-form>
...
</template>

<script setup>
import { reactive, toRefs, toRaw } from 'vue'

const formStore = reactive({
sk: '',
region: '',
_fileObj: [],
});
const { sk, region, _fileObj } = toRefs(formStore);

function clickDigit(params) {
_fileObj.value = params.value;
}

</script>

4.3 多组件嵌套

项目常需要用到el-table表格组件,在使用中有众多不便利性:

  1. 表格title属性需要手动且正确输入,当表格众多、对应属性众多时,非常不便利;

  2. 针对数据行,如果数据需要配置tag,那就需要通过三元运算符修改,代码非常复杂(简单写一下案例)。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    //原本样式
    <el-table-column prop="state" label="State" width="120" />

    //对应修改
    <el-table-column prop="state" label="State" width="120">
    <template #default={row}>
    <el-tag class="ml-2" :type="row.state === 0 ? 'danger' : 'success'">
    {{row.state === 0 ? '禁用' : '启用'}}
    </el-tag>
    </template>
    </el-table-column>
  3. element-plus组件库没有在table中封装分页,这也需要我们每次重复配套添加el-pagination组件,绑定各种事件、获取页码数。

这个可以预先改进,我还没完成,要去东北旅游+寒假结束准备实习,后面有时间进行更新…