[Js进阶]axios + blob文件下载完整开发流程

前端经常碰到有需要下载文件或图片的业务情况,即后端返回来的是文件流,那么该如何处理之呢?现总结开发流程如下。

步骤一:请求数据

const resp = await axios.get('/xxx', {
    params: {
        url
    }
});

后端直接返回来的就是文件流数据,即axios response对象打印如下。

axios配置文件放在哪里 axios 文件_axios配置文件放在哪里

axios response对象的data属性打印如下。

axios配置文件放在哪里 axios 文件_axios配置文件放在哪里_02

步骤二:把文件流包装成Blob对象

在请求的config中添加 responseType: 'blob'

const resp = await axios.get('/xxx', {
    params: {
        url
    },
    responseType: 'blob'
});

axios配置文件放在哪里 axios 文件_首部_03

{
    "data": {},
    "status": 200,
    "statusText": "OK",
    "headers": {
        "content-type": "text/plain;charset=UTF-8, application/vnd.openxmlformats-officeddocument.spreadsheetml.sheet;charset=UTF-8"
    },
    "config": {
        "url": "xxx",
        "method": "get",
        "headers": {},
        "params": {
            "url": "xxx.xlsx"
        },
        "baseURL": "xxxx",
        "transformRequest": [
            null
        ],
        "transformResponse": [
            null
        ],
        "timeout": 0,
        "withCredentials": true,
        "responseType": "blob",
        "xsrfCookieName": "XSRF-TOKEN",
        "xsrfHeaderName": "X-XSRF-TOKEN",
        "maxContentLength": -1,
        "maxBodyLength": -1,
        "transitional": {
            "silentJSONParsing": true,
            "forcedJSONParsing": true,
            "clarifyTimeoutError": false
        }
    },
    "request": {}
}

axios配置文件放在哪里 axios 文件_axios配置文件放在哪里_04

步骤三:Axios全局封装

针对Blob类型修改一番响应拦截。

$http.interceptors.response.use(
    response => {
        console.log('响应拦截');
        console.log(response);

        // 如果是没有状态码的响应
        if (!response.data.code) {
            const resType = Object.prototype.toString.call(response.data);
            const isBlob = resType === '[object Blob]';
            if (isBlob || resType === '[object String]') return response;
        }

        // 如果有响应的状态码
        switch (response.data.code) {
            case 2000:
                break;
            case 1001:
                if (router.currentRoute.value.path !== '/Login') {
                    router
                        .replace({
                            path: '/Login',
                            query: { redirect: router.currentRoute.value.path }
                        })
                        .catch(err => {});
                }
                localStorage.removeItem('SonicToken');
                break;
            case 1003:
                ElMessage.error({
                    message: $tc('dialog.permissionDenied')
                });
                break;
            default:
                if (response.data.message) {
                    ElMessage.error({
                        message: response.data.message
                    });
                }
        }
        return response.data;
    },
    err => {
        ElMessage.error({
            message: '系统出错了!'
        });
        return Promise.reject(err);
    }
);

步骤四、封装下载文件方法

/**
 * desc: 文件下载 导出结果处理  type:文件类型 zip .xls .xlsx ...
 */
export function handleDownLoadFile(response, type, fileName) {
    let blob = new Blob([response], {
        type: type + ';charset=utf-8'
    });
    let src = window.URL.createObjectURL(blob);
    if (src) {
        let link = document.createElement('a');
        link.style.display = 'none';
        link.href = src;
        link.setAttribute('download', fileName);
        document.body.appendChild(link);
        link.click();
        document.body.removeChild(link); //下载完成移除元素
        window.URL.revokeObjectURL(src); //释放掉blob对象
    }
}

步骤五:后端添加暴露响应头支持

根据MDN文档:Access-Control-Expose-Headers

默认情况下,header只有六种 simple response headers (简单响应首部)可以暴露给外部:

  • Cache-Control
  • Content-Language
  • Content-Type
  • Expires
  • Last-Modified
  • Pragma

这里的暴露给外部,意思是让客户端可以访问得到,既可以在Network里看到,也可以在代码里获取到他们的值。

上面问题提到的content-disposition不在其中,所以即使服务器在协议回包里加了该字段,但因没“暴露”给外部,客户端就“看得到,吃不到”。

而响应首部 Access-Control-Expose-Headers 就是控制“暴露”的开关,它列出了哪些首部可以作为响应的一部分暴露给外部。

所以如果想要让客户端可以访问到其他的首部信息,服务器不仅要在header里加入该首部,还要将它们在 Access-Control-Expose-Headers 里面列出来。

后端设置

response.setHeader("Access-Control-Expose-Headers", "Content-Disposition")
response.setHeader("Content-Disposition", ...)

成功设置后,服务台Network可以看到:

axios配置文件放在哪里 axios 文件_ios_05

最终,js就能获取到响应header的Content-Disposition字段的值了。

步骤六:正式处理业务逻辑

const netDownLoadFile = async url => {
    ElMessage.success({
        message: '操作成功'
    });
    try {
        const resp = await axios.get('/xxx', {
            params: {
                url
            },
            timeout: 60 * 1000
        });


        // 处理下载文件逻辑
        if (resp.data && resp.data.type === 'application/json') {
            ElMessage.error({
                message: '操作失败'
            });
        } else {
            // 传回来的是文件
            handleDownLoadFileFromHeader(resp);
            ElMessage.success({
                message: '操作成功'
            });
        }
    } catch (e) {
        console.log(e);
        ElMessage.error({
            message: '操作失败'
        });
    } finally {
    }
};

补充:针对图片的处理

有时候需要拿到图片,然后从文件流中生成图片url,填入img的src中。

例如,需要上传图片,如果已上传,就展示,未上传,就显示上传按钮。

ant design vue组件调用

<a-upload
          name="avatar"
          list-type="picture-card"
          class="avatar-uploader"
          :show-upload-list="false"
          accept="image/jpeg,image/jpg,image/png"
          :beforeUpload="beforeUpload"
          :customRequest="uploadImage"
          >
    <div v-if="imageUrl">
        <img style="width: 200px" :src="imageUrl" alt="" />
        <a-button style="margin-top: 15px" size="small" @click="e => handlePreview(e, imageUrl)"
                  >查看大图
        </a-button>
    </div>
    <div v-else>
        <a-icon :type="loading ? 'loading' : 'plus'" />
        <div class="ant-upload-text">Upload</div>
    </div>

    <a-modal :visible="previewVisible" :footer="null" @cancel="handleCancelPreview">
        <img alt="example" style="width: 100%" :src="previewImage" />
    </a-modal>
</a-upload>

自定义上传方法

const methods = {
    // 上传图片相关
    beforeUpload(file) {
        const isJpgOrPng = file.type === 'image/jpeg' || file.type === 'image/jpg' || file.type === 'image/png';
        if (!isJpgOrPng) {
            this.$message.error('只能上传jpg/png格式的图片');
        }
        const isLt2M = file.size / 1024 / 1024 < 10;
        if (!isLt2M) {
            this.$message.error('图?不得?于10MB');
        }
        return isJpgOrPng && isLt2M;
    }

    // 上传图片的正式请求
    uploadImage(file) {
        this.netSavPic(params);
        setTimeout(() => {
            this.init();
        }, 500);
    },
}

图片业务逻辑

async netGetPic(params) {
    this.spinning = true;
    let resp,
        err = {};
    try {
        resp = await getPicture(params);
        err = resp;
    } catch (error) {    
        err = error;
    }
    if (resp.data && resp.data.type == 'application/json') {
        this.imageUrl = '';
    } else {
        // 传回来的是图片
        const blob = new Blob([resp.data], { type: 'image/png' });
        this.imageUrl = window.URL.createObjectURL(blob);
    }

    this.spinning = false;
},

请求方法

export function getReferencePicture(data, params) {
    const form = new FormData();
    Object.keys(data).forEach(key => {
        form.append(key, data[key]);
    });
    return axios({
        url: `${baseUrl}/getPicture`,
        responseType: 'blob', //或者是blob
        headers: { 'Content-Type': 'multipart/form-data' },
        method: 'post',
        data: form,
        params
    });
}