Skip to content

Instantly share code, notes, and snippets.

@sma
Created February 28, 2021 08:58
Show Gist options
  • Save sma/f3fd89105aed21244638103951420ae3 to your computer and use it in GitHub Desktop.
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
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