zliq
Version:
slim and quick framework in low loc
345 lines (314 loc) • 9.6 kB
JavaScript
import deepEqual from "deep-equal";
/*
* stream constructor
* constructor returns a stream
* get the current value of stream like: stream.value
*/
export const stream = function(init_value) {
let s = function(value) {
update(s, value);
return s;
};
s.IS_STREAM = true;
s.value = init_value;
s.listeners = [];
// better debugging output for streams
s.toString = () => "Stream(" + s.value + ")";
s.map = fn => map(s, fn);
s.is = value => map(s, cur => cur === value);
s.flatMap = fn => flatMap(s, fn);
s.filter = fn => filter(s, fn);
s.deepSelect = fn => deepSelect(s, fn);
s.distinct = fn => distinct(s, fn);
s.query = selectorArr => query(s, selectorArr);
s.$ = selectorArr => query(s, selectorArr).distinct();
s.until = stopEmit$ => until(s, stopEmit$);
s.patch = partialChange => patch(s, partialChange);
s.reduce = (fn, startValue) => reduce(s, fn, startValue);
s.debounce = timer => debounce(s, timer);
s.schedule = (scheduleItems, onDone) => schedule(s, scheduleItems, onDone);
s.next = () => next(s);
s.log = (prefix = "Stream:") => log(s, prefix);
return s;
};
/*
* wrapper for the diffing of stream values
*/
function valuesChanged(oldValue, newValue) {
return !deepEqual(oldValue, newValue);
}
/*
* update the stream value and notify listeners on the stream
*/
function update(parent$, newValue) {
parent$.value = newValue;
notifyListeners(parent$.listeners, newValue);
}
/*
* provide a new value to all listeners registered for a stream
*/
function notifyListeners(listeners, value) {
listeners.forEach(function notifyListener(listener) {
listener(value);
});
}
/*
* Do not pipe the value undefined. This allows to wait for an external initialization.
* It also saves you from checking for an initial null on every map function.
*/
function fork$(parent$, mapFunction) {
let initValue =
parent$.value !== undefined ? mapFunction(parent$.value) : undefined;
return stream(initValue);
}
/*
* provides a new stream applying a transformation function to the value of a parent stream
*/
function map(parent$, fn) {
let newStream = fork$(parent$, fn);
parent$.listeners.push(function mapValue(value) {
newStream(fn(value));
});
return newStream;
}
/*
* helper function to debug, calls console.log on every value returnin the parent stream
*/
function log(parent$, prefix) {
map(parent$, value => console.log(prefix, value));
return parent$;
}
/*
* provides a new stream applying a transformation function to the value of a parent stream
*/
function flatMap(parent$, fn) {
let result$;
let listener = function updateOuterStream(result) {
newStream(result);
};
function attachToResult$(mapFn, parentValue, listener) {
let result$ = mapFn(parentValue);
result$.listeners.push(listener);
return result$;
}
let newStream = fork$(parent$, function getChildStreamValue(value) {
result$ = attachToResult$(fn, value, listener);
return result$.value;
});
parent$.listeners.push(function flatMapValue(value) {
// clean up listeners or they will stack on child streams
if (result$) {
removeItem(result$.listeners, listener);
}
result$ = attachToResult$(fn, value, listener);
newStream(result$.value);
});
return newStream;
}
/*
* provides a new stream that only serves the values that a filter function returns true for
* still a stream ALWAYS has a value -> so it starts at least with NULL
*/
function filter(parent$, fn) {
let newStream = fork$(parent$, value => (fn(value) ? value : undefined));
parent$.listeners.push(function filterValue(value) {
if (fn(value)) {
newStream(value);
}
});
return newStream;
}
/*
* recursivly return the nested property of an object defined by an array of selectors
* parent: {foo: {bar:1}}, selectors: ['foo','bar'] returns 1
*/
function select(parent, selectors) {
if (parent === null || parent === undefined) {
return null;
}
if (selectors.length === 0) {
return parent;
}
let selector = selectors[0];
return select(parent[selector], selectors.splice(1, selectors.length - 1));
}
/*
* provides a new stream that has a selected sub property of the object value of the parent stream
* the selector has the format [{propertyName}.]*
*/
function deepSelect(parent$, selector) {
let selectors = selector.split(".");
let newStream = fork$(parent$, value => select(value, selectors));
parent$.listeners.push(function deepSelectValue(newValue) {
newStream(select(newValue, selectors));
});
return newStream;
}
function query(parent$, selectorsArr) {
if (!Array.isArray(selectorsArr)) {
return parent$.map(value => select(value, selectorsArr.split(".")));
}
return parent$.map(value =>
selectorsArr.map(selectors => select(value, selectors.split(".")))
);
}
// TODO: maybe refactor with filter
/*
* provide a new stream that only notifys its children if the containing value actualy changes
*/
function distinct(parent$, fn = (a, b) => valuesChanged(a, b)) {
let newStream = fork$(parent$, value => value);
parent$.listeners.push(function deepSelectValue(value) {
if (fn(newStream.value, value)) {
newStream(value);
}
});
return newStream;
}
/*
* update only the properties of an object passed
* i.e. {name: 'Fabian', lastname: 'Weber} patched with {name: 'Fabo'} produces {name: 'Fabo', lastname: 'Weber}
*/
function patch(parent$, partialChange) {
setImmediate(() => {
if (
partialChange === null ||
typeof partialChange !== "object" ||
typeof parent$.value !== "object"
) {
parent$(partialChange);
} else {
parent$(Object.assign({}, parent$.value, partialChange));
}
});
return parent$;
}
function until(parent$, stopEmitValues$) {
let newStream = stream();
let subscribeTo = (stream, listener) => {
listener(parent$.value);
stream.listeners.push(listener);
};
if (stopEmitValues$.value === undefined) {
subscribeTo(parent$, newStream);
}
stopEmitValues$.map(stopEmitValues => {
if (stopEmitValues) {
removeItem(parent$.listeners, newStream);
} else {
subscribeTo(parent$, newStream);
}
});
return newStream;
}
/*
* reduce a stream over time
* this will pass the last output value to the calculation function
* reads like the array reduce function
*/
function reduce(parent$, fn, startValue) {
let aggregate = startValue;
let newStream = stream();
function reduceValue(value) {
aggregate = fn(aggregate, parent$.value);
newStream(aggregate);
}
if (parent$.value !== undefined) {
reduceValue(parent$.value);
}
parent$.listeners.push(reduceValue);
return newStream;
}
function debounce(parent$, timer) {
let curTimer;
function debounceValue(value) {
if (curTimer) {
window.clearTimeout(curTimer);
}
curTimer = setTimeout(function updateChildStream() {
newStream(value);
curTimer = null;
}, timer);
}
let newStream = stream();
if (parent$.value !== undefined) {
debounceValue(parent$.value);
}
parent$.listeners.push(debounceValue);
return newStream;
}
/*
* Execute the scheduled function or return the scheduled value for a stream iteration
*/
function executeScheduleItem(schedule, iteration, value) {
if (schedule.length < iteration + 1) {
throw Error("ZLIQ: schedule for iteration " + iteration + " not defined");
}
let item = schedule[iteration];
if (typeof item === "function") {
return item(value);
} else {
return item;
}
}
/*
* Especially in tests you want to define a reaction to a certain iteration of a stream
* The iteration can be a function or a value
*/
function schedule(parent$, schedule, onDone = () => {}) {
let iteration = 0;
let newStream = fork$(parent$, value =>
executeScheduleItem(schedule, iteration++, value)
);
if (schedule.length === iteration) onDone();
parent$.listeners.push(function checkSchedule(value) {
// do immediate to prevent schedule items to update parent streams before child streams ran
setImmediate(async () => {
newStream(await executeScheduleItem(schedule, iteration++, value));
if (iteration === schedule.length) onDone();
});
});
return newStream;
}
/*
* allow to use a stream in an async await cycle
* i.e: const value = await myStream.next()
*/
function next(parent$) {
let resolve;
const promise = new Promise(_resolve => {
resolve = _resolve;
});
subscribeTo(parent$, resolve);
promise.then(() => removeItem(parent$.listeners, resolve));
return promise;
}
/*
* merge several streams into one stream providing the values of all streams as an array
* accepts also non stream elements, those are just copied to the output
* the merge will only have a value if every stream for the merge has a value
*/
export function merge$(potentialStreamsArr) {
let values = potentialStreamsArr.map(
parent$ => (parent$ && parent$.IS_STREAM ? parent$.value : parent$)
);
let newStream = stream(values.indexOf(undefined) === -1 ? values : undefined);
potentialStreamsArr.forEach((potentialStream, index) => {
if (potentialStream.IS_STREAM) {
potentialStream.listeners.push(function updateMergedStream(value) {
values[index] = value;
newStream(values.indexOf(undefined) === -1 ? values : undefined);
});
}
});
return newStream;
}
export function isStream(parent$) {
return parent$ != null && !!parent$.IS_STREAM;
}
function removeItem(arr, item) {
var index = arr.indexOf(item);
if (index !== -1) {
arr.splice(index, 1);
}
}