Vue组件优雅的使用Vuex异步数据
今天在检查代码的时候发现了一个平时都忽略的问题,就是在组件使用 vuex 数据时,组件使用都是同步取的 vuex 值。关于 vuex 的使用可以查看官网文档:https://vuex.vuejs.org/zh/,如果我们需要的 vuex 里面的值是异步更新获取的,在网络和后台请求特别快的情况下不会有什么问题。但是网络慢或者后台数据返回较慢的情况下问题就来了。
Vue组件优雅的使用Vuex异步数据
前端:
Vue
+element
项目为前后端分离项目,通过
Ajax
交换数据。更新时间:2020-09-10 19:11:42
2020-09-10 19:11:42
- 拆分 vue 文件支持代码高亮
2020-05-23 22:02:45
0x1 缘起
今天在检查代码的时候发现了一个平时都忽略的问题,就是在组件使用vuex数据时,组件使用都是同步取的
vuex
值。关于vuex
的使用可以查看官网文档:https://vuex.vuejs.org/zh/ ,如果我们需要的vuex
里面的值是异步更新获取的,在网络和后台请求特别快的情况下不会有什么问题。但是网络慢或者后台数据返回较慢的情况下问题就来了。
0x2 案例
${app}
代表你的项目根目录,项目目录结构同大部分Vue
项目。
需求
我需要实现这样一个效果,我需要在
foo.vue
,bar.vue
,两个不同的页面建立一个使用相同信息的socket
连接,当我离开foo.vue
页面的时候断开连接,在bar.vue
页面的时候重新连接。而且我的socket连接信息(连接地址,端口等)来自于接口请求。
初次实现
在
App.vue
初始化的时候dispatch
一个action
去获取socket
的连接信息,然后在foo.vue
或者bar.vue
页面mounted
的时候进行连接。
Vuex
${app}/src/store/index.js
import Vue from 'vue'
import Vuex from 'vuex'
import api from '@/apis'
import handleError from '@/utils/HandleError'
Vue.use(Vuex)
export default new Vuex.Store({
strict: process.env.NODE_ENV !== 'production',
state: {
socketInfo: {
serverName: '',
host: '',
port: 8080
}
},
mutations: {
// Update token
UPDATE_SOCKET_INFO(state, { socketInfo }) {
// state.socketInfo = socketInfo
// Update vuex token
Object.assign(state.socketInfo, socketInfo)
}
},
actions: {
// Get socket info
async GET_SOCKET_INFO({ commit }) {
// Rquest socket info
try {
const res = await api.Common.getSocketUrl()
// Success
if (res.success) {
commit('UPDATE_SOCKET_INFO', {
socketInfo: res.obj
})
}
} catch (e) {
// Handle api request exception
handleError.handleApiRequestException(e)
}
}
}
})
App.vue
${app}/src/App.vue
<!-- App -->
<div id="app"></div>
export default {
name: 'App',
mounted() {
// Get socket info
this.$store.dispatch('GET_SOCKET_INFO')
}
}
foo.vue
${app}/src/views/foo/foo.vue
import io from 'socket.io-client'
export default {
name: 'Foo',
mounted() {
const { serverName, host, port } = this.$store.state.socketInfo
const socket = io(`ws://${host}:${port}`, {
path: `/${serverName}`,
transports: ['websocket', 'polling']
})
}
}
❓ 问题
问题很显而易见,当我直接访问
foo.vue
页面的时候,如果我的后台api或者网络请求慢的情况下,我的vuex
的store
还未更新,也就是App.vue
的请求还未回来,这个时候foo.vue
页面的mounted
生命周期函数已经执行,很显然,我需要的socket
连接信息拿不到,这个时候控制台就会飘红。
WebSocket connection to 'ws://%27%27/''/?EIO=3&transport=websocket' failed: Error in connection establishment: net::ERR_NAME_NOT_RESOLVED
✅ 第一次解决
既然是需要等到请求回来在连接,那么好办了,我在
foo.vue
页面也获取一次socket
的连接信息获取成功了在进行连接,此时foo.vue
代码变成了如下这样
foo.vue
${app}/src/views/foo/foo.vue
import io from 'socket.io-client'
import api from '@/apis'
import handleError from '@/utils/HandleError'
export default {
name: 'Foo',
async mounted() {
// Rquest socket info
try {
const res = await api.Common.getSocketUrl()
// Success
if (res.success) {
commit('UPDATE_APP_SESSION_STATUS', {
socketInfo: res.obj
})
// Connect to socket
const { serverName, host, port } = this.$store.state.socketInfo
const socket = io(`ws://${host}:${port}`, {
path: `/${serverName}`,
transports: ['websocket', 'polling']
})
}
} catch (e) {
// Handle api request exception
handleError.handleApiRequestException(e)
}
}
}
❓ 新的问题
上一个办法确实解决了问题,但是新的问题又来了,我发了两次请求,每个页面都要写一个请求。仔细想想这要是个十几二十个页面都要用的方法,那不得累死?有没有更好的解决办法呢?答案是有的。
✅ 第二次解决
既然我在
foo.vue
页面需要等待vuex
的更新,那我监听一下socketInfo
的更新,有更新我在连接,然后在mounted
里面判断socketInfo
是否有值再连接不就可以了吗。这个时候foo.vue
页面的代码变成了下面这样
foo.vue
${app}/src/views/foo/foo.vue
import io from 'socket.io-client'
import api from '@/apis'
import handleError from '@/utils/HandleError'
export default {
name: 'Foo',
async mounted() {
if (this.$store.state.socketInfo.host) {
// Handle create socket
this.handleCreateSocket()
}
},
watch: {
'$store.state.socketInfo.host'() {
if (this.$store.state.socketInfo.host) {
// Handle create socket
this.handleCreateSocket()
}
}
},
methods: {
// Handle create socket
handleCreateSocket() {
// Connect to socket
const { serverName, host, port } = this.$store.state.socketInfo
const socket = io(`ws://${host}:${port}`, {
path: `/${serverName}`,
transports: ['websocket', 'polling']
})
}
}
}
这里为啥监听的是
$store.state.socketInfo.host
呢,因为我们的mutations
里面的UPDATE_SOCKET_INFO
更新socketInfo
的方式是Object.assign()
,这种更新方式的好处是,如果api
请求返回的字段是这样的一个对象,少了port
字段(后台开发更新字段很常见){ "serverName":"msgServer1", "host":"192.168.0.2", }
我自己的
socketInfo对象
{ "serverName":"", "host":"", "port":"8080" }
假如我在初始化
state
的时候指定一个默认的端口,Object.assign()
合并的对象,只会合并我没有的,并且更新与我socketInfo
键值对相同的键的值,这样我的socketInfo
对象依然是有一个默认的端口,更新后为{ "serverName":"msgServer1", "host":"192.168.0.2", "port":"8080" }
我的
socket
依然能够连接上。不至于报错。回到之前的问题,如果我们监听的是$store.state.socketInfo
,这是个引用类型的对象,你会发现watch
不会执行,因为你的对象没有改变。关于
JavaScript
引用数据类型和基础数据类型可以查看:https://developer.mozilla.org/zh-CN/docs/Web/JavaScript/Guide/Grammar_and_types
❓ 思考新的问题
目前看来完成我的需求是不会有什么问题了。但是这样是完美的了吗?
如果我的
foo.vue
页面不只是创建连接的时候需要取vuex
的数据,我在页面渲染的时候,也需要vuex
里面的数据。比如我的foo.vue
,和bar.vue
都需要显示我的网站名,网站名是通过接口拉取存在vuex
的。这个时候怎么办呢?,刚刚解决上面问题的办法就无能为力了。毕竟mounted
不能阻止页面渲染。
✅ 最佳方案?
借用
watch
的方案,我在页面判断一下vuex
的值是否更新,然后再渲染不就ok了嘛?这也是很多网站骨架屏渲染的使用场景。很多网站在刚刚打开的一刻,数据未准备好的时候是会显示一个骨架加载的动画,等到加载完毕再把内容呈现给用户。看代码
${app}/src/views/foo/foo.vue
<div>
<!-- 我的网站名 -->
<div v-if="$store.state.webConfig.webName">{{ $store.state.webConfig.webName }}</div>
<!-- 骨架屏 -->
<skeleton v-else></skeleton>
</div>
import io from 'socket.io-client'
import api from '@/apis'
import handleError from '@/utils/HandleError'
export default {
name: 'Foo',
async mounted() {
if (this.$store.state.socketInfo.host) {
// Handle create socket
this.handleCreateSocket()
}
},
watch: {
'$store.state.socketInfo.host'() {
if (this.$store.state.socketInfo.host) {
// Handle create socket
this.handleCreateSocket()
}
}
},
methods: {
// Handle create socket
handleCreateSocket() {
// Connect to socket
const { serverName, host, port } = this.$store.state.socketInfo
const socket = io(`ws://${host}:${port}`, {
path: `/${serverName}`,
transports: ['websocket', 'polling']
})
}
}
}
✅ 优化代码
在
vuex
的socketInfo
对象加一个isUpdated
字段,如果更新了,直接取值进行我需要的操作,没更新的话就行请求api
更新。这是目前能想到的比较优雅的方案了。
${app}/src/views/foo/foo.vue
<div>
<!-- 我的网站名 -->
<div v-if="webConfig.isUpdated">
{{ webConfig.webName }}
</div>
<!-- 骨架屏 -->
<skeleton v-else></skeleton>
</div>
import io from 'socket.io-client'
import { mapState } from 'vuex'
import api from '@/apis'
import handleError from '@/utils/HandleError'
export default {
name: 'Foo',
computed: {
...mapState(['webConfig', 'socketInfo'])
},
async mounted() {
// Handle get socket info
this.handleGetSocketInfo()
},
methods: {
// Handle create socket
handleCreateSocket() {
// Connect to socket
const { serverName, host, port } = this.$store.state.socketInfo
const socket = io(`ws://${host}:${port}`, {
path: `/${serverName}`,
transports: ['websocket', 'polling']
})
},
// Handle get socket info
handleGetSocketInfo() {
if (this.socketInfo.isUpdated) {
// Handle create socket
this.handleCreateSocket()
} else {
this.$store.dispatch('GET_SOCKET_INFO', this.handleCreateSocket)
}
}
}
}
${app}/src/store/index.js
import Vue from 'vue'
import Vuex from 'vuex'
import api from '@/apis'
import handleError from '@/utils/HandleError'
Vue.use(Vuex)
export default new Vuex.Store({
strict: process.env.NODE_ENV !== 'production',
state: {
socketInfo: {
serverName: '',
host: '',
port: '',
isUpdated: false
},
webConfig:{
webName: '',
isUpdated: false
}
},
mutations: {
// Update token
UPDATE_SOCKET_INFO(state, { socketInfo }) {
// state.socketInfo = socketInfo
// Update vuex token
Object.assign(
state.socketInfo,
{
isUpdated: true
},
socketInfo
)
}
},
actions: {
// Get socket info
async GET_SOCKET_INFO({ commit }, callback) {
// Rquest socket info
try {
const res = await api.Common.getSocketUrl()
// Success
if (res.success) {
commit('UPDATE_SOCKET_INFO', {
socketInfo: res.obj
})
// Call back you custom function
if (callback) {
callback()
}
}
} catch (e) {
// Handle api request exception
handleError.handleApiRequestException(e)
}
}
}
})
由于在
foo.vue
页面需要使用数据的时候我们才去请求数据,因此App.vue
的请求可以取消,这样一来用户只是打开我们的网站,并不会去请求无意义的数据。优化了后台的接口请求压力。同时在第一次进入foo.vue
页面的时候已经请求了数据,如果用户没有刷新页面,再次访问该页面我们的socketInfo
对象的isUpdated
为true
,可以直接使用,不会去发送新的请求。
${app}/src/App.vue
<!-- App -->
<div id="app"></div>
export default {
name: 'App',
}
🤗更新方案
既然是进入页面之后可以判断数据是否加载完毕,我们也可以直接在页面进入之前,通过路由元信息配置该页面需要的全局异步数据,然后通过路由跳转的守卫去拉取异步数据(全局公用的异步数据只需要加载一遍就行,如果加载失败我们可以跳转到服务器错误页面)。完了再显示我们的页面,同时使用Promise.all
来进行多个异步数据的读取。nice~!上代码
${app}/src/utils/permission.js
这个文件用来做路由拦截。
/**
* @name Global router permission controller
* @description Do not delete comments
* @author SunSeekerX
* @time 2019-08-20 11:14:34
* @LastEditors: SunSeekerX
* @LastEditTime: 2020-05-21 15:28:39
*/
import NProgress from 'nprogress' // progress bar
import 'nprogress/nprogress.css' // progress bar style
NProgress.configure({ showSpinner: false }) // NProgress Configuration
import router from '@/router'
import store from '@/store'
import { i18n } from '@/lang/index'
import { NotifyFun, handleApiRequestException } from '@/utils/handle-error'
router.beforeEach(async (to, from, next) => {
// 启动进度条
NProgress.start()
// 公用vuex数据
const {
siteConfig,
appConfig,
socketInfo,
coinDecimal,
} = store.state.appSessionStatus
try {
// 异步任务列表
const task = []
// 站点信息
if (!siteConfig.isUpdated) {
task.push(store.dispatch('GET_SITE_CONFIG'))
}
// 站点配置
if (!appConfig.isUpdated) {
task.push(store.dispatch('GET_APP_CONFIG'))
}
/**
* @name 检查前去的页面需要的公用数据是否加载
*/
if (to.meta.isUsingCoinDecimal && !coinDecimal.isUpdated) {
// 需要全局小数点位数
task.push(store.dispatch('GET_COIN_DECIMAL'))
}
if (to.meta.isUsingSocketInfo && !socketInfo.isUpdated) {
// 需要全局socket链接信息
task.push(store.dispatch('GET_SOCKET_INFO'))
}
// 异步同时执行请求任务
await Promise.all(task)
} catch (error) {
// 提示错误
handleApiRequestException(error)
// 显示网络错误白屏图
store.commit('UPDATE_SERVER_ERROR', true)
// 请求失败,路由导航终止
return next(false)
} finally {
NProgress.done()
}
// Permission
if (store.state.token) {
// Has login
next()
} else {
// 判断是否是公开页面
if (to.meta.isPublic) {
next()
} else {
// Redirect to login
next('/user/user-login')
}
}
})
router.afterEach(() => {
// finish progress bar
NProgress.done()
})
路由配置
${app}/src/router/Exchange.js
/**
* @name Exchange.js
* @author SunSeekerX
* @time 2019-09-21 10:51:25
* @LastEditors: SunSeekerX
* @LastEditTime: 2020-05-21 14:59:37
*/
import { i18n } from '@/lang/index'
export default [
// 币币交易》首页
{
path: '/exchange',
name: 'ExchangeIndex',
component: () => import('@/views/exchange/index/index'),
meta: {
title: i18n.t('Title_Exchange'),
// 代表路由权限公开
isPublic: true,
// 需要socketInfo信息
isUsingSocketInfo: true,
// 需要coinDecimal信息
isUsingCoinDecimal: true,
},
},
]
0x3 总结
记录下自己平时解决问题的思考方式和解决方案。
本文章代码仅用工具检查语法错误,纯手写,并未实际运行,不保证逻辑合理,如果你有更好的方案,欢迎你和我讨论。
有问题才有更好的解决方案。谢谢你的阅读。
0x4 谢谢你的阅读 💝
关于我
SunSeekerX,前端开发、Nodejs开发、小程序、uni-app
开发、等等
喜欢探讨技术实现方案和细节,完美主义者,见不得bug
。
Github:https://github.com/SunSeekerX
个人博客:https:https://yoouu.cn//yoouu.cn/
个人在线笔记:https://sunseekerx.yoouu.cn/