Skip to content

Instantly share code, notes, and snippets.

@Minigugus
Last active July 31, 2021 18:53
Show Gist options
  • Save Minigugus/fdf80daa33cd03946a52494c3182db8f to your computer and use it in GitHub Desktop.
Save Minigugus/fdf80daa33cd03946a52494c3182db8f to your computer and use it in GitHub Desktop.
Lightweight (~2kB minified), performent and type checked reactive state management library inspired by Vue 3 supporting IE9.
// MIT License (C) Minigugus 2021
/**
* Handy reactive state management inspired by Vue 3.
*
* You might be insterested by this lib if:
* * Your project targets an old browser (this code should support IE9 without transform)
* * You like to Keep It Simple
* * You're a VanillaJS lover but also a declarative state management lover
* * You have to prototype/develop fast but:
* * Introducing a framework is risky
* * You prefer declarative over imperative state management (don't want jQuery anymore ?)
* * No framework is known by all members of your team and your team don't have time to learn a new one
* * Most of you website is static and you don't want to waste your time refactoring
* * You're bored of heavy, complex frameworks like Vue/React/Svelte (but still like proper reactive stage management)
*
*
* @example
* const counter = ref(0);
*
* document.body.appendChild(
* h('button', {
* onclick: () => counter.value++
* }, [
* counter
* ])
* )
*
* @see https://demo-minigugus.vercel.app
* @author Minigugus <https://github.com/Minigugus>
*/
!function () {
/** @type {Ref<any>[]} */
var pendingUpdate = [];
/** @type {Ref<any>[]} */
var otherPendingUpdate = [];
var updateTask = null;
function render() {
// console.clear();
// console.time('render');
var update = pendingUpdate;
pendingUpdate = otherPendingUpdate;
otherPendingUpdate = update;
updateTask = null;
// console.time('render.update');
for (var i = 0; i < update.length; i++) {
var ref = update[i];
ref._u = true;
ref._o = ref._v;
ref._v = ref._n;
ref._n = null;
var dyns = ref._ds;
for (var j = 0; j < dyns.length; j++) {
var dyn = dyns[j];
if (dyn._w && !dyn._u)
update.push(dyn);
}
}
// console.timeEnd('render.update');
// console.time('render.notify');
for (var i = 0; i < update.length; i++) {
var ref = update[i];
var o = ref._o;
ref._o = null;
var as = ref._as;
for (var j = 0; j < as.length; j++) {
var c = as[j];
// @ts-ignore
c.el[c.k] = ref.value;
}
var ts = ref._ts;
for (var j = 0; j < ts.length; j++)
ts[j].textContent = String(ref.value);
var ns = ref._ns;
for (var j = 0; j < ns.length; j++)
ns[j].replaceChild(ref.value, o);
var ws = ref._ws;
for (var j = 0; j < ws.length; j++)
ws[j](o);
}
// console.timeEnd('render.notify');
// console.time('render.cleanup');
update.splice(0, update.length);
// console.timeEnd('render.cleanup');
// console.timeEnd('render');
}
/**
* The {@link Ref} constructor.
*
* A {@link Ref} is a reference that notify watchers whenever its value changes.
* For performences reasons, notifications are batched together to avoid useless code update.
* The value returned by `.value` is always the last value assigned before the previous update
* (i.e. `var i = ref(0); i.value++; i.value++;` only increment the reference value by 1, not 2).
* The goal of this behavior is to prevent infinite update loops (they are still possible yet).
*
* This constructor is part of the public API in order to be able to use `instanceof` in user code.
* *Prefer {@link ref} to create instance of this class* (`ref(v)` is more user-friendly than `new Ref(v)`).
*
* @class
* @template T The type of the owned value
* @param {T} value The initial value of the reference
* @property {T} value The current value of this {@link Ref}.
* Any *assignment* to this field will cause watchers to be notified on the next update cycle, **even if the value hasn't changed at all**.
*/
function Ref(value) {
this._u = true;
this._w = 0;
/** @type {T | null} */
this._o = this._n = null;
/** @type {T} */
this._v = value;
/** @type {Dyn<any[], any>[]} */
this._ds = [];
/** @type {{ k: keyof HTMLElement, el: HTMLElement }[]} */
this._as = [];
/** @type {Text[]} */
this._ts = [];
/** @type {HTMLElement[]} */
this._ns = [];
/** @type {((oldValue: T) => void)[]} */
this._ws = [];
}
Object.defineProperty(Ref.prototype, 'value', {
configurable: false,
enumerable: true,
get: function getRefValue() {
return this._v;
},
set: function setRefValue(v)
if (this._w && this._u) {
this._n = v;
this._u = false;
pendingUpdate.push(this);
if (updateTask === null) {
var r = window.requestAnimationFrame;
updateTask = r
? r(render)
: setTimeout(render, 0);
}
} else {
this._v = v;
}
}
});
/**
* The {@link Dyn} constructor.
*
* A {@link Dyn} reference is a reference (a subclass of {@link Ref}) that changes whenever one or more
* references change.
* For performences reasons, **{@link Dyn} values are computed lazily**, only when the `value` field is accessed.
* This behavior is only visible by the user if the {@link compute} callback isn't *stateless*.
*
* This constructor is part of the public API in order to be able to use `instanceof` in user code.
* *Prefer {@link dyn} to create instance of this class* (`dyn([a], b)` is more user-friendly than `new Dyn([a], b)`).
*
* @class
* @template {Ref<any>[]} T Types of the watched references
* @template U The type of the computed value
* @param {T} refs References to watch (the references values will be passed in the same order to the {@link compute} callback as parameters)
* @param {(this: Dyn<T, U>, ...refs: { [i in keyof T]: T[i] extends Ref<infer R> ? R : never }) => U} compute
* The callback used to generate a new value from the updated one
*
* Prefer the callback's parameters over callback's scope to access reference values.
* Using callback's scope may cause some issues such as some change events not detected for instance.
* @property {T} value The current value of this {@link Ref} (this value read only because it's a {@link Dyn})
* @extends {Ref<U>} A {@link Dyn} is a read-only {@link Ref}
*/
function Dyn(refs, compute) {
var self = this;
Ref.call(this, compute.apply(this, refs.map(function (r) {
r._w++;
// @ts-expect-error
r._ds.push(self);
return r.value;
})));
this._u = false;
this._c = compute;
this._r = refs;
}
Object.defineProperty(Dyn.prototype = Object.create(Ref.prototype), 'value', {
configurable: false,
enumerable: true,
get: function getDynValue() {
if (this._u) {
this._u = false;
this._v = this._c.apply(this, this._r.map(function (r) {
return r.value;
}));
}
return this._v;
}
});
/**
* @template T The {@link Ref}'s type
* @callback Watcher A function that is invoked when a change event occurs on a {@link Ref}.
* @param {T} oldValue The previous value of the {@link Ref} been watched
* @returns {void}
*/
/**
* Subscribe to a {@link Ref}'s change event.
*
* The `listener` callback will be called whenever the `ref`'s value is updated.
* The parameter of the callback is the previous `ref` value.
* You can access the new value via the callback's scope (e.g. `ref.value`)
*
* You can unsubscribe by calling the function returned by a call to {@link watch}.
*
* @template T The value's type of the `ref` reference
* @param {Ref<T> | Dyn<any[], T>} ref The reference to watch
* @param {Watcher<T>} listener The listener to change events
* @returns A `unwatch` function that disabled `listener` from the `ref` li
*/
function watch(ref, listener) {
ref._w++;
ref._ws.push(listener);
return function unwatch() {
ref._w--;
ref._ws = ref._ws.filter(l => l !== listener);
};
}
/**
* A wrapper around `document.createElement` that use reactive properties of {@link Ref}s to automatically updates the DOM.
*
* In constrast to other famous frameworks, this render function returns *real* DOM nodes (not virtual one) and is therefore
* more expensive, and shouldn't be called repetidely whenever a value changes. Prefer {@link ref} and {@link dyn} to
* re-compute only values when they change instead of the whole Node.
*
* It is also important to that the **child Node list isn't reactive**. It is the user responsibility to manage dynamically
* inserted/removed Nodes, for instance with {@link Node.appendChild}, {@link Node.replaceChild} or {@link Node.removeChild}.
*
* @template {keyof HTMLElementTagNameMap} T
* @param {T} name
* @param {HTMLElementTagNameMap[T] extends infer R ? { [key in keyof R]?: R[key] | Ref<R[key]> } : never} attributes
* @param {(string | Element | Ref<any>)[]} children
* @returns {HTMLElementTagNameMap[T]}
*/
function h(name, attributes = {}, children = []) {
var el = document.createElement(name);
for (var key in attributes)
if (Object.prototype.hasOwnProperty.call(attributes, key)) {
var valueOrRef = attributes[key];
if (!(valueOrRef instanceof Ref))
el[key] = valueOrRef;
else {
el[key] = valueOrRef.value;
valueOrRef._w++;
// @ts-ignore
valueOrRef._as.push({ k: key, el: el });
}
}
for (var i = 0; i < children.length; i++) {
var childOrRef = children[i];
if (childOrRef == null) // == null <=> === null || === undefined
continue;
if (!(childOrRef instanceof Ref)) // string | Node
if (childOrRef instanceof Node) // Node
el.appendChild(childOrRef);
else // string
el.appendChild(document.createTextNode(String(childOrRef)));
else if (childOrRef.value instanceof Node) { // Ref<Node> (user should avoid mixing string & Node types...)
el.appendChild(childOrRef.value);
childOrRef._w++;
childOrRef._ns.push(el);
} else { // Ref<string>
var txt = document.createTextNode(childOrRef.value);
el.appendChild(txt);
childOrRef._w++;
childOrRef._ts.push(txt);
}
}
return el;
};
/**
* Creates a {@link Ref} that wraps a value. Should be used to store componants or application state's values
* that are displayed across the web page.
* @template T The type of the owned value
* @param {T} v The initial value of the reference
* @returns {Ref<T>} The newly created reference.
* @see Ref
*/
function ref(v) {
return new Ref(v);
}
/**
* Creates a {@link Dyn} (dynamic reference) whose value is computed from one or more other {@link Ref}s values.
* Intended to be used for reactive values with {@link h}.
* @template {Ref<any>[]} T Types of the watched references
* @template U The type of the computed value
* @param {T} refs References to watch (the references values will be passed in the same order to the {@link compute} callback as parameters)
* @param {(this: Dyn<T, U>, ...refs: { [i in keyof T]: T[i] extends Ref<infer R> ? R : never }) => U} compute
* The callback used to generate a new value from the updated one.
*
* NOTE: Prefer this callback's parameters over callback's scope to access reference values.
* Using callback's scope may cause some issues such as some change events not detected for instance.
* @returns {Dyn<T, U>} The newly created dynamic reference.
* @see Dyn
*/
function dyn(refs, compute) {
return new Dyn(refs, compute);
}
var w = window;
w.h = h;
w.ref = ref;
w.Ref = Ref;
w.dyn = dyn;
w.Dyn = Dyn;
w.watch = watch;
}();
/**
* @param {TemplateStringsArray} xs
* @param {...any} args
* @returns {Dyn<any[], string>}
*/
const txt = (xs, ...args) => {
const refs = args.filter(x => x instanceof Ref);
return dyn(refs, (...refs) =>
xs.slice(1).reduce((arr, x, i) => {
const v = args[i];
return arr + (v instanceof Ref ? refs.pop() : v) + x;
}, xs[0])
);
};
// example
(() => {
const counter = ref(0);
const input = ref('');
const tab1 = h('p', {}, [
txt`Tab 1: counter = ${counter}`,
]);
const tab2 = h('p', {
className: dyn([counter], counter => counter % 2 ? 'red' : '')
}, [
txt`Tab 2: input = ${input}`,
]);
const tab = ref(tab1);
const el = h('div', {}, [
h('p', {}, [dyn([counter, input], (counter, input) => 'counter = ' + counter + ', input = ' + input)]),
h('input', { onkeyup() { input.value = this.value; } }, []),
h('button', { onclick: () => (counter.value += 1) }, ['Increment']),
h('hr'),
h('button', {
disabled: dyn([tab], tab => tab === tab1),
onclick: () => (tab.value = tab1)
}, ['Go to tab 1']),
h('button', {
disabled: dyn([tab], tab => tab === tab2),
onclick: () => (tab.value = tab2)
}, ['Go to tab 2']),
tab
]);
watch(counter, oldValue => console.log('counter: ' + oldValue + ' -> ' + counter.value));
document.body.appendChild(el);
})();
/**
* @param {string} name
* @param {(self: HTMLElement) => Node} setup
* @returns
*/
const component = (name, setup) => {
const cpnt = class extends HTMLElement {
constructor() {
super();
this.attachShadow({ mode: 'closed' }).appendChild(setup(this))
}
}
customElements.define(name, cpnt);
return cpnt;
};
const Counter = component('my-counter', () => {
const counter = ref(0);
return h('div', {}, [
h('span', {}, [dyn([counter], c => 'counter = ' + c)]),
h('button', { onclick: () => (counter.value++) }, ['+']),
h('button', { onclick: () => (counter.value--) }, ['-']),
])
});
document.body.appendChild(document.createElement('my-counter'));
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Demo</title>
<style>
.red {
color: red;
}
.green {
color: green;
}
</style>
</head>
<body>
<script src="./index.js"></script>
<script>
/** Count the number of clicks on the `Increment` button */
const counter = ref(0);
/** The content of the input box */
const input = ref('');
/** Tab 1 color (green when `input.length` even, red when odd) */
const tab1Color = dyn([input], input => input.length % 2 ? 'red' : 'green');
/** Tab 2 color (green when `counter` even, red when odd) */
const tab2Color = dyn([counter], counter => counter % 2 ? 'red' : 'green');
/** Last update timestamp of either `counter` ou `input` */
const lastUpdate = dyn([counter, input], () => Date.now());
// Show a log in the console every time the counter changes
// TIP: feel free to cheat... (enter `counter.value = 99999` in dev tools) :)
watch(counter, oldValue => console.log('counter: ' + oldValue + ' -> ' + counter.value));
/** Tab 1 DOM */
const tab1 = h('p', { className: tab1Color }, ['Tab 1: counter = ', counter]);
/** Tab 2 DOM */
const tab2 = h('p', { className: tab2Color }, ['Tab 2: input = ', input]);
/** A reference to the displayed tab */
const tab = ref(tab1);
const gist = 'https://gist.github.com/Minigugus/fdf80daa33cd03946a52494c3182db8f';
document.body.appendChild(h('div', {}, [
'Source code and library available on ',
h('a', { href: gist }, ['Github']),
'.',
h('hr'),
'last updated at: ', lastUpdate,
h('hr'),
h('p', {}, ['counter = ', counter, ', input = ', input]),
h('input', {
placeholder: 'Enter something here...',
onkeyup() { input.value = this.value; }
}, []),
h('button', { onclick: () => (counter.value += 1) }, ['Increment']),
h('hr'),
h('button', {
disabled: dyn([tab], tab => tab === tab1), // disabled when tab1 is visible
onclick: () => (tab.value = tab1)
}, ['Go to tab 1']),
h('button', {
disabled: dyn([tab], tab => tab === tab2), // disabled when tab2 is visible
onclick: () => (tab.value = tab2)
}, ['Go to tab 2']),
tab, // like a `v-if` with Vue
h('hr'),
]));
</script>
<script>
/**
* Wrapper around `customElements.define`
* @param {string} name
* @param {(self: HTMLElement) => Node} setup
* @returns
*/
const component = (name, setup) => {
const cpnt = class extends HTMLElement {
constructor() {
super();
this.attachShadow({ mode: 'closed' }).appendChild(setup(this))
}
}
customElements.define(name, cpnt);
return cpnt;
};
const Counter = component('my-counter', () => {
const counter = ref(0);
return h('div', {}, [
h('p', {}, ['Your browser supports web components:']),
h('span', {}, ['counter = ', counter]),
h('button', { onclick: () => (counter.value++) }, ['+']),
h('button', { onclick: () => (counter.value--) }, ['-']),
])
});
document.body.appendChild(document.createElement('my-counter'));
</script>
</body>
</html>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment