大家都已习惯使用像axios等封装好的http请求库,对原生的XMLHttpRequest使用基本上都忘得差不多了。但是总会有一些特殊的情况需要用到,最近在做图床系统改版中的文件上传功能就碰到了这样的问题。

为了用户体验,上传过大的文件时需要反馈上传的进度,所以前端需要监听文件上传进度事件并显示,正常情况图片上传流程如下图。

由上图可知,文件上传到服务器的过程中,客户端会先收到上传进度的通知,然后再接收到上传结果,而且这两个返回是分开的。找个现成的Element-UI的上传组件看看具体是怎么实现的:

上传组件都会暴露出上传进度与上传成功的钩子,我们来看底层逻辑具体是怎么触发的。

function httpRequest(option) {
    const xhr = new XMLHttpRequest();
    const action = option.action;

    if (xhr.upload) {
        xhr.upload.onprogress = function progress(e) {
            if (e.total > 0) {
                e.percent = e.loaded / e.total * 100;
            }
            option.onProgress(e);
        };
    }
    ........

    xhr.onload = function onload() {
        if (xhr.status < 200 || xhr.status >= 300) {
            return option.onError(getError(action, option, xhr));
        }

        option.onSuccess(getBody(xhr));
    };
}

由源码可见,上传进度是通过xhr.upload对象接收的,结果返回是通过xhr对象接收的,来看下这两个对象的解释与使用场景。

  • xhr 与服务器进行交互可用于检索任何类型的数据且无需进行整页刷新的对象
  • xhr.upload 可以对上传的进度进行监控,绑定事件对上传进度各个阶段做处理的对象

xhr主要负责请求的建立与请求结果的处理,而xhr.upload对象只负责xhr建立请求后对数据传输到服务器阶段的监控。根据文档比较,xhrxhr.upload的事件基本是差不多 比如aborterrorloadstartloadloadendprogresstimeout,只是负责处理的数据不一样

知道了上传进度的基本原理,再回归需求。跟常规上传不同的是,图床系统文件上传到服务器之后还要把文件上传到阿里云OSS,并且是需要显示上传进度的。如下图:

上传流程变成了服务端做中转,前端上传到服务端的进度并不能代表文件真实上传的进度了,xhr.upload.progress处理的进度顶多算是50%。从上文我们可以知道,xhr对象也是有progress事件的,而且是接收数据的返回,只需要把两部分的进度相加就是真实的上传进度了,来看下代码实现。

1. 服务端返回由一次性返回改成分段返回

// 原本是
// ctx.body = result

async function uploadProgress(percent, checkpoint) {
  if (ctx.status !== 200) {
    ctx.res.writeHead(200, {
      'Content-Type': 'application/json',
    });
  }

  // 追加返回, 添加分隔符方便前端解析,完成percent=1
  ctx.res.write(percent + ',,,');
}

2. 前端添加数据返回进度事件监听
Element-UI的上传组件并没有提供数据返回进度的钩子,但是好的地方在于提供了上传方法覆盖的钩子。

只需要把上传逻辑的源码复制出来,加上xhr.onprogress的返回进度监听即可

xhr.addEventListener('progress', () => {
    if (typeof option.onServerProgress === 'function') {
        option.onServerProgress(xhr.responseText);
    }
});

3. 解析服务端的返回并结合xhr.upload.progress的进度得出真实进度

onServerProgress(data) {
    // 以res.write方式返回oss上传进度
    const process = (data || '').split(',,,') || [];

    this.serverProgress = process.includes('1') ? 1 : Math.max(...process);
},

percent() {
    return (this.clientProgress + parseInt(this.serverProgress * 100)) / 2;
},

这样整个上传就完成了,但是发现在本地测得好好地,部署到stable环境之后发现进度并没有按预期分段返回,而是等全部上传完成后一次返回。经过排查,发现是nginx代理的问题,理论上流程应该如下图的:

但是由于nginx默认开启proxy_buffering: on的选项,导致上传进度直接从0到100了,来看下这个这个配置的解释:

proxy_buffering

  • 禁用缓冲时,响应会在收到响应时立即同步传递给客户端. nginx不会尝试从代理服务器读取整个响应
  • 启用缓冲后,nginx会尽快从代理服务器接收响应,并将其保存到proxy_buffer_size和proxy_buffers指令设置的缓冲区中,等待接收结束再一次性返回给客户端

显然,由于这个nginx配置的存在,服务端的分段返回到nginx时,nginx会先缓存起来等待结束后再一次性返回给客户端

问题拓展

  • 文件下载进度应该也是通过xhr.onprogress事件监听实现的?
  • 前端接口单个接口请求到服务端,服务端并发请求多块数据时,是不是也可以先请求到部分数据然后实时返回给前端,提升首屏展示的速度?