问题描述
项目采用elementUI的el-upload组件进行图片上传,在这样的情况下会出现loading无法关闭的卡死现象:
点击图片上传,选择图片后开始上传,触发beforeUpload 事件,图片检测不通过停止上传。
继续点击上传选择图片,通过beforeUpload事件并上传成功,但loading一直无法关闭。查看控制台,可以看到el-upload组件内部的handleProgress报错了。
点击进入报错的代码位置,提示有一个变量(保存当前上传文件信息的一个变量)获取到的是null值,对其设置属性时报错了。
变量为啥获取为空,打断点进入一个叫getFile的方法, 该方法会传入一个原始的文件对象(当前上传文件)作为入参,通过判断文件列表中是否有这个uid(当前的文件信息的属性)来确定是否返回,如果没有这个uid,那么返回null,如果列表中有这个uid,则返回列表中对应的文件对象(原始文件对象在el-upload的描述信息)。因为找不到uid,结果返回null,接下来执行属性赋值操作就报错了。
{
handleProgress(ev, rawFile) {
const file = this.getFile(rawFile); // 通过原始的文件对象获取到对应的文件信息
this.onProgress(ev, file, this.uploadFiles);
file.status = 'uploading';
file.percentage = ev.percent || 0;
},
getFile(rawFile) {
let fileList = this.uploadFiles;
let target;
fileList.every(item => {
// 查询uid
target = rawFile.uid === item.uid ? item : null;
return !target;
});
return target;
},
}
分析原因
找到el-upload组件uid生成的地方,有两个地方会生成uid。相关代码如下
{
watch: {
/**
* fileList 业务组件通过props传过来的文件列表
* this.uploadFiles el-upload组件对应的文件列表信息
*/
fileList: {
immediate: true,
handler(fileList) {
this.uploadFiles = fileList.map(item => {
item.uid = item.uid || (Date.now() + this.tempIndex++);
item.status = 'success';
return item;
});
}
}
},
methods: {
// 图片上传开始后触发handleStart,生成当前上传图片的描述信息,加入到this.uploadFiles
handleStart(rawFile) {
rawFile.uid = Date.now() + this.tempIndex++;
let file = {
status: 'ready',
name: rawFile.name,
size: rawFile.size,
percentage: 0,
uid: rawFile.uid,
raw: rawFile
};
try {
file.url = URL.createObjectURL(rawFile);
} catch (err) {
console.error(err);
return;
}
this.uploadFiles.push(file);
this.onChange(file, this.uploadFiles);
},
}
}
根据上述uid生成方式:图片上传时会先调用handleStart,创建当前文件的描述对象,给对象添加uid,然后添加到uploadFiles。后面上传到远程服务器后触发onProgress事件时却查询不到这个uid了,猜测应该是handleStart到onProgress事件这一阶段,某些未知的意外触发了上面watch的fileList回调,即fileList被意外地变化了。那么,同样的uploadFiles也更新了(uid也重新生成),一旦uploadFiles更新了,原来那个uid肯定就找不到了。
诡异的问题是,看了一遍业务代码,在图片开始上传到onSuccess / onError / onRemove等事件触发前,别的地方不会对fileList更新呀。报错位置是在onProgress,此时还没有触发onSuccess / onError / onRemove等事件。
那只有一种可能,fileList, 即业务组件中定义的文件列表,在哪里被偷偷更改了。
数据更新过程
梳理了一遍业务代码和elementUI的el-upload组件
-
业务组件
-
图片上传组件
统一监听处理el-upload组件传递的beforeUpload / onSuccess / onError / onRemove等事件,在onSuccess / onError / onRemove事件回调中通知父组件(更新数据)
-
el-upload组件
组件引用关系: 业务组件 -> 图片上传组件 -> el-upload,具体示意图如下:
imgList变量是业务组件定义的,对应的是图片列表信息,通过props下发到子组件中; el-upload组件会订阅imgList(即上述代码的fileList)的变化,添加uid和status等属性,返回新的一份数据存在el-upload本地的uploadFiles中。
点击图片上传,触发handleStart, 创建一个图片对象,添加uid和status属性,将新的图片对象push到uploadFiles中等信息。图片上传到服务器前会触发beforeUpload事件,之后会根据情况触发onSuccess / onError / onRemove事件,最终通过公共组件将图片数据uploadFiles传到业务组件中。业务组件更新imgList。
所以这里,如果uploadFiles传递给业务组件时,如果imgList直接接收,是不是会有风险,引用同一份内存,一个地方改了,其他引用的地方也会跟着改了。
看了下公共上传组件原先写事件监听的代码,发现果真是直接传递的,业务代码处也是直接接收的。
// 简化后的代码
{
{
onSuccess(file, fileList){
// 一些处理...
// 通知业务组件
this.$emit('event-name', fileList);
},
onRemove(file, fileList){
// 一些处理...
// 通知业务组件
this.$emit('event-name', fileList);
}
}
所以安全起见,应该传给组件一份新的数据,当然也可以在父组件中做数据拷贝,这里为了方便,在公共上传组件中传一份新的给父组件。
// 简化后的代码
{
onSuccess(file, fileList){
// 一些处理...
// 返回新的一份,防止同一个引用导致的意外情况
const fileListCopy = Array.isArray(fileList) ? [...fileList] : [];
// 通知业务组件
this.$emit('event-name', fileListCopy);
}
onRemove(file, fileList){
// 一些处理...
// 返回新的一份,防止同一个引用导致的意外情况
const fileListCopy = Array.isArray(fileList) ? [...fileList] : [];
// 通知业务组件
this.$emit('event-name', fileListCopy);
}
}
修改代码 重新上传后,按照原先的一顿操作流程: 先第一次上传一张beforeUpload中无法通过的,然后再上传一张可以通过的,图片上传成功,没有报错,loading正常关闭。
错误分析
最后,在回顾下,为啥只有按照上述流程才会有这个问题。
beforeUpload事件未通过后,会触发onRemove事件,原先公共上传组件的代码是将el-upload组件的uploadFiles直接传给业务组件,业务组件未做拷贝直接使用,这样el-upload组件的uploadFiles和业务组件imgList就引用了同一个对象, 此时是空数组。
第二次点击上传,触发handleStart,添加uid和status,上传图片到服务器。
uploadFiles此时已push进去了新上传的图片,相当于imgList变化了,通过props传递到el-upload组件,触发watch注册的事件,添加uid和status后返回新的uploadFiles。注意, 此时图片还在上传,onProgress事件并未执行,但是uploadFiles中的uid都重新生成了。
图片上传到服务器后会触发ajax的onProgress事件,onProgress事件回调中发现原先的uid找不到了,最终出现null报错问题
// 简化后的代码
{
onRemove(file, fileList){
// 一些处理...
// 通知业务组件 业务组件的imgList直接接收fileList,这样两个变量就引用同一份数据了
this.$emit('event-name', fileList);
}
}
收获
熟读八股文的我们都知道:js基本类型的变量赋值给另一个变量是直接拷贝新值,两个变量相互独立,之后的修改不会互相干扰;对于对象和数组等引用类型的数据,赋值给另一个变量意味着两个变量引用了同一份内存空间,任意一个变量都可以对这份内存修改,在vue组件我们经常会遇到数据从父组件到子孙组件,子孙组件通过事件传递更新父组件数据,子孙组件不能直接随意修改父组件的数据,同样地,子孙组件在向上传递待更新的引用数据时,如果该数据在子孙组件本身也有一份的话,不要直接传递组件本身的数据,应该传递一份新的拷贝(至于在哪里拷贝处理,这个有待商榷),防止父子(孙)组件引用了同一份数据造成意外的情况。