开始
本次记录我在开发中遇到的一个bug,如果你也遇到了相同的问题,并且刚好看到这篇文章,希望这篇文章能够提供一些思路供你参考。
需求是这样的:
场景是新增和编辑用户信息,分别有页面上的两个按钮控制,当点击新增用户按钮后填写用户信息,当点击编辑用户的时候,根据用户id查询用户信息并在表单回显。很简单的表单操作,但是问题就出现在了回显这一步。
bug复现过程是下面这样的:
- 点击编辑用户按钮,打开模态框,请求用户信息
- 在用户信息请求结束前,关闭模态框
- 立刻点击“新增用户”按钮或其它用户的“编辑用户”按钮重新打开模态框
- 重新打开的模态框显示上一个用户的信息
这里我们再用页面来模拟下这种情况,我们后端请求用户信息的接口用express来简单编写下:
app.get('/getUserInfo', (req, res) => {
setTimeout(() => {
res.send({ username: 'M - ' + Math.random() });
},2000)
})
前端的代码大概是这样的:
有一个父组件上面有两个按钮,分别用于用户信息的新增和编辑。
<template>
<a-space>
<a-button type="primary" @click="formModalRef.openModal()">新增用户</a-button>
<a-button @click="formModalRef.openModal(1)">编辑用户</a-button>
</a-space>
<FormModal ref="formModalRef" />
</template>
<script setup>
import { ref, reactive, toRefs, onBeforeMount, onMounted, watchEffect, computed } from 'vue';
import FormModal from './formModal.vue'
const formModalRef = ref(null)
</script>
其中FormModal为表单组件,当点击按钮的时候调用子组件的方法,用来打开表单模态框,子组件的代码是这样的:
<template>
<a-modal v-model:open="open" title="Basic Modal" @cancel="handleCancel">
<a-spin :spinning="loading">
用户名: <a-input v-model:value="userName" />
</a-spin>
</a-modal>
</template>
<script setup>
import { ref } from 'vue'
import axios from 'axios'
const open = ref(false) // 控制是否打开模态框
const userName = ref('') // userName表单字段
const loading = ref(false)// 控制模态框加载状态
// 打开模态框方法,如果有userId,则获取用户数据进行回显
const openModal = async (userId) => {
open.value = true
if (userId) {
loading.value = true
let { data: { username } } = await axios({ url: 'http://localhost:3000/getUserInfo' })
userName.value = username
}
loading.value = false
}
// 关闭模态框方法
const handleCancel = () => {
open.value = false
userName.value = ''
}
// 暴露方法
defineExpose({
openModal
})
</script>
- 先点击编辑,再点击新增,我们会发现,当我们点击新增的时候表单项的初始值不是空
- 先点击编辑,再点击编辑,注意表单值的变化
解决方案
通过上面的效果,拿第一种情况来举例,我们能很容以的分析出来,先点击编辑按钮再点击新增按钮,表单出现数据的原因是因为我们的网络请求是异步的
,过程是这样的:
- 点击编辑按钮,发送网络请求查询用户信息
- 关闭模态框,此时网络请求的结果未返回
- 点击新增按钮,此时网络请求的结果返回,程序会走await后面的代码,导致表单值回显(实际上当我们新增的时候,表单项应该是空的)
第二种情况也是同理,之所以表单项的值会发生改变是因为两次网络请求返回后,都走了await后面的代码,第一是会导致表单项的值会变一下,第二是网络请求的返回顺序是不能确定的,我们不确定最终我们表单值呈现的是哪个用户的信息。我们的标题提到了“竞态条件”这个词,这个词是意思就是说:两个不同的请求 “相互竞争”,并以与你预期不符的顺序返回。
理解了bug发生的原因,那么解决就简单了,我们只需要在模态框关闭的时候来取消掉当前的网络请求即可,这里我们用到的请求库是axios,如果不了解axiso如何取消请求,可以到这里查看取消请求 | Axios中文文档 | Axios中文网。
<script setup>
// ......
let controller // 定义全局变量
const openModal = async (userId) => {
open.value = true
if (userId) {
// 在这里实例化AbortController并配置axiso请求标识
controller = new AbortController()
loading.value = true
let { data: { username } } = await axios({ url: 'http://localhost:3000/getUserInfo', signal: controller.signal })
// 如果请求被取消,这里的代码是不会走到的
userName.value = username
}
loading.value = false
}
// 关闭模态框方法
const handleCancel = () => {
// 如果实例存在,则取消请求
if (controller) {
controller.abort()
controller = null
}
// ....
}
</script>
针对这个场景,只需要添加几行代码就能解决上面这个bug了。
拓展
尝试思考,如果上面我们的网络请求换成其它异步操作,如Promise,定时器等,我们不能像使用controller.abort()的方法来取消请求,那么我们应该怎么做?我们来把ModalForm组件中的代码修改为这样:
<template>
<a-modal v-model:open="open" title="Basic Modal" @cancel="handleCancel">
<a-spin :spinning="loading">
用户名 <a-input v-model:value="userName" />
</a-spin>
</a-modal>
</template>
<script setup>
import { ref } from 'vue'
const open = ref(false) // 控制是否打开模态框
const userName = ref('') // userName表单字段
const loading = ref(false)// 控制模态框加载状态
// 打开模态框方法,如果有userId,则获取用户数据进行回显
const openModal = async (userId) => {
open.value = true
if (userId) {
loading.value = true
// 模拟异步请求
let { res } = await new Promise((resolve) => {
setTimeout(() => {
resolve({ res: 'M - ' + Math.random() })
}, 2000)
})
userName.value = res
}
loading.value = false
}
const handleCancel = () => {
open.value = false
userName.value = ''
}
defineExpose({
openModal
})
</script>
其它的没有变,主要在openModal
方法中,我们使用Promise来模拟异步操作,可以尝试下,上面的bug又会出现。根据上面的经验,要修复这个bug我们要在关闭模态框的方法中入手。
初次尝试我的想法是这样的。
- 定义一个全局的变量ignore默认值是false
- 当关闭模态款的时候把这个变量的值置为true
- 等到异步操作结束后,判断ignore的值如果是true就证明当前的模态框已经关闭了,就不走await后面的代码
<template>
<a-modal v-model:open="open" title="Basic Modal" @cancel="handleCancel">
<a-spin :spinning="loading">
用户名 <a-input v-model:value="userName" />
</a-spin>
</a-modal>
</template>
<script setup>
import { ref } from 'vue'
const open = ref(false) // 控制是否打开模态框
const userName = ref('') // userName表单字段
const loading = ref(false)// 控制模态框加载状态
let ignore
// 打开模态框方法,如果有userId,则获取用户数据进行回显
const openModal = async (userId) => {
open.value = true
if (userId) {
ignore = false
loading.value = true
// 模拟异步请求
let { res } = await new Promise((resolve) => {
setTimeout(() => {
resolve({ res: 'M - ' + Math.random() })
}, 2000)
})
if (ignore) return
userName.value = res
}
loading.value = false
}
const handleCancel = () => {
ignore = true
// ...
}
</script>
这样做确实可以避免第一种情况,但是针对第二种情况(先点击编辑,关闭模态款后再点击编辑)就不好使了,原因是我们这里的ignore是这个组件的全局变量,每次编辑触发openModal方法的时候都会把变量的值置为false,那么如果定义在函数作用域中呢?
为了解决这个问题,我决定把ignore定义在openModal的函数作用域中,每次触发openModal方法它都有自己的执行上下文。这样的话又会出现一个新的问题,现在ignore变量在openModal方法的函数作用域中,我如何在模态框关闭的时候来访问这个变量呢?
由于作用域是相互隔离的,访问的话肯定是访问不到的,其实这里我们在可以关闭模态款的时候,通知ignore变量自己去变化。提到这个词快想到的应该是发布订阅模式
了,这里我们来简单的实现一个发布订阅中心的类,并实现他的几个方法:
class EventBus {
constructor() {
this.eventList = []
}
on (eventName, callback) {
this.eventList.push({ eventName, callback })
}
emit (eventName, data) {
this.eventList.forEach((item) => {
if (item.eventName === eventName) {
item.callback(data)
}
})
}
off (eventName) {
this.eventList = this.eventList.filter((item) => item.eventName !== eventName)
}
once (eventName, callback) {
const fn = (data) => {
callback(data)
this.off(eventName)
}
this.on(eventName, fn)
}
}
接下来要做的就简单了,我们只需要在进行异步操作的同时,订阅模态关闭的消息,等到模态框关闭的时候, 我们来修改ignore:
const eventBus = new EventBus()
const openModal = async (id) => {
open.value = true
if (id) {
let ignore = false
// 在这里订阅handleCancel方法触发,并修改ignore的值
eventBus.once('handleCancel', () => {
ignore = true
})
let { res } = await new Promise((resolve) => {
setTimeout(() => {
resolve({ res: Math.random() })
}, 2000)
})
if (ignore) return
userName.value = res
} else {
userName.value = ''
}
}
const handleCancel = () => {
// ... 在这里发送通知
eventBus.emit('handleCancel')
}
到这儿我们的bug就算解决了,如果有不足的地方或者其它解决思路欢迎在评论区补充分享😄。