Created
February 28, 2021 08:58
-
-
Save sma/f3fd89105aed21244638103951420ae3 to your computer and use it in GitHub Desktop.
a proof of concept micro web framework inspired by an older version of Jorge Bucaran's hyperapp
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
import 'dart:async'; | |
import 'dart:html'; | |
/// [H] is what you'd call a virtual DOM node, representing HTML elements. | |
/// | |
/// You call it with a tag name that optionally contains one or more class | |
/// names prefixed by `.` and/or an ID prefixed by `#`. If you pass a `Map`, | |
/// it is used as properties. If you pass other objects, they are used as | |
/// children. `List` objects are flattened, `null` values are ignored, other | |
/// [H] instances are taken literally, everything else is converted into | |
/// strings. Property maps must contain `null`, strings, or [EventListener]. | |
/// Other values are converted into strings. | |
/// | |
/// Examples: | |
/// | |
/// H('br') | |
/// H('img', {'src': 'http://...', 'alt': '...'}) | |
/// H('p', 'some ', H('b', 'bold'), ' text') | |
/// H('a.external', {'href': 'http://...'}, [H('pre', 'code')]) | |
class H { | |
factory H( | |
String tag, [ | |
dynamic argument1, | |
dynamic argument2, | |
dynamic argument3, | |
]) { | |
final props = <String, Object?>{}; | |
final children = <H>[]; | |
var name = 'div'; | |
for (final match in _re.allMatches(tag)) { | |
final p = match[1]; | |
final n = match[2]!; | |
if (p == '.') { | |
var c = props['class']; | |
props['class'] = c != null ? '$c $n' : n; | |
} else if (p == '#') { | |
props['id'] = n; | |
} else { | |
name = n; | |
} | |
} | |
void compile(dynamic argument) { | |
if (argument == null) return; | |
if (argument is H) { | |
children.add(argument); | |
} else if (argument is String) { | |
if (argument.isNotEmpty) children.add(H.text(argument)); | |
} else if (argument is Map<String, dynamic>) { | |
for (final kv in argument.entries) { | |
final v = kv.value; | |
props[kv.key] = v == null || v is String || v is EventListener ? v : v.toString(); | |
} | |
} else if (argument is Iterable) { | |
argument.forEach(compile); | |
} else { | |
children.add(H.text('$argument')); | |
} | |
} | |
compile(argument1); | |
compile(argument2); | |
compile(argument3); | |
return H._( | |
name, | |
props.isEmpty ? const {} : props, | |
children.isEmpty ? const [] : children, | |
null, | |
); | |
} | |
factory H.text(String text) => H._('', const {}, const [], text); | |
H._(this.tag, this.props, this.children, this.text); | |
final String tag; | |
final Map<String, Object?> props; // Map<String, Null|String|EventListener> | |
final List<H> children; | |
final String? text; | |
bool get isText => text != null; | |
static final _re = RegExp(r'([.#])?([-\w]+)'); | |
/// Returns a new HTML node for the receiver. | |
/// | |
/// For [H.text] an HTML [Text] is created. | |
/// Otherwise an HTML [Element] is created. | |
/// | |
/// Some properties are treated special. An `id` property is assigned as | |
/// [Element.id]. A `class` property is assigned as [Element.className]. | |
/// A `style` property is assigned as [Element.style]. All property values | |
/// must be strings. Property names that begin with `on` must contain | |
/// [EventListener] functions which are then added as listeners. A `key` | |
/// property is used to uniquely identify list items and is stored as | |
/// element data. Everything else is set as [Element.attributes] with the | |
/// exception of `value` for `input` elements, which is assigned directly | |
/// because otherwise it seems not to work. | |
/// | |
/// Perhaps I need to distinguish more element properties from element attribute. | |
/// | |
Node create() { | |
if (isText) return Text(text!); | |
final element = document.createElement(tag); | |
_modifyProperties(element, props, const {}); | |
for (final child in children) { | |
element.append(child.create()); | |
} | |
return element; | |
} | |
/// Assuming that [parent] has child nodes that match [oldc], those nodes | |
/// are modified in such a way that they match [newc] instead. Then [newc] | |
/// is returned. | |
/// | |
/// This is a very simplistic algorithm which should be improved. | |
static List<H> patchNodes(Node parent, List<H> newc, List<H> oldc) { | |
final newl = newc.length; | |
final oldl = oldc.length; | |
final nodes = parent.childNodes; | |
assert(nodes.length == oldl); | |
for (var i = 0; i < newl; i++) { | |
if (i < oldl) { | |
_patch(nodes[i], newc[i], oldc[i]); | |
} else { | |
parent.append(newc[i].create()); | |
} | |
} | |
for (var i = newl; i < oldl; i++) { | |
nodes[i].remove(); | |
} | |
return newc; | |
} | |
/// Assuming that [node] matches [oldh], it is either modified in such a way | |
/// that it matches [newh] or replaced with a new node created from [newh]. | |
/// | |
/// To modify [node], both [newh] and [oldh] must be either represent text or | |
/// normal elements. In case of text, the [Node.text] is changed. In case of | |
/// elements, the tag name must be equal. Then properties that exist only in | |
/// [newh] are added, properties that exist both in [newh] and [oldh] are | |
/// changed and properties that exist only in [oldh] are removed. Last but | |
/// not least, [patchChilden] is called for the children. | |
static void _patch(Node node, H newh, H oldh) { | |
if (newh.isText && oldh.isText) { | |
if (newh.text != oldh.text) node.text = newh.text; | |
} else if (!newh.isText && !oldh.isText && newh.tag == oldh.tag) { | |
final element = node as Element; | |
_modifyProperties(element, newh.props, oldh.props); | |
_removeProperties(element, newh.props, oldh.props); | |
patchNodes(element, newh.children, oldh.children); | |
} else { | |
node.replaceWith(newh.create()); | |
} | |
} | |
static void _modifyProperties( | |
Element element, | |
Map<String, Object?> newprops, | |
Map<String, Object?> oldprops, | |
) { | |
for (final kv in newprops.entries) { | |
final k = kv.key; | |
final v = kv.value; | |
final ov = oldprops[k]; | |
assert(v == null || v is String || v is EventListener); | |
assert(ov == null || ov is String || ov is EventListener); | |
if (k == 'id') { | |
if (v != ov) element.id = v as String? ?? ''; | |
} else if (k == 'class') { | |
if (v != ov) element.className = v as String? ?? ''; | |
} else if (k == 'style') { | |
if (v != ov) element.style.cssText = v as String? ?? ''; | |
} else if (k == 'key') { | |
if (v != ov) { | |
if (v == null) { | |
element.dataset.remove('key'); | |
} else { | |
element.dataset['key'] = v as String; | |
} | |
} | |
} else if (k.startsWith('on')) { | |
if (v != ov) { | |
if (ov != null) element.removeEventListener(k.substring(2), ov as EventListener); | |
if (v != null) element.addEventListener(k.substring(2), v as EventListener); | |
} | |
} else { | |
if (v != ov) { | |
if (element is InputElement && k == 'value') { | |
element.value = v as String? ?? ''; | |
} else { | |
if (v == null) { | |
element.removeAttribute(k); | |
} else { | |
element.setAttribute(k, v.toString()); | |
} | |
} | |
} | |
} | |
} | |
} | |
static void _removeProperties( | |
Element element, | |
Map<String, Object?> newprops, | |
Map<String, Object?> oldprops, | |
) { | |
for (final k in oldprops.keys) { | |
if (!newprops.containsKey(k)) { | |
if (k == 'id') { | |
element.id = ''; | |
} else if (k == 'class') { | |
element.className = ''; | |
} else if (k == 'style') { | |
element.style.cssText = ''; | |
} else if (k == 'key') { | |
element.dataset.remove('key'); | |
} else if (k.startsWith('on')) { | |
element.removeEventListener(k.substring(2), oldprops[k] as EventListener); | |
} else { | |
if (element is InputElement && k == 'value') { | |
element.value = ''; | |
} else { | |
element.removeAttribute(k); | |
} | |
} | |
} | |
} | |
} | |
} | |
// --------------------------------------------------------------------------- | |
/// An operation that modifies a model [M]. | |
typedef Update<M> = M Function(M); | |
/// An application is rendered as the first child of [el]. | |
/// Its initial model is [model] of type [M]. | |
/// This is then modified using functions stored in [update]. | |
/// These functions must take one argument and return an update. | |
/// They are automagically wrapped in such a way that calling | |
/// them will modify the internal state and rerender the view. | |
/// [view] is a function that takes the current state and a | |
/// source of all those magically wrapped functions. | |
void app<M>({ | |
required M model, | |
required Map<Symbol, Update<M> Function(dynamic)> update, | |
required H Function(M, dynamic) view, | |
String? el, | |
}) { | |
// determine the application's root element which must exist | |
final root = (el == null ? document.body : document.getElementById(el))!; | |
while (root.hasChildNodes()) { | |
root.lastChild!.remove(); | |
} | |
// initialize the current state | |
var state = model; | |
// provides a reference to the render function which must be set | |
// later because of the mutual recursive nature of this setup | |
late void Function() doRender; | |
// modifies the current state, then re-renders the UI | |
void apply(Update<M> update) { | |
state = update(state); | |
Timer.run(doRender); | |
} | |
// previous list if root nodes (either 0 or 1) | |
var oldc = <H>[]; | |
// renders the UI in the most effective way by comparing the new | |
// virtual dom to the previous version and then modifying the real | |
// dom accordingly. | |
void render() { | |
oldc = H.patchNodes(root, [view(state, _Msg(apply, update))], oldc); | |
} | |
// provide the reference, see above | |
doRender = render; | |
// let's start the fun | |
render(); | |
} | |
class _Msg<M> { | |
_Msg(this.apply, this.update); | |
final void Function(Update<M>) apply; | |
final Map<Symbol, Update<M> Function(dynamic)> update; | |
@override | |
EventListener noSuchMethod(Invocation invocation) { | |
if (invocation.isGetter) { | |
final u = update[invocation.memberName]; | |
if (u != null) { | |
return (event) => apply(u(event)); | |
} | |
} | |
if (invocation.isMethod) { | |
final u = update[invocation.memberName]; | |
if (u != null) { | |
final u2 = u(invocation.positionalArguments.first); | |
return (event) => apply(u2); | |
} | |
} | |
return super.noSuchMethod(invocation); | |
} | |
} | |
void main() { | |
app<int>( | |
model: 0, | |
update: { | |
#add: (_) => (model) => model + 1, | |
#sub: (_) => (model) => model - 1, | |
}, | |
view: (model, msg) => H('div', [ | |
H('button', {'onclick': msg.add}, '+'), | |
H('h1', model), | |
H('button', {'onclick': msg.sub}, '-'), | |
]), | |
el: 'app', | |
); | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment