Merge pull request #13 from yoshuawuyts/rehydrate

toString: support rehydration
This commit is contained in:
Yoshua Wuyts
2016-05-24 03:17:31 +09:00
8 changed files with 159 additions and 33 deletions
+48 -4
View File
@@ -21,6 +21,7 @@ productive package.
- [keyboard](#keyboard)
- [websockets](#websockets)
- [Rendering in Node](#rendering-in-node)
- [Rehydration](#rehydration)
- [API](#api)
- [FAQ](#faq)
- [Installation](#installation)
@@ -310,9 +311,10 @@ queueing data. You might want to use a package from `npm` or [write your
own][ws-reconnect] if you're building something for production.
## Rendering in Node
Sometimes it's necessary to render code inside of Node; for serving first
requests, testing or other purposes. Applications that are capable of being
rendered in both Node and the browser are called _[isomorphic][isomorphic]_.
Sometimes it's necessary to render code inside of Node; for serving hyper fast
first requests, testing or other purposes. Applications that are capable of
being rendered in both Node and the browser are called
_[isomorphic][isomorphic]_.
Rendering in Node is slightly different than in the browser. First off, to
maintain performance all calls to `subscriptions`, `effects`, and `reducers`
@@ -352,6 +354,45 @@ if (module.parent) module.exports = app
else document.body.appendChild(app.start())
```
### Rehydration
Now that your application is succesfully rendering in Node, the next step would
be to make it load a JavaScript bundle once has loaded the HTML. To do this we
will use a technique called _rehydration_.
_Rehydration_ is when you take the static, server-rendered version of your
application (static HTML, _dehydrated_ because it has no logic) and _rehydrate_
it by booting up the JS and attaching event handlers on the DOM to make it
dynamic again. It's like restoring flavor to cup noodles by adding hot water.
Because we're using something called `morphdom` under the hood, all we need is
point at an `id` at the root of the application. The syntax for this is
slightly different from what we've seen so far, because we're _updating_ a
dehydrated DOM nodes to make them dynamic, rather than a new DOM tree and
attaching it to the DOM.
```js
const choo = require('choo')
const app = choo()
app.router((route) => [
route('/', (params, state, send) => choo.view`
<h1 id="app-root">${state.message}</h1>
`)
])
if (module.parent) module.exports = app
else app.start('#app-root'))
```
When the JS is booted on top of the dehydrated application, it will look for
the `#app-root` id and load on top of it. You can choose any name you like for
the id, but __make sure it's the same on every possible top level DOM node__,
or else things might break. Furthermore to ensure things go smoothly, try and
keep the initial state identical on both the server and the client.
And that's it! If you want to go down the route of mad performance, consider
make all first request static and caching them using something like [bl][bl],
[nginx][nginx], [varnish][varnish] or a global CDN.
## API
### app = choo()
Create a new `choo` app
@@ -404,7 +445,7 @@ for the past year. I originally used `virtual-dom` with `virtual-app` and
`wayfarer` where now it's `yo-yo` with `send-action` and `sheet-router`. The
main benefit of using `choo` over these technologies separately is that it
becomes easier for teams to pick up and gather around. The code base for `choo`
itself is super petite (`~150` LOC) and mostly acts to enforce structure around
itself is super petite (`~200` LOC) and mostly acts to enforce structure around
some excellent npm packages. This is my take on modular frameworks; I hope
you'll find it pleasant.
@@ -539,3 +580,6 @@ $ npm install choo
[module-parent]: https://nodejs.org/dist/latest-v6.x/docs/api/modules.html#modules_module_parent
[sse-reconnect]: http://stackoverflow.com/questions/24564030/is-an-eventsource-sse-supposed-to-try-to-reconnect-indefinitely
[ws-reconnect]: http://stackoverflow.com/questions/13797262/how-to-reconnect-to-websocket-after-close-connection
[bl]: https://github.com/rvagg/bl
[varnish]: https://varnish-cache.org
[nginx]: http://nginx.org/
+9 -2
View File
@@ -4,6 +4,14 @@ const mainView = require('./views/main')
const app = choo()
app.model({
namespace: 'message',
state: {
server: 'rehydration has kicked in, server data was tossed',
client: 'hello client!'
}
})
app.router((route) => [
route('/', mainView)
])
@@ -11,6 +19,5 @@ app.router((route) => [
if (module.parent) {
module.exports = app
} else {
const tree = app.start()
document.body.appendChild(tree)
app.start('#app-root')
}
+2
View File
@@ -7,7 +7,9 @@
},
"dependencies": {
"bankai": "^2.0.2",
"bl": "^1.1.2",
"browserify": "^13.0.1",
"hyperstream": "^1.2.2",
"server-router": "^2.1.0"
}
}
+17 -6
View File
@@ -1,4 +1,7 @@
const serverRouter = require('server-router')
const hyperstream = require('hyperstream')
const browserify = require('browserify')
const bankai = require('bankai')
const http = require('http')
const PORT = 8080
@@ -17,18 +20,26 @@ server.listen(PORT, () => process.stdout.write(`listening on port ${PORT}\n`))
// null -> fn
function createRouter () {
const apiRouter = serverRouter('/404')
apiRouter.on('/404', (req, res) => res.end('not found'))
apiRouter.on('/', (req, res) => {
res.end(JSON.stringify({ routes: [ '/', '/404' ] }))
})
apiRouter.on('/404', (req, res) => res.end('404 not found'))
apiRouter.on('/', (req, res) => res.end('nothing to be found here'))
const js = bankai.js(browserify, require.resolve('./client.js'))
apiRouter.on('/bundle.js', (req, res) => js(req, res).pipe(res))
return apiRouter
}
// render the client to string
// based on the requested url
// (obj, obj) -> null
const createIndex = bankai.html({ favicon: false, css: false })
function handleHtml (req, res) {
const html = client.toString(req.url, { message: 'hello server!' })
res.setHeader('Content-Type', 'text/html; charset=utf-8')
res.end(html)
const state = { message: { server: 'hello server!' } }
const inner = client.toString(req.url, state)
const hs = hyperstream({ 'body': { _appendHtml: inner } })
createIndex(req, res).pipe(hs).pipe(res)
}
+18 -3
View File
@@ -1,10 +1,25 @@
const assert = require('assert')
const choo = require('../../../')
module.exports = function (params, state, send) {
const message = state.message
const serverMessage = state.message.server
const clientMessage = state.message.client
assert.equal(typeof serverMessage, 'string', 'server should be a string')
assert.equal(typeof clientMessage, 'string', 'server should be a string')
return choo.view`
<section>
<h1>${message}</h1>
<section id="app-root">
<h1>server message: ${serverMessage}</h1>
<h1>client message: ${clientMessage}</h1>
<p>${`
The first message is passed in by the server on compile time,
the second message was set by the client.
The more static the data you pass in, the more cachable your site
beocmes (and thus performant). Try and keep the amount of properties
you pass in on the server to a minimum for most applications - it'll
make life a lot easier in the long run, hah.
`}</p>
</section>
`
}
+37 -10
View File
@@ -25,14 +25,23 @@ function choo () {
// render the application to a string
// (str, obj) -> str
function toString (route, state) {
function toString (route, serverState) {
const initialState = {}
const nsState = {}
_models.forEach(function (model) {
if (model.state) apply(model.name, model.state, initialState)
const ns = model.namespace
if (ns) {
if (!nsState[ns]) nsState[ns] = {}
apply(ns, model.state, nsState)
nsState[ns] = xtend(nsState[ns], serverState[ns])
} else {
apply(model.namespace, model.state, initialState)
}
})
const tree = _router(route, xtend(initialState, state), function () {
const state = xtend(initialState, xtend(serverState, nsState))
const tree = _router(route, state, function () {
throw new Error('send() cannot be called on the server')
})
@@ -40,8 +49,9 @@ function choo () {
}
// start the application
// obj -> DOMNode
function start (opts) {
// (str?, obj?) -> DOMNode
function start (rootId, opts) {
if (!opts) opts = rootId
opts = opts || {}
const name = opts.name || 'choo'
const initialState = {}
@@ -75,15 +85,32 @@ function choo () {
}
})
// the rootId is determined to find the application root
// If an id is provided, the application will rehydrate
// on the node. If no id is provided it will return
// a tree that's ready to be appended to the DOM.
//
// The rootId is determined to find the application root
// on update. Since the DOM nodes change between updates,
// we must call document.querySelector() to find the root.
// Use different names when loading multiple choo applications
// on the same page
const rootId = name + '-root'
const tree = _router(send.state().app.location, send.state(), send)
tree.setAttribute('id', rootId)
return tree
if (rootId) {
document.addEventListener('DOMContentLoaded', function (event) {
rootId = rootId.replace(/^#/, '')
const oldTree = document.querySelector('#' + rootId)
assert.ok(oldTree, 'could not find node #' + rootId)
const newTree = _router(send.state().app.location, send.state(), send)
yo.update(oldTree, newTree)
})
} else {
rootId = name + '-root'
const tree = _router(send.state().app.location, send.state(), send)
tree.setAttribute('id', rootId)
return tree
}
// handle an action by either reducers, effects
// or both - return the new state when done
-7
View File
@@ -1,7 +0,0 @@
const test = require('tape')
const choo = require('./')
test('should assert input types', function (t) {
t.plan(1)
t.throws(choo)
})
+28 -1
View File
@@ -30,7 +30,7 @@ tape('should render on the server', function (t) {
t.equal(html, expected, 'strings are equal')
})
t.test('should extend existing models', function (t) {
t.test('should extend flat existing models', function (t) {
t.plan(1)
const app = choo()
@@ -47,6 +47,33 @@ tape('should render on the server', function (t) {
t.equal(html, expected, 'strings are equal')
})
t.test('should extend namespaced existing models', function (t) {
t.plan(1)
const app = choo()
app.model({
namespace: 'hello',
state: { bin: 'baz', beep: 'boop' }
})
app.router((route) => [
route('/', function (params, state) {
return choo.view`
<h1>${state.hello.foo} ${state.hello.bin} ${state.hello.beep}</h1>
`
})
])
const state = {
hello: {
foo: 'bar!',
beep: 'beep'
}
}
const html = app.toString('/', state)
const expected = '<h1>bar! baz beep</h1>'
t.equal(html, expected, 'strings are equal')
})
t.test('should throw if called without route', function (t) {
t.plan(1)