-
Notifications
You must be signed in to change notification settings - Fork 79
/
Loader.js
602 lines (524 loc) · 18.1 KB
/
Loader.js
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
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
import Signal from 'mini-signals';
import parseUri from 'parse-uri';
import * as async from './async';
import Resource from './Resource';
// some constants
const MAX_PROGRESS = 100;
const rgxExtractUrlHash = /(#[\w-]+)?$/;
/**
* Manages the state and loading of multiple resources to load.
*
* @class
*/
export default class Loader {
/**
* @param {string} [baseUrl=''] - The base url for all resources loaded by this loader.
* @param {number} [concurrency=10] - The number of resources to load concurrently.
*/
constructor(baseUrl = '', concurrency = 10) {
/**
* The base url for all resources loaded by this loader.
*
* @member {string}
*/
this.baseUrl = baseUrl;
/**
* The progress percent of the loader going through the queue.
*
* @member {number}
*/
this.progress = 0;
/**
* Loading state of the loader, true if it is currently loading resources.
*
* @member {boolean}
*/
this.loading = false;
/**
* A querystring to append to every URL added to the loader.
*
* This should be a valid query string *without* the question-mark (`?`). The loader will
* also *not* escape values for you. Make sure to escape your parameters with
* [`encodeURIComponent`](https://mdn.io/encodeURIComponent) before assigning this property.
*
* @example
* const loader = new Loader();
*
* loader.defaultQueryString = 'user=me&password=secret';
*
* // This will request 'image.png?user=me&password=secret'
* loader.add('image.png').load();
*
* loader.reset();
*
* // This will request 'image.png?v=1&user=me&password=secret'
* loader.add('iamge.png?v=1').load();
*/
this.defaultQueryString = '';
/**
* The middleware to run before loading each resource.
*
* @member {function[]}
*/
this._beforeMiddleware = [];
/**
* The middleware to run after loading each resource.
*
* @member {function[]}
*/
this._afterMiddleware = [];
/**
* The tracks the resources we are currently completing parsing for.
*
* @member {Resource[]}
*/
this._resourcesParsing = [];
/**
* The `_loadResource` function bound with this object context.
*
* @private
* @member {function}
* @param {Resource} r - The resource to load
* @param {Function} d - The dequeue function
* @return {undefined}
*/
this._boundLoadResource = (r, d) => this._loadResource(r, d);
/**
* The resources waiting to be loaded.
*
* @private
* @member {Resource[]}
*/
this._queue = async.queue(this._boundLoadResource, concurrency);
this._queue.pause();
/**
* All the resources for this loader keyed by name.
*
* @member {object<string, Resource>}
*/
this.resources = {};
/**
* Dispatched once per loaded or errored resource.
*
* The callback looks like {@link Loader.OnProgressSignal}.
*
* @member {Signal}
*/
this.onProgress = new Signal();
/**
* Dispatched once per errored resource.
*
* The callback looks like {@link Loader.OnErrorSignal}.
*
* @member {Signal}
*/
this.onError = new Signal();
/**
* Dispatched once per loaded resource.
*
* The callback looks like {@link Loader.OnLoadSignal}.
*
* @member {Signal}
*/
this.onLoad = new Signal();
/**
* Dispatched when the loader begins to process the queue.
*
* The callback looks like {@link Loader.OnStartSignal}.
*
* @member {Signal}
*/
this.onStart = new Signal();
/**
* Dispatched when the queued resources all load.
*
* The callback looks like {@link Loader.OnCompleteSignal}.
*
* @member {Signal}
*/
this.onComplete = new Signal();
/**
* When the progress changes the loader and resource are disaptched.
*
* @memberof Loader
* @callback OnProgressSignal
* @param {Loader} loader - The loader the progress is advancing on.
* @param {Resource} resource - The resource that has completed or failed to cause the progress to advance.
*/
/**
* When an error occurrs the loader and resource are disaptched.
*
* @memberof Loader
* @callback OnErrorSignal
* @param {Loader} loader - The loader the error happened in.
* @param {Resource} resource - The resource that caused the error.
*/
/**
* When a load completes the loader and resource are disaptched.
*
* @memberof Loader
* @callback OnLoadSignal
* @param {Loader} loader - The loader that laoded the resource.
* @param {Resource} resource - The resource that has completed loading.
*/
/**
* When the loader starts loading resources it dispatches this callback.
*
* @memberof Loader
* @callback OnStartSignal
* @param {Loader} loader - The loader that has started loading resources.
*/
/**
* When the loader completes loading resources it dispatches this callback.
*
* @memberof Loader
* @callback OnCompleteSignal
* @param {Loader} loader - The loader that has finished loading resources.
*/
}
/**
* Adds a resource (or multiple resources) to the loader queue.
*
* This function can take a wide variety of different parameters. The only thing that is always
* required the url to load. All the following will work:
*
* ```js
* loader
* // normal param syntax
* .add('key', 'http://...', function () {})
* .add('http://...', function () {})
* .add('http://...')
*
* // object syntax
* .add({
* name: 'key2',
* url: 'http://...'
* }, function () {})
* .add({
* url: 'http://...'
* }, function () {})
* .add({
* name: 'key3',
* url: 'http://...'
* onComplete: function () {}
* })
* .add({
* url: 'https://...',
* onComplete: function () {},
* crossOrigin: true
* })
*
* // you can also pass an array of objects or urls or both
* .add([
* { name: 'key4', url: 'http://...', onComplete: function () {} },
* { url: 'http://...', onComplete: function () {} },
* 'http://...'
* ])
*
* // and you can use both params and options
* .add('key', 'http://...', { crossOrigin: true }, function () {})
* .add('http://...', { crossOrigin: true }, function () {});
* ```
*
* @param {string} [name] - The name of the resource to load, if not passed the url is used.
* @param {string} [url] - The url for this resource, relative to the baseUrl of this loader.
* @param {object} [options] - The options for the load.
* @param {boolean} [options.crossOrigin] - Is this request cross-origin? Default is to determine automatically.
* @param {Resource.LOAD_TYPE} [options.loadType=Resource.LOAD_TYPE.XHR] - How should this resource be loaded?
* @param {Resource.XHR_RESPONSE_TYPE} [options.xhrType=Resource.XHR_RESPONSE_TYPE.DEFAULT] - How should
* the data being loaded be interpreted when using XHR?
* @param {object} [options.metadata] - Extra configuration for middleware and the Resource object.
* @param {HTMLImageElement|HTMLAudioElement|HTMLVideoElement} [options.metadata.loadElement=null] - The
* element to use for loading, instead of creating one.
* @param {boolean} [options.metadata.skipSource=false] - Skips adding source(s) to the load element. This
* is useful if you want to pass in a `loadElement` that you already added load sources to.
* @param {function} [cb] - Function to call when this specific resource completes loading.
* @return {Loader} Returns itself.
*/
add(name, url, options, cb) {
// special case of an array of objects or urls
if (Array.isArray(name)) {
for (let i = 0; i < name.length; ++i) {
this.add(name[i]);
}
return this;
}
// if an object is passed instead of params
if (typeof name === 'object') {
cb = url || name.callback || name.onComplete;
options = name;
url = name.url;
name = name.name || name.key || name.url;
}
// case where no name is passed shift all args over by one.
if (typeof url !== 'string') {
cb = options;
options = url;
url = name;
}
// now that we shifted make sure we have a proper url.
if (typeof url !== 'string') {
throw new Error('No url passed to add resource to loader.');
}
// options are optional so people might pass a function and no options
if (typeof options === 'function') {
cb = options;
options = null;
}
// if loading already you can only add resources that have a parent.
if (this.loading && (!options || !options.parentResource)) {
throw new Error('Cannot add resources while the loader is running.');
}
// check if resource already exists.
if (this.resources[name]) {
throw new Error(`Resource named "${name}" already exists.`);
}
// add base url if this isn't an absolute url
url = this._prepareUrl(url);
// create the store the resource
this.resources[name] = new Resource(name, url, options);
if (typeof cb === 'function') {
this.resources[name].onAfterMiddleware.once(cb);
}
// if actively loading, make sure to adjust progress chunks for that parent and its children
if (this.loading) {
const parent = options.parentResource;
const incompleteChildren = [];
for (let i = 0; i < parent.children.length; ++i) {
if (!parent.children[i].isComplete) {
incompleteChildren.push(parent.children[i]);
}
}
const fullChunk = parent.progressChunk * (incompleteChildren.length + 1); // +1 for parent
const eachChunk = fullChunk / (incompleteChildren.length + 2); // +2 for parent & new child
parent.children.push(this.resources[name]);
parent.progressChunk = eachChunk;
for (let i = 0; i < incompleteChildren.length; ++i) {
incompleteChildren[i].progressChunk = eachChunk;
}
this.resources[name].progressChunk = eachChunk;
}
// add the resource to the queue
this._queue.push(this.resources[name]);
return this;
}
/**
* Sets up a middleware function that will run *before* the
* resource is loaded.
*
* @method before
* @param {function} fn - The middleware function to register.
* @return {Loader} Returns itself.
*/
pre(fn) {
this._beforeMiddleware.push(fn);
return this;
}
/**
* Sets up a middleware function that will run *after* the
* resource is loaded.
*
* @alias use
* @method after
* @param {function} fn - The middleware function to register.
* @return {Loader} Returns itself.
*/
use(fn) {
this._afterMiddleware.push(fn);
return this;
}
/**
* Resets the queue of the loader to prepare for a new load.
*
* @return {Loader} Returns itself.
*/
reset() {
this.progress = 0;
this.loading = false;
this._queue.kill();
this._queue.pause();
// abort all resource loads
for (const k in this.resources) {
const res = this.resources[k];
if (res._onLoadBinding) {
res._onLoadBinding.detach();
}
if (res.isLoading) {
res.abort();
}
}
this.resources = {};
return this;
}
/**
* Starts loading the queued resources.
*
* @param {function} [cb] - Optional callback that will be bound to the `complete` event.
* @return {Loader} Returns itself.
*/
load(cb) {
// register complete callback if they pass one
if (typeof cb === 'function') {
this.onComplete.once(cb);
}
// if the queue has already started we are done here
if (this.loading) {
return this;
}
if (this._queue.idle()) {
this._onStart();
this._onComplete();
}
else {
// distribute progress chunks
const numTasks = this._queue._tasks.length;
const chunk = 100 / numTasks;
for (let i = 0; i < this._queue._tasks.length; ++i) {
this._queue._tasks[i].data.progressChunk = chunk;
}
// notify we are starting
this._onStart();
// start loading
this._queue.resume();
}
return this;
}
/**
* The number of resources to load concurrently.
*
* @member {number}
* @default 10
*/
get concurrency() {
return this._queue.concurrency;
}
// eslint-disable-next-line require-jsdoc
set concurrency(concurrency) {
this._queue.concurrency = concurrency;
}
/**
* Prepares a url for usage based on the configuration of this object
*
* @private
* @param {string} url - The url to prepare.
* @return {string} The prepared url.
*/
_prepareUrl(url) {
const parsedUrl = parseUri(url, { strictMode: true });
let result;
// absolute url, just use it as is.
if (parsedUrl.protocol || !parsedUrl.path || url.indexOf('//') === 0) {
result = url;
}
// if baseUrl doesn't end in slash and url doesn't start with slash, then add a slash inbetween
else if (this.baseUrl.length
&& this.baseUrl.lastIndexOf('/') !== this.baseUrl.length - 1
&& url.charAt(0) !== '/'
) {
result = `${this.baseUrl}/${url}`;
}
else {
result = this.baseUrl + url;
}
// if we need to add a default querystring, there is a bit more work
if (this.defaultQueryString) {
const hash = rgxExtractUrlHash.exec(result)[0];
result = result.substr(0, result.length - hash.length);
if (result.indexOf('?') !== -1) {
result += `&${this.defaultQueryString}`;
}
else {
result += `?${this.defaultQueryString}`;
}
result += hash;
}
return result;
}
/**
* Loads a single resource.
*
* @private
* @param {Resource} resource - The resource to load.
* @param {function} dequeue - The function to call when we need to dequeue this item.
*/
_loadResource(resource, dequeue) {
resource._dequeue = dequeue;
// run before middleware
async.eachSeries(
this._beforeMiddleware,
(fn, next) => {
fn.call(this, resource, () => {
// if the before middleware marks the resource as complete,
// break and don't process any more before middleware
next(resource.isComplete ? {} : null);
});
},
() => {
if (resource.isComplete) {
this._onLoad(resource);
}
else {
resource._onLoadBinding = resource.onComplete.once(this._onLoad, this);
resource.load();
}
},
true
);
}
/**
* Called once loading has started.
*
* @private
*/
_onStart() {
this.progress = 0;
this.loading = true;
this.onStart.dispatch(this);
}
/**
* Called once each resource has loaded.
*
* @private
*/
_onComplete() {
this.progress = MAX_PROGRESS;
this.loading = false;
this.onComplete.dispatch(this, this.resources);
}
/**
* Called each time a resources is loaded.
*
* @private
* @param {Resource} resource - The resource that was loaded
*/
_onLoad(resource) {
resource._onLoadBinding = null;
// remove this resource from the async queue, and add it to our list of resources that are being parsed
this._resourcesParsing.push(resource);
resource._dequeue();
// run all the after middleware for this resource
async.eachSeries(
this._afterMiddleware,
(fn, next) => {
fn.call(this, resource, next);
},
() => {
resource.onAfterMiddleware.dispatch(resource);
this.progress += resource.progressChunk;
this.onProgress.dispatch(this, resource);
if (resource.error) {
this.onError.dispatch(resource.error, this, resource);
}
else {
this.onLoad.dispatch(this, resource);
}
this._resourcesParsing.splice(this._resourcesParsing.indexOf(resource), 1);
// do completion check
if (this._queue.idle() && this._resourcesParsing.length === 0) {
this._onComplete();
}
},
true
);
}
}