Last active
July 31, 2021 18:53
-
-
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.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// 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; | |
}(); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
/** | |
* @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); | |
})(); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
/** | |
* @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')); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
<!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