Last active
April 26, 2024 13:00
-
-
Save culas/96664bda9249a98f36c26bc79c1c2f62 to your computer and use it in GitHub Desktop.
Homemade Signals and a Signal-based framework
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"> | |
<title>Basic Homemade Signals: Implementation</title> | |
<style> | |
html { | |
font-family: sans-serif; | |
} | |
</style> | |
</head> | |
<body> | |
<div id="app"></div> | |
</body> | |
<script> | |
/* | |
* IMPLEMENTATION | |
*/ | |
// global variable to store the current caller | |
let caller; | |
// simple implementation without cleanups and checks for infinite loops | |
function signal(initialValue) { | |
let _value = initialValue; | |
const observers = []; | |
return { | |
get value() { | |
if (caller && !observers.includes(caller)) { | |
observers.push(caller); | |
} | |
return _value; | |
}, | |
set value(newValue) { | |
if (newValue !== _value) { | |
_value = newValue; | |
observers.forEach(fn => fn()); | |
} | |
} | |
} | |
} | |
function effect(fn) { | |
caller = fn; | |
fn(); | |
caller = undefined; | |
} | |
function computed(computation) { | |
return { | |
get value() { | |
return computation(); | |
} | |
} | |
} | |
// inspired by: https://www.thisdot.co/blog/deep-dive-into-how-signals-work-in-solidjs | |
</script> | |
<script> | |
/* | |
* USAGE | |
*/ | |
const counter = signal(0); | |
const double = computed(() => counter.value * 2); | |
const button = document.createElement('button'); | |
const paragraph = document.createElement('p'); | |
document.body.append(button, paragraph); | |
button.addEventListener('click', () => counter.value++); | |
// these effects are hidden by the framework, | |
// but it's essentially what happens with signals in a component template | |
const updateButton = () => button.innerText = counter.value; | |
effect(updateButton); | |
effect(() => paragraph.innerText = double.value); | |
</script> | |
</html> |
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"> | |
<title>Basic Homemade Signals: Signal-based Framework</title> | |
<style> | |
html { | |
font-family: sans-serif; | |
} | |
</style> | |
</head> | |
<body> | |
<div id="app"></div> | |
</body> | |
<script type="module"> | |
import {signal, effect, computed} from 'https://cdn.jsdelivr.net/npm/@preact/signals-core@1.5.1/+esm'; | |
/* | |
* FRAMEWORK | |
*/ | |
class BaseElement { | |
/** @type {HTMLElement} */ | |
#element; | |
constructor(tag_name) { | |
this.#element = document.createElement(tag_name); | |
} | |
children(...elements) { | |
elements.forEach(element => this.#element.append(element)); | |
} | |
get element() { | |
return this.#element; | |
} | |
} | |
// to specify the root element, based on the islands architecture concept | |
const Island = (id) => { | |
const root = document.getElementById(id); | |
return (elements) => { | |
root.append(elements); | |
}; | |
}; | |
// creates an HTML element | |
// returns a function to set attributes, which then returns one to pass child elements | |
function getElement(tag_name) { | |
return (attributes = {}) => { | |
const el = new BaseElement(tag_name); | |
Object.entries(attributes).forEach( | |
([name, value]) => { | |
if (name.startsWith('on')) { | |
el.element.addEventListener(name.slice(2), value); | |
} else { | |
el.element.setAttribute(name, value); | |
} | |
}, | |
); | |
return (...children) => { | |
children.forEach(child => { | |
if (child.brand?.description === 'preact-signals') { | |
if (Array.isArray(child.value)) { | |
effect(() => el.element.replaceChildren(...child.value)); | |
} else { | |
const text_node = document.createTextNode(''); | |
effect(() => text_node.data = child.value); | |
el.children(text_node); | |
} | |
} else { | |
el.children(child); | |
} | |
}); | |
return el.element; | |
}; | |
}; | |
} | |
// helper function to simplify handling lists | |
const each = (array_signal, mapper) => { | |
return computed(() => array_signal.value.map(mapper)); | |
}; | |
// simulate module export | |
window.SignalBasedFramework = { | |
Island, | |
Button: getElement('button'), | |
Input: getElement('input'), | |
Div: getElement('div'), | |
P: getElement('p'), | |
Span: getElement('span'), | |
Section: getElement('section'), | |
Ul: getElement('ul'), | |
Li: getElement('li'), | |
each, | |
signal, | |
computed, | |
}; | |
</script> | |
<script type="module"> | |
// simulated import from framework module | |
const {Island, Button, Input, Div, P, Span, Section, Ul, Li, each, signal, computed} = window.SignalBasedFramework; | |
/* | |
* APP | |
*/ | |
// global signals | |
const counter = signal(0); | |
const double = computed(() => counter.value * 2); | |
const name = signal(''); | |
// we can easily create custom components with their own local state | |
function CounterButton() { | |
const myCount = signal(0); | |
const onclick = () => myCount.value++; | |
return Button({onclick})( | |
'You pressed me ', myCount, ' times', | |
); | |
} | |
function TodoItem({text, onDelete}) { | |
return Li()( | |
Div({style: 'display: flex; gap: 1rem;'})( | |
Span()(text), | |
Button({onclick: onDelete})( | |
'X', | |
), | |
), | |
); | |
} | |
function TodoApp() { | |
const todos = signal([]); | |
const currentTodo = signal(); | |
function onSubmit() { | |
return todos.value = [...todos.value, currentTodo.value]; | |
} | |
function onDelete(index) { | |
todos.value = todos.value.toSpliced(index, 1); | |
} | |
return Section()( | |
P()('Proof that you can build a Todo app:'), | |
Input({ | |
type: 'text', | |
onchange: (event) => currentTodo.value = event.target.value, | |
})(), | |
Button({onclick: () => onSubmit()})( | |
'Add todo', | |
), | |
Ul()( | |
each(todos, (todo, i) => TodoItem({text: todo, onDelete: () => onDelete(i)})), | |
), | |
); | |
} | |
// here we hook up our app by specifying the element with ID "app" to be our island root | |
// inside, we can nest predefined and custom components to declaratively define our interactive UI | |
Island('app')( | |
Div()( | |
P()( | |
'This button is has its own reactive counter:', | |
), | |
CounterButton(), | |
P()( | |
'This one manipulates global state:', | |
), | |
Button({onclick: () => counter.value++})( | |
'Press me to do some maths!', | |
), | |
P()( | |
'Counter: ', counter, ' * 2 = ', double, | |
), | |
P()( | |
'Here we have just a regular old number input field:', | |
), | |
Input({type: 'number', value: 42})(), | |
P()( | |
'Last but not least: a reactive input field:', | |
), | |
Section()( | |
Input({ | |
type: 'text', | |
placeholder: 'Enter your name', | |
oninput: (event) => name.value = event.target.value | |
})(), | |
Span()('Hello ', name, '!'), | |
), | |
TodoApp(), | |
), | |
); | |
</script> | |
</html> |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment