I've been experimenting with wiring up a vanilla web component in a reactive manner to a state box. The goal is that when something changes on the state object, the web component reacts. I realize that there are frameworks and libraries for this, but I want to understand fundamentally what goes on underneath. After two decades on server stuff, I want to branch out. I also wonder if we really need more dependencies if this is simple and straight-forward enough.
My approach is this:
- A base class that extends
HTMLElement
that can declaratively (via attribute in the markup) specify what data to watch. - A state base class that is used to facilitate subscription to changes and notifications back to the subscribers.
- An attribute to decorate properties that you want to watch on those classes derived from the state base class.
First, the state base class:
class StateBase {
constructor() {
this._subs = new Map<string, Array<Function>>();
}
private _subs: Map<string, Array<Function>>;
subscribe(propertyName: string, eventHandler: any) {
if (!this._subs.has(propertyName))
this._subs.set(propertyName, new Array<Function>());
var callbacks = this._subs.get(propertyName);
callbacks.push(eventHandler);
}
notify(propertyName: string) {
var callbacks = this._subs.get(propertyName);
if (callbacks)
for (let i of callbacks) {
i();
}
}
}
The subscribe
method is called by the web component base with the name of the property to monitor and a reference to its update mechanism. Of course, you could arbitrarily subscribe to any method on an instance. I'm using a Map
as a dictionary of callback method arrays, one item for each property. notify
is called by the decorator when a property value changes. Instances of this class have to be in the global (window
) scope so the web components can find it.
Here's the attribute/decorator function:
const WatchProperty = (target: any, memberName: string) => {
let currentValue: any = target[memberName];
Object.defineProperty(target, memberName, {
set(this: any, newValue: any) {
console.log("watchProperty called on " + memberName + " with value " + newValue);
currentValue = newValue;
this.notify(memberName);
},
get() {return currentValue;}
});
};
The part I don't understand well, but learned about on SO, is Object.defineProperty
taking the target, which is a prototype, to get to the actual instance of the state box. It works though. The important part is that it calls notify
on the aforementioned base state class.
Here's the web component base:
abstract class ElementBase extends HTMLElement {
// Derived class constructor must call super("IDofTemplateHTML") first.
constructor(templateID: string) {
super();
this.attachShadow({ mode: 'open' });
var el = document.getElementById(templateID) as HTMLTemplateElement;
if (!el)
throw Error(`No template found for ID '{templateID}'. Must pass the ID of the template in constructor to base class, like super('myID');`)
const template = el.content;
this.shadowRoot.appendChild(template.cloneNode(true));
}
connectedCallback() {
var attr = this.getAttribute('caller');
if (!attr)
throw Error("There is no 'caller' attribute on the component.");
var varAndProp = this.parseCallerString(attr);
var state = window[varAndProp[0]];
var delegate = this.update.bind(this);
state.subscribe(varAndProp[1], delegate);
}
update() {
console.log("update called start");
var attr = this.getAttribute('caller');
if (!attr)
throw Error("There is no 'caller' attribute on the component.");
var varAndProp = this.parseCallerString(attr);
var externalValue = window[varAndProp[0]][varAndProp[1]];
this.updateUI(externalValue);
console.log("update called end - " + externalValue);
}
private parseCallerString(caller: string): [string, string] {
var segments = caller.split(".");
if (segments.length != 2)
throw Error("caller attribute must follow 'globalVariable.property' format.");
return [segments[0], segments[1]];
}
// Use this.shadowRoot in the implementation to manipulate the DOM as needed in response to the new data.
abstract updateUI(data: any);
}
Importantly, your constructor has to take in the ID of the template. I imagine I could also include the template in the derived class, but it depends on how you like to organize and encapsulate things. I'd rather have the markup not in the code. The constructor sets up the shadowRoot
from the template. connectedCallback
takes the declarative caller
attribute on the component instance and uses it to register the update
method with the base state class. update
again parses the caller
attribute to find the state and its property value. Finally it calls the abstract updateUI
method that the derived web component has to implement.
OK, so here are a concrete implementation examples deriving from the base classes:
class TestState extends StateBase {
constructor() {
super();
this.texty = "";
this.numbery = 0;
}
@WatchProperty
texty: string;
@WatchProperty
numbery: number;
}
class TestElement extends ElementBase {
constructor() {
super("pf-test");
}
updateUI(data) {
this.shadowRoot.querySelector("h1").innerHTML = data;
}
}
And here's a page where it all comes together:
<!DOCTYPE html>
<html>
<head>
<title>TypeScript Hello Web</title>
</head>
<body>
<script src="dist/test.js"></script> <!-- has all of the above stuff -->
<script>
var state = new TestState();
customElements.define('pf-test', TestElement);
document.addEventListener("DOMContentLoaded", () => {
state.subscribe("texty", () => {
console.log("Call me for texty!");
});
state.subscribe("numbery", () => {
console.log("anon call for numbery");
});
state.texty = "first change";
state.numbery = 42;
state.texty = "second change!";
state.numbery = 123;
});
</script>
<template id="pf-test">
<div>
<h1>original</h1>
</div>
</template>
<pf-test caller="state.texty">
</pf-test>
<pf-test caller="state.numbery">
</pf-test>
</body>
</html>
As you can guess, when the page is all done, there's a line that says "second change!" and another that says "123."
I'm not sure what specific feedback I seek, because I've spent so little time in TypeScript and front-end code in general. I'm interested in:
- Style problems.
- Syntax around member modifiers (public/private), appropriate use of
var/let/const
which I'm still learning. - General portability and reuse.
- Potential gotchas or performance issues.
- Sensible error checking.
- Accusations that this entire approach sucks. :)
Thank you in advance!
EDIT: You can follow along with my changes on the Github: https://github.com/jeffputz/ts-observable-web-component