UNPKG

simple-bound

Version:

A simple and customizable reactive data-binding library.

354 lines (346 loc) 12.6 kB
class BoundError extends Error { constructor(message) { /* istanbul ignore next */ super(message ? `[bound]: ${message}` : message); } } var config = { debug: false }; /** * Responsible for binding objects' properties together, storing their values inside and updating subscribers. * * It helps to manipulate bindings on the lowest possible level. * * It only binds a SINGLE property at a time! * * @template T captures a type of property to bind. Once the class is initialied - only properties of types that extend T are allowed for binding. */ class Binding { /** * Creates an instance of Binding. * @param twoWay defines if a binding should always be 2-way and ignore roles. * @param value initial value to assign to slave bindings. * @param [plugins] to call on events. */ constructor(twoWay, value, plugins) { this.twoWay = twoWay; this.value = value; this.plugins = plugins; /** * Stores subscribers for further manipulations. */ this.subscribers = []; } /** * Responsible for executing the plugins synchronyously, * * @param type describes the type of action to be transmitted to a plugin */ callPlugins(type) { if (this.plugins) { this.plugins.forEach(plugin => plugin && plugin(this.value, Object.freeze({ type, subscribers: this.subscribers }))); } } /** * Adds a subscriber to the list of subscribers. * * @param subscriber to add */ bind(subscriber) { if (this.subscribers.every(b => !Binding.subscriptionsEqual(b, subscriber))) { this.subscribers.push(subscriber); } else if (Binding.config.debug) { throw new BoundError(`Binding for ${subscriber.prop} is already declared.`); } return subscriber; } /** * A generic get function that is applied to subscribers. * * Can also be used to get the current binding value. */ get() { this.callPlugins('get'); return this.value; } /** * A generic set function that is applied to subscribers. * * Can also be used to set the current binding value. */ set(newValue) { // Bind value for all masters at once this.value = newValue; // Then notify all slaves about the change this.notify(newValue); // Then call plugins this.callPlugins('set'); } /** * Asynchroniously notifies the subscribers about the value change. * * @param newValue is the value to set to subscribers' properties. */ notify(newValue) { return new Promise((resolve, _) => { this.subscribers.forEach(subscriber => { if (subscriber.role !== 'master') { // Set value for each slave subscriber.obj[subscriber.prop] = newValue; } }); resolve(); }); } addSubscriber(obj, prop, role) { if (this.twoWay || role === 'master') { if (obj[prop] !== undefined) { // Bind value for all masters at once this.value = obj[prop]; // Then notify all slaves about the change this.notify(this.value); } else { obj[prop] = this.value; } this.bind({ obj, prop, role: 'master' }); // TODO: account for a case of having enumerable get/set on a prop instead of normal value Object.defineProperty(obj, prop, { get: this.get.bind(this), set: this.set.bind(this), enumerable: true }); } else { obj[prop] = this.value; this.bind({ obj, prop, role: 'slave' }); } return this; } addMasterSubscriber(obj, prop) { return this.addSubscriber(obj, prop, 'master'); } addSlaveSubscriber(obj, prop) { return this.addSubscriber(obj, prop, 'slave'); } removeSubscriber() { let index = -1; if (typeof arguments[0] === 'number') { index = arguments[0]; } else { const obj = arguments[0]; const prop = arguments[1]; index = this.subscribers.findIndex(b => Binding.subscriptionsEqual(b, { obj, prop })); } if (index !== -1) { // Also remove getters and setters if (this.subscribers[index].role === 'master') { Object.defineProperty(this.subscribers[index].obj, this.subscribers[index].prop, { value: this.value, writable: true }); } this.subscribers.splice(index, 1); } return this; } /** * Clears all subscribers from the binding. */ clearSubscribers() { this.subscribers.forEach((_, index) => this.removeSubscriber(index)); return this; } /** * Global binding config. Changes affect all instances. */ static get config() { return config; } } /** * Checks subscribers' objects for reference equality. */ Binding.subscriptionsEqual = (src1, src2) => !!src1 && !!src2 && src1.prop === src2.prop && src1.obj === src2.obj; /** * fromPath * Returns a value from an object by a given path (usually string). * * @param obj an object to get a value from. * @param path to get a value by. * @returns a value from a given path. If a path is invalid - returns undefined. */ function fromPath(obj, path) { if (!path) return obj; if (typeof path === 'number' || !~path.indexOf('.')) return obj[path]; return path.split('.').reduce((o, i) => (o === Object(o) ? o[i] : o), obj); } /** * assignToPath * Assigns a value to an object by a given path (usually string). * If the path is invalid, silently creates the required path and assigns a value * * @param obj an object to get a value from. * @param path to get a value by. * @param value a value to assign. */ function assignToPath(obj, path, value) { if (!path) return obj; const pathArr = (typeof path === 'string' && ~path.indexOf('.')) ? path.split('.') : [path]; const key = pathArr.pop(); const final = pathArr.length === 0 ? obj : pathArr.reduce((o, i) => { if (o[i] === undefined) o[i] = {}; return o[i]; }, obj); final[key] = value; } const hasProxy = !!Proxy; class BaseBound { /** * Creates an instance of BaseBound. * @param proto used as an object prototype for the creation of boundObject and storage. Doesn't become bound itself. * @param [plugins] to plug into the binding events. */ constructor(proto, plugins) { this.plugins = plugins; /** * Stores bindings in a structure that is identical to the binding-prototype-object. */ this.storage = {}; /** * A bound object created from a constuctor's snapshot object. * * Contains an instance of the Bound class itself by the `__bound__` key. */ this.boundObject = { __bound__: this }; // Make __bound__ non-enumerable. Object.defineProperty(this.boundObject, '__bound__', { value: this, writable: true }); if (BaseBound.config.debug && typeof proto !== 'object') { throw new BoundError('Only object binds are allowed. For property and pure value bindings use Binding from "bound/binding".'); } if (BaseBound.config.debug && proto instanceof BaseBound || BaseBound.isBound(proto)) { throw new BoundError('Cannot rebind a bound object.'); } } /** * [NOT_IMPLEMENTED] Maps the object of a different shape to the original binding object * @param obj target object to bind * @param mapToOriginal a map for target object's keys relative to the original binding object type * @param twoWay whether the binding should be two-way */ bindAndMap(obj, mapToOriginal, twoWay) { throw new BoundError('Method not implemented.'); } /** * Global binding config. Changes affect all instances. */ static get config() { return config; } /** * Checks whether an object is already bound. * * @param obj an object ot check */ static isBound(obj) { return !!obj.__bound__ && (obj.__bound__ instanceof BaseBound); } } /** * Allows multiple full-object bindings. * Stores bindings and binds objects together, providing the highest possible abstraction level for bindings. * * @extends {BaseBound<T>} * @template T captures a type of proto object for later usage in binding type inference */ class Bound extends BaseBound { /** * Creates an instance of Bound using a proto object. * @param proto used as an object prototype for the creation of boundObject and storage. Doesn't become bound itself. * @param [plugins] to plug into the binding events. Do not work yet. */ //TODO: Bound plugins! constructor(proto, plugins) { super(proto, plugins); this.storage = {}; const original = JSON.parse(JSON.stringify(proto)); for (const key in original) { if (typeof original[key] === 'object') { // If the value is object - then treat it like another bound target const bound = new Bound(original[key], (plugins || {})[key]); this.boundObject[key] = bound.boundObject; this.storage[key] = bound.storage; } else { const binding = new Binding(false, original[key], [(plugins || {})[key]]); binding.addSubscriber(this.boundObject, key); this.storage[key] = binding; } } } /** * Binds an object to all other current subscribers * * @template U used to capture the bound object type. Must extends original template type. * @param obj to bind * @param [twoWay] whether the binding should be two-way */ //TODO: rework this function. It's a mess. bind(obj, twoWay = true) { const __bind = (_obj, _twoWay = true, path = '') => { Object.defineProperty(_obj, '__bound__', { value: fromPath(this.boundObject, path).__bound__, writable: true }); for (const key in fromPath(this.storage, path)) { const nextPath = !path ? key : `${path}.${key}`; const nextStorage = fromPath(this.storage, nextPath); const nextValue = _obj[key]; if (nextStorage instanceof Binding) { nextStorage.addSubscriber(_obj, key, _twoWay ? 'master' : 'slave'); } else { __bind(nextValue, _twoWay, nextPath); } } }; __bind(obj, twoWay); return this; } /** * Unbinds an object and destroys all of its listeners * * @param obj reference of object to be unbound */ unbind(obj) { const __unbind = (_obj, path = '') => { _obj.__bound__ = undefined; for (const key in fromPath(this.storage, path)) { const nextPath = !path ? key : `${path}.${key}`; const nextStorage = fromPath(this.storage, nextPath); const nextValue = _obj[key]; if (nextStorage instanceof Binding) { nextStorage.removeSubscriber(_obj, key); } else { _obj[key] = __unbind(nextValue, nextPath); } } return JSON.parse(JSON.stringify(_obj)); }; return __unbind(obj); } } // TODO: account for a class decorator case function bound(target) { return new Bound(target).boundObject; } export default Bound; export { bound, Binding, BaseBound as BoundBase, BoundError, fromPath, assignToPath, hasProxy }; //# sourceMappingURL=bound.es.js.map