项目介绍

1. 项目流程

  • 需求分析(评审和分析)
  • 等视觉与交互设计
  • 项目开发(项目完成度基本与视觉设计和交互设计保持一致)
  • 预留充分的自测时间(测试功能和样式)
  • 前后端联调(调接口功能与字段限制);经常需要改接口逻辑、甚至加接口造成前端需要修改对应的校验或功能;时间必须
  • 视觉与交互验收
  • 产品验收
  • 提交测试
  • 线上接口回归
  • 新项目的上线前准备
    • 资源申请
    • 沙盒环境部署测试
    • 线上部署测试

2. 项目迭代

需求分析、系统设计、代码实现、系统测试。

3. 联调

本地模拟假数据,使用mock数据

项目难点和亮点

1. 权限管理

1.1 登录权限

概述: 登录权限控制要做的事情,是实现哪些页面能被游客访问,哪些页面只有登录后才能被访问.

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
// 路由守卫 - 登录拦截
router.beforeEach((to, from, next) => {
const token = store.state.token; // 表示用户登录的凭证

if (to.meta.auth) {
// 如果路由中的 meta 字段下的 auth 为true,表示该路由必须登录后访问
if (token) {
// token 存在,表示已经登陆了,那么想干嘛干嘛去
next();
} else {
const next_page = to.name
// 不存在,表明你没有登录,不好意思,回到登录页
next({
name:'login',
params:{
redirect_page : next_page ,
...form.params //如果跳转需要携带参数就把参数也传递过去
}
});
}
} else {
// 不需要登录就可以访问
next(); // 直接下一步
}
});

1.2 页面权限

第一种:前端处理

概述:根据不同的角色赋予其不同的页面访问权限

  • 用户登陆成功之后,后端返回用户信息,然后保存在vuexlocalStorage里面
1
2
3
4
5
{
user_id:1,
user_name:"张三",
permission_list:["List","Detail","Manage"]
}
  • vuex里面拿到当前用户的权限列表,然后遍历动态路由数组dynamic_routes,从里面过滤出允许访问的路由,最后将这些路由动态添加到路由实例里.
1
2
3
4
5
6
7
8
9
10
if(store.state.user != null){ //从vuex中拿到用户信息
//用户已经登录
const { permission_list } = store.state.user; // 从用户信息中获取权限列表
const allow_routes = dynamic_routes.filter((route)=>{ //过滤允许访问的路由
return permission_list.includes(route.name);
})
allow_routes.forEach((route)=>{ // 将允许访问的路由动态添加到路由栈中
router.addRoute(route);
})
}
  • 嵌套子路由 router.addRoute接受两个参数,第一个参数对应父路由的name属性,第二个参数是要添加的子路由信息.

  • router.addRoute("Tabs", {
            path: "/list",
            name: "List",
            component: List,
     });
    
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20

    问题:当超级管理员登录后退出,路由实例任然存在,导致普通用户也能访问

    解决: 当用户选择登出后,清除掉路由实例里面处存放的路由栈信息

    ```js
    const router = useRouter(); // 获取路由实例
    const logOut = () => { //登出函数
    //将整个路由栈清空
    const old_routes = router.getRoutes();//获取所有路由信息
    old_routes.forEach((item) => {
    const name = item.name;//获取路由名词
    router.removeRoute(name); //移除路由
    });
    //生成新的路由栈
    routes.forEach((route) => {
    router.addRoute(route);
    });
    router.push({ name: "Login" }); //跳转到登录页面
    };
第二种:后端返回路由表

React:动态创建菜单

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
// 创建 ICON
function createIcon(name) {
return React.createElement(Icon[name])
}

// 动态创建菜单
function filterMenu(data) {
const cloneData = _.cloneDeep(data)

const newData = cloneData.filter((item, index) => {
if (!item.hidden) {
// 判断是否需要在菜单中显示当前菜单项
item.icon = createIcon(item.icon)
item.label = <Link to={item.path}>{item.label}</Link>

if (item.children && item.children.length > 0) {
item.children = filterMenu(item.children)
}

return true
} else {
return false
}
})
return newData
}

export default function SideMenu() {
const { Sider } = Layout
const authList = useSelector((state) => state.user.authList)
const menuData = filterMenu(authList)

return (
<Sider trigger={null}>
<div className="logo" />
<Menu
theme="dark"
mode="inline"
defaultSelectedKeys={['1']}
defaultOpenKeys={['sub1']}
items={menuData}
/>
</Sider>
)
}

动态创建路由:

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
59
60
61
62
63
64
65
66
67
// 树状转扁平
function treeToLevel1(data) {
let result = []

for (var i = 0; i < data.length; i++) {
var obj = data[i]
var cloneObj = _.cloneDeep(obj)
delete cloneObj.children

if (obj.component) {
result.push(cloneObj)
}

if (obj.children && obj.children.length > 0) {
var tem = treeToLevel1(obj.children)
result = result.concat(tem)
}
}

return result
}

// 自定义 hooks
function useRouteToDom() {
const authList = useSelector((state) => state.user.authList)
const domData = treeToLevel1(authList)
console.log('转树状:', domData)

// <Route path="authority/employee" element={<Employee />} />

const arr = domData.map((item, index) => {
const Com = React.lazy(() => import(`../${item.component}/index.jsx`))
console.log('动态加载组件:', Com)

return <Route key={index} path={item.path} element={<Com />} />
})
console.log('处理后路由:', arr)
return arr
}

const AsyncRouter = () => {
const authList = useSelector((state) => state.user.authList)
const domData = treeToLevel1(authList)
console.log('转树状:', domData)

const arr = domData.map((item, index) => {
const Com = React.lazy(() =>
import(`../../Views/${item.component}/index.jsx`)
)

return <Route key={index} path={item.path} element={<Com />} />
// <React.Suspense callback={<div>loading...</div>}>
// <Com />
{
/* </React.Suspense>} />; */
}
})
console.log('处理后路由:', arr)

return (
<React.Suspense callback={<div>loading...</div>}>
<Routes>{arr}</Routes>
</React.Suspense>
)
}

export default React.memo(AsyncRouter)

在Layout文件中

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
<Layout
style={{
padding: '0 24px 24px',
}}
>
<Bread />
<Content
className="site-layout-background"
style={{
padding: 24,
margin: 0,
minHeight: 280,
}}
>
{/* <Outlet /> */}
<AsyncRouter />
</Content>
</Layout>

1.3 内容权限,按钮权限控制

概述: 不同的角色都能进入页面,但要求看到的页面内容不一样

按照增删查改四个维度对页面内容进行归类.使用简称CURD来标识(CURD分别代表创建(Create)、更新(Update)、读取(Retrieve)和删除(Delete)).

  • 后端返回用户列表
1
2
3
4
5
6
7
8
{
user_id:1,
user_name:"张三",
permission_list:{
"List":"CR", //权限编码
"Detail":"CURD" //权限编码
}
}
  • 设置全局自定义指令

当元素挂载完毕后,通过binding.value获取该元素要求的权限编码.然后拿到当前路由名称,通过路由名称可以在vuex中获取到该用户在该页面所拥有的权限编码.如果该用户不具备访问该元素的权限,就把元素dom移除.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
import router from './router';
import store from './store';

const app = createApp(App); //创建vue的根实例

app.directive('permission', {
mounted(el, binding, vnode) {
const permission = binding.value; // 获取权限值
const page_name = router.currentRoute.value.name; // 获取当前路由名称
const have_permissions = store.state.permission_list[page_name] || ''; // 当前用户拥有的权限
if (!have_permissions.includes(permission)) {
el.parentElement.removeChild(el); //不拥有该权限移除dom元素
}
},
});
  • 页面中使用
1
2
3
4
5
6
7
8
9
10
11
12
13
<template>
<div>
<button v-permission="'U'">修改</button> <button v-permission="'D'">删除</button>
</div>
<p>
<button v-permission="'C'">发布需求</button>
</p>

<!--列表页-->
<div v-permission="'R'">
...
</div>
</template>

2. 上传图片

1. 前端

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
import React from 'react'
import { InboxOutlined } from '@ant-design/icons'
import { message, Upload } from 'antd'
const { Dragger } = Upload
const props = {
name: 'file', // 必须与后端一致
multiple: true,
method: 'post',
listType: 'picture-card',
action: 'http://localhost:3001/purchase/apply/uploadFile', // 请求路径
onChange(info) {
const { status } = info.file
if (status !== 'uploading') {
console.log(info.file, info.fileList)
}
if (status === 'done') {
message.success(`${info.file.name} 文件上传成功.`)
} else if (status === 'error') {
message.error(`${info.file.name} 文件上传失败.`)
}
},
onDrop(e) {
console.log('取消文件上传', e.dataTransfer.files)
},
}
const uploadFile = () => (
<Dragger {...props}>
<p className="ant-upload-drag-icon">
<InboxOutlined />
</p>
<p className="ant-upload-text">将文件拖拽到此处或点击上传</p>
</Dragger>
)
export default uploadFile

2. 后端

配置文件
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
const multer = require('multer')

module.exports = {
/* 文件上传函数 */
file_load() {
// 配置存储位置
const fileDir = './public/uploads' // 前提:必须保证该目录存在
const storage = multer.diskStorage({
destination: function (req, file, cb) {
// 存储地址
cb(null, fileDir)
},
filename: function (req, file, cb) {
// 配置文件名
console.log('上传文件:', file)
// file.originalname 可以获取前端传递的图片名及其后缀,比如 10.small.jpg
const filenameArr = file.originalname.split('.') // ['10', 'small', 'jpg']
const ext = filenameArr[filenameArr.length - 1] // 数组最后一位,数组长度长度-1,后缀名 - 'jpg'

const uniqueSuffix = Date.now() + '-' + Math.round(Math.random() * 1e9) // 随机名字
// file.fieldname 上传图片时约定的字段名
cb(null, file.fieldname + '-' + uniqueSuffix + '.' + ext) // 必须上传后对图片重新命名
},
})
const upload = multer({ storage: storage })

return upload
},
}

路由文件
1
2
3
4
5
6
7
// 引入配置文件
const file_load = require('../../utils/uploadFile')

// 上传文件
const upload = file_load.file_load()
router.post('/uploadFile', upload.single('file'), // 该字段必须与前段一致
applyController.uploadFile)

3. 封装的公共组件

3.1 Vue

3.1.1 二次封装
1. el-image 二次封装

概述: image组件, 我们需要统一在图片加载失败的时候展示的特定图

技术点:

  • v-bind=”$attrs”的妙用是在创建更高级别的组件,在封装第三方组件时,可以自动将在父作用域中使用的v-bind的属性自动绑定,并向下传入被封装的使用了v-bind=”$attrs”的组件。

  • v-on=”listeners”的作用 它可以将父作用域中的使用v-on的时间监听器向下传入到使用了v-on=”listeners”组件中, 和v-bind=”listeners“组件中,和vbin**d=”attrs”的功效类似,只不过一个属性一个是事件。

使用:

1
<custom-Image fit="fill" class="icon-img" :src="picPreview(expert)"></custom-Image>

事例:

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
<template>
<div id="CustomImage">
<el-image v-bind="$attrs" v-on="$listeners">
<div slot="error" class="image-slot">
<img :src="require('image-f/icon-empty-img.png')" alt="图片加载失败.png"/>
</div>
<div slot="placeholder" class="placeholder-slot">加载中...</div>
</el-image>
</div>
</template>

<script>
export default {
name: 'CustomImage'
}
</script>

<style scoped lang="scss">
#CustomImage {
.image-slot {
text-align: center;
}

.placeholder-slot {
text-align: center;
}
}
</style>

3.1.2 初始造轮子
1. 模态框
  • 创建一个Vue文件,实现基本的结构和样式
  • 标题通过Props传入,设置为必传。按钮默认一个确认按钮,可定制。可显示取消按钮,可定制。主体通过slot插槽处理。
  • 弹窗组件开关由外部控制,但并不用show来控制, 而是对show进行监听,赋值给组件内部变量showSelf。 方便组件内部控制弹窗的隐藏。
  • 保证弹窗的层级足够高,且弹窗内容的层级要比遮罩层的高,后弹窗的层级高于之前的层级。
  • 点击遮罩层关闭弹窗和处理弹窗地步页面的的内容不可滚动。.self只在当前元素触发,不从内部触发。
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
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
<template>
<div v-if="showSelf" class="dialog" :style="{'z-index': zIndex}">
<div class="dialog-mark" @click.self="closeMyself" :style="{'z-index': zIndex + 1}"></div>
<transition name="dialog">
<div class="dialog-sprite" :style="{'z-index': zIndex + 2}">
<!-- 标题 -->
<section v-if="title" class="header">{{ title }}</section>

<!-- 弹窗的主题内容 -->
<section class="dialog-body">
<slot></slot>
</section>

<!-- 按钮 -->
<section class="dialog-footer">
<div v-if="showCancel" class="btn btn-refuse" @click="cancel">{{cancelText}}</div>
<div class="btn btn-confirm" @click="confirm">{{confirmText}}</div>
</section>
</div>
</transition>
</div>
</template>

<script>
export default {
props: {
//弹窗组件是否显示 默认不显示 必传属性
show: {
type: Boolean,
default: false,
required: true,
},
title: {
type: String,
required: true,
},
showCancel: {
typs: Boolean,
default: false,
required: false,
},
cancelText: {
type: String,
default: '取消',
required: false,
},
confirmText: {
type: String,
default: '确定',
required: false,
},
},
data() {
return {
name: 'dialog',
showSelf: false,
zIndex: this.getZIndex(),
bodyOverflow: ''
}
},
watch: {
show(val) {
if (!val) {
this.closeMyself()
} else {
this.showSelf = val
}
}
},
created() {
this.showSelf = this.show;
},
mounted() {
this.forbidScroll()
},
methods: {
/** 禁止页面滚动 */
forbidScroll() {
this.bodyOverflow = document.body.style.overflow
document.body.style.overflow = 'hidden'
},

/** 每次获取之后 zindex 自动增加 */
getZIndex() {
let zIndexInit = 20190315;
return zIndexInit++
},

/** 取消按钮操作 */
cancel() {
this.$emit('cancel', false);
},

/** 确认按钮操作 */
confirm() {
this.$emit('confirm', false)
},

/** 点击遮罩关闭弹窗 */
closeMyself(event) {
this.showSelf = false;
this.sloveBodyOverflow()
},

/** 恢复页面的滚动 */
sloveBodyOverflow() {
document.body.style.overflow = this.bodyOverflow;
},
}
}
</script>

<style lang="less" scoped>
// 弹窗动画
.dialog-enter-active,
.dialog-leave-active {
transition: opacity .5s;
}

.dialog-enter,
.dialog-leave-to {
opacity: 0;
}

// 最外层 设置position定位
// 遮罩 设置背景层,z-index值要足够大确保能覆盖,高度 宽度设置满 做到全屏遮罩
.dialog {
position: fixed;
top: 0;
right: 0;
width: 100%;
height: 100%;
// 内容层 z-index要比遮罩大,否则会被遮盖
.dialog-mark {
position: absolute;
top: 0;
height: 0;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, .6);
}
}

.dialog-sprite {
// 移动端使用felx布局
position: absolute;
top: 10%;
left: 15%;
right: 15%;
bottom: 25%;
display: flex;
flex-direction: column;
max-height: 75%;
min-height: 180px;
overflow: hidden;
z-index: 23456765435;
background: #fff;
border-radius: 8px;
.header {
padding: 15px;
text-align: center;
font-size: 18px;
font-weight: 700;
color: #333;
}
.dialog-body {
flex: 1;
overflow-x: hidden;
overflow-y: scroll;
padding: 0 15px 20px 15px;
}
.dialog-footer {
position: relative;
display: flex;
width: 100%;
// flex-shrink: 1;
&::after {
content: '';
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 1px;
background: #ddd;
transform: scaleY(.5);
}
.btn {
flex: 1;
text-align: center;
padding: 15px;
font-size: 17px;
&:nth-child(2) {
position: relative;
&::after {
content: '';
position: absolute;
left: 0;
top: 0;
width: 1px;
height: 100%;
background: #ddd;
transform: scaleX(.5);
}
}
}
.btn-confirm {
color: #43ac43;
}
}
}
</style>

使用:

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
<the-dialog :show="showDialog" @confirm="confirm2" @cancel="cancel" :showCancel="true" :title="'新标题'" :confirmText="`知道了`" :cancelText="`关闭`">
<p>主题内容</p>
<p>主题内容</p>
</the-dialog>


<script>
export default {
data() {
return {
// 控制两个弹窗组件的初始显示与隐藏
showDialog: true,
showDialog2: true,
}
},
methods: {
cancel(show) {
this.showDialog = show
},
confirm(show) {
this.showDialog = show
},
cancel2(show) {
this.showDialog2 = show
},
confirm2(show) {
this.showDialog2 = show;
},
}
}
</script>

3.2 React

1. 全局dialog
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
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
import React, { Component } from 'react';
import { is, fromJS } from 'immutable';
import ReactDOM from 'react-dom';
import ReactCSSTransitionGroup from 'react-addons-css-transition-group';
import './dialog.css';
let defaultState = {
alertStatus:false,
alertTip:"提示",
closeDialog:function(){},
childs:''
}
class Dialog extends Component{
state = {
...defaultState
};
// css动画组件设置为目标组件
FirstChild = props => {
const childrenArray = React.Children.toArray(props.children);
return childrenArray[0] || null;
}
//打开弹窗
open =(options)=>{
options = options || {};
options.alertStatus = true;
var props = options.props || {};
var childs = this.renderChildren(props,options.childrens) || '';
console.log(childs);
this.setState({
...defaultState,
...options,
childs
})
}
//关闭弹窗
close(){
this.state.closeDialog();
this.setState({
...defaultState
})
}
renderChildren(props,childrens) {
//遍历所有子组件
var childs = [];
childrens = childrens || [];
var ps = {
...props, //给子组件绑定props
_close:this.close //给子组件也绑定一个关闭弹窗的事件
};
childrens.forEach((currentItem,index) => {
childs.push(React.createElement(
currentItem,
{
...ps,
key:index
}
));
})
return childs;
}
shouldComponentUpdate(nextProps, nextState){
return !is(fromJS(this.props), fromJS(nextProps)) || !is(fromJS(this.state), fromJS(nextState))
}

render(){
return (
<ReactCSSTransitionGroup
component={this.FirstChild}
transitionName='hide'
transitionEnterTimeout={300}
transitionLeaveTimeout={300}>
<div className="dialog-con" style={this.state.alertStatus? {display:'block'}:{display:'none'}}>
{this.state.childs}
</div>
</ReactCSSTransitionGroup>
);
}
}
let div = document.createElement('div');
let props = {

};
document.body.appendChild(div);
let Box = ReactD
3.2.1 高阶组件

4. 封装的工具函数

4.1 自定义hooks

1. useSessionStorage
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
import { useState } from 'react'

export function useSessionStorage(key) {
const [state, setState] = useState(() => getStorage())

function getStorage() {
const value = sessionStorage.getItem(key)
if (value) {
try {
return JSON.parse(value)
} catch (error) {
console.log(error)
}
} else {
return undefined
}
}

const updateState = (newState) => {
// 如果为undefined 则删除该键
if (typeof newState == 'undefined') {
sessionStorage.removeItem(key)
setState(undefined)
} else {
sessionStorage.setItem(key, JSON.stringify(newState))
setState(newState)
}
}

return { state, updateState }
}

2. useList
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
/* 
listReq:api请求
option:obj
{
cur:当前页数
size:一页多少条数据
data:查询参数
flag:是否自动请求
}
*/

import { useState } from 'react'

export function useList(listReq, option) {
const [list, setList] = useState([])

const flag = option?.flag || true // 默认自动请求
const cur = option?.cur || 1
const size = option?.size || 5
const data = option?.data || {}
const getList = () => {
const payload = { ...data, current: cur, pageSize: size }
listReq(payload)
.then((res) => {
console.log(res)
if (res.code == '200') {
console.log(res.data)
// setList(res.)
} else {
console.log('获取数据错误')
}
})
.catch((err) => {
console.log(err)
})
}
flag && getList()

return {
list,
getList,
}
}

4.2 自定义指令

4.3 封装插件

1. 全局try catch

思路:

  • 借助AST抽象语法树,遍历查找代码中的await关键字
  • 找到await节点后,从父路径中查找声明的async函数,获取该函数的body(函数中包含的代码)
  • 创建try/catch语句,将原来async的body放入其中
  • 最后将async的body替换成创建的try/catch语句

5. 封装的高阶组件

5.1 权限控制

概述: 利用高阶组件的 条件渲染 特性可以对页面进行权限控制,

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
function withAdminAuth(WrappedComponent) {
return class extends React.Component {
state = {
isAdmin: false,
}
async UNSAFE_componentWillMount() {
const currentRole = await getCurrentUserRole();
this.setState({
isAdmin: currentRole === 'Admin',
});
}
render() {
if (this.state.isAdmin) {
return <WrappedComponent {...this.props} />;
} else {
return (<div>您没有权限查看该页面,请联系管理员!</div>);
}
}
};
}

5.2 页面复用

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
const withFetching = fetching => WrappedComponent => {
return class extends React.Component {
state = {
data: [],
}
async UNSAFE_componentWillMount() {
const data = await fetching();
this.setState({
data,
});
}
render() {
return <WrappedComponent data={this.state.data} {...this.props} />;
}
}
}

// pages/page-a.js
export default withFetching(fetching('science-fiction'))(MovieList);

3. 统一处理 loading

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// 公共逻辑处理 - 统一处理loading 高阶组件
const WidthTable = (Component) => {
console.log('接受组件', Component)

const NewComponent = ({ loading, ...otherProps }) => {
console.log('HOC 的 props', otherProps)
if (loading) {
return <div>loading...</div>
} else {
return <Component></Component>
}
}
return NewComponent
}
export default WidthTable

4. 路由鉴权

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
import React from 'react'
import { Navigate, useLocation } from 'react-router-dom'
import { useSelector } from 'react-redux'

const PrivateRoute = ({ component: Component, ...rest }) => {
let location = useLocation()
const token = useSelector((state) => state.user.token)
console.log(token)
return token ? (
<Component {...rest}></Component>
) : (
<Navigate
to={{
pathname: '/login',
state: { from: location }, // 重定向登录页并且记录想要访问的页面
}}
></Navigate>
)
}

export default PrivateRoute

项目性能优化

一、 登陆

概述:访问资源时所需要的凭证

1. 登陆处理流程

1.1 token 处理

前端通过发送账号和密码或账号和验证码给到后端后,后端验证通过会返回一个唯一的 token 作为该用户的登录凭证,前端将此凭证存储在vuex以及本地中,在之后的每个请求当中,请求头中都需带上这个 token 作为后端的登录校验。

token 有过期的机制,可以在请求拦截中做逻辑判断处理,若当前时间接近了过期时间,则通过更新 token 的接口请求更新 token ,在之后的请求中带上新的 token 。以此循环,若用户过长时间无操作,则可认为用户为离线状态,在用户之后的第一次请求时,由于 token 已经过期,访问后端接口会发生错误,根据后端返回的错误状态码作为判断,将系统定向至登录页面。
通过带有 token 请求头的请求方法,后端可以判断到是哪一个用户,前端也可以通过获取权限接口获得该用户的权限列表,根据权限列表做一份路由映射表,如果后端返回的数据结构与前端的路由设置的数据结构不同,此时还需编写此映射路由的业务功能函数。如果该用户拥有此路由权限,则通过在全局路由监控中 router.beforeEach 进行 router 中的 addRoutes 方法将有权限的路由配置添加到路由当中,侧边栏也可根据路由列表中的 meta 字段中关键字的判断进行相应的渲染。如果权限的颗粒度小到一个按钮,则可根据后端返回的权限列表映射出的权限参数,通过 v-if 进行判断该功能组件是否渲染。
在路由管理中通过 router.beforeEach 钩子中判断当前的路由权限是否为空,是的话则可执行获取权限路由的接口:

1.2 手机验证码登陆

  • 前端点击发送验证码,把手机号传给后端,后端深沉随机数,然后把号码以及短信内容(包括随机数)一起发送给第三方平台
  • 短信发送成功的会调里把手机号和随机数存到数据库,
  • 用户收到短信点登陆后,前端手机手机号和验证码传给后端和数据库进行对比验证成果就返回登陆成功和用户信息

2. token生成(服务器生成):

利用 jsonwebtoken 模块, 该模块提供 sign生成 tokenverify验证 token(一般使用 express-jwt 替代)
服务器端 - 安装:

npm i jsonwebtoken

1
2
3
4
5
6
7
8
const jwt = require('jsonwebtoken');

const token = jwt.sign(载荷, 密钥, options)
// 载荷为一个对象,可以写任何非敏感信息
// 密钥:是一个包含 HMAC 算法的密钥或者 密钥对,在验证 token 的时候也需要用到
// options
algorithm:加密算法,默认 HS256
expiresIn:过期时间

3. token 验证

利用express-jwt 模块:

npm i express-jwt

4. token 的使用

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// 使用 
const { expressjwt: expressJwt } = require('express-jwt');

//拦截路由、验证 token 是否合法
app.use(expressJwt({
secret, // 与 生成 token 的 密钥 相同
algorithms: ['HS256'], // 与生成 token 的加密算法一致
}).unless({ // 路由白名单,以下路由不受验证,可以直接写路径,也可以是正则匹配
path: ['/users/login']
}))

// 如果没有通过,需要做错误处理
app.use(function(err, req, res, next) {
if(err.name === 'UnauthorizedError') {
res.status(401).json() // 这里响应状态吗 401,表示无权限访问
}
})

5. 前端处理 token

5.1 保存 token

登陆之后获取token并保存在本地

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
loginAction (context, data) {
return new Promise((resolve, reject) => {
axios({
method: 'post',
url: '/users/login',
data
})
.then(res => {
console.log('登陆请求成功',res);

if (res.code == 200) {
context.commit('loginMutation', {
token:res.token,
userName:data.userName
})

// 本地缓存
sessionStorage.setItem('userName',data.userName)
sessionStorage.setItem('token',res.token)

resolve()
}
})
.catch(err => {
console.log('请求错误',err);
reject()
})
})
},

5.2 请求携带

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
instance.interceptors.request.use(function (config) {

// 获取 token
const token = store.state.token;
console.log('获取 token:', token);

// 将 token 放在请求头
if(token) {
// 该请求头由前后端约定、但是后端使用 express-jwt,就已经约束 Authorization 字段,并且值以 'Bearer ' 开头
config.headers['Authorization'] = 'Bearer ' + token;
}

return config;
}, function (error) {
return Promise.reject(error);
});

5.3 未登录处理

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
 instance.interceptors.response.use(function (response) {
// 响应成功-200,先在这里接收到服务器响应
// 2xx 范围内的状态码都会触发该函数。
// 对响应数据做点什么

return response.data; // 对后端返回的数据过滤,直将 data 返回给页面
}, function (error) {
// 超出 2xx 范围的状态码都会触发该函数。
// 对响应错误做点什么

if(error.response.status === 401) { // 表示未登录或者登录过期
// 第一步、移除 token
store.commit('removeToken');

// 第二步、重定向登录页
router.push('/login');
}

return Promise.reject(error);
});

// removeToken
removeToken (state) {
state.token = ''
state.userName = ''
}

二、 项目错误

1. Axios统一错误处理

对网络请求的响应进行统一处理

1
2
3
4
5
6
7
8
9
10
11
12
13
apiClient.interceptors.response.use(
response => {
return response;
},
error => {
if (error.response.status == 401) {
router.push({ name: "Login" });
} else {
message.error("出错了");
return Promise.reject(error);
}
}
);

2. Vue统一错误处理

使用全局的errorHandler

1
2
3
4
5
6
7
8
9
10
11
12
Vue.config.errorHandler = function(err) {
console.log("global", err);
message.error("出错了");
};

// ...此处省略其他配置

new Vue({
router,
store,
render: h => h(App)
}).$mount("#app");

3. React 统一错误处理

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
import React, { Component, lazy, Suspense } from 'react'

class ErrorBoundary extends Component {
constructor(props) {
super(props)
this.state = { hasError: false }
}
static getDerivedStateFromError(error) {
console.log('getDerivedStateFromError') // 先打印出来
// 更新 state 使下一次渲染能够显示降级后的 UI
return { hasError: true }
}
componentDidCatch(error, errorInfo) {
// 你同样可以将错误日志上报给服务器
console.log('componentDidCatch') // 后打印出来
this.setState({
hasError: true,
})
}
render() {
if (this.state.hasError) {
// 你可以自定义降级后的 UI 并渲染
return <h1>Something went wrong.</h1>
}
return this.props.children
}
}
export default ErrorBoundary

1
2
3
4
5
6
7
8
9
root.render(
<ErrorBoundary>
<Provider store={store}>
<BrowserRouter>
<App />
</BrowserRouter>
</Provider>
</ErrorBoundary>
)

4. 全局捕获错误处理

1
2
3
4
5
6
window.addEventListener('unhandledrejection', function (event) {
处理事件对象
event.reason //获取到catch的err的原因(内容) 与控制台报错一致
event.promise //获取到未处理的promise对象
.....
});

三、 两万条数据如何处理

使用虚拟滚动条,后端进行分页,当每次下滑到一定的像素的时候发送请求,获取到下一页的数据。

四、 版本管理

jenkins自动push打包

五、 流量大,接口多如何处理

缓存,ssr,预渲染,通过脚本平台,白名单等等,在上一个页面中预渲染下一个页面的数据。

六、excel 导入导出

引入xsml依赖,主函数对数据进行过滤,使用数组的reduce方法(接受一个函数作为参数,函数接受四个参数,若没有设置初始值,则第一个参数为计算结束的返回值,若有初始值,则为初始值,第二个参数为当前元素,第三个参数为当前元素的索引,常用于累加和累乘)生成工作簿对象,再将工作簿转化为json对象。

接着创建a标签,利用a标签的download属性进行下载文件aLink.href = url

然后将工作簿对象转化为我们需要的。首先生成excel的配置项,然后将字符串转化为ArrayBuffer

最后进行转码操作。

配置文件
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
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
import * as XLSX from 'xlsx'

// ?+++++++++++++++++++++++++++++++++++++++++++++++ 主函数形参注释
// *--------------------------- header
// 注释:excel表头
// 类型:object
// 结构:
// {
// name: '姓名',
// age: '年龄',
// address: '地址',
// }
// *--------------------------- data
// 注释:导出数据
// 类型:array
// *--------------------------- fileName
// 注释:自定义导出文件名
// 类型:string

// ?+++++++++++++++++++++++++++++++++++++++++++++++ 主函数
function clickToExport(header, data, fileName) {
// 数据过滤
let newData = []
// 生成header数组
let headerKeys = Object.keys(header)
data.forEach((item, index) => {
let newItem = {}
// 将data中的每一项遍历,给newItem重新赋值
for (let i = 0; i < headerKeys.length; i++) {
newItem[headerKeys[i]] = item[headerKeys[i]]
}
newItem.id = index + 1
newData.push(newItem)
})

// 生成工作簿对象
const json = newData.map((item) => {
// 数组reduce方法,第一个参数为回调函数,第二个参数为初始值。回调函数接受两个参数,第一个为累积变量,第二个为每一项。
// 生成excel头部对应值
return Object.keys(item).reduce((newData, key) => {
const newKey = header[key] || key
newData[newKey] = item[key]
return newData
}, {})
})

// 将工作簿对象转化为json对象
const sheet = XLSX.utils.json_to_sheet(json)

openDownloadDialog(sheet2blob(sheet, undefined), `${fileName}.xlsx`)
}

// ?+++++++++++++++++++++++++++++++++++++++++++++++ 创建a标签 利用a标签的download属性进行下载文件
const openDownloadDialog = (url, saveName) => {
// 创建blob地址
if (typeof url == 'object' && url instanceof Blob) {
url = URL.createObjectURL(url)
}
let aLink = document.createElement('a')
aLink.href = url
// HTML5新增的属性
// 指定保存文件名
// 可以不要后缀
// 注意,file:///模式下不会生效
aLink.download = saveName || ''

let event
if (window.MouseEvent) event = new MouseEvent('click')
else {
event = document.createEvent('MouseEvents')
event.initMouseEvent(
'click',
true,
false,
window,
0,
0,
0,
0,
0,
false,
false,
false,
false,
0,
null
)
}

aLink.dispatchEvent(event)
}

// ?+++++++++++++++++++++++++++++++++++++++++++++++ 将工作簿对象转化为我们需要的
const sheet2blob = (sheet, sheetName) => {
// 生成excel的配置项
sheetName = sheetName || 'sheet1'
let workbook = {
SheetNames: [sheetName],
Sheets: {},
}
workbook.Sheets[sheetName] = sheet

// 字符串转ArrayBuffer
let workbookOut = XLSX.write(workbook, {
// 要生成的文件类型
bookType: 'xlsx',
// 是否生成Shared String Table
// 官方解释是,如果开启生成速度会下降
// 但在低版本IOS设备上有更好的兼容性
bookSST: false,
type: 'binary',
})
let blob = new Blob([s2ab(workbookOut)], {
type: 'application/octet-stream',
})

return blob
}

// ?+++++++++++++++++++++++++++++++++++++++++++++++ 转码操作
function s2ab(wbo) {
let buf = new ArrayBuffer(wbo.length)
let view = new Uint8Array(buf)
for (let i = 0; i !== wbo.length; ++i) view[i] = wbo.charCodeAt(i) & 0xff

return buf
}

export default clickToExport

使用
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
//   导出excel
const exportExcel = () => {
chooseData?.length > 0
? Modal.confirm({
title: '导出',
icon: <ExclamationCircleOutlined />,
content: '是否确认导出',
onOk() {
let header = {}
columns.slice(1, -1).forEach((item) => {
let keys = Object.keys(item)
header[item[keys[1]]] = item[keys[0]]
})
chooseData?.length > 0 &&
clickToExport(header, chooseData, '采购申请表')
setChooseData(() => []) // 异步无法清空 或者重新请求数据
},
onCancel() {},
})
: Modal.confirm({
title: '导出',
icon: <ExclamationCircleOutlined />,
content: '请选择导出列表',
})
}

七、 定时器问题

概述: 我在a页面写一个定时,让他每秒钟打印一个1,然后跳转到b页面,此时可以看到,定时器依然在执行。这样是非常消耗性能的。

解决: 该方法是通过$once这个事件侦听器器在定义完定时器之后的位置来清除定时器 。

1
2
3
4
5
6
7
const timer = setInterval(() =>{                    
// 某些定时器操作
}, 500);
// 通过$once来监听定时器,在beforeDestroy钩子可以被清除。
this.$once('hook:beforeDestroy', () => {
clearInterval(timer);
})

八、 gzip压缩

spa这种单页应用,首屏由于一次性加载所有资源,所有首屏加载速度很慢。解决这个问题非常有效的手段之一就是前后端开启gizp(其他还有缓存、路由懒加载等等)

下载插件:

npm i compression-webpack-plugin**@1**.1.11

然后在config/index.js中开启

1
2
3
4
5
6
build: {
// 其他代码
…………
productionGzip: true, // false不开启gizp,true开启
// 其他代码
}

九、实时通讯

1. Websocket

概述: WebSocket是HTML5提供的一种浏览器与服务器进行全双工通讯的网络技术,属于应用层协议。它基于TCP传输协议,并复用HTTP的握手通道。浏览器和服务器只需要完成一次握手,两者之间就直接可以创建持久性的连接, 并进行双向数据传输。

作用: :服务器可以向客户端主动推动消息,客户端也可以主动向服务器推送消息。

原理: 客户端向 WebSocket 服务器通知(notify)一个带有所有接收者ID(recipients IDs)的事件(event),服务器接收后立即通知所有活跃的(active)客户端,只有ID在接收者ID序列中的客户端才会处理这个事件。

使用:

npm i ws

服务端:

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
const { WebSocketServer } = require('ws')
const WebSocket = require('ws')
const wss = new WebSocketServer({
port: 8080,
})
let users = []

wss.on('connection', (ws, req) => {
console.log('客户端已连接:', req.socket.remoteAddress) // 获取客户端 ip

ws.on('message', (res) => {
const data = JSON.parse(res.toString())
console.log('收到客户端发送的消息:', data)

if (data.type == 'online') {
users.push(data)
} else if (data.type == 'offline') {
users = users.filter((item) => item.username != data.username)
} else if (data.type == 'msg') {
// console.log("已连接客户端", wss.clients);
wss.clients.forEach((client) => {
if (client.readyState === WebSocket.OPEN) {
client.send(JSON.stringify(data))
}
})
// ws.send(data);
}

// ws.send("我是服务端"); // 向当前客户端发送消息
})

ws.on('error', (err) => {
console.log('连接出错:', err)
})
ws.on('close', (e) => {
console.log('客户端断开:', e)
})
})

客户端:

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
let wsUrl = 'ws://127.0.0.1:8080/?name=' + username
const ws = new WebSocket(wsUrl)

useEffect(() => {
// 连接上
ws.onopen = function (e) {
console.log("客户端socket'开启")

ws.send(
JSON.stringify({
type: 'online',
username,
})
)
}

// 收到服务端消息
ws.onmessage = function (res) {
const data = JSON.parse(res.data)
console.log('收到消息', data)
setChatList((pre) => [...pre, data])
// ws.close();
}

// 断开连接
ws.onclose = function (e) {
console.log('关闭', e)
}

// 连接出错
ws.onerror = function (err) {
console.log('出错', err)
}

return () => {
ws.send(
JSON.stringify({
type: 'offline',
username,
})
)
ws.close()
}
}, [])

2. 短轮询

概述: 浏览器每隔一段时间向浏览器发送 http 请求,服务器端在收到请求后,不论是否有数据更新,都直接进行响应。

3. 长轮询

概述: 首先由客户端向服务器发起请求,当服务器收到客户端发来的请求后,服务器端不会直接进行响应,而是先将这个请求挂起,然后判断服务器端数据是否有更新。如果有更新,则进行响应,如果一直没有数据,则到达一定的时间限制才返回。

十、浏览器的Devtool的使用

元素面板 Elements

能在元素面板查看选择当前页的DOM树,能够实时的修改和调试。

可以再每行代码前面右键添加断点。属性改变,子节点改变,节点删除。

可以右键选择节点的状态:active``:hovwe等等

网络面板 Network

与后端调试接口时,有时候会重新请求一次(Replay XHR)或者阻断请求(Block request URL)

查看请求参数和请求到的数据

性能面板 Performance

在这个面板你可以录制一段操作,然后查看录制期间的一些页面性能信息,录制前有几个选项可选,包括网络环境模拟,CPU速度模拟,是否开启录制期间截屏

控制台面板 Console

打印输出调试bug,观察数据结构

源代码面板 Sources

可以看到一些原文件和代码

类似元素面板中设置断点(debugger),这里也可以在每一行代码前设置断点,利用这些断点使代码运行到适当的时候或位置停下来,以便查看特定时刻的变量值、调用堆栈、样式等;

内存面板 Memory

在此面板录制,可以使用CPU 分析器识别开销大的js函数

CPU 分析器准确地记录调用了哪些函数和每个函数花费的时间。也可以将配置文件可视化为火焰图。(执行js比较卡的时候可以用这个工具来查找原因);

应用面板 Application

查看和修改Local Storage与Session Storage,并可查看cookies: