I write a lot of small single page apps and games using TypeScript. Even with games there is often a need to interact with input fields on the page, for example to allow the user to change settings or show a help panel.
In the past I have used Angular to do that. But the more I used Angular the more I felt like I was wasting a bunch of time fighting with the framework. Angular, in my opinion, is too heavy for small apps and introduces too much complexity and overhead. I love its templating features but don't like being locked into its way of doing things. It's like driving a semi truck when all I need is a compact car.
So I started looking around for a replacement and soon found that I liked
Knockout.js. It's really easy to use and doesn't force you to use a monolithic framework. I found it to work excellently for the size of apps I'm usually writing.
Today I thought I would write about the experience I've had making Knockout and TypeScript work nice together. There is a type definition file in the Definitely Typed package for KO that gives you all of the tooling you need (there's a Nuget package available), so right out of the gate you're getting a better experience than using plain JavaScript. However, all of the online documentation for KO is for JavaScript so you need to make a few modifications to optimize it for TypeScript.
KO relies heavily on view models, which are just plain objects that KO uses to interact with the view. The view model can have constant values, observable values, or computed values. Constant values are object members that aren't tracked, therefore if you change them the view won't get updated.
Observables are at the heart of KO. These values are properties that track when the value of the property has changed. Therefore if you change its value the view will be notified to update the value. And vice versa; if you change an input in the view it will update the value in the model. You can also add your own handlers to track changes of observables if you want. You create an observable by calling ko.observable() passing it a default value (note: "ko" is the global Knockout object).
Computed values are computed from one or more observables. For example you might want to convert a value to a percentage to display in the view. Like observables computed values are tracked by the view and automatically updated. You create a computed value by calling ko.computed() and giving it a function that computes the value.
Let's take a look at a simple view model. You can use an object literal for a view model, and this may work for very simple ones, but more often than not you'll find yourself needing to define a class instead. I'll explain why a little later.
class ViewModel {
public appName = "My Application";
public taskName = ko.observable("");
public pctComplete = ko.observable("0");
}
Here appName is a constant value, and taskName and pctComplete are observable values. Although I haven't explicitly specified a type for the observables, they are generics and it implicitly defines them as observable of strings (the type is KnockoutObservable<string>).
You're probably wondering why I didn't make pctComplete a number instead of a string. This is one of the areas where KO and TypeScript don't interact too well together. Since JavaScript doesn't know anything about types, neither does KO. You can make an observable of any type, but if you bind it to an input field in your view (even if type=number) it will replace the value with a string. So you might as well save yourself the trouble and always use a string, then convert it as you need to.
OK, great, we have a view model. Now we need to tell KO about it. For that you call ko.applyBindings() passing it an instance of the view model.
var viewModel = new ViewModel();
ko.applyBindings(viewModel);
Now KO will apply the view model to your view. Let's define a view that uses the model.
<h1 data-bind="text: appName"></h1>
<input type="text" data-bind="value: taskName" />
<input type="number" min="0" max="100" step="1" data-bind="value: pctComplete" />
KO makes use of the data-bind attribute to interact with it in HTML. First we're telling it to set the
text of the h1 element to whatever the value of appName is in the view model. Then we set the
value of the text input element to the value of taskName. Finally we set the value of the number input element to the value of pctComplete.
Now if you were to change any of the values of the input fields the values in the view model will automatically get updated as well.
Lets add a computed field now. Say we want a boolean value called isComplete and it will be set to true if and only if pctComplete is 100.
class ViewModel {
// ...
public isComplete: KnockoutComputed<boolean>;
constructor() {
this.isComplete = ko.computed(() => this.pctComplete() === "100");
}
}
Remember earlier when I said you probably want to use a class instead of an object literal? Computed values are the reason why. You can't define computed values in an object literal that reference other properties of the object. Therefore you're going to need to do it in a constructor.
So what we did here was define an isComplete member of the object and type it as a computed boolean value using KnockoutComputed<boolean>. In the constructor is where we define the function behind the computed value. Here we are defining a function that returns true if the value of pctComplete is "100". Note that we need to use parens to get the value of pctComplete. That's because observables are actually property functions that either get or set the value of the property.
Now we can add a checkbox to the view that uses the computed value.
<input type="checkbox" data-bind="checked: isComplete" />
Here we tell the checkbox to become
checked whenever isComplete is true. Now if we were to change the value of the pctComplete input to "100" the checkbox would become checked.
Lets look at one more basic feature of KO view models. In addition to the three properties of a view model I mentioned above you may also define functions. Functions are useful for handing click events in your view. Say we wanted to add a reset button, then we would need a function to reset the view model.
class ViewModel {
// ...
public reset(): void {
this.pctComplete("0");
this.taskName("");
}
}
Here's the button in the view.
<button data-bind="click: reset">Reset</button>
We create a button and tell KO that on
click to call the reset() function in the view model. Now if we click the reset button the pctComplete and taskName fields will be reset.
Those are the basics of using KO with TS. In another post I'll go over some more advanced topics like view models within view models and creating custom elements.
<jmg/>