Exploring Proxy to Achieve DOM Intellisense with TypeScript for Element Binding, Two Way Data Binding, Events, and More





5.00/5 (6 votes)
Learning about how the Proxy class can be used to, among other things, eliminate element ID string literals and with TypeScript, provide Intellisense for HTML elements
Contents
- Introduction
- What is a Proxy?
- About The Code
- Simple Data Binding of Inner HTML
- Reactive Behavior
- Conditionals
- Loops
- Button Clicks
- Data Conversion
- Two Way Binding
- Checkboxes
- Radio Buttons
- ComboBoxes
- Implementation Patterns
- Integration Tests
- Behind the Scenes
- Conclusion
- History
Introduction
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.
Pros
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.
Cons
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!
So Why Bother?
- 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!
What is a Proxy?
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 Example
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 to accept change.
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!
A DOM Example
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 to accept change.
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!";
// Simulate the user having changed the input box:
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.
The Point Being...
- 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!
About the Code
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.
Simple Data Binding of Inner HTML
Let's start with simple data binding of the inner HTML associated with a DIV
.
The Vue Way
<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.
The IX Way
<div id="app"></div>
let form = IX.CreateNullProxy(); // No associated view model.
form.app = "Hello Interacx!";
That's it!
Reactive Behavior
The next example is displaying some realtime computed value as part of a SPAN
title.
The Vue Way
<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()
}
})
The IX Way
<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.
Conditionals
I also really don't like to make a mess of the markup with declarative code elements.
The Vue Way
<div id="app-3">
<span v-if="seen">Now you see me</span>
</div>
var app3 = new Vue({
el: '#app-3',
data: {
seen: true
}
})
The IX Way
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>
<!-- Two ways to declare a button -->
<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.
Loops
The Vue Way
<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' }
]
}
})
The IX Way
<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;
Button Clicks
The Vue Way
<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('')
}
}
})
The IX Way
<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:
Data Conversion
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();
// Converters, so 1 + 2 != '12'
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.
Two Way Binding
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:
The Vue Way
<div id="app-6">
<p>{{ message }}</p>
<input v-model="message">
</div>
var app6 = new Vue({
el: '#app-6',
data: {
message: 'Hello Vue!'
}
})
The IX Way
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('')
});
// onInput2KeyUp = new IXEvent().Add((v, p: BidirectionalExample) => p.message2 = v);
}
and thus we get:
Checkboxes
Binding Checkbox State
The Vue Way
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>
The IX Way
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());
Binding Checkbox Values
The View Way
<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:
The IX Way
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!
Radio Buttons
The View Way
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>
The IX Way
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;
ComboBoxes
The Vue Way
<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>
The IX Way
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 };
Implementation Patterns
IX requires that class properties match the DOM element ID and that event handlers have specific signatures.
ID and Class Property Name
KeyUp, Changed, and Convert Events
Notice:
The event name uses the property name with the first letter capitalized, so firstName
becomes FirstName
.
Supported Events
KeyUp
on[Prop]KeyUp
- realtime key up events
Changed
on[Prop]Changed
- element loses focus
This event applies to text, radio and checkbox inputs and "select
" (combobox
) elements.
Convert
onConvert[Prop]
- if defined, executes the function before the KeyUp
and Changed
events fire.
Hover
on[Prop]Hover
- if defined and the property has the signature:
{
attr: { title: "" }
};
This will set the element's title on mouse hover.
Integration Tests
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 Test Runner
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 => {
// Get just the name of the test function.
let testName = IX.LeftOf(test.testFnc.toString(), "(");
// The ID will start with a lowercase letter
let id = IX.LowerCaseFirstChar(testName);
// Push a template to OL, where the template value is simply the test name,
// to the test results ordered list.
testForm.tests.push(IXTemplate.Create({ value: testName, id: id }));
// Create an object with the id and proxy it.
// This will match the id of the template we just created, so we can set its style.
// This is a great example of not actually needing to create a class, which is really
// just a dictionary.
let obj = {};
// The classList here allows us to set the test LI element style class
// to indicate success/failure of the test.
obj[id] = { classList: new IXClassList() };
let testProxy = IX.CreateProxy(obj);
// Create the DOM needed for the test.
this.CreateTestDom(testForm, test.dom);
// Run the test and indicate the result.
this.RunTest(testForm, idx, testProxy, test, id);
// Remove the DOM needed for the test.
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:
Defining the Tests
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.ShouldFail },
{ 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);
}
The IXAssert Helper
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 Don't Need TypeScript Classes
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)
Behind the Scenes
TypeScript Does Not Mean Runtime Type Reflection
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.
Proxy Initialization
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
CreatePropertyHandlers
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 element exists and we haven't assigned a proxy to the container's field,
// then wire up the events.
if (el && !anonEl._proxy) {
anonEl._proxy = this;
if (container[k].attr) {
// Proxy the attributes of the container so we can intercept the setter for attributes
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);
// TODO: create a dictionary to handle this.
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);
}
// Change event is always wired up so we set the container's value
// when the UI element value changes.
switch (el.nodeName) {
case "SELECT":
case "INPUT":
// TODO: If this is a button type, then what?
IX.WireUpEventHandler(el, container, proxy, "value", "change", changedEvent);
break;
}
if (container[keyUpEvent]) {
switch (el.nodeName) {
case "INPUT":
// TODO: If this is a button type, then what?
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.
WireUpEventHandler
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:
// buttons are click events, not change properties.
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.
CustomConverter
private static CustomConverter<T>(container: T, ucPropName: string, newVal: string): any {
let converter = `onConvert${ucPropName}`;
if (container[converter]) {
newVal = container[converter](newVal);
}
return newVal;
}
CreateButtonHandlers
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":
// sort of not necessary to test type but a good idea,
// especially for checkboxes and radio buttons.
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;
}
}
}
}
});
}
CreateBinders
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.
// We assume binders are created on input elements. Probably not a great assumption.
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");
// Limited support at the moment.
if (typeAttr == "checkbox" || typeAttr == "radio") {
el.addEventListener("click", ev => {
let values: string[] = [];
// Get all the items currently checked
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 {
// Realtime typing
el.addEventListener("keyup", ev => {
let v = (ev.currentTarget as HTMLInputElement).value;
// proxy[elName] = v; --- why?
v = b.op === undefined ? v : b.op(v);
proxy[k] = v;
});
// Lost focus, or called when value is set programmatically in the proxy setter.
el.addEventListener("changed", ev => {
let v = (ev.currentTarget as HTMLInputElement).value;
v = b.op === undefined ? v : b.op(v);
proxy[k] = v;
});
}
});
}
}
});
}
Initialize
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]; // Force the proxy to handle the initial value.
break;
case "Array":
// Special handling of arrays that have an initial set of elements
// so we don't duplicate the elements.
// At this point, container[k] IS the proxy (IXArrayProxy)
// so we have the issue that the proxy is set to
// the array but the UI elements haven't been created. If we just do:
// proxy[k] = container[k];
// This will initialize the UI list but push duplicates of the into the array.
// So, for arrays, we want to create the array proxy
// as an empty array during initialization instead,
// then set the empty proxy to the container, then the container to the proxy.
if (container[k]._id != k) {
let newProxy = IXArrayProxy.Create(k, container);
newProxy[k] = container[k];
container[k] = newProxy;
}
break;
case "IXSelector":
// Similar to "Array" above, except we are proxying the IXSelector.options array,
// not the container itself.
if (container[k]._id != k) {
// Set the element that this IXSelector manages
// so we know what to do when value and text are assigned.
container[k]._element = document.getElementById(k);
let selector = container[k] as IXSelector;
// Proxy the options array so we can initialize it as well as push/pop.
if (selector.options.length > 0) {
let newProxy = IXArrayProxy.Create(k, container);
newProxy[k] = selector.options;
selector.options = newProxy;
}
}
break;
}
});
}
Arrays
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) {
// return true for this special property,
// so we know that we're dealing with a ProxyArray object.
if (prop == "_isProxy") {
return true;
}
// Setup for push and pop, preserve state when the setter is called.
// Very kludgy but I don't know of any other way to do this.
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) {
// we're looking for this pattern:
// "setting 0 for someList with value Learn Javascript"
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:
// We might be setting an array item, or we might be doing a push,
// in either case "prop" is the index value.
if (!isNaN(prop)) {
let el = document.getElementById(id);
switch (el.nodeName) {
// TODO: "UL"!
case "OL": {
let n = Number(prop);
let ol = el as HTMLOListElement;
if (n < ol.childNodes.length && !receiver._push) {
// We are replacing a node
// innerText or innerHTML?
(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;
// innerText or innerHTML?
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);
// TODO: remove all child elements?
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
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[] = [];
// We probably only usually want the new value, followed by the container,
// followed by the old value.
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));
}
}
IXTemplate
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.
Conclusion
Having come to the top of the other end of the U, I'm now reconsidering the entire implementation.
Do I need a Proxy?
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!";
Both Proxy and DOM Wrapper
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!
The Future Is Emerging
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!
History
- 5th July, 2020: Initial version