src/beloader.js
/**
* @file beloader.js
* @author Liqueur de Toile <contact@liqueurdetoile.com>
* @licence AGPL-3.0 {@link https://github.com/liqueurdetoile/beloader/blob/master/LICENSE}
*/
import 'core/publicpath';
import 'es6-promise/auto';
import ObjectArray from 'dot-object-array';
import uniqid from 'uniqid';
import AbstractEventManager from 'core/AbstractEventManager';
import AbstractPlugin from 'core/AbstractPlugin';
import QueueItem from 'queueitem';
/**
* Highly customizable and lightweight assets loader
*
* You can create as many loaders instance as needed. Inside one instance, you can access
* specific loaders or functionnalities and order loading sequence with the use
* of defer or awaiting options, see {@link Beloader#fetch}.
*
* @version 1.0.0
* @since 1.0.0
* @author Liqueur de Toile <contact@liqueurdetoile.com>
* @extends {AbstractEventManager}
*
* @example
* var loader = new Beloader();
*
* // Display a text only when font is ready to avoit FOUT
* // It relies on webfontloader
* loader.fetch('font', {
* webfont: {
* google: {
* families: ['Droid Sans', 'Droid Serif']
* }
* }
* }).then(function() {
* document.body.innerHTML += '<div style="font-family:\'Droid Sans\'">Fixture</div>';
* });
*
* // ********************
* // Load external libraries and only run custom script when they're loaded
* // *********************
*
* // Example with defer
* // Not the best use case because the two external libraries don't depend on each other
* // Lodash loading will not resolve until elementify resolved
* loader.fetch('script', {
* url: 'https://cdn.jsdelivr.net/npm/elementify@latest',
* defer: true;
* });
*
* loader.fetch('script', {
* url: 'https://cdnjs.cloudflare.com/ajax/libs/lodash.js/4.17.5/lodash.min.js',
* defer: true;
* });
*
* loader.fetch('script', {
* url: 'myscript',
* defer: true
* });
*
* // Example with awaiting
* // More suitable to optimize loading
* loader.fetch('script', {
* url: 'myscript',
* awaiting: ['elementify', 'lodash']
* });
*
* loader.fetch('script', {
* id: 'elementify',
* url: 'https://cdn.jsdelivr.net/npm/elementify@latest'
* });
*
* loader.fetch('script', {
* id: 'lodash',
* url: 'https://cdnjs.cloudflare.com/ajax/libs/lodash.js/4.17.5/lodash.min.js'
* });
*
*/
export default class Beloader extends AbstractEventManager {
/**
* Beloader constructor
*
* @version 1.0.0
* @since 1.0.0
* @author Liqueur de Toile <contact@liqueurdetoile.com>
*
* @param {BeloaderOptions} options Global options for Beloader instance
* @returns {Beloader} Beloader instance
*/
constructor(options = {}) {
super(options.on);
delete options.on;
/**
* List of queued items in Beloader instance
*
* @since 1.0.0
* @type {Array}
*
*/
this._items = [];
/**
* List of named queued items in Beloader instance
*
* @since 1.0.0
* @type {Object}
*/
this._awaitables = {};
/**
* Options for the Beloader instance. See {@link Beloader#constructor}
*
* @since 1.0.0
* @type {DotObjectArray}
*/
this.options = new ObjectArray(options);
this.options.define('autoprocess', true);
this.options.define('async', true);
this.options.define('defer', false);
this.options.define('cache', true);
this.options.define('fallbackSync', true);
/**
* Progress statistics for the Beloader instance.
*
* @since 1.0.0
* @type {BeloaderProgress}
*/
this.progress = new ObjectArray({
items: {
total: 0,
waiting: 0,
pending: 0,
processed: 0,
loaded: 0,
error: 0,
abort: 0,
timeout: 0,
ready: 0
},
loading: {
complete: 0,
elapsed: 0,
loaded: 0,
rate: 0
}
});
/**
* Active Plugins list
*
* @since 1.0.0
* @type {DotObjectArray}
*/
this._plugins = new ObjectArray();
// Import plugins set in options
if (options.plugins) {
let plugins = {};
// Force array format
if (!(options.plugins instanceof Array)) options.plugins = [options.plugins];
options.plugins.forEach(plugin => {
if (typeof plugin === 'string') {
plugins[plugin] = {
type: 'plugin',
name: plugin
};
} else {
if (plugin.name) {
plugins[plugin.name] = {
type: 'plugin',
name: plugin.name,
url: plugin.url,
alias: plugin.alias
};
} else {
plugins[Object.keys(plugin)[0]] = {
type: 'plugin',
name: Object.keys(plugin)[0],
url: Object.values(plugin)[0]
};
}
}
});
/**
* Promise resolved when all plugins
* have been resolved
* @since 1.0.0
* @type {Promise} plugins Description for plugins
*/
this.ready = this.fetchAll(plugins).promise;
}
}
/**
* Add an item to the loading queue and return a promise
*
* An item is resolved (or failed) under several conditions :
* - Item have been loaded
* - Item is ready (webfontloader for instance)
* - If deferred, all previously defferred items in the queue are resolved
* - If awaiting items, all required items are resolved
*
* Supported types relies on loaders that may accept/require specific options :
* - `font`, `webfont` : {@link FontLoader} - Async only
* - `css`, `style`, `styles`, `stylesheet` : {@link StylesheetLoader}
* - `js`, `script`, `javascript`, `ecmascript` : {@link ScriptLoader}
* - `json` : {@link JsonLoader} - Async only
* - `img`, `image` : {@link ImageLoader}
* - `plugin` : {@link PluginLoader}
* - `none` : Beloader will simulate
* a loading sequence that is ever successfull. It can be used
* with awaiting to create side-effect and trigger callback
*
* Any other value will throw an error, except if a custom loader callback
* is provided in `options.loader`.
*
* @version 1.0.0
* @since 1.0.0
* @author Liqueur de Toile <contact@liqueurdetoile.com>
*
* @param {!String} type Type of item to load (see above)
* @param {String|QueueItemOptions} [options]
* If a string is provided, Beloader will use it as a value
* for the `url` option.
*/
fetch(type, options = {}) {
let item;
if (typeof options === 'string') options = {url: options};
options = new ObjectArray(options);
options.define('autoprocess', this.options.pull('autoprocess'));
options.define('async', this.options.pull('async'));
options.define('defer', this.options.pull('defer'));
options.define('cache', this.options.pull('cache'));
options.define('fallbackSync', this.options.pull('fallbackSync'));
options.define('xhr', this.options.pull('xhr'));
options.define('loader', this.options.pull('loader'));
// Append a unique hash to url if cache set to false
if (options.data.url && !options.data.cache) {
if (options.data.url.indexOf('?') > 0) options.data.url += '&' + uniqid();
else options.data.url += '?' + uniqid();
}
item = new QueueItem(type, this, options);
this._items.push(item);
if (options.data.id) this._awaitables[options.data.id] = false;
this.fire('itemadded', this, item);
this.progress.push('items.total', this._items.length);
this.progress.push('items.waiting', this.progress.pull('items.waiting') + 1);
return item;
}
/**
* Convenient method for bulk loading
*
* provided object must have id as keys and provide the type
* parameter as an option value.
*
* @version 1.0.0
* @since 1.0.0
* @author Liqueur de Toile <contact@liqueurdetoile.com>
*
* @param {Object} items Items to load
* @returns {QueueItem[]} Items loaded
* The array expose a promise property that is resolved
* with Promise.all
* @see https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Promise/all
* @example
* var loader = new Beloader({
* defer: true // load in same order than declared
* });
*
* loader.fetchAll({
* elementify: {
* type: 'js',
* url: 'https://cdn.jsdelivr.net/npm/elementify@latest'
* },
* Droid: {
* type: 'font',
* webfont: {
* google: {
* families: ['Droid Sans', 'Droid Serif']
* }
* }
* }).promise.then((...items) => start());
*
*/
fetchAll(items) {
let queuedItems = [];
let promises = [];
items = new ObjectArray(items);
items.forEach(function (options, id) {
let item, type = options.type;
delete options.type;
options.id = id;
item = this.fetch(type, options);
queuedItems.push(item);
promises.push(item.promise);
}.bind(this));
queuedItems.promise = Promise.all(promises);
return queuedItems;
}
/**
* Process all items in the queue that are waiting.
* Obviously, it must be used with `autoprocess` set to `false`
*
* @version 1.0.0
* @since 1.0.0
* @author Liqueur de Toile <contact@liqueurdetoile.com>
*/
process() {
this._items.forEach(item => {
/* istanbul ignore else */
if (item.state.waiting) item.process();
});
}
/**
* Import plugin into Beloader instance and extends
* native method of Beloader, QueueItem and Loader
* instances with plugin instance.
*
* The plugin will extends an {@link AbtractPlugin} instance and
* therefor will be able to throw events.
*
* A plugin will only be available in QueueItem and Loader
* instances created __after__ plugin import.
*
* The `init` method of a plugin is automatically
* called after plugin import.
*
* @version 1.0.0
* @since 1.0.0
* @author Liqueur de Toile <contact@liqueurdetoile.com>
*
* @param {string} name Name for the plugin
* @param {Object|Function} Plugin Plugin constructor or singleton
* @param {Object} [options={}] Plugin's options passed to constructor
* @return {Object} Plugin instance
* @throws {Error} If unable to load plugin
*/
pluginize(name, Plugin, options = {}) {
let plugin = AbstractPlugin;
options = new ObjectArray(options);
options.push('name', name);
options.define('alias', options.data.name);
try {
if (Plugin instanceof AbstractPlugin) {
plugin = new Plugin(this, options);
this._plugins.push(name, plugin);
} else {
plugin = new AbstractPlugin(this, options);
if (Plugin instanceof Function) Plugin = new Plugin();
for (let p in Plugin) {
/* istanbul ignore else */
if (Plugin.hasOwnProperty(p)) plugin[p] = Plugin[p];
}
this._plugins.push(name, plugin);
}
/* istanbul ignore else */
if (plugin.init instanceof Function) plugin.init(options);
/** @ignore */
/* istanbul ignore else */
this[options.data.alias] = plugin;
return plugin;
} catch (e) {
throw new Error('Unable to pluginize : ' + name + ' [' + e + ']');
}
}
/**
* loadstart built-in callback
*
* @version 1.0.0
* @since 1.0.0
* @author Liqueur de Toile <contact@liqueurdetoile.com>
* @listens {loadstart}
* @emits {beforeprocess}
*/
_loadstart() {
if (!this.progress.has('loading.start')) {
this.fire('beforeprocess', this);
this.progress.push('loading.start', +new Date());
}
this.progress.data.items.waiting -= 1;
this.progress.data.items.pending += 1;
}
/**
* progress built-in callback
*
* @version 1.0.0
* @since 1.0.0
* @author Liqueur de Toile <contact@liqueurdetoile.com>
* @listens {progress}
*/
_progress() {
this._updateProgress();
}
/**
* load built-in callback
*
* @version 1.0.0
* @since 1.0.0
* @author Liqueur de Toile <contact@liqueurdetoile.com>
* @listens {load}
*/
_load() {
this.progress.data.items.loaded += 1;
}
/**
* error built-in callback
*
* @version 1.0.0
* @since 1.0.0
* @author Liqueur de Toile <contact@liqueurdetoile.com>
* @listens {error}
*/
_error() {
this.progress.data.items.error += 1;
}
/**
* abort built-in callback
*
* @version 1.0.0
* @since 1.0.0
* @author Liqueur de Toile <contact@liqueurdetoile.com>
* @listens {abort}
*/
_abort() {
this.progress.data.items.abort += 1;
}
/**
* timeout built-in callback
*
* @version 1.0.0
* @since 1.0.0
* @author Liqueur de Toile <contact@liqueurdetoile.com>
* @listens {timeout}
*/
_timeout() {
this.progress.data.items.timeout += 1;
}
/**
* loadend built-in callback
*
* @version 1.0.0
* @since 1.0.0
* @author Liqueur de Toile <contact@liqueurdetoile.com>
* @listens {loadend}
* @emits {ready}
* @emits {afterprocess}
*/
_loadend() {
let previousDeferResolved = true;
// Resolve item or defer resolving
this._items.forEach(item => {
// Resolvable except if deferred and previous deffered not solved
let resolvable = item.defer ? previousDeferResolved : true;
// Check dependencies and update resolvable if dependencies not loaded
if (item.awaiting) {
item.awaiting.forEach(function (dependency) {
if (!this._awaitables[dependency]) resolvable = false;
}.bind(this));
}
// Resolve item
if (item.state.processed && !item.state.resolved && resolvable) {
// Update progress
this.progress.data.items.pending -= 1;
this.progress.data.items.processed += 1;
this._updateProgress();
item.state.resolved = true;
if (item.state.loaded) {
item._resolve(item);
item.state.ready = true;
item.fire('ready', item);
this.progress.data.items.ready += 1;
} else {
item._reject(item);
}
}
// Update previous defer
if (item.defer && !item.state.processed) previousDeferResolved = false;
});
// After event
if (this.progress.data.items.processed === this.progress.data.items.total) {
this.fire('afterprocess', this);
this.progress.push('loading.complete', 100);
this.progress.push('loading.end', +new Date());
this.progress.push('loading.elapsed', this.progress.data.loading.end - this.progress.data.loading.start);
}
}
/**
* Update loading progress statistics
*
* @version 1.0.0
* @since 1.0.0
* @author Liqueur de Toile <contact@liqueurdetoile.com>
*/
_updateProgress() {
let loaded = 0, total = 0, elapsed;
elapsed = +new Date() - this.progress.data.loading.start;
this._items.forEach(function (item) {
loaded += item.progress.data.loaded;
total += item.progress.data.total;
});
this.progress.push('loading.elapsed', elapsed); // milliseconds
this.progress.push('loading.loaded', loaded); // Bytes
this.progress.push('loading.rate', loaded / elapsed * 1000); // Bytes/s
/* istanbul ignore else */
if (total) {
this.progress.push('loading.total', total); // Bytes
this.progress.push('loading.complete', loaded / total * 100); // Bytes
}
}
}
export {Beloader};