vue项目中数据传递过程中处理不当带来的问题

971 阅读6分钟

问题描述

项目采用elementUI的el-upload组件进行图片上传,在这样的情况下会出现loading无法关闭的卡死现象:

点击图片上传,选择图片后开始上传,触发beforeUpload 事件,图片检测不通过停止上传。

继续点击上传选择图片,通过beforeUpload事件并上传成功,但loading一直无法关闭。查看控制台,可以看到el-upload组件内部的handleProgress报错了。

点击进入报错的代码位置,提示有一个变量(保存当前上传文件信息的一个变量)获取到的是null值,对其设置属性时报错了。

image.png

变量为啥获取为空,打断点进入一个叫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;
  },
}

1683688316420.png

分析原因

找到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,具体示意图如下:

index.png

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组件我们经常会遇到数据从父组件到子孙组件,子孙组件通过事件传递更新父组件数据,子孙组件不能直接随意修改父组件的数据,同样地,子孙组件在向上传递待更新的引用数据时,如果该数据在子孙组件本身也有一份的话,不要直接传递组件本身的数据,应该传递一份新的拷贝(至于在哪里拷贝处理,这个有待商榷),防止父子(孙)组件引用了同一份数据造成意外的情况。