@gullerya/object-observer
Version:
object-observer utility provides simple means to (deeply) observe specified object/array changes; implemented via native Proxy; changes delivered in a synchronous way
714 lines (639 loc) • 19.3 kB
JavaScript
export { Observable, ObjectObserver };
const
INSERT = 'insert',
UPDATE = 'update',
DELETE = 'delete',
REVERSE = 'reverse',
SHUFFLE = 'shuffle',
oMetaKey = Symbol.for('object-observer-meta-key-0'),
validObservableOptionKeys = { async: 1 },
processObserveOptions = options => {
if (!options || typeof options !== 'object') {
return null;
}
const result = {};
const invalidOptions = [];
for (const [optName, optVal] of Object.entries(options)) {
if (optName === 'path') {
if (typeof optVal !== 'string' || optVal === '') {
throw new Error('"path" option, if/when provided, MUST be a non-empty string');
}
result[optName] = optVal;
} else if (optName === 'pathsOf') {
if (options.path) {
throw new Error('"pathsOf" option MAY NOT be specified together with "path" option');
}
if (typeof optVal !== 'string') {
throw new Error('"pathsOf" option, if/when provided, MUST be a string (MAY be empty)');
}
result[optName] = options.pathsOf.split('.').filter(Boolean);
} else if (optName === 'pathsFrom') {
if (options.path || options.pathsOf) {
throw new Error('"pathsFrom" option MAY NOT be specified together with "path"/"pathsOf" option/s');
}
if (typeof optVal !== 'string' || optVal === '') {
throw new Error('"pathsFrom" option, if/when provided, MUST be a non-empty string');
}
result[optName] = optVal;
} else {
invalidOptions.push(optName);
}
}
if (invalidOptions.length) {
throw new Error(`'${invalidOptions.join(', ')}' is/are not a valid observer option/s`);
}
return result;
},
prepareObject = (source, oMeta, visited) => {
const target = {};
target[oMetaKey] = oMeta;
for (const key in source) {
target[key] = getObservedOf(source[key], key, oMeta, visited);
}
return target;
},
prepareArray = (source, oMeta, visited) => {
let l = source.length;
const target = new Array(l);
target[oMetaKey] = oMeta;
for (let i = 0; i < l; i++) {
target[i] = getObservedOf(source[i], i, oMeta, visited);
}
return target;
},
prepareTypedArray = (source, oMeta) => {
source[oMetaKey] = oMeta;
return source;
},
filterChanges = (options, changes) => {
if (options === null) {
return changes;
}
let result = changes;
if (options.path) {
const oPath = options.path;
result = changes.filter(change =>
change.path.join('.') === oPath
);
} else if (options.pathsOf) {
const oPathsOf = options.pathsOf;
const oPathsOfStr = oPathsOf.join('.');
result = changes.filter(change =>
(change.path.length === oPathsOf.length + 1 ||
(change.path.length === oPathsOf.length && (change.type === REVERSE || change.type === SHUFFLE))) &&
change.path.join('.').startsWith(oPathsOfStr)
);
} else if (options.pathsFrom) {
const oPathsFrom = options.pathsFrom;
result = changes.filter(change =>
change.path.join('.').startsWith(oPathsFrom)
);
}
return result;
},
callObserverSafe = (listener, changes) => {
try {
listener(changes);
} catch (e) {
console.error(`failed to notify listener ${listener} with ${changes}`, e);
}
},
callObserversFromMT = function callObserversFromMT() {
const batches = this.batches;
this.batches = [];
for (const [listener, changes] of batches) {
callObserverSafe(listener, changes);
}
},
callObservers = (oMeta, changes) => {
let currentObservable = oMeta;
let isAsync, observers, target, options, relevantChanges, i;
const l = changes.length;
do {
isAsync = currentObservable.options.async;
observers = currentObservable.observers;
i = observers.length;
while (i--) {
[target, options] = observers[i];
relevantChanges = filterChanges(options, changes);
if (relevantChanges.length) {
if (isAsync) {
// this is the async dispatch handling
if (currentObservable.batches.length === 0) {
queueMicrotask(callObserversFromMT.bind(currentObservable));
}
let rb;
for (const b of currentObservable.batches) {
if (b[0] === target) {
rb = b;
break;
}
}
if (!rb) {
rb = [target, []];
currentObservable.batches.push(rb);
}
Array.prototype.push.apply(rb[1], relevantChanges);
} else {
// this is the naive straight forward synchronous dispatch
callObserverSafe(target, relevantChanges);
}
}
}
// cloning all the changes and notifying in context of parent
const parent = currentObservable.parent;
if (parent) {
for (let j = 0; j < l; j++) {
const change = changes[j];
changes[j] = new Change(
change.type,
[currentObservable.ownKey, ...change.path],
change.value,
change.oldValue,
change.object
);
}
currentObservable = parent;
} else {
currentObservable = null;
}
} while (currentObservable);
},
getObservedOf = (item, key, parent, visited) => {
if (visited !== undefined && visited.has(item)) {
return null;
} else if (typeof item !== 'object' || item === null) {
return item;
} else if (Array.isArray(item)) {
return new ArrayOMeta({ target: item, ownKey: key, parent: parent, visited }).proxy;
} else if (ArrayBuffer.isView(item)) {
return new TypedArrayOMeta({ target: item, ownKey: key, parent: parent }).proxy;
} else if (item instanceof Date) {
return item;
} else {
return new ObjectOMeta({ target: item, ownKey: key, parent: parent, visited }).proxy;
}
},
proxiedPop = function proxiedPop() {
const oMeta = this[oMetaKey],
target = oMeta.target,
poppedIndex = target.length - 1;
let popResult = target.pop();
if (popResult && typeof popResult === 'object') {
const tmpObserved = popResult[oMetaKey];
if (tmpObserved) {
popResult = tmpObserved.detach();
}
}
const changes = [new Change(DELETE, [poppedIndex], undefined, popResult, this)];
callObservers(oMeta, changes);
return popResult;
},
proxiedPush = function proxiedPush() {
const
oMeta = this[oMetaKey],
target = oMeta.target,
l = arguments.length,
pushContent = new Array(l),
initialLength = target.length;
for (let i = 0; i < l; i++) {
pushContent[i] = getObservedOf(arguments[i], initialLength + i, oMeta);
}
const pushResult = Reflect.apply(target.push, target, pushContent);
const changes = [];
for (let i = initialLength, j = target.length; i < j; i++) {
changes[i - initialLength] = new Change(INSERT, [i], target[i], undefined, this);
}
callObservers(oMeta, changes);
return pushResult;
},
proxiedShift = function proxiedShift() {
const
oMeta = this[oMetaKey],
target = oMeta.target;
let shiftResult, i, l, item, tmpObserved;
shiftResult = target.shift();
if (shiftResult && typeof shiftResult === 'object') {
tmpObserved = shiftResult[oMetaKey];
if (tmpObserved) {
shiftResult = tmpObserved.detach();
}
}
// update indices of the remaining items
for (i = 0, l = target.length; i < l; i++) {
item = target[i];
if (item && typeof item === 'object') {
tmpObserved = item[oMetaKey];
if (tmpObserved) {
tmpObserved.ownKey = i;
}
}
}
const changes = [new Change(DELETE, [0], undefined, shiftResult, this)];
callObservers(oMeta, changes);
return shiftResult;
},
proxiedUnshift = function proxiedUnshift() {
const
oMeta = this[oMetaKey],
target = oMeta.target,
al = arguments.length,
unshiftContent = new Array(al);
for (let i = 0; i < al; i++) {
unshiftContent[i] = getObservedOf(arguments[i], i, oMeta);
}
const unshiftResult = Reflect.apply(target.unshift, target, unshiftContent);
for (let i = 0, l = target.length, item; i < l; i++) {
item = target[i];
if (item && typeof item === 'object') {
const tmpObserved = item[oMetaKey];
if (tmpObserved) {
tmpObserved.ownKey = i;
}
}
}
// publish changes
const l = unshiftContent.length;
const changes = new Array(l);
for (let i = 0; i < l; i++) {
changes[i] = new Change(INSERT, [i], target[i], undefined, this);
}
callObservers(oMeta, changes);
return unshiftResult;
},
proxiedReverse = function proxiedReverse() {
const
oMeta = this[oMetaKey],
target = oMeta.target;
let i, l, item;
target.reverse();
for (i = 0, l = target.length; i < l; i++) {
item = target[i];
if (item && typeof item === 'object') {
const tmpObserved = item[oMetaKey];
if (tmpObserved) {
tmpObserved.ownKey = i;
}
}
}
const changes = [new Change(REVERSE, [], undefined, undefined, this)];
callObservers(oMeta, changes);
return this;
},
proxiedSort = function proxiedSort(comparator) {
const
oMeta = this[oMetaKey],
target = oMeta.target;
let i, l, item;
target.sort(comparator);
for (i = 0, l = target.length; i < l; i++) {
item = target[i];
if (item && typeof item === 'object') {
const tmpObserved = item[oMetaKey];
if (tmpObserved) {
tmpObserved.ownKey = i;
}
}
}
const changes = [new Change(SHUFFLE, [], undefined, undefined, this)];
callObservers(oMeta, changes);
return this;
},
proxiedFill = function proxiedFill(filVal, start, end) {
const
oMeta = this[oMetaKey],
target = oMeta.target,
changes = [],
tarLen = target.length,
prev = target.slice(0);
start = start === undefined ? 0 : (start < 0 ? Math.max(tarLen + start, 0) : Math.min(start, tarLen));
end = end === undefined ? tarLen : (end < 0 ? Math.max(tarLen + end, 0) : Math.min(end, tarLen));
if (start < tarLen && end > start) {
target.fill(filVal, start, end);
let tmpObserved;
for (let i = start, item, tmpTarget; i < end; i++) {
item = target[i];
target[i] = getObservedOf(item, i, oMeta);
if (i in prev) {
tmpTarget = prev[i];
if (tmpTarget && typeof tmpTarget === 'object') {
tmpObserved = tmpTarget[oMetaKey];
if (tmpObserved) {
tmpTarget = tmpObserved.detach();
}
}
changes.push(new Change(UPDATE, [i], target[i], tmpTarget, this));
} else {
changes.push(new Change(INSERT, [i], target[i], undefined, this));
}
}
callObservers(oMeta, changes);
}
return this;
},
proxiedCopyWithin = function proxiedCopyWithin(dest, start, end) {
const
oMeta = this[oMetaKey],
target = oMeta.target,
tarLen = target.length;
dest = dest < 0 ? Math.max(tarLen + dest, 0) : dest;
start = start === undefined ? 0 : (start < 0 ? Math.max(tarLen + start, 0) : Math.min(start, tarLen));
end = end === undefined ? tarLen : (end < 0 ? Math.max(tarLen + end, 0) : Math.min(end, tarLen));
const len = Math.min(end - start, tarLen - dest);
if (dest < tarLen && dest !== start && len > 0) {
const
prev = target.slice(0),
changes = [];
target.copyWithin(dest, start, end);
for (let i = dest, nItem, oItem, tmpObserved; i < dest + len; i++) {
// update newly placed observables, if any
nItem = target[i];
if (nItem && typeof nItem === 'object') {
nItem = getObservedOf(nItem, i, oMeta);
target[i] = nItem;
}
// detach overridden observables, if any
oItem = prev[i];
if (oItem && typeof oItem === 'object') {
tmpObserved = oItem[oMetaKey];
if (tmpObserved) {
oItem = tmpObserved.detach();
}
}
if (typeof nItem !== 'object' && nItem === oItem) {
continue;
}
changes.push(new Change(UPDATE, [i], nItem, oItem, this));
}
callObservers(oMeta, changes);
}
return this;
},
proxiedSplice = function proxiedSplice() {
const
oMeta = this[oMetaKey],
target = oMeta.target,
splLen = arguments.length,
spliceContent = new Array(splLen),
tarLen = target.length;
// observify the newcomers
for (let i = 0; i < splLen; i++) {
spliceContent[i] = getObservedOf(arguments[i], i, oMeta);
}
// calculate pointers
const
startIndex = splLen === 0 ? 0 : (spliceContent[0] < 0 ? tarLen + spliceContent[0] : spliceContent[0]),
removed = splLen < 2 ? tarLen - startIndex : spliceContent[1],
inserted = Math.max(splLen - 2, 0),
spliceResult = Reflect.apply(target.splice, target, spliceContent),
newTarLen = target.length;
// reindex the paths
let tmpObserved;
for (let i = 0, item; i < newTarLen; i++) {
item = target[i];
if (item && typeof item === 'object') {
tmpObserved = item[oMetaKey];
if (tmpObserved) {
tmpObserved.ownKey = i;
}
}
}
// detach removed objects
let i, l, item;
for (i = 0, l = spliceResult.length; i < l; i++) {
item = spliceResult[i];
if (item && typeof item === 'object') {
tmpObserved = item[oMetaKey];
if (tmpObserved) {
spliceResult[i] = tmpObserved.detach();
}
}
}
const changes = [];
let index;
for (index = 0; index < removed; index++) {
if (index < inserted) {
changes.push(new Change(UPDATE, [startIndex + index], target[startIndex + index], spliceResult[index], this));
} else {
changes.push(new Change(DELETE, [startIndex + index], undefined, spliceResult[index], this));
}
}
for (; index < inserted; index++) {
changes.push(new Change(INSERT, [startIndex + index], target[startIndex + index], undefined, this));
}
callObservers(oMeta, changes);
return spliceResult;
},
proxiedTypedArraySet = function proxiedTypedArraySet(source, offset) {
const
oMeta = this[oMetaKey],
target = oMeta.target,
souLen = source.length,
prev = target.slice(0);
offset = offset || 0;
target.set(source, offset);
const changes = new Array(souLen);
for (let i = offset; i < (souLen + offset); i++) {
changes[i - offset] = new Change(UPDATE, [i], target[i], prev[i], this);
}
callObservers(oMeta, changes);
},
proxiedArrayMethods = {
pop: proxiedPop,
push: proxiedPush,
shift: proxiedShift,
unshift: proxiedUnshift,
reverse: proxiedReverse,
sort: proxiedSort,
fill: proxiedFill,
copyWithin: proxiedCopyWithin,
splice: proxiedSplice
},
proxiedTypedArrayMethods = {
reverse: proxiedReverse,
sort: proxiedSort,
fill: proxiedFill,
copyWithin: proxiedCopyWithin,
set: proxiedTypedArraySet
};
class Change {
constructor(type, path, value, oldValue, object) {
this.type = type;
this.path = path;
this.value = value;
this.oldValue = oldValue;
this.object = object;
}
}
class OMetaBase {
constructor(properties, cloningFunction) {
const { target, parent, ownKey, visited = new Set() } = properties;
if (parent && ownKey !== undefined) {
this.parent = parent;
this.ownKey = ownKey;
} else {
this.parent = null;
this.ownKey = null;
}
visited.add(target);
const targetClone = cloningFunction(target, this, visited);
visited.delete(target);
this.observers = [];
this.revocable = Proxy.revocable(targetClone, this);
this.proxy = this.revocable.proxy;
this.target = targetClone;
this.options = this.processOptions(properties.options);
if (this.options.async) {
this.batches = [];
}
}
processOptions(options) {
if (options) {
if (typeof options !== 'object') {
throw new Error(`Observable options if/when provided, MAY only be an object, got '${options}'`);
}
const invalidOptions = Object.keys(options).filter(option => !(option in validObservableOptionKeys));
if (invalidOptions.length) {
throw new Error(`'${invalidOptions.join(', ')}' is/are not a valid Observable option/s`);
}
return Object.assign({}, options);
} else {
return {};
}
}
detach() {
this.parent = null;
return this.target;
}
set(target, key, value) {
let oldValue = target[key];
if (value !== oldValue) {
const newValue = getObservedOf(value, key, this);
target[key] = newValue;
if (oldValue && typeof oldValue === 'object') {
const tmpObserved = oldValue[oMetaKey];
if (tmpObserved) {
oldValue = tmpObserved.detach();
}
}
const changes = oldValue === undefined
? [new Change(INSERT, [key], newValue, undefined, this.proxy)]
: [new Change(UPDATE, [key], newValue, oldValue, this.proxy)];
callObservers(this, changes);
}
return true;
}
deleteProperty(target, key) {
let oldValue = target[key];
delete target[key];
if (oldValue && typeof oldValue === 'object') {
const tmpObserved = oldValue[oMetaKey];
if (tmpObserved) {
oldValue = tmpObserved.detach();
}
}
const changes = [new Change(DELETE, [key], undefined, oldValue, this.proxy)];
callObservers(this, changes);
return true;
}
}
class ObjectOMeta extends OMetaBase {
constructor(properties) {
super(properties, prepareObject);
}
}
class ArrayOMeta extends OMetaBase {
constructor(properties) {
super(properties, prepareArray);
}
get(target, key) {
return proxiedArrayMethods[key] || target[key];
}
}
class TypedArrayOMeta extends OMetaBase {
constructor(properties) {
super(properties, prepareTypedArray);
}
get(target, key) {
return proxiedTypedArrayMethods[key] || target[key];
}
}
const Observable = Object.freeze({
from: (target, options) => {
if (!target || typeof target !== 'object') {
throw new Error('observable MAY ONLY be created from a non-null object');
} else if (target[oMetaKey]) {
return target;
} else if (Array.isArray(target)) {
return new ArrayOMeta({ target: target, ownKey: null, parent: null, options: options }).proxy;
} else if (ArrayBuffer.isView(target)) {
return new TypedArrayOMeta({ target: target, ownKey: null, parent: null, options: options }).proxy;
} else if (target instanceof Date) {
throw new Error(`${target} found to be one of a non-observable types`);
} else {
return new ObjectOMeta({ target: target, ownKey: null, parent: null, options: options }).proxy;
}
},
isObservable: input => {
return !!(input && input[oMetaKey]);
},
observe: (observable, observer, options) => {
if (!Observable.isObservable(observable)) {
throw new Error(`invalid observable parameter`);
}
if (typeof observer !== 'function') {
throw new Error(`observer MUST be a function, got '${observer}'`);
}
const observers = observable[oMetaKey].observers;
if (!observers.some(o => o[0] === observer)) {
observers.push([observer, processObserveOptions(options)]);
} else {
console.warn('observer may be bound to an observable only once; will NOT rebind');
}
},
unobserve: (observable, ...observers) => {
if (!Observable.isObservable(observable)) {
throw new Error(`invalid observable parameter`);
}
const existingObs = observable[oMetaKey].observers;
let el = existingObs.length;
if (!el) {
return;
}
if (!observers.length) {
existingObs.splice(0);
return;
}
while (el) {
let i = observers.indexOf(existingObs[--el][0]);
if (i >= 0) {
existingObs.splice(el, 1);
}
}
}
});
class ObjectObserver {
#observer;
#targets;
constructor(observer) {
this.#observer = observer;
this.#targets = new Set();
Object.freeze(this);
}
observe(target, options) {
const r = Observable.from(target);
Observable.observe(r, this.#observer, options);
this.#targets.add(r);
return r;
}
unobserve(target) {
Observable.unobserve(target, this.#observer);
this.#targets.delete(target);
}
disconnect() {
for (const t of this.#targets) {
Observable.unobserve(t, this.#observer);
}
this.#targets.clear();
}
}