}
}
}
})
复制代码
这样就完成了CSS的管理啦,当然我这种比较简单,现在还有一种比较新的用法就是使用CSS Module,希望将来能用上吧。
Sass
的编写指南,大家可以参考一下:Sass Guidelines
2.4 接口管理
2.4.1 基于Axios二次封装
基于Axios二次封装已经是一种常规操作了,下面来看看项目中我是如何对API进行管理的:
-
抽象一个HttpRequest类,主要有请求拦截/取消、REST请求(GET、POST、PUT、Delete)、统一错误处理等功能
-
实例化这个类,然后分模块编写API请求函数,URL这种常量单独放一个文件,接口请求参数和请求体由使用者决定,最后导出一个对象出口
-
在组件引入对应的模块即可使用
Vue3.0中最推荐的使用方式是Composition API
,组件中this
不推荐使用,所以如果想全局引入,需要这么做:
import { createApp } from ‘vue’
import App from ‘./App.vue’
import http from ‘@/api’
const app = createApp(App)
app.config.globalProperties.http = http
app.mount(‘#app’)
复制代码
在组件中使用:
import { getCurrentInstance } from ‘vue’
const {
proxy: { http }
} = getCurrentInstance()
// demo…
async fetchData() {
await http.getData(…)
}
复制代码
而之前在Vue2中我们只需要在Vue.prototype
上定义属性,然后在组件中使用this
引入就可以了。但是全局引入会导致Vue原型很臃肿,每个组件的实例都会有这个属性,会造成一定的性能开销。
Vue3这种全局引入的做法我觉得也很麻烦,所以我的做法是在使用的组件中导入对应的API模块。
打个小广告:详细的Axios封装可以参考我的另外一篇文章在Vue项目中对Axios进行二次封装。
2.4.2 载入不同模式下全局变量
此外,我们也可以通过使用.env
文件来载入不同环境下的全局变量,Vite中也使用了 dotenv来加载额外的环境变量,设置的全局变量必须以VITE_
为前缀才可以正常被加载,使用方式如下:
.env.development
# 以下变量在development
被载入
VITE_APP_BASE_API = ‘/api/v1’
复制代码
.env.production
# 以下变量在production
被载入
VITE_APP_BASE_API = ‘http://192.168.12.116:8888/api/v1’
复制代码
全局变量使用方式:
import.meta.env.VITE_APP_BASE_API
复制代码
2.4.3 跨域问题
Vite是基于Node服务器开发的,所以它也提供了一些配置来实现本地代理,使用方式大家应该很熟悉,这里直接上一个例子:
vite.config.js
server: {
open: true,
proxy: {
‘/api/v1/chart’: {
target: ‘http://192.168.12.116:8887’,
changeOrigin: true
},
‘/api/v1’: {
target: ‘http://192.168.12.116:8888’,
changeOrigin: true,
rewrite: (path) => path.replace(/^/api/v1, ‘’)
}
}
}
复制代码
如果线上的服务器后端服务器不是同源部署也会有跨域问题,那么需要在Ngnix
中配置反向代理,好在后端实现了CORS规范,那我们不需要操心线上的跨域问题了。当然解决跨域的方式有很多,下面要介绍的WebSocket
就没有这个问题。
到这里接口管理相关问题也差不多说完了,项目接口数量并不是特别多,就20多个吧,所以并没有把全部接口全部交给Vuex
接管,只有一少部分组件依赖的全局状态才放到Vuex
中。
2.4.4 WebSocket+Vuex状态管理方案
大屏项目有将近20多个图表都是实时数据,包括设备健康度状态、设备运行指标等等,必须使用WebSocket
。但是我们项目是SPA应用,每个组件都需要发消息,并且需要共享一个WebSocket
实例,跨组件通信很麻烦,所以需要对这一块进行封装。
网上找了很多方案都没有解决我的问题,但是偶然却翻到了一个大佬的文章(websocket长连接和公共状态管理方案),他的文章里提到了基于WebSocket+Vuex
实现“发布-订阅”模式对全局组件状态进行统一管理。我看完后受益匪浅,我这才知道如果把设计模式用于开发中能有如此“功效”。
我基于大佬的封装又优化了一些:
-
对一些代码细节进行了修改和优化
-
使用
Class
语法糖增强代码可读性 -
增加了数据分发的功能
下面是一个简单的图:
image.png
组件通过emit
方法来发送消息,消息里面标识了任务名,后端返回的数据里面也会返回这个任务名,这就形成了一个“管道”。Vuex通过订阅所有消息,然后根据任务名commit
对应的mutation
来完成状态变更,最后组件通过Vuex的store
或者getter
就能拿到数据了。
下面是一个完整的例子:
首先封装一个VueSocket
类,它有发布、订阅、断线重连、心跳检测、错误调度等功能。组件只需要通过emit
方法来发布消息,通过subscribe
方法订阅服务端消息,然后通过Vuex的mutation
来分发消息。
我们目前只需要关注emit
和subscribe
两个方法,当然handleData
这个函数也很重要,主要是对来自不同任务的数据进行分发。
src/utils/VueSocket.js
class VueSocket {
/**
* VueSocket构造器
* @param {string} url socket服务器URL
* @param {function} commit Vuex中的commit函数,提交mutation触发全局状态变更
* @param {function} handleData 数据分发处理函数,根据订阅将数据分发给不同的任务处理
*/
constructor(url, commit, handleData = null) {
this.url = url // socket连接地址
this.commit = commit
this.distributeData = handleData
this.ws = null // 原生WebSocket对象
this.heartbeatTimer = null
this.errorResetTimer = null // 错误重连轮询器
this.disconnectSource = ‘’ // 断开来源: ‘close’ 由close事件触发断开, 'error’由error事件触发断开
this.reconnectNumber = 0 // 重连次数
this.errorDispatchOpen = true // 开启错误调度
this.closeSocket = false // 是否关闭socket
this.init()
}
/**
* 错误调度
* @param {string} type 断开来源=> ‘close’ | ‘error’
* @returns {function}
*/
static errorDispatch(type) {
return () => {
if (this.disconnectSource === ‘’ && this.errorDispatchOpen) {
this.disconnectSource = type
}
console.log([Disconnected] WebSocket disconnected from ${type} event
)
// socket断开处理(排除手动断开的可能)
if (this.disconnectSource === type && !this.closeSocket) {
this.errorResetTimer && clearTimeout(this.errorResetTimer)
VueSocket.handleDisconnect()
}
}
}
/**
* 断开处理
* @returns {undefined}
*/
static handleDisconnect() {
// 重连超过4次宣布失败
if (this.reconnectNumber >= 4) {
this.reconnectNumber = 0
this.disconnectSource = ‘’
this.errorResetTimer = null
this.errorDispatchOpen = false
this.ws = null
console.log(‘[failed] WebSocket connect failed’)
return
}
// 重连尝试
this.errorResetTimer = setTimeout(() => {
this.init()
this.reconnectNumber++
console.log([socket reconnecting ${this.reconnectNumber} times...]
)
}, this.reconnectNumber * 1000)
}
/**
* 事件轮询器
* @param {function} event 事件
* @param {number|string} outerConditon 停止条件
* @param {number} time
* @param {function} callback
*/
static eventPoll(event, outerConditon, time, callback) {
let timer
let currentCondition
timer = clearInterval(() => {
if (currentCondition === outerConditon) {
clearInterval(timer)
callback && callback()
}
currentCondition = event()
}, time)
}
/**
* 初始化连接,开始订阅消息
* @param {function} callback
*/
init(callback) {
// 如果已经手动关闭socket,则不允许初始化
if (this.closeSocket) {
throw new Error(‘[Error] WebSocket has been closed.’)
}
// 清除心跳检测计时器
this.heartbeatTimer && clearTimeout(this.heartbeatTimer)
this.ws = new WebSocket(this.url)
this.ws.onopen = () => {
callback && callback()
this.reconnectNumber = 0
this.disconnectSource = ‘’
this.errorResetTimer = null
this.errorDispatchOpen = true
// 订阅消息
this.subscribe()
// 开启心跳侦测
this.heartbeatDetect()
console.log(‘[Open] Connected’)
}
this.ws.onclose = VueSocket.errorDispatch(‘close’)
this.ws.onerror = VueSocket.errorDispatch(‘error’)
}
/**
* 订阅器
*/
subscribe() {
this.ws.onmessage = (res) => {
if (res.data) {
const data = JSON.parse(res.data)
// 根据任务类型,分发数据
try {
this.distributeData && this.distributeData(data, this.commit)
} catch (e) {
console.log(e)
}
}
// 收到消息关闭上一个心跳定时器并启动新的定时器
this.heartbeatDetect()
}
}
/**
* 发布器(组件发消息的)
* @param {String} data
* @param {Function} callback
*/
emit(data, callback) {
const state = this.getSocketState()
if (state === this.ws.OPEN) {
this.ws.send(JSON.stringify(data))
callback && callback()
this.heartbeatDetect()
} else if (state === this.ws.CONNECTING) {
// 连接中轮询
VueSocket.eventPoll(state, this.ws.OPEN, 500, () => {
this.ws.send(JSON.stringify(data))
callback && callback()
this.heartbeatDetect()
})
} else {
this.init(() => {
this.emit(data, callback)
})
}
}
/**
* 心跳侦测
*/
heartbeatDetect() {
this.heartbeatTimer && clearTimeout(this.heartbeatTimer)
this.heartbeatTimer = setTimeout(() => {
const state = this.getSocketState()
if (state === WebSocket.OPEN || state === WebSocket.CONNECTING) {
// 发送心跳
this.ws.send(‘ping’)
} else {
this.init()
}
}, 50000)
}
/**
* 手动关闭连接
*/
close() {
this.heartbeatTimer && clearTimeout(this.heartbeatTimer)
this.errorResetTimer && clearTimeout(this.errorResetTimer)
this.closeSocket = true
this.ws.close()
}
/**
* 手动连接
*/
open() {
if (!this.closeSocket) {
throw new Error(‘[Error] WebSocket is connected’)
}
this.heartbeatTimer = null
this.reconnectNumber = 0
this.disconnectSource = 0
this.errorResetTimer = null
this.errorDispatchOpen = true
this.closeSocket = false
this.init()
}
/**
* 获取当前socket状态
*/
getSocketState() {
return this.ws.readyState
}
}
export default VueSocket
复制代码
在Vuex中定义初始化WebSocket
连接的action
和mutation
:
import { createStore, createLogger } from ‘vuex’
import VueSocket from ‘@/utils/VueSocket’
import { round } from ‘@/use/useToolFunction’
import {
handleData
} from ‘@/utils/handleSocketData’ // 分发任务的关键函数
import { WEBSOCKET } from ‘@/config’ // 导出一个常量
const debug = import.meta.env.MODE.startsWith(‘dev’)
const store = createStore({
state: {
ws: null // websorket实例
},
mutations: {
// 初始化socket连接
createSocket(state, { commit }) {
const baseURL = ` i m p o r t . m e t a . e n v . V I T E A P P S O C K E T ? p o r t N a m e = {import.meta.env.VITE_APP_SOCKET}?portName= import.meta.env.VITEAPPSOCKET?portName={
WEBSOCKET.TARGET
}`
state.ws = new VueSocket(baseURL, commit, handleData)
},
},
actions: {
// 创建实例
socketInit({ commit }) {
commit(‘createSocket’, { commit })
}
}
// debug console
// plugins: debug ? [createLogger()] : [],
})
export default store
复制代码
重点说一下这个handleData
方法吧,VueSocket
实例调用subscribe
方法后就会订阅服务器所有的消息,而这个方法就是根据消息里面的任务名把消息送达各个组件。
比如现在有一个场景:有很多设备的子设备健康度需要实时展示:
const handleData = (data, commit) => {
// 当前任务
const [task] = Object.keys(data)
// 任务执行器
const taskRunner = {
healthRunner() {
const {
message: { dataContent }
} = data[task]
// 更新状态
dataContent &&
commit(‘updateHealthDegree’, {
prop: task,
healthDegree: dataContent[0].health
})
},
defaultRunner(mutation) {
const {
message: { dataContent }
} = data[task]
// 更新状态
dataContent && commit(mutation, dataContent)
}
}
// 任务映射委托
const taskMap = {
// 健康度
completeMachineHealthDegree() {
taskRunner.healthRunner()
},
pressureHealthDegree() {
taskRunner.healthRunner()
},
axletreeHealthDegree() {
taskRunner.healthRunner()
},
gearboxHealthDegree() {
taskRunner.healthRunner()
}
}
// 执行任务
if (task in taskMap) {
taskMaptask
}
}
复制代码
这个方法十分关键,所有的任务其实只是一个对象中的属性,然后映射的值是一个函数,只要判断这个任务在这个对象里面就会执行对应的函数。而最后任务的执行器其实就是调用了传进来的commit
函数,触发mutation
变更状态。我最开始是使用if/else
或者switch/case
来处理这个逻辑,但是随着任务越来越多(20多个),代码可读性也变得糟糕起来,所以想了这个办法处理。
下面是Vuex的定义,store/getters
必须与任务名对应:
state: {
completeMachineHealthDegree: 1, // 整机健康度
pressureHealthDegree: 1, // 液压系统健康度
axletreeHealthDegree: 1, // 泵健康度
gearboxHealthDegree: 1 // 齿轮箱健康度
},
getters: {
pressureHealthDegree(state) {
return round(state.pressureHealthDegree, 2)
},
axletreeHealthDegree(state) {
return round(state.axletreeHealthDegree, 2)
},
gearboxHealthDegree(state) {
return round(state.gearboxHealthDegree, 2)
},
completeMachineHealthDegree(state) {
return round(state.completeMachineHealthDegree, 2)
}
},
mutation: {
// 健康度
updateHealthDegree(state, { healthDegree, prop }) {
state[prop] = healthDegree
}
}
复制代码
万事俱备只欠东风,有了初始化连接的方法后,现在App.vue
这个组件触发一下action
:
App.vue
import { useStore } from ‘vuex’
const store = useStore()
store.dispatch(‘socketInit’)
复制代码
连接建立后,就可以搞事情咯。我们先根据后端的数据格式封装一个Compostion Function
,因为有很多组件都需要使用这个实例发消息。
src/use/useEmit.js
import { useStore } from ‘vuex’
function useEmit(params) {
const store = useStore()
const ws = store.state.ws
const data = {
msgContent: ${JSON.stringify(params)}
,
postsId: 1
}
ws.emit(data)
}
export default useEmit
复制代码
在组件里面使用:
HealthChart.vue
import useEmit from ‘@/use/useEmit’
// params由父组件传进来,这里就不详细展开了
const params = { … }
onMounted(() => {
useEmit(params)
})
复制代码
然后通过watch
或watchEffect
方法监听数据变化,每次变化都去调用echarts
实例的setOption
方法来重绘图表,这样就可以实现动态数据变更了,这里就不展开讲了。
2.4.5 数据Mock
我们是前后端同步开发,有时候会出现前端开发完接口没开发完的情况,我们可以先根据接口文档(没有接口文档可以问后端要数据库的表)来Mock数据。我们通常有下面的解决方法:
-
使用
mockjs
-
使用Node部署一个
MockServer
-
使用静态
JSON
文件
第一种我们比较常用,但是浏览器NetWork
工具看不到发不出去的请求;第二种需要单独写一套Node服务,或者用第三方服务本地部署,很好用,但是有些麻烦;第三种比较原始就不考虑了。
最后使用了vite-plugin-mock,这是一个基于Vite开发的插件。
提供本地和生产模拟服务。vite 的数据模拟插件,是基于 vite.js 开发的。并同时支持本地环境和生产环境。Connect 服务中间件在本地使用,mockjs 在生产环境中使用。
先安装:
yarn add mockjs
yarn add vite-plugin-mock -D
复制代码
配置:
// vite.config.js
import { viteMockServe } from ‘vite-plugin-mock’
export default defineConfig({
plugins: [
vue(),
viteMockServe({
supportTs: false
})
]
})
复制代码
它默认会在根目录下请求mock
文件下的数据,不过可以进行配置。先这个文件下配置需要的数据,然后在组件发请求就可以了。
// mock/index.js
export default [
{
url: ‘/api/get’,
method: ‘get’,
response: ({ query }) => {
return {
code: 0,
data: {
name: ‘vben’
}
}
}
},
{
url: ‘/api/post’,
method: ‘post’,
timeout: 2000,
response: {
code: 0,
data: {
name: ‘vben’
}
}
}
]
复制代码
3.项目遇到的坑
Vite
和Vue3
都是较新的技术,而且使用UI框架也是beta
版本,遇到的坑真是不少,大部分的坑都是靠着官方的issue
来解决的,好在都能找到对应的issue
,不然得自己提issue
等待回复了。
3.1 启动项目就报错(esbuild error)
后面查看相关issue,主要可以从下面几个方法尝试:
-
不要用中文名路径
-
不用
Git Bash
来启动 -
删除
node_modules
重新安装 -
将
npm
换成yarn
具体issue忘记记录了,大家如果也碰到相关问题可以按照上面的方式尝试。
3.2 @vue/compiler-sfc
3.0.7 版本后打包后,style scoped
里面的样式失效
相关 issues:
-
Scoped CSS not generating correctly
-
Vue 3 scoped styles do not work on preview
解决方案:将@vue/compiler-sfc
锁定为版本3.0.7
3.3 父组件使用ref
访问子组件(子组件使用了 setup sugar)时,ref
值为{}
相关issue:
- Setup sugar cannot pass the ref object to the ref function param.But I can get the ref object correctly with no sugar
官方目前还在讨论具体的解决方案,我们现在只需要在这种场景下让子组件不使用setup sugar
。
4.可优化的地方
4.1 线上错误监控(sentry)
Vite生产环境下是通过rollup
进行打包的,即使本地开发进行了测试也没有复现的BUG,但是我们是无法知道用户的使用场景的,线上的BUG总会有我们想不到的地方,这一块的基础设施后续有时间必须安排上。
4.2 Monorepo
我为了追求速度,搭建完一套系统后,就复制它给其他系统用。但其实里面有很多可以复用的模块,除了上传私有Npm
,还有一个更好的方式就是Monorepo
,它把所有子项目集中在一起管理,而且这几个子项目都跟业务强相关,不用再切来切去了,最重要就的就是它只需要走一套CI/CD
流程就行了。
4.3 Git工作流
Git工作流没有搭建是因为我们就2-3个人,走这套流程时间不允许。但是以后有新成员加入,团队人员变多后就需要这套工作流了。
4.4 定制脚手架
项目搭建完,可以把一些业务代码与配置抽离出去,然后搭建一个自己的脚手架,当然也可以基于业务定制化脚手架,这样以后有相关架构的项目可以直接基于这个脚手架开发,节省前期基础设施搭建的时间。
4.5 多个Echarts组件实时渲染数据掉帧,吃内存
这是我之前没有考虑到的性能优化问题,我以为我考虑很全面了,结果还是把最重要的性能优化给忘了,这是我的失职。所以以后开发任何业务功能,都需要考虑性能,在满足基本需求下,加大量级去考虑问题。
比如我这个Echarts图表渲染问题,20多个图表,上万的数据实时渲染,目前还只是掉帧吃内存,如果把量级加大到几十万条数据呢?
5.总结
结尾
学习html5、css、javascript这些基础知识,学习的渠道很多,就不多说了,例如,一些其他的优秀博客。但是本人觉得看书也很必要,可以节省很多时间,常见的javascript的书,例如:javascript的高级程序设计,是每位前端工程师必不可少的一本书,边看边用,了解js的一些基本知识,基本上很全面了,如果有时间可以读一些,js性能相关的书籍,以及设计者模式,在实践中都会用的到。
开源分享:【大厂前端面试题解析+核心总结学习笔记+真实项目实战+最新讲解视频】
高级程序设计,是每位前端工程师必不可少的一本书,边看边用,了解js的一些基本知识,基本上很全面了,如果有时间可以读一些,js性能相关的书籍,以及设计者模式,在实践中都会用的到。