Fornt-end with Knockout.js, require.js and TypeScript
Let’s talk about how to correctly organize the front-end with Knockout.js require.js and TypeScript.
The problem
If we read TypeScript handbook we will find a lot of information about how to load modules with AMD and require.js, and everywhere in samples, we will find something like this
import module=require('./module');
But in a real application we always have some folder structure to keep the files organized, we are using different package managers and so on, thus in most cases import should look like
import ko=require('./node_modules/knockout/build/output/knockout-latest')
Unfortunately, for some unknown reason, this is not working with TypeScript, at least with versions 1.3 and 1.4. Really, why current folder path is working, but the more complex path is not? We have to deal somehow with this.
The only way is to use import ko=require(knockout)
instead of the full path to knockout.
In this post I will describe the way how to build HTML application with MS VisualStudio and I will use node package manager to load all the libraries, but the same idea will work for nuget or any other package managed and IDE.
Application structure
- node_modules
- knockout
- build
- output
- knockout-latest.js
- output
- build
- requirejs
- require.js
- moment
- moment.js
- knockout
- typings
- knockout
- knockout.d.ts
- moment
- moment.d.ts
- knockout
- config.js
- application.ts
- mainViewmodel.ts
- bindings.ts
- index.html
Require.js enabled JavaScript (or TypeScript) application should start with single “ tag in html. In our case it looks like:
<script data-main='config.js' src='node_modules/requirejs/require.js'></script>
This config.js is the only JavaScript file, all other logic is done in TypeScript. Maybe there is some way to write it on TypeScript, but I’m not sure that it makes any sense, because you have to do JS specific low level things here. The config.js looks like the following:
require.config({ baseUrl: "", paths: { knockout: "./node_modules/knockout/build/output/knockout-latest", moment: "./node_modules/moment/moment" } }); define(["require", "exports", 'application'], function (require, exports, app) { app.Application.instance = new app.Application(); });
Firstly, in this file, we are configuring require.js to make it understand where to search for libraries. We will load our index.html from the file system and of course in a real app you should not use folder structure but think about URLs. Please note that you should not specify a file extension.
Now require.js will understand how to load knockout. But this will tell nothing to our TypeScript compiler and the compiler will report errors about the undefined module.
To fix this problem with the compiler simply add corresponding typings to the project. Now TypeScript will build everything without errors. Please note that in this case, TypeScript will not verify the correctness of the path to modules because it can’t determine the real URL structure of the application. That may be the reason of complex path is not working in import.
Note: don’t forget to switch TypeScript module type to AMD (Asynchronous Module Definition). This will conflict with node.js and next time I will explain how to deal with node.js and AMD.
Application startup
Our application entry point (after config.js
) is application.ts file with the following content:
import vm = require('mainViewModel'); import ko = require('knockout'); import bindings = require('bindings'); export class Application{ public static instance: Application; constructor(){ bindings.register(); ko.applyBindings(new vm.mainViewModel()); } }
Here we load module(s) (as dependency) with all custom bindings, create main view model and apply it to whole page.
Note that we don’t need to specify a path to bindings and mainViewModel in config.js
because they are located in the same directory. You can use more complex structure and everything will work with TypeScript just don’t forget to explain require.js how to find all your modules.
Custom bindings
Custom binding is wrapped in a single module and can be loaded as any other module. Binding handlers will be registered with bindings.register()
the call. This can be done with the following content of bindings.ts
:
import ko = require("knockout") import moment = require("moment") export function register(): void { ko.expressionRewriting["_twoWayBindings"].datevalue = true; var formatValue = function (value, format) { format = ko.unwrap(format); if (format == null) { format = "DD.MM.YYYY"; } return moment(ko.unwrap(value).toString()).format(format); } ko.bindingHandlers["datevalue"] = { init: function (element: HTMLInputElement, valueAccessor, allBindings, viewModel) { element.value = formatValue(valueAccessor(), allBindings.get("dateFormat")); element.addEventListener("change", function (event) { var dateValue: any = moment(element.value, ko.unwrap(allBindings.get("dateFormat"))) .format("YYYY-MM-DD") + "T00:00:00"; if (ko.unwrap(valueAccessor()) instanceof Date) { dateValue = new Date(dateValue); } if (ko.isObservable(valueAccessor())) { valueAccessor()(dateValue); } else { allBindings()._ko_property_writers.datevalue(dateValue); } }); }, update: function (element: HTMLInputElement, valueAccessor, allBindings) { element.value = formatValue(valueAccessor(), allBindings.get("dateFormat")); } } }
Here we create very usefully datevalue
binding, which allows editing and displaying dates as strings in a specific format. This binding is able to work with observables and flat values and store data in JSON compatible format or Date, depending on the initial value of the bound property. This binding contains some knockout and TypeScript tricks like ko.expressionRewriting["_twoWayBindings"].datevalue = true
and, butallBindings()._ko_property_writers.datevalue(dateValue)
but let’s talk in the next blog posts about these tricks.
ViewModel
Nothing special, just usual view model organized as a module
import ko = require('knockout'); export class mainViewModel{ constructor(){ } public name = ko.observable("John Doe"); public birthday = ko.observable("1983-01-01"); }
Conclusion
Everybody is waiting for ECMASript 6 support in all browsers with all sweet things like classes, arrows, modules, and so on. Life is too short to wait — let’s use TypeScript today! I’ve tested it on a big project and yes, sometimes it looks a little raw, but it’s working and make our life easier with type check and better intelligence.