:metal: KnockoutJS Goodies Monorepo
The real power and extensibility of the router comes in the form of middleware. In this case, middleware is a series of functions, sync or async, that compose a route.
If used correctly, you can have complete control over the lifecycle of each view and keep your viewModel as slim as possible (think skinny controllers, fat models).
App middleware is ran for every route and is registered using Router.use
import { Router } from '@profiscience/knockout-contrib'
Router.use(fn)
First, let’s look at some code…
This…
{
'/user/:id': 'user'
}
…is really just shorthand for…
{
'/user/:id': ['user']
}
…which is really just shorthand for…
{
'/user/:id': [(ctx) => ctx.route.component = 'user']
}
…so with that in mind, let’s talk route middleware.
As you can — hopefully — see, each route boils down to an array of functions: middleware.
To add middleware to a route, simply add it to the array…
{
'/user/:id': [
fn,
'user'
]
}
NOTE: Putting functions after the component will not cause the functions to run after the component is rendered. For how to accomplish that, keep reading.
Middleware functions are passed a context object as the first and only argument, and may optionally return a promise that will delay execution of the next middleware until resolved (to run middleware concurrently, call ctx.queue
with the promise instead of returning it).
Let’s look at some example logging middleware…
import { Router } from '@profiscience/knockout-contrib'
Router.use((ctx) => console.log('[router] navigating to', ctx.pathname))
But wait, there’s more!
Take our users route from earlier, and let’s posit that you’re trying to refactor your data calls out of the viewmodel…
{
'/user/:id': [
(ctx) => getUser().then((u) => ctx.user = u),
'user'
]
}
In the viewmodel for the user
component, ctx.user
will contain the user. Since
we’re returning a promise, the next middleware (in this case the component setter)
will not be executed until after the call has completed. If you wished to continue
middleware execution immediately, but still ensure any asynchronous operations
have completed before render, you could use ctx.queue
.
Let’s see how we can take some finer control. As has been the theme, you’ve got options…
You can return an object from your middleware that contains functions to be executed at different points in the page lifecycle.
import Query from 'ko-query'
export default function (ctx) {
return {
beforeRender() {
console.log('[router] navigating to', ctx.pathname)
ctx.query = new Query({}, ctx.pathname)
return loadSomeAsyncData.then((data) => {
ctx.data = data
})
},
afterRender() {
console.log('[router] navigated to', ctx.pathname)
},
beforeDispose() {
console.log('[router] navigating away from', ctx.pathname)
},
afterDispose() {
console.log('[router] navigated away from', ctx.pathname)
},
}
}
You may be wondering, “why a function returning an object instead of just an object?”
Well, if you read the docs on nested routing, you’ll see that you can define routes by passing an object to a route. To avoid too much polymorphism that could cause confusion, this was the ideal approach. It also enables dynamic middleware and more meta-programming opportunities.
Now for the real fun — in my humble opinion, of course —, generator middleware.
If you’re unfamiliar with generators, read up, but fear not. In short, they are functions that are able to suspend and resume execution.
Let’s write the same monolithicMiddleware
with a generator, then walk through what is going on…
import { Router } from '@profiscience/knockout-contrib-router'
import Query from 'ko-query'
function* monolithicMiddleware(ctx) {
console.log('[router] navigating to', ctx.pathname)
ctx.query = new Query({}, ctx.pathname)
yield loadSomeAsyncData().then((data) => (ctx.data = data))
console.log('[router] navigated to', ctx.pathname)
yield
console.log('[router] navigating away from', ctx.pathname)
yield
console.log('[router] navigated away from', ctx.pathname)
ctx.query.dispose()
}
Router.use(monolithicMiddleware)
Hopefully it’s pretty obvious what is going on here, but if not, I’ll elaborate.
Generator middleware is expected to yield up to 3 times, and will be resumed at the same points in the lifecycle: beforeRender, afterRender, beforeDispose, and afterDispose.
Function entry to the first yield
contains logic to be executed before the component
is initialized, the second just after render, the third just before dispose, and the last
just after.
For async with generator middleware, yield a promise or use an async generator. The following are equivalent…
function* middleware(ctx): IterableIterator<Promise<void>> {
yield
yield Promise.resolve()
}
async function* middleware(ctx): AsyncIterableIterator<void> {
yield
await Promise.resolve()
yield
}
NOTE: This will actually work with any iterable, that is _anything with a
.next()
method. Want to write your middleware using ES2017 Observables? Go for it.
I :heart: future JS.
Assuming navigation from from => to, where “X/app” indicates app middleware for route X, middleware is executed in the following order…
from/app: before dispose
to: before render
to: render
from/app: after dispose
Why is the next page’s before render middleware called before this one is disposed entirely!?
Good question. This gives the best possible UX by preventing intermediate whitespace while asynchronous beforeRender middleware is executing. See the loading-animation example for more.
When used with nested routing, child middleware will also be executed as part of the execution chain, meaning as long as data is gathered in middleware, it will all be available down the chain for a deep synchronous render.