Merge pull request #13 from yoshuawuyts/rehydrate
toString: support rehydration
This commit is contained in:
@@ -21,6 +21,7 @@ productive package.
|
|||||||
- [keyboard](#keyboard)
|
- [keyboard](#keyboard)
|
||||||
- [websockets](#websockets)
|
- [websockets](#websockets)
|
||||||
- [Rendering in Node](#rendering-in-node)
|
- [Rendering in Node](#rendering-in-node)
|
||||||
|
- [Rehydration](#rehydration)
|
||||||
- [API](#api)
|
- [API](#api)
|
||||||
- [FAQ](#faq)
|
- [FAQ](#faq)
|
||||||
- [Installation](#installation)
|
- [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.
|
own][ws-reconnect] if you're building something for production.
|
||||||
|
|
||||||
## Rendering in Node
|
## Rendering in Node
|
||||||
Sometimes it's necessary to render code inside of Node; for serving first
|
Sometimes it's necessary to render code inside of Node; for serving hyper fast
|
||||||
requests, testing or other purposes. Applications that are capable of being
|
first requests, testing or other purposes. Applications that are capable of
|
||||||
rendered in both Node and the browser are called _[isomorphic][isomorphic]_.
|
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
|
Rendering in Node is slightly different than in the browser. First off, to
|
||||||
maintain performance all calls to `subscriptions`, `effects`, and `reducers`
|
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())
|
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
|
## API
|
||||||
### app = choo()
|
### app = choo()
|
||||||
Create a new `choo` app
|
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
|
`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
|
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`
|
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
|
some excellent npm packages. This is my take on modular frameworks; I hope
|
||||||
you'll find it pleasant.
|
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
|
[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
|
[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
|
[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/
|
||||||
|
|||||||
@@ -4,6 +4,14 @@ const mainView = require('./views/main')
|
|||||||
|
|
||||||
const app = choo()
|
const app = choo()
|
||||||
|
|
||||||
|
app.model({
|
||||||
|
namespace: 'message',
|
||||||
|
state: {
|
||||||
|
server: 'rehydration has kicked in, server data was tossed',
|
||||||
|
client: 'hello client!'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
app.router((route) => [
|
app.router((route) => [
|
||||||
route('/', mainView)
|
route('/', mainView)
|
||||||
])
|
])
|
||||||
@@ -11,6 +19,5 @@ app.router((route) => [
|
|||||||
if (module.parent) {
|
if (module.parent) {
|
||||||
module.exports = app
|
module.exports = app
|
||||||
} else {
|
} else {
|
||||||
const tree = app.start()
|
app.start('#app-root')
|
||||||
document.body.appendChild(tree)
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,7 +7,9 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"bankai": "^2.0.2",
|
"bankai": "^2.0.2",
|
||||||
|
"bl": "^1.1.2",
|
||||||
"browserify": "^13.0.1",
|
"browserify": "^13.0.1",
|
||||||
|
"hyperstream": "^1.2.2",
|
||||||
"server-router": "^2.1.0"
|
"server-router": "^2.1.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,7 @@
|
|||||||
const serverRouter = require('server-router')
|
const serverRouter = require('server-router')
|
||||||
|
const hyperstream = require('hyperstream')
|
||||||
|
const browserify = require('browserify')
|
||||||
|
const bankai = require('bankai')
|
||||||
const http = require('http')
|
const http = require('http')
|
||||||
|
|
||||||
const PORT = 8080
|
const PORT = 8080
|
||||||
@@ -17,18 +20,26 @@ server.listen(PORT, () => process.stdout.write(`listening on port ${PORT}\n`))
|
|||||||
// null -> fn
|
// null -> fn
|
||||||
function createRouter () {
|
function createRouter () {
|
||||||
const apiRouter = serverRouter('/404')
|
const apiRouter = serverRouter('/404')
|
||||||
apiRouter.on('/404', (req, res) => res.end('not found'))
|
|
||||||
apiRouter.on('/', (req, res) => {
|
apiRouter.on('/404', (req, res) => res.end('404 not found'))
|
||||||
res.end(JSON.stringify({ routes: [ '/', '/404' ] }))
|
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
|
return apiRouter
|
||||||
}
|
}
|
||||||
|
|
||||||
// render the client to string
|
// render the client to string
|
||||||
// based on the requested url
|
// based on the requested url
|
||||||
// (obj, obj) -> null
|
// (obj, obj) -> null
|
||||||
|
const createIndex = bankai.html({ favicon: false, css: false })
|
||||||
function handleHtml (req, res) {
|
function handleHtml (req, res) {
|
||||||
const html = client.toString(req.url, { message: 'hello server!' })
|
|
||||||
res.setHeader('Content-Type', 'text/html; charset=utf-8')
|
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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,25 @@
|
|||||||
|
const assert = require('assert')
|
||||||
const choo = require('../../../')
|
const choo = require('../../../')
|
||||||
|
|
||||||
module.exports = function (params, state, send) {
|
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`
|
return choo.view`
|
||||||
<section>
|
<section id="app-root">
|
||||||
<h1>${message}</h1>
|
<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>
|
</section>
|
||||||
`
|
`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,14 +25,23 @@ function choo () {
|
|||||||
|
|
||||||
// render the application to a string
|
// render the application to a string
|
||||||
// (str, obj) -> str
|
// (str, obj) -> str
|
||||||
function toString (route, state) {
|
function toString (route, serverState) {
|
||||||
const initialState = {}
|
const initialState = {}
|
||||||
|
const nsState = {}
|
||||||
|
|
||||||
_models.forEach(function (model) {
|
_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')
|
throw new Error('send() cannot be called on the server')
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -40,8 +49,9 @@ function choo () {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// start the application
|
// start the application
|
||||||
// obj -> DOMNode
|
// (str?, obj?) -> DOMNode
|
||||||
function start (opts) {
|
function start (rootId, opts) {
|
||||||
|
if (!opts) opts = rootId
|
||||||
opts = opts || {}
|
opts = opts || {}
|
||||||
const name = opts.name || 'choo'
|
const name = opts.name || 'choo'
|
||||||
const initialState = {}
|
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,
|
// on update. Since the DOM nodes change between updates,
|
||||||
// we must call document.querySelector() to find the root.
|
// we must call document.querySelector() to find the root.
|
||||||
// Use different names when loading multiple choo applications
|
// Use different names when loading multiple choo applications
|
||||||
// on the same page
|
// on the same page
|
||||||
const rootId = name + '-root'
|
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)
|
const tree = _router(send.state().app.location, send.state(), send)
|
||||||
tree.setAttribute('id', rootId)
|
tree.setAttribute('id', rootId)
|
||||||
return tree
|
return tree
|
||||||
|
}
|
||||||
|
|
||||||
// handle an action by either reducers, effects
|
// handle an action by either reducers, effects
|
||||||
// or both - return the new state when done
|
// or both - return the new state when done
|
||||||
|
|||||||
@@ -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
@@ -30,7 +30,7 @@ tape('should render on the server', function (t) {
|
|||||||
t.equal(html, expected, 'strings are equal')
|
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)
|
t.plan(1)
|
||||||
|
|
||||||
const app = choo()
|
const app = choo()
|
||||||
@@ -47,6 +47,33 @@ tape('should render on the server', function (t) {
|
|||||||
t.equal(html, expected, 'strings are equal')
|
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.test('should throw if called without route', function (t) {
|
||||||
t.plan(1)
|
t.plan(1)
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user