浅析Angular中的文件上传进度监控

最近老是跟文件上传打交道,趁着搞完需求的间隙,整理一下拖了好久的文章,浅析 Angular 中的文件上传进度监控的原理。其主要的核心是利用了 XMLHttpRequest 和 Observable 对象来实现的。

文件上传源码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
/**
* 上传单个文件
* @param uploadFile 上传文件对象
*/
upload(uploadFile: ThyUploadFile): Observable<ThyUploadResponse> {
this.ensureFileName(uploadFile);

return new Observable(observer => {
const { xhr, cleanup } = this.uploadByXhr(observer, uploadFile);
return () => {
cleanup();

if (xhr.readyState !== xhr.DONE) {
xhr.abort();
}
};
});
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
private uploadByXhr(observer: Subscriber<ThyUploadResponse>, uploadFile: ThyUploadFile) {
// 注入 XhrFactory,调用其 build() 方法,返回 XMLHttpRequest 对象
const xhr = this.xhrFactory.build();
const time: number = new Date().getTime();
let speed = 0;
let estimatedTime: number | null = null;

uploadFile.progress = {
status: ThyUploadStatus.started,
percentage: 0,
startTime: time
};

const onProgress = (event: ProgressEvent): void => {
if (event.lengthComputable) {
let percentage = Math.round((event.loaded * 100) / event.total);
if (percentage === 100) {
percentage = 99;
}
const diff = new Date().getTime() - time;
speed = Math.round((event.loaded / diff) * 1000);
const progressStartTime = (uploadFile.progress && uploadFile.progress.startTime) || new Date().getTime();
estimatedTime = Math.ceil((event.total - event.loaded) / speed);

uploadFile.progress.status = ThyUploadStatus.uploading;
uploadFile.progress.percentage = percentage;
uploadFile.progress.speed = speed;
uploadFile.progress.speedHuman = `${humanizeBytes(speed, false, 2)}/s`;
uploadFile.progress.startTime = progressStartTime;
uploadFile.progress.estimatedTime = estimatedTime;
uploadFile.progress.estimatedTimeHuman = this.secondsToHuman(estimatedTime);

observer.next({ status: ThyUploadStatus.uploading, uploadFile: uploadFile });
}
};

const onError = (error: ErrorEvent) => observer.error(error);

const onReadyStateChange = () => {
if (xhr.readyState === XMLHttpRequest.DONE) {
const speedTime = (new Date().getTime() - uploadFile.progress.startTime) * 1000;
const speedAverage = Math.round(uploadFile.nativeFile.size / speedTime);

uploadFile.progress.status = ThyUploadStatus.done;
uploadFile.progress.percentage = 100;
uploadFile.progress.speed = speedAverage;
uploadFile.progress.speedHuman = `${humanizeBytes(speed, false, 2)}/s`;
uploadFile.progress.estimatedTime = estimatedTime;
uploadFile.progress.estimatedTimeHuman = this.secondsToHuman(estimatedTime || 0);

uploadFile.responseStatus = xhr.status;

try {
uploadFile.response = JSON.parse(xhr.response);
} catch (e) {
uploadFile.response = xhr.response;
}

// file.responseHeaders = this.parseResponseHeaders(xhr.getAllResponseHeaders());

observer.next({ status: ThyUploadStatus.done, uploadFile: uploadFile });

observer.complete();
}
};

xhr.upload.addEventListener('progress', onProgress, false);
xhr.upload.addEventListener('error', onError);
// When using the [timeout attribute](https://xhr.spec.whatwg.org/#the-timeout-attribute) and an XHR
// request times out, browsers trigger the `timeout` event (and executes the XHR's `ontimeout`
// callback). Additionally, Safari 9 handles timed-out requests in the same way, even if no `timeout`
// has been explicitly set on the XHR.
xhr.upload.addEventListener('timeout', onError);
xhr.addEventListener('timeout', onError);
xhr.addEventListener('readystatechange', onReadyStateChange);

xhr.open(uploadFile.method, uploadFile.url, true);
xhr.withCredentials = uploadFile.withCredentials ? true : false;

try {
const formData = new FormData();

Object.keys(uploadFile.data || {}).forEach(key => formData.append(key, uploadFile.data[key]));
Object.keys(uploadFile.headers || {}).forEach(key => xhr.setRequestHeader(key, uploadFile.headers[key]));

formData.append(uploadFile.fileField || 'file', uploadFile.nativeFile, uploadFile.fileName);

observer.next({ status: ThyUploadStatus.started, uploadFile: uploadFile });
xhr.send(formData);
} catch (error) {
observer.error(error);
}

return {
xhr,
cleanup: () => {
xhr.upload.removeEventListener('progress', onProgress);
xhr.upload.removeEventListener('error', onError);
xhr.upload.removeEventListener('timeout', onError);
xhr.removeEventListener('timeout', onError);
xhr.removeEventListener('readystatechange', onReadyStateChange);
}
};
}
1
2
3
private ensureFileName(uploadFile: ThyUploadFile) {
uploadFile.fileName = uploadFile.fileName || uploadFile.nativeFile.name;
}

分析

代码看着挺长的,但是其逻辑不难,核心点就两个部分。

  1. upload 方法返回了一个 ThyUploadResponse 类型的 Observable 可观察对象, 其构造函数传入一个 observer(观察者/订阅者),return了一个 unsubscribe 函数,用于取消订阅。

  2. 重点是创建 Observable 时的回调函数,它里面调用了最关键的 uploadByXhr(observer, uploadFile) 方法,其中把观察者 observer 和 文件详情对象 uploadFile 作为参数传了进去。

    • 注入 XhrFactory 服务,调用 build() 函数获取 XMLHttpRequest 对象

    • 定义了 progress、error、readystatechange 事件的回调函数

    • 调用 open() 实例化方法来创建一个新的请求

    • 调用 send() 实例化方法来发送 HTTP 请求,数据体为 FormData 类型

    • 最后 return 出去一个包含 XMLHttpRequest 对象和一个用于取消监听的函数

  3. 最后看一下 upload() 函数中 return 的 unsubscribe 函数中都干了些啥?

    • 调用 cleanup() 函数,用于取消各种 upload 时的监听事件

    • 当 xhr.readyState !== xhr.DONE 时,执行 xhr.abort() 函数,abort() 函数的作用是取消 http 请求,用作取消上传。

  4. progress 事件会在请求接收到数据的时候被周期性触发,ProgressEvent.loaded 只读属性是一个整数,表示底层的进程已经执行的工作量,可以使用该属性和 ProgressEvent.total 计算完成工作的比率。

  5. 由于 progress 事件会周期性的传输数据,所以调用 upload 函数的时候也会周期性的接受到数据,返回值中已经有计算好的各种数值,就可以拿来直接展示了。

与上传有关的类

上述分析中简单的涉及了一些类和函数,下面来系统的看一下。

XMLHttpRequest

其实例方法、事件比较多,可参考:使用XMLHttpRequest

ProgressEvent

ProgressEvent 接口是测量如 HTTP 请求(一个XMLHttpRequest,或者一个 img,audio,video,style 或 link 等底层资源的加载)等底层流程进度的事件。

实例属性

lengthComputable
ProgressEvent.lengthComputable 只读属性是一个布尔Boolean (en-US) 标志,表示ProgressEvent 所关联的资源是否具有可以计算的长度。

loaded
ProgressEvent.loaded 只读属性是一个整数,表示底层的进程已经执行的工作量。

total
是一个无符号 64 位整数值,表明正在处理或者传输的数据的总大小。

FormData

FormData 接口提供了一种表示表单数据的键值对 key/value 的构造方式,并且可以轻松的将数据通过XMLHttpRequest.send() 方法发送出去,本接口和此方法都相当简单直接。如果送出时的编码类型被设为 “multipart/form-data”,它会使用和表单一样的格式。

实例方法

append()
添加一个新值到 FormData 对象内的一个已存在的键中,如果键不存在则会添加该键。

set()
对 FormData 对象里的某个 key 设置一个新的值,如果该 key 不存在,则添加。

set() 和 FormData.append 不同之处在于:如果某个 key 已经存在,set() 会直接覆盖所有该 key 对应的值,而 FormData.append 则是在该 key 的最后位置再追加一个值。

功能优化

通常涉及到文件上传功能时,当离开页面时如果还有正在上传的文件,则需要给用户友好的提示。如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
initBeforeunload() {
fromEvent(window,'beforeunload')
.pipe(takeUntil(this.$unSubscribe))
.subscribe(e => {
const queues = Object.values(this.queues);
const isUploading = queues.find(queue => {
return queue?.find(item => {
return item?.result?.status === ThyUploadStatus.started || item?.result?.status === ThyUploadStatus.uploading;
});
})
if (isUploading) {
e = e || window.event;
const tips = this.util.translate.instant('validation.BEFOREUNLOAD_CHECK');
if (e) {
e.returnValue = true;
}
e.preventDefault();
return tips;
} else {
this.$unSubscribe.next(0);
this.$unSubscribe.complete();
}
})
}

Window: beforeunload event

当浏览器窗口关闭或者刷新时,会触发 beforeunload 事件。当前页面不会直接关闭,可以点击确定按钮关闭或刷新,也可以取消关闭或刷新。

注意

  1. 根据规范,要显示确认对话框,事件处理程序需要在事件上调用preventDefault(),不加不生效。

  2. 并非所有浏览器都支持此方法,而有些浏览器需要事件处理程序实现两个遗留方法中的一个作为代替,所以最好做一下浏览器的兼容:
    1)将字符串分配给事件的returnValue属性
    2)从事件处理程序返回一个字符串。

  3. 某些浏览器过去在确认对话框中显示返回的字符串,从而使事件处理程序能够向用户显示自定义消息。但是,此方法已被弃用,并且在大多数浏览器中不再支持。

  4. 在 beforeunload 事件中,window 对象的 alert、confirm等方法无效。