I have found it a useful exploration of how to leverage the Proxy type to bind class properties to HTML elements, achieve two way binding, subscribe to UI events, and so forth, all using actual edit-time types for type safety and Intellisense support - as in, eliminate literal strings for referencing DOM elements by their IDs.
Contents
I despise two things about front-end development:
- Element IDs are string literals
- JavaScript code in my HTML
- JavaScript
- Actually anything having to do with front-end development, but that's life
Oh wait! That's four things.
When I started writing the code for this article, I ended up experiencing something that Theory U describes as "Leading From the Future As It Emerges." Riiiight. Nonetheless, this was my experience:
So my "future" was discovering that the code I'm about to present here is, well, not what I would actually want to use, now that the future as arrived in the present and by the time I finished writing this article, I realized there's a lot of things I would do differently! Regardless, I have found it a useful exploration of how to leverage the Proxy type to bind class properties to models, achieve two way binding, subscribe to UI events, and so forth, all using actual "edit-time" types for type safety and Intellisense support - as in, no strings referencing DOM elements by their IDs. Thus "IX" is born, which is short for "Interacx", which was a WinForm suite of tools that I created a long time ago to automate data manipulation without using an ORM. I decided to repurpose the name since WinForm applications are, well, passe, and the reality is that the thing I despise, writing web apps, is where it's app, I mean, at. And for your endless amusement, I decided to use some Vue examples as comparison to the implementation I've developed here using proxies.
Working with the code I've developed here, I find several advantages:
- I'm not hardcoding DOM ID string literals.
- I'm able to leverage the type safety of TypeScript.
- Being able to refer to DOM elements as object properties leverages Visual Studio's Intellisense.
- It's really easy to wire up events and bindings.
- It was quite easy to write unit tests - in fact, the unit tests are one of the more interesting aspects of this code, in my opinion.
- I'm not putting "declarative code" in the HTML
- The HTML remains completely clean.
- The business logic is implemented in code.
- You don't have to inspect both code and HTML to figure out what in the world is actually going on.
- Point #6
- Point #6
- Point #6
I cannot reiterate enough how important, at least to me, point #6 is. With a large web application, I have pulled my hair out bouncing between code and markup to figure out what the conditions, loops, and rendering is, and it is a frustrating experience. To me, the idea of including declarative syntax at the UI level that is driven by effectively business data/rules is bad, no horrible, design. It's why I don't use Razor or similar rendering engines. I personally think that arcane custom tags in the HTML, "if" and "loop" tags, etc., to control UI rendering is one of the worst ideas to come out of so-called modern web development.
So let's be realistic:
- The syntax requires a specific mapping between the DOM element ID and the object's property name.
- Proxies are slower.
- The code to work with proxies is highly specialized.
- The code to work with arrays is bizarre.
- The code here is really incomplete with regards to all the DOM attributes, properties, and events that could be handled.
- I have no idea whether the code here is actually robust enough to handle #4.
- I have yet to explore whether this concept works well with third party widget libraries, my favorite being jqWidgets.
- The "future" arrived rather late, basically by the time I was done writing this article.
And I really doubt anyone is going to say, "ooh, let's use IX to build a major website", except perhaps for me!
- I like to explore different ways to solve the warts of web development.
- I haven't come across anyone else attempting this.
- It's quite interesting to learn about proxies.
- This was fun!
A Proxy
, at least in JavaScript, is an object that replaces your object and lets you intercept the "get
" and "set
" methods. Read more about the Proxy
object here.
A simple demonstration will suffice. First, a simple proxy stub that just does the get
/set
operations with console logging:
private myProxyHandler = {
get: (obj, prop) => {
console.log(`get ${prop}`);
return obj[prop];
},
set: (obj, prop, val) => {
console.log(`set ${prop} to ${val}`);
obj[prop] = val;
return true;
}
}
And a simple test case:
let proxy = new Proxy({}, this.myProxyHandler);
proxy.foo = 1;
let foo = proxy.foo;
console.log(`foo = ${foo}`);
and the output:
set foo to 1
get foo
foo = 1
Ahh, feel the power! The world is now mine!
Now let's do something a little more interesting. We'll create a class with a property whose name matches a DOM element. The DOM element, with a label because input elements should have labels:
<div class="inline marginTop5">
<div class="inline label">Name:</div>
<div class="inline"><input id="name"/></div>
</div>
Exciting!
Now the class:
class NameContainer {
name: string;
}
And the new proxy:
private valueProxy = {
get: (obj, prop) => {
console.log(`get ${prop}`);
return obj[prop];
},
set: (obj, prop, val) => {
console.log(`set ${prop} to ${val}`);
let el = document.getElementById(prop) as HTMLInputElement;
el.value = val;
obj[prop] = val;
return true;
}
}
Notice the only thing I've added is this:
let el = document.getElementById(prop) as HTMLInputElement;
el.value = val;
Here, the assumption is that the property name is the element ID!
Now we can set the value and it proxies to setting both the object's name
property and the DOM value
property:
let nc = new Proxy(new NameContainer(), this.valueProxy);
nc.name = "Hello World!";
The result is:
What if I type something in and I want to see that value when I "get" the name
property? Easy enough, the getter changes to this:
get: (obj, prop) => {
console.log(`get ${prop}`);
let el = document.getElementById(prop) as HTMLInputElement;
let val = el.value;
obj[prop] = val;
return obj[prop];
},
We can test the code by simulating a change the user made:
let nc = new Proxy(new NameContainer(), this.valueProxy);
nc.name = "Hello World!";
let el = document.getElementById("name") as HTMLInputElement;
el.value = "fizbin";
let newName = nc.name;
console.log(`The new name is: ${newName}`);
and in the console log, we see:
set name to Hello World!
get name
The new name is: fizbin
It's important to note that obj[prop] = val
makes the assignment on the non-proxy'd object, therefore the proxy setter does not get called.
- I'm using types (and therefore Intellisense) to get/set the DOM element value.
- I've eliminated the string literal for the name by assuming that the name of the class property is the same as the element ID.
Snazzy! One small step for Marc, one giant leap for better front-end development! Unfortunately, getting to the moon requires a whole lot more effort, infrastructure, time, and a lot of disasters along the way (a pause here to recognize the lives that have been lost in space exploration, as I don't want to appear flippant about "disasters.")
So, let's start the journey down the slope of the U!
The code is TypeScript with simple HTML and CSS, implemented in VS2017 solution.
The source code can also be found at https://github.com/cliftonm/IX.
There are two HTML pages to play with:
- index.html is the demo page.
- Tests/IntegrationTests.html runs the integration tests.
Let's start with simple data binding of the inner HTML associated with a DIV
.
<div id="app">
{{ message }}
</div>
var app = new Vue({
el: '#app',
data: {
message: 'Hello Vue!'
}
})
What I don't like:
- The "Mustache"
{{ }}
usage. - The
#app
. - The whole
data
object thing.
<div id="app"></div>
let form = IX.CreateNullProxy(); // No associated view model.
form.app = "Hello Interacx!";
That's it!
The next example is displaying some realtime computed value as part of a SPAN
title.
<div id="app-2">
<span v-bind:title="message">
Hover your mouse over me for a few seconds
to see my dynamically bound title!
</span>
</div>
var app2 = new Vue({
el: '#app-2',
data: {
message: 'You loaded this page on ' + new Date().toLocaleString()
}
})
<span id="mySpan">However your mouse over me for a few seconds
to see the dynamically bound title!</span>
class HoverExample {
mySpan = {
attr: { title: "" }
};
onMySpanHover = new IXEvent();
}
let form = IX.CreateProxy(new HoverExample());
form
.onMySpanHover
.Add(() =>
hform.mySpan.attr.title = `You loaded this page on ${new Date().toLocaleString()}`);
More verbose, but the benefit is that you're using a repeatable pattern of using a multicast event handler. I did have an implementation where I could just set the title as a function, but I didn't like the one-off implementation behind the scenes that this required.
I also really don't like to make a mess of the markup with declarative code elements.
<div id="app-3">
<span v-if="seen">Now you see me</span>
</div>
var app3 = new Vue({
el: '#app-3',
data: {
seen: true
}
})
In IX, conditional behaviors are implemented through the event mechanism, usually to manipulate element attributes. Diverging slightly from the Vue example above, note the addition of two buttons to toggle the visibility of the SPAN
:
<span id="seen">Now you see me...</span>
<!--
<button id="show">Show</button>
<input id="hide" type="button" value="Hide" />
class VisibilityExample {
seen = {
attr: { visible: true }
};
onShowClicked = new IXEvent().Add((_, p) => p.seen.attr.visible = true);
onHideClicked = new IXEvent().Add((_, p) => p.seen.attr.visible = false);
}
IX.CreateProxy(new VisibilityExample());
These are wired up to two buttons, hence the event handlers.
Here:
- We have a consistent way of manipulating element attributes.
- Intellisense works perfectly in Visual Studio.
- No "
string
" element name.
<div id="app-4">
<ol>
<li v-for="todo in todos">
{{ todo.text }}
</li>
</ol>
</div>
var app4 = new Vue({
el: '#app-4',
data: {
todos: [
{ text: 'Learn JavaScript' },
{ text: 'Learn Vue' },
{ text: 'Build something awesome' }
]
}
})
<ol id="someList"></ol>
class ListExample {
someList: string[] = ["Learn Javascript", "Learn IX", "Wear a mask!"];
}
IX.CreateProxy(new ListExample());
Result:
Given that most lists come from a data source rather being hard coded:
<ol id="someList"></ol>
class ListExample {
someList: string[] = [];
}
let listForm = IX.CreateProxy(new ListExample());
listForm.someList.push("Learn Javascript");
listForm.someList.push("Learn IX");
listForm.someList.push("Wear a mask!");
Or:
let listForm = IX.CreateProxy(new ListExample());
let items = ["Learn Javascript", "Learn IX", "Wear a mask!"];
listForm.someList = items;
<div id="app-5">
<p>{{ message }}</p>
<button v-on:click="reverseMessage">Reverse Message</button>
</div>
var app5 = new Vue({
el: '#app-5',
data: {
message: 'Hello Vue.js!'
},
methods: {
reverseMessage: function () {
this.message = this.message.split('').reverse().join('')
}
}
})
<div>
<p id="message"></p>
<button id="reverseMessage">Reverse Message</button>
</div>
class ReverseExample {
message = "Hello From Interacx!";
onReverseMessageClicked = new IXEvent()
.Add((_, p: ReverseExample) => p.message = p.message.split('').reverse().join(''));
}
IX.CreateProxy(new ReverseExample());
Again, notice:
- No "
Mustache
" {{ }}
syntax required. - No "
#id
" string
to identify the element ID. - The event mechanism, being multicast, allows us to wire up more than one event (not illustrated, but that's point of using events.)
After clicking on the button:
The following example is similar to Vue's .number
attribute, but the actual implementation is much more general purpose.
Consider this UI:
And the markup (CSS and extraneous DIVs removed for readability):
X:
<input id="x" class="fieldInputSmall" />
Y:
<input id="y" class="fieldInputSmall" />
Here, we do not want the strings "1
" and "2
" to sum to "12
", so we implement converters:
class InputForm {
x: number;
y: number;
onXChanged = new IXEvent();
onYChanged = new IXEvent();
onConvertX = x => Number(x);
onConvertY = y => Number(y);
Add = () => this.x + this.y;
}
class OutputForm {
sum: number;
}
And the events are wired up like this:
let inputForm = IX.CreateProxy(new InputForm());
let outputForm = IX.CreateProxy(new OutputForm());
inputForm.onXChanged.Add(() => outputForm.sum = inputForm.Add());
inputForm.onYChanged.Add(() => outputForm.sum = inputForm.Add());
Behind the scenes, the input box text is converted to a Number
with the onConvertX
and onConvertY
converters, and the rest is handled by the standard data binding of the properties for setting sum
to the values of x
and y
.
Also, notice how you can create classes as containers to sections of the HTML. We could easily have put sum
in the InputForm
, but instead I wanted to illustrate how to use a separate container object, OutputForm
, as a way of compartmentalizing the properties into separate containers.
We've already seen in the examples above binding between the view and the model. One of Vue's examples is direct update of one element based on the realtime update of an input element. While I can't think of a real-life example where one would need this, real-time updating, say of a filter criteria, is definitely useful, so we'll start with the Vue example:
<div id="app-6">
<p>{{ message }}</p>
<input v-model="message">
</div>
var app6 = new Vue({
el: '#app-6',
data: {
message: 'Hello Vue!'
}
})
This is already easily accomplished with events:
First Name:
<p id="message2">/p>
<input id="input2"/>
class BidirectionalExample {
message2: string = "";
input2: string = "";
onInput2KeyUp = new IXEvent().Add((v, p: BidirectionalExample) => p.message2 = v);
}
IX.CreateProxy(new BidirectionalExample());
However, to make this more "Vue-ish", we can do:
class BidirectionalExample {
message2 = new IXBinder({ input2: null });
input2: string = "";
Here, we are specifying the "from
" element as the key and any "value
" of the key. What displeases me about this is that the key cannot be implemented in a way that leverages Intellisense and type checking. The best we can do is runtime checking that the "from
" binder element exists. So at this point, specifying the "bind from
" property as a string
almost makes sense. Instead, I opted for this implementation:
class BidirectionalExample {
input2: string = "";
message2 = new IXBinder({ bindFrom: IX.nameof(() => this.input2) });
onInput2KeyUp = new IXEvent().Add((v, p: BidirectionalExample) => p.message2 = v);
}
Which is somewhat lame as well but has the advantage of supporting Intellisense, albeit the property your binding to must already be declared previously. Behind the scenes, we have a very simple implementation to extract the name, by converting the function into a string
:
public static nameof<TResult>(name: () => TResult): string {
let ret = IX.RightOf(name.toString(), ".");
return ret;
}
Sadly, that's seems to be the best we can do with JavaScript unless you want to use something like ts-nameof, which I do not because ts-nameof
is a compile-time transformation, and I do not want the developer that uses this library to have to go through hoops to get this to work.
We can also bind the same source to different targets:
<p>
<label id="message2"></label>
<label id="message3"></label>
</p>
<input id="input2" />
class BidirectionalExample {
input2: string = "";
message2 = new IXBinder({ bindFrom: IX.nameof(() => this.input2) });
message3 = new IXBinder({ bindFrom: IX.nameof(() => this.input2) });
}
As well as different sources to the same target:
<p>
<label id="message2"></label>
<label id="message3"></label>
</p>
<input id="input2" />
<input id="input3" />
class BidirectionalExample {
input2: string = "";
input3: string = "";
message2 = new IXBinder({ bindFrom: IX.nameof(() => this.input2) });
message3 = new IXBinder({ bindFrom: IX.nameof(() => this.input2) })
.Add({ bindFrom: IX.nameof(() => this.input3) });
}
Here, typing in the left edit box sets messages 2 & 3:
Typing in the right edit box sets message 3:
But as I said earlier, doing this kind of binding really doesn't make much sense. Typically, a transformation does something "useful", so we have this contrived example:
class BidirectionalExample {
input2: string = "";
input3: string = "";
message2 = new IXBinder({ bindFrom: IX.nameof(() => this.input2) });
message3 = new IXBinder({ bindFrom: IX.nameof(() => this.input2) }).Add({
bindFrom: IX.nameof(() => this.input3),
op: v => v.split('').reverse().join('')
});
}
and thus we get:
Vue has an elegant demonstration of binding the checkbox
state to the label:
<input type="checkbox" id="checkbox" v-model="checked">
<label for="checkbox">{{ checked }}</label>
Given:
<input id="checkbox" type="checkbox" />
<label id="ckLabel" for="checkbox"></label>
We continue to follow the pattern of using TypeScript classes and properties:
class CheckboxExample {
checkbox: boolean = false;
ckLabel = new IXBinder({ bindFrom: IX.nameof(() => this.checkbox) });
}
IX.CreateProxy(new CheckboxExample());
or, because the nameof
syntax above is clumsy and we don't have a real "nameof
" operator in JavaScript by the time the code is transpiled, so we have to revert to string literals in this case:
class CheckboxExample {
checkbox: boolean = false;
ckLabel = new IXBinder({ bindFrom: "checkbox" });
}
IX.CreateProxy(new CheckboxExample());
Or we can wire up the click
event:
class CheckboxExample {
checkbox: boolean = false;
ckLabel: string = "Unchecked";
onCheckboxClicked =
new IXEvent().Add(
(_, p: CheckboxExample) =>
p.ckLabel = p.checkbox ? "Checked" : "Unchecked");
}
IX.CreateProxy(new CheckboxExample());
<input type="checkbox" id="jack" value="Jack" v-model="checkedNames">
<label for="jack">Jack</label>
<input type="checkbox" id="john" value="John" v-model="checkedNames">
<label for="john">John</label>
<input type="checkbox" id="mike" value="Mike" v-model="checkedNames">
<label for="mike">Mike</label>
<br>
<span>Checked names: {{ checkedNames }}</span>
Note that the span
text includes the array brackets:
Given:
<input id="jane" value="Jane" type="checkbox" />
<label for="jane">Jane</label>
<input id="mary" value="Mary" type="checkbox" />
<label for="mary">Mary</label>
<input id="grace" value="Grace" type="checkbox" />
<label for="grace">Grace</label>
<br />
<label id="ckNames"></label>
We implement the container object with a special array binding (because the properties don't exist in the class, I can't use the "nameof
" kludge, so the "ID
"s are, sadly, string literals.) Of course, in the next example, I do have properties for the checkboxes, but I still used the string literals!
class CheckboxListExample {
ckNames = IXBinder.AsArray(items => items.join(", "))
.Add({ bindFrom: "jane", attribute: "value" })
.Add({ bindFrom: "mary", attribute: "value" })
.Add({ bindFrom: "grace", attribute: "value" });
}
IX.CreateProxy(new CheckboxListExample());
And we get:
Notice that we did not initialize properties with the checkbox state! If we do this:
class CheckboxListExample {
jane: boolean = false;
mary: boolean = false;
grace: boolean = false;
ckNames = IXBinder.AsArray(items => items.join(", "))
.Add({ bindFrom: "jane", attribute: "value" })
.Add({ bindFrom: "mary", attribute: "value" })
.Add({ bindFrom: "grace", attribute: "value" });
}
let ckListExample = IX.CreateProxy(new CheckboxListExample());
We can programmatically set the check state:
ckListExample.jane = true;
ckListExample.mary = true;
and we see:
So one thing we note here is that the property referring to the HTML element is associated with the checked attribute of the element. That is an artifact of how IX is coded, and actually points out an interesting problem -- the object property maps to only one attribute of the DOM element, and IX is very opinionated as to what that DOM element should be, depending on what the element is!
This example binds the value of the radio
button to the span
:
<input type="radio" id="one" value="One" v-model="picked">
<label for="one">One</label>
<br>
<input type="radio" id="two" value="Two" v-model="picked">
<label for="two">Two</label>
<br>
<span>Picked: {{ picked }}</span>
Given:
<input id="marc" value="Marc" type="radio" name="group1" />
<label for="marc">Marc</label>
<input id="chris" value="Chris" type="radio" name="group1" />
<label for="chris">Chris</label>
<br />
<label id="rbPicked"></label>
We add two binders, whichever one is clicked becomes the one whose binder event is fired. Again, note in this example, I'm not using the "nameof
" syntax because in this case the property doesn't exist!
class RadioExample {
rbPicked = new IXBinder({ bindFrom: "marc", attribute: "value" })
.Add({ bindFrom: "chris", attribute: "value" });
}
IX.CreateProxy(new RadioExample());
thus updating to the current radio button:
And if we want to programmatically set the radio button state, define the properties:
class RadioExample {
marc: boolean = false;
chris: boolean = false;
rbPicked = new IXBinder({ bindFrom: "marc", attribute: "value" })
.Add({ bindFrom: "chris", attribute: "value" });
}
and after proxy initialization, set the state:
let rbExample = IX.CreateProxy(new RadioExample());
rbExample.chris = true;
<select v-model="selected">
<option disabled value="">Please select one</option>
<option>A</option>
<option>B</option>
<option>C</option>
</select>
<span>Selected: {{ selected }}</span>
Given:
<select id="selector">
<option selected disabled>Please select one</option>
<option value="1">A</option>
<option value="2">B</option>
<option value="3">C</option>
</select>
<br />
<span id="selection"></span>
and the container class:
class ComboboxExample {
selector = new IXSelector();
selection: string = "";
onSelectorChanged =
new IXEvent().Add((_, p) =>
p.selection = `Selected: ${p.selector.text} with value ${p.selector.value}`);
}
IX.CreateProxy(new ComboboxExample());
We then see:
and after selection:
Note that the selector
property, implemented as an IXSelector
, contains two properties, text
and value
, for the selected item.
We can also initialize the options programmatically. Given:
<select id="selector2"></select>
<br />
<span id="selection2"></span>
and:
class ComboboxInitializationExample {
selector2 = new IXSelector().Add
({ selected:true, disabled: true, text: "Please select one" })
.Add({ value: 12, text: "AAA" })
.Add({ value: 23, text: "BBB" })
.Add({ value: 34, text: "CCC" });
selection2: string = "";
onSelector2Changed = new IXEvent().Add((_, p) =>
p.selection2 = `Selected: ${p.selector2.text} with value ${p.selector2.value}`);
}
let cb = IX.CreateProxy(new ComboboxInitializationExample());
We see:
And programmatically set the selection with the option value:
cb.selector2.value = 34;
or with the option text:
cb.selector2.text = "AAA";
<img border="0" height="45" src="5272881/select5.png" width="182" />
Or add to the list of options:
cb.selector2.options.push({ text: "DDD", value: 45 });
Or remove the option item:
cb.selector2.options.pop();
Or change an option's text and value:
cb.selector2.options[2] = { text: "bbb", value: 999 };
IX requires that class properties match the DOM element ID and that event handlers have specific signatures.
Notice:
The event name uses the property name with the first letter capitalized, so firstName
becomes FirstName
.
on[Prop]KeyUp
- realtime key up events
on[Prop]Changed
- element loses focus
This event applies to text, radio and checkbox inputs and "select
" (combobox
) elements.
onConvert[Prop]
- if defined, executes the function before the KeyUp
and Changed
events fire.
on[Prop]Hover
- if defined and the property has the signature:
{
attr: { title: "" }
};
This will set the element's title on mouse hover.
We can easily test the behavior of IX by directly inspecting DOM elements after model changes, and vice versa. And I prefer to use the phrase "integration test" rather than "unit test" because we're not testing low level functions in the IX library -- we are testing the integration of the DOM elements with object properties.
The HTML for the test cases is simple:
<div id="testResults" class="inline" style="min-width:600px">
<ol id="tests"></ol>
</div>
<div id="testDom"></div>
We have an ordered list for the test results, and a div
in which we place the HTML required for each test.
The tests actually use IX to manipulate the test results, and direct DOM manipulation to simulate UI changes. The runner looks like this:
let testForm = IX.CreateProxy(new TestResults());
let idx = 0;
tests.forEach(test => {
let testName = IX.LeftOf(test.testFnc.toString(), "(");
let id = IX.LowerCaseFirstChar(testName);
testForm.tests.push(IXTemplate.Create({ value: testName, id: id }));
let obj = {};
obj[id] = { classList: new IXClassList() };
let testProxy = IX.CreateProxy(obj);
this.CreateTestDom(testForm, test.dom);
this.RunTest(testForm, idx, testProxy, test, id);
this.RemoveTestDom(testForm);
++idx;
});
And we have these three helper functions:
CreateTestDom(testForm: TestResults, testDom: string): void {
testForm.testDom = testDom || "";
}
RemoveTestDom(testForm: TestResults, ): void {
testForm.testDom = "";
}
RunTest(testForm: TestResults, idx:number, testProxy: object, test, id: string): void {
let passFail = "pass";
try {
test.testFnc(test.obj, id);
} catch (err) {
passFail = "fail";
let template = testForm.tests[idx];
template.SetValue(`${template.value} => ${err}`);
}
testProxy[id].classList.Add(passFail);
}
A passing test is indicated in green, a failing test in red, along with the error message.
.pass {
color: green;
}
.fail {
color: red;
}
So for example, we can test that failure is handled:
static ShouldFail(obj): void {
throw "Failed!!!";
}
And we see:
The tests are defined as an array of objects that specify:
- The test to be run.
- The "object" being manipulated in the test.
- The HTML to support the test.
Like this:
let tests = [
{ testFnc: IntegrationTests.InputElementSetOnInitializationTest,
obj: { inputTest: "Test" }, dom: "<input id='inputTest'/>" },
{ testFnc: IntegrationTests.InputElementSetOnAssignmentTest,
obj: { inputTest: "" }, dom: "<input id='inputTest'/>" },
{ testFnc: IntegrationTests.InputSetsPropertyTest,
obj: { inputTest: "" }, dom: "<input id='inputTest'/>" },
{ testFnc: IntegrationTests.ListInitializedTest,
obj: { list: ["A", "B", "C"] }, dom: "<ol id='list'></ol>" },
{ testFnc: IntegrationTests.ReplaceInitializedTest,
obj: { list: ["A", "B", "C"] }, dom: "<ol id='list'></ol>" },
{ testFnc: IntegrationTests.ChangeListItemTest,
obj: { list: ["A", "B", "C"] }, dom: "<ol id='list'></ol>" },
{ testFnc: IntegrationTests.PushListItemTest,
obj: { list: ["A", "B", "C"] }, dom: "<ol id='list'></ol>" },
{ testFnc: IntegrationTests.PopListItemTest,
obj: { list: ["A", "B", "C"] }, dom: "<ol id='list'></ol>" },
{
testFnc: IntegrationTests.ButtonClickTest,
obj: { clicked: false, onButtonClicked : new IXEvent().Add((_, p) => p.clicked = true)},
dom: "<button id='button'></button>"
},
{
testFnc: IntegrationTests.OnlyOneClickEventTest,
obj: { clicked: 0, onButtonClicked: new IXEvent().Add((_, p) => p.clicked += 1) },
dom: "<button id='button'></button>"
},
{
testFnc: IntegrationTests.CheckboxClickTest,
obj: { clicked: false, checkbox: false,
onCheckboxClicked: new IXEvent().Add((_, p) => p.clicked = p.checkbox)},
dom: "<input id='checkbox' type='checkbox'/>"
},
{
testFnc: IntegrationTests.RadioButtonClickTest,
obj: { clicked: false, checkbox: false,
onRadioClicked: new IXEvent().Add((_, p) => p.clicked = p.radio) },
dom: "<input id='radio' type='radio'/>"
},
{
testFnc: IntegrationTests.ConvertTest,
obj: { inputTest: "", onConvertInputTest: s => `${s} Converted!` },
dom: "<input id='inputTest'/>"
},
{ testFnc: IntegrationTests.VisibleAttributeTest,
obj: { inputTest: { attr: { visible: true } } }, dom: "<input id='inputTest'/>" },
{ testFnc: IntegrationTests.ControlBindingTest,
obj: { input: "123", output: new IXBinder({ bindFrom: "input" }) },
dom: "<input id='input'><p id='output'>" },
{ testFnc: IntegrationTests.ControlBindingWithOperationTest,
obj: { input: "123", output: new IXBinder({ bindFrom: "input",
op: v => `${v} Operated!` }) }, dom: "<input id='input'><p id='output'>" },
{ testFnc: IntegrationTests.ControlBindingAssignmentTest,
obj: { input: "", output: new IXBinder({ bindFrom: "input" }) },
dom: "<input id='input'><p id='output'>" },
];
I'm not going to bore you with the actual tests, but I'll point out that in some cases we have to simulate clicking and therefore the test must dispatch the appropriate event, for example:
static ButtonClickTest(obj): void {
let test = IX.CreateProxy(obj);
let el = document.getElementById("button") as HTMLButtonElement;
el.dispatchEvent(new Event('click'));
IXAssert.Equal(test.clicked, true);
}
This class simply wraps the if
statement into a one-liner, as I rather dislike if
statements for assertions.
export class IXAssert {
public static Equal(got: any, expected: any): void {
let b = got == expected;
if (!b) {
throw `Expected ${expected}, got ${got}`;
}
}
public static IsTrue(b: boolean): void {
if (!b) {
throw "Not true";
}
}
}
You should realize from looking at how the tests are implemented that you don't need actual TypeScript classes, you just need an object, like obj: { inputTest: "Test" }
- after all, a TypeScript class is a purely development-side construct used for type checking and Intellisense by the IDE. Even a JavaScript class is really just "syntactical sugar of JavaScript's existing prototype-based inheritance." (JavaScript classes)
TypeScript is fantastic for ensuring type safety when writing code. However, by the time the code has been transpiled to JavasScript, all that type information that the IDE is using is of course lost. This is unfortunate because there are times in the code that I really wish I had type information. There are some workarounds, such as for native types and classes:
let a = 1;
let b = "foo";
let c = true;
let d = [];
let e = new SomeClass();
[a, b, c, d, e].forEach(q => console.log(q.constructor.name));
let listForm = IX.CreateProxy(new ListExample());
You get:
This is useful. However, given this class:
class SomeClass {
a: number;
b: string;
}
What that gets transpiled to is simply an empty object {}
. So, Object.keys(new SomeClass())
return an empty array []
. To determine properties of the class, the properties must be initialized, and they can even be initialized to null
or undefined:
class SomeClass {
a: number = null;
b: string = undefined;
}
Hence, the constraint in IX that you must initialize properties, otherwise the wire-up cannot be made between the class property and the element with the ID of the property name.
public static CreateProxy<T>(container: T): T {
let proxy = new Proxy(container, IX.uiHandler);
IX.CreatePropertyHandlers(container, proxy);
IX.CreateButtonHandlers(container, proxy);
IX.CreateBinders(container, proxy);
IX.Initialize(container, proxy);
return proxy;
}
Besides instantiating the proxy, we can see that several other steps are required:
- Special property handlers
- Button handlers
- Binders
- Final initialization
This code is intended to handle attributes, class lists, and event wireups. Events are only wired up once, in case the proxy is re-initialized after class property assignments. To make matters a bit more complicated, specific cases are handled here, such as proxy'ing the attr
key to accommodate the custom syntax for assigning attributes to the associated DOM element. The class
attribute is handled similarly, creating a proxy for the classList
key. A better implementation is discussed in the conclusion. Otherwise, the initial purpose of the function was solely to handle the mouseover
, change
, and keyup
events.
private static CreatePropertyHandlers<T>(container: T, proxy: T) {
Object.keys(container).forEach(k => {
let el = document.getElementById(k);
let anonEl = el as any;
if (el && !anonEl._proxy) {
anonEl._proxy = this;
if (container[k].attr) {
console.log(`Creating proxy for attr ${k}`);
container[k].attr = IXAttributeProxy.Create(k, container[k].attr);
}
if (container[k].classList) {
console.log(`Creating proxy for classList ${k}`);
container[k].classList = IXClassListProxy.Create(k, container[k].classList);
}
let idName = IX.UpperCaseFirstChar(el.id);
let changedEvent = `on${idName}Changed`;
let hoverEvent = `on${idName}Hover`;
let keyUpEvent = `on${idName}KeyUp`;
if (container[hoverEvent]) {
IX.WireUpEventHandler(el, container, proxy, null, "mouseover", hoverEvent);
}
switch (el.nodeName) {
case "SELECT":
case "INPUT":
IX.WireUpEventHandler(el, container, proxy, "value", "change", changedEvent);
break;
}
if (container[keyUpEvent]) {
switch (el.nodeName) {
case "INPUT":
IX.WireUpEventHandler(el, container, proxy, "value", "keyup", keyUpEvent);
break;
}
}
}
});
}
It should be obvious that this is a very incomplete implementation sufficient for the proof-of-concept.
The event handler that is attached the event listener implements a custom check for the SELECT
HTML element and makes the assumption that the class property has been initialized with an IXSelector
instance. This was done so that the selected item's text and value could be assigned on selection to the IXSelector
instance. Otherwise, the event handler updates the class' property (as in, the class that has been proxied.) Because "buttons" don't have a property but are just an event, we check if there is actually a property on the DOM that needs to be read and set on the corresponding class property. Lastly, if the class implements an event handler, any multicast events are fired. A custom converter, if defined in the class, is invoked first for non-button events.
private static WireUpEventHandler<T>(el: HTMLElement, container: T, proxy: T,
propertyName: string, eventName: string, handlerName: string) {
el.addEventListener(eventName, ev => {
let el = ev.srcElement as HTMLElement;
let oldVal = undefined;
let newVal = undefined;
let propName = undefined;
let handler = container[handlerName];
switch (el.nodeName) {
case "SELECT":
let elSelector = el as HTMLSelectElement;
let selector = container[el.id] as IXSelector;
selector.value = elSelector.value;
selector.text = elSelector.options[elSelector.selectedIndex].text;
break;
default:
if (propertyName) {
oldVal = container[el.id];
newVal = el[propertyName];
propName = el.id;
}
let ucPropName = IX.UpperCaseFirstChar(propName ?? "");
if (propertyName) {
newVal = IX.CustomConverter(proxy, ucPropName, newVal);
container[propName] = newVal;
}
break;
}
if (handler) {
(handler as IXEvent).Invoke(newVal, proxy, oldVal);
}
});
}
Again, enough to implement the proof-of-concept.
private static CustomConverter<T>(container: T, ucPropName: string, newVal: string): any {
let converter = `onConvert${ucPropName}`;
if (container[converter]) {
newVal = container[converter](newVal);
}
return newVal;
}
Buttons (and button-like things, like checkboxes and radio buttons) have their own unique requirements. Checkboxes and radio buttons (which are INPUT
HTML elements) have a checked
property, whereas buttons do not. The proxy'd class must implement the expected "on...." which must be assigned to an IXEvent
to support multicast events.
private static CreateButtonHandlers<T>(container: T, proxy: T) {
Object.keys(container).forEach(k => {
if (k.startsWith("on") && k.endsWith("Clicked")) {
let elName = IX.LeftOf(IX.LowerCaseFirstChar(k.substring(2)), "Clicked");
let el = document.getElementById(elName);
let anonEl = el as any;
if (el) {
if (!anonEl._proxy) {
anonEl._proxy = this;
}
if (!anonEl._clickEventWiredUp) {
anonEl._clickEventWiredUp = true;
switch (el.nodeName) {
case "BUTTON":
IX.WireUpEventHandler(el, container, proxy, null, "click", k);
break;
case "INPUT":
let typeAttr = el.getAttribute("type");
if (typeAttr == "checkbox" || typeAttr == "radio") {
IX.WireUpEventHandler(el, container, proxy, "checked", "click", k);
} else {
IX.WireUpEventHandler(el, container, proxy, null, "click", k);
}
break;
}
}
}
}
});
}
Binders handle real-time events such as keyup as well as when an input element loses focus. Adding to the complexity is the concept that a binder might be associated with multiple checkboxes or radio buttons and bind the list of currently selected items. This is a confusing piece of code, as both array and non-array properties can be bound. It is assumed that if an array is being bound, the array is populated with the selected checkboxes or radio buttons (though technically, a radio button should be exclusive.) Otherwise, the property itself is set with either the checked state or the element's value. Lastly, an optional "op" (operation) can be defined before the value is set on the proxy. Setting the value on the proxy rather than the proxy'd object invokes the proxy setter which can define further behaviors, but ultimately also assigns the value to the original container object.
private static CreateBinders<T>(container: T, proxy: T): void {
Object.keys(container).forEach(k => {
if (container[k].binders?.length ?? 0 > 0) {
let binderContainer = container[k] as IXBinder;
let binders = binderContainer.binders as IXBind[];
if (binderContainer.asArray) {
binders.forEach(b => {
let elName = b.bindFrom;
let el = document.getElementById(elName);
let typeAttr = el.getAttribute("type");
if (typeAttr == "checkbox" || typeAttr == "radio") {
el.addEventListener("click", ev => {
let values: string[] = [];
binders.forEach(binderItem => {
let boundElement = (document.getElementById(binderItem.bindFrom)
as HTMLInputElement);
let checked = boundElement.checked;
if (checked) {
values.push(boundElement[binderItem.attribute]);
}
});
let ret = binderContainer.arrayOp(values);
proxy[k] = ret;
});
}
});
} else {
binders.forEach(b => {
let elName = b.bindFrom;
let el = document.getElementById(elName);
console.log(`Binding receiver ${k} to sender ${elName}`);
let typeAttr = el.getAttribute("type");
if (typeAttr == "checkbox" || typeAttr == "radio") {
el.addEventListener("click", ev => {
let boundAttr = b.attribute ?? "checked";
let v = String((ev.currentTarget as HTMLInputElement)[boundAttr]);
v = b.op === undefined ? v : b.op(v);
proxy[k] = v;
});
} else {
el.addEventListener("keyup", ev => {
let v = (ev.currentTarget as HTMLInputElement).value;
v = b.op === undefined ? v : b.op(v);
proxy[k] = v;
});
el.addEventListener("changed", ev => {
let v = (ev.currentTarget as HTMLInputElement).value;
v = b.op === undefined ? v : b.op(v);
proxy[k] = v;
});
}
});
}
}
});
}
This last function started off simple and ended up being more complicated as it needs to handle not just native non-array types, but also arrays and DOM elements like "select
":
private static Initialize<T>(container: T, proxy: T): void {
Object.keys(container).forEach(k => {
let name = container[k].constructor?.name;
switch (name) {
case "String":
case "Number":
case "Boolean":
case "BigInt":
proxy[k] = container[k];
break;
case "Array":
if (container[k]._id != k) {
let newProxy = IXArrayProxy.Create(k, container);
newProxy[k] = container[k];
container[k] = newProxy;
}
break;
case "IXSelector":
if (container[k]._id != k) {
container[k]._element = document.getElementById(k);
let selector = container[k] as IXSelector;
if (selector.options.length > 0) {
let newProxy = IXArrayProxy.Create(k, container);
newProxy[k] = selector.options;
selector.options = newProxy;
}
}
break;
}
});
}
Arrays are something of a nightmare. Array functions, such as push
, pop
, and length
, are actually vectored through the proxy's getter (as would any other function on a proxy'd object):
static ArrayChangeHandler = {
get: function (obj, prop, receiver) {
if (prop == "_isProxy") {
return true;
}
if (prop == "push") {
receiver._push = true;
}
if (prop == "pop") {
receiver._pop = true;
}
if (prop == "length") {
return obj[receiver._id].length;
}
return obj[prop];
},
Notice that a flag is being set as to whether the operation about to be performed, in the setter, is a push
or pop
! This information is used to determine how the array should be adjusted in the setter when the length is changed. Popping an array element, it turns out, merely changes the length of the array:
set: function (obj, prop, val, receiver) {
let id = receiver._id;
console.log('setting ' + prop + ' for ' + id + ' with value ' + val);
if (prop == "length" && receiver._pop) {
let el = document.getElementById(id);
let len = obj[id].length;
for (let i = val; i < len; i++) {
el.childNodes[val].remove();
obj[id].pop();
}
receiver._pop = false;
} else {
If the setter is not a pop, then it is either updating an existing item in the array:
if (!isNaN(prop)) {
let el = document.getElementById(id);
switch (el.nodeName) {
case "OL": {
let n = Number(prop);
let ol = el as HTMLOListElement;
if (n < ol.childNodes.length && !receiver._push) {
(ol.childNodes[n] as HTMLLIElement).innerText = val;
or we are adding an item to the array:
} else {
let li = document.createElement("li") as HTMLLIElement;
let v = val;
if (val._isTemplate) {
let t = val as IXTemplate;
li.innerText = t.value;
li.id = t.id;
v = t.value;
} else {
li.innerText = val;
}
(el as HTMLOListElement).append(li);
obj[id].push(v);
receiver._push = false;
}
Lastly, the array property might be set to a whole new array:
} else if (val.constructor.name == "Array") {
let el = document.getElementById(id);
switch (el.nodeName) {
case "SELECT":
(val as IXOption[]).forEach(v => {
let opt = document.createElement("option") as HTMLOptionElement;
opt.innerText = v.text;
opt.value = String(v.value);
opt.disabled = v.disabled;
opt.selected = v.selected;
(el as HTMLSelectElement).append(opt);
});
break;
case "OL":
case "UL":
(val as []).forEach(v => {
let li = document.createElement("li") as HTMLLIElement;
li.innerText = v;
(el as HTMLOListElement).append(li);
});
break;
}
}
IXEvent
(and its helper, IXSubscriber
) are wrappers to implement multicast events:
export class IXSubscriber {
subscriber: (obj: any, oldVal: string, newVal: string) => void;
constructor(subscriber: (obj: any, oldVal: string, newVal: string) => void) {
this.subscriber = subscriber;
}
Invoke(obj: any, oldVal: string, newVal: string): void {
this.subscriber(obj, oldVal, newVal);
}
}
import { IXSubscriber } from "./IXSubscriber"
export class IXEvent {
subscribers: IXSubscriber[] = [];
Add(subscriber: (newVal: string, obj: any, oldVal: string) => void) : IXEvent {
this.subscribers.push(new IXSubscriber(subscriber));
return this;
}
Invoke(newVal: string, obj: any, oldVal: string): void {
this.subscribers.forEach(s => s.Invoke(newVal, obj, oldVal));
}
}
This class is my lame attempt to provide for templates.
export class IXTemplate {
public _isTemplate: boolean = true;
public value?: string;
public id?: string;
public static Create(t: any): IXTemplate {
let template = new IXTemplate();
template.value = t.value;
template.id = t.id;
return template;
}
public SetValue(val: string): void {
document.getElementById(this.id).innerText = val;
}
}
This is dubious at best because it sets innerText
rather than innerHtml
, and I'm really not sure of the usefulness of it except that it's used in the integration tests.
Having come to the top of the other end of the U, I'm now reconsidering the entire implementation.
By the time I was implementing an example of the combobox
, and using this construct:
selector = new IXSelector();
It occurred to me, hmm, maybe all the class properties that map to DOM elements should be wrapped by an actual "helper." This would allow the wrapper to directly implement the DOM attributes and properties of an element which effectively eliminates the need for a Proxy! It would also eliminate the "make up my own syntax", like:
.Add({ bindFrom: "jane", attribute: "value" })
or:
mySpan = {
attr: { title: "" }
};
The "initialization" process would merely iterate over the class properties (they still have to exist) and initialize the property with the DOM ID, thus the specific implementation can manipulate the DOM directly rather than through a proxy. And I'd still have Intellisense because the wrapper implemention would have the DOM attributes and properties I'd be touching.
Of course, this involves a ton of work - ideally the DOM for each element would have to be re-implemented, and that's just for native HTML elements. What about third party UI libraries? One approach would be to do this a piece at a time, as needed in the web apps that I'd be writing with this framework. Furthermore, I could derive such wrapper classes from the HTML.... interfaces that already exist, for example, HTMLButtonElement. That would work, but I also really like the beauty of:
nc.name = "Hello World!";
There's no reason the implementation cannot support both by inspecting the constructor name, as I'm already doing in the initialization process. This would therefore leave it up to the developer:
- For native types, a proxy would wrap basic behaviors.
- For wrapper types, a proxy would not be used, giving the developer more fine-grained control of the element.
This would also eliminate any arcane syntax that the developer would have to learn, as the wrapper would implement the HTML... interfaces that already exist and with which one would already be familiar.
Also, all that krufty code to handle array push, pop, and assignment would be a lot cleaner!
Therefore, I can only conclude that after having gone through the U process, I now know what the future will look like, at least for the framework that I want to use!
- 5th July, 2020: Initial version