项目介绍
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) { if (token) { next(); } else { const next_page = to.name next({ name:'login', params:{ redirect_page : next_page , ...form.params } }); } } else { next(); } });
|
1.2 页面权限
第一种:前端处理
概述:根据不同的角色赋予其不同的页面访问权限
- 用户登陆成功之后,后端返回用户信息,然后保存在
vuex
和localStorage
里面
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){ 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
| 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 }
function useRouteToDom() { 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(`../${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 />} /> { } }) 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);
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); } }, });
|
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) const filenameArr = file.originalname.split('.') const ext = filenameArr[filenameArr.length - 1]
const uniqueSuffix = Date.now() + '-' + Math.round(Math.random() * 1e9) 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“组件中,和v−bin**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' }, 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 }; 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, _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) => { 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
|
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) } 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} />; } } }
export default withFetching(fetching('science-fiction'))(MovieList);
|
3. 统一处理 loading
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
| 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
生成 token
,verify
验证 token
(一般使用 express-jwt
替代)
服务器端 - 安装:
npm i jsonwebtoken
1 2 3 4 5 6 7 8
| const jwt = require('jsonwebtoken');
const token = jwt.sign(载荷, 密钥, 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');
app.use(expressJwt({ secret, algorithms: ['HS256'], }).unless({ path: ['/users/login'] }))
app.use(function(err, req, res, next) { if(err.name === 'UnauthorizedError') { res.status(401).json() } })
|
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) {
const token = store.state.token; console.log('获取 token:', token);
if(token) { 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) { return response.data; }, function (error) {
if(error.response.status === 401) { store.commit('removeToken');
router.push('/login'); }
return Promise.reject(error); });
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') return { hasError: true } } componentDidCatch(error, errorInfo) { console.log('componentDidCatch') this.setState({ hasError: true, }) } render() { if (this.state.hasError) { 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 event.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'
function clickToExport(header, data, fileName) { let newData = [] let headerKeys = Object.keys(header) data.forEach((item, index) => { let 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) => { return Object.keys(item).reduce((newData, key) => { const newKey = header[key] || key newData[newKey] = item[key] return newData }, {}) })
const sheet = XLSX.utils.json_to_sheet(json)
openDownloadDialog(sheet2blob(sheet, undefined), `${fileName}.xlsx`) }
const openDownloadDialog = (url, saveName) => { if (typeof url == 'object' && url instanceof Blob) { url = URL.createObjectURL(url) } let aLink = document.createElement('a') aLink.href = url 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) => { sheetName = sheetName || 'sheet1' let workbook = { SheetNames: [sheetName], Sheets: {}, } workbook.Sheets[sheetName] = sheet
let workbookOut = XLSX.write(workbook, { bookType: 'xlsx', 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
| 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);
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, }
|
九、实时通讯
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)
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') { wss.clients.forEach((client) => { if (client.readyState === WebSocket.OPEN) { client.send(JSON.stringify(data)) } }) }
})
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.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. 长轮询
概述: 首先由客户端向服务器发起请求,当服务器收到客户端发来的请求后,服务器端不会直接进行响应,而是先将这个请求挂起,然后判断服务器端数据是否有更新。如果有更新,则进行响应,如果一直没有数据,则到达一定的时间限制才返回。
元素面板 Elements
能在元素面板查看选择当前页的DOM树,能够实时的修改和调试。
可以再每行代码前面右键添加断点。属性改变,子节点改变,节点删除。
可以右键选择节点的状态:active``:hovwe
等等
网络面板 Network
与后端调试接口时,有时候会重新请求一次(Replay XHR)或者阻断请求(Block request URL)
查看请求参数和请求到的数据
在这个面板你可以录制一段操作,然后查看录制期间的一些页面性能信息,录制前有几个选项可选,包括网络环境模拟,CPU速度模拟,是否开启录制期间截屏
控制台面板 Console
打印输出调试bug,观察数据结构
源代码面板 Sources
可以看到一些原文件和代码
类似元素面板中设置断点(debugger),这里也可以在每一行代码前设置断点,利用这些断点使代码运行到适当的时候或位置停下来,以便查看特定时刻的变量值、调用堆栈、样式等;
内存面板 Memory
在此面板录制,可以使用CPU 分析器识别开销大的js函数。
CPU 分析器准确地记录调用了哪些函数和每个函数花费的时间。也可以将配置文件可视化为火焰图。(执行js比较卡的时候可以用这个工具来查找原因);
应用面板 Application
查看和修改Local Storage与Session Storage,并可查看cookies: