yoshuawuyts / choo
- пятница, 24 июня 2016 г. в 03:14:55
JavaScript
🚂 🚋 🚋 🚋 - sturdy frontend framework
7kb
framework for creating sturdy frontend applications
7kb
, choo
is a tiny little frameworkbrowserify
compilereffects
and subscriptions
brings
clarity to IOnote: If you've built something cool using choo
or are using it in
production, we'd love to hear from you!
Let's create an input box that changes the content of a textbox in real time. Click here to see the final app.
First we import choo
and create a new instance:
const choo = require('choo')
const app = choo()
Then we define a model. We set an initial value of state
and a reducer
that
can be called to modify it:
app.model({
state: { title: 'Set the title' },
reducers: {
update: (action, state) => ({ title: action.value })
}
})
Then we create a new view. It has an h1
tag which displays the current title,
and an <input>
field which sends the current value of the text box on every
input:
const mainView = (params, state, send) => choo.view`
<main>
<h1>${state.title}</h1>
<input
type="text"
oninput=${(e) => send('update', { value: e.target.value })}>
</main>
`
Note: if an id
property is defined on the outer-most element it will be
replaced.
We then bind the view to the /
route on our application
app.router((route) => [
route('/', mainView)
])
And then start the app and append it to the DOM. You can now run it and see it in action!
const tree = app.start()
document.body.appendChild(tree)
And all together now:
const choo = require('choo')
const app = choo()
app.model({
state: { title: 'Set the title' },
reducers: {
update: (action, state) => ({ title: action.value })
}
})
const mainView = (params, state, send) => choo.view`
<main>
<h1>${state.title}</h1>
<input
type="text"
oninput=${(e) => send('update', { value: e.target.value })}>
</main>
`
app.router((route) => [
route('/', mainView)
])
const tree = app.start()
document.body.appendChild(tree)
choo
cleanly structures internal data flow, so that all pieces of logic can
be combined into a nice, cohesive machine. Internally all logic lives within
models
that contain several properties. subscriptions
are functions that
are called at startup and have send()
passed in, so they act as read-only
sources of data. effects
react to changes, perform an action
and can then
post the results. reducers
take data, modify it, and update the internal
state
.
Communication of data is done using objects called actions
. Each action
has
any number of properties for data, and a unique type
that can trigger
properties on the models.
When a reducer
modifies state
, the router
is called, which in turn calls
views
. views
take state
and return DOM nodes which are then
efficiently rendered on the screen.
In turn when the views
are rendered, the user
can interact with elements by
clicking on them, triggering actions
which then flow back into the
application logic. This is the unidirectional architecture of choo
.
┌─────────────────┐
│ Subscriptions ─┤ User ───┐
└─ Effects ◀─────┤ ▼
┌─ Reducers ◀─────┴──Actions── DOM ◀┐
│ │
└▶ Router ─────State ───▶ Views ────┘
effects
and reducers
that have been registered in models
subscriptions
,
effects
, reducers
and initial state
actions
action
when donestate
view
to renderstate
and returns a new DOM tree
that is rendered in the
browsermodels
are objects that contain initial state
, subscriptions
, effects
and reducers
. They're generally grouped around a theme (or domain, if you
like). To provide some sturdiness to your models
, they can either be
namespaced or not. Namespacing means that only state within the model can be
accessed. Models can still trigger actions on other models, though it's
recommeded to keep that to a minimum.
So say we have a todos
namespace, an add
reducer and a todos
model.
Outside the model they're called by send('todos:add')
and
state.todos.todos
. Inside the namespaced model they're called by
send('todos:add')
and state.todos
. An example namespaced model:
const app = choo()
app.model({
namespace: 'todos',
state: { todos: [] },
reducers: {
add: (action, state) => ({ todos: state.todos.concat(action.payload) })
}
})
In most cases using namespaces is beneficial, as having clear boundries makes
it easier to follow logic. But sometimes you need to call actions
that
operate over multiple domains (such as a "logout" action
), or have a
subscription
that might trigger multiple reducers
(such as a websocket
that calls a different action
based on the incoming data).
In these cases you probably want to have a model
that doesn't use namespaces,
and has access to the full application state. Try and keep the logic in these
models
to a minimum, and declare as few reducers
as possible. That way the
bulk of your logic will safely shielded, with only a few points touching every
part of your application.
Side effects are done through effects
declared in app.model()
. Unlike
reducers
they cannot modify the state by returning objects, but get a
callback passed which is used to emit actions
to handle results. Use effects
every time you don't need to modify the state object directly, but wish to
respond to an action.
A typical effect
flow looks like:
Subscriptions are a way of receiving data from a source. For example when
listening for events from a server using SSE
or Websockets
for a
chat app, or when catching keyboard input for a videogame.
An example subscription that logs "dog?"
every second:
const app = choo()
app.model({
namespace: 'app',
subscriptions: [
(send) => setInterval(() => send('app:print', { payload: 'dog?' }), 1000)
],
effects: {
print: (action, state) => console.log(action.payload)
}
})
The router
manages which views
are rendered at any given time. It also
supports rendering a default view
if no routes match.
const app = choo()
app.router('/404', (route) => [
route('/', require('./views/empty')),
route('/404', require('./views/error')),
route('/:mailbox', require('./views/mailbox'), [
route('/:message', require('./views/email'))
])
])
Routes on the router
are passed in as a nested array. This means that the
entry point of the application also becomes a site map, making it easier to
figure out how views relate to each other.
Under the hood choo
uses sheet-router. Internally the
currently rendered route is kept in state.app.location
. If you want to modify
the location programmatically the reducer
for the location can be called
using send('app:location', { location: href })
. This will not work from
within namespaced models
, and usage should preferably be kept to a minimum.
Changing views all over the place tends to lead to messiness.
Views are pure functions that return a DOM tree for the router to render. They’re passed the current state, and any time the state changes they’re run again with the new state.
Views are also passed the send
function, which they can use to dispatch actions that can update the state. For example, the DOM tree can have an onclick
handler that dispatches an add
action.
const view = (params, state, send) => {
return choo.view`
<div>
<h1>Total todos: ${state.todos.length}</h1>
<button onclick=${(e) => send('add', { payload: {title: 'demo'})}>Add</button>
</div>`
}
In this example, when the Add
button is clicked, the view will dispatch an add
action that the model’s add
reducer will receive. As seen above, the reducer will add an item to the state’s todos
array. The state change will cause this view to be run again with the new state, and the resulting DOM tree will be used to efficiently patch the DOM.
choo
ships with a built-in http
module
that weighs only 2.4kb
:
const http = require('choo/http')
const choo = require('choo')
const app = choo()
app.model({
effects: {
'app:error': (state, event) => console.error(`error: ${event.payload}`)),
'app:print': (state, event) => console.log(`http: ${event.payload}`),
'http:get_json': getJson,
'http:post_json': postJson,
'http:delete': httpDelete
}
})
function getJson (state, action, send) {
http.get('/my-endpoint', { json: true }, function (err, res, body) {
if (err) return send('app:error', { payload: err.message })
if (res.statusCode !== 200 || !body) {
return send('app:error', { payload:'something went wrong' })
}
send('app:print', { payload: body })
})
}
function postJson (state, action, send) {
const body = { foo: 'bar' }
http.post('/my-endpoint', { json: body }, function (err, res, body) {
if (err) return send('app:error', { payload: err.message })
if (res.statusCode !== 200 || !body) {
return send('app:error', { payload:'something went wrong' })
}
send('app:print', { payload: body })
})
}
function httpDelete (state, action, send) {
const body = { foo: 'bar' }
http.post('/my-endpoint', { json: body }, function (err, res, body) {
if (err) return send('app:error', { payload: err.message })
if (res.statusCode !== 200) {
return send('app:error', { payload:'something went wrong' })
}
})
}
Note that http
only runs in the browser to prevent accidental requests when
rendering in Node. For more details view the raynos/xhr
documentation.
Server Sent Events (SSE) allow servers to push data to the browser.
They're the unidirectional cousin of websockets
and compliment HTTP
brilliantly. To enable SSE
, create a new EventSource
, point it at a local
uri (generally /sse
) and setup a subscription
:
const stream = new document.EventSource('/sse')
app.model({
subscriptions: [
function (send) {
stream.onerror = (e) => send('app:error', { payload: JSON.stringify(e) })
stream.onmessage = (e) => send('app:print', { payload: e.data })
}
],
effects: {
'sse:close': () => stream.close(),
'app:error': (state, event) => console.error(`error: ${event.payload}`),
'app:print': (state, event) => console.log(`sse: ${event.payload}`)
}
})
This code does not handle reconnects, server timeouts, exponential backoff and
queueing data. You might want to use a package from npm
or write your
own if you're building something for production.
Most browsers have basic support for keyboard events. To
capture keyboard events, setup a subscription
:
app.model({
namespace: 'input',
subscriptions: [
function (send) {
onkeypress = (e) => send('input:print', { payload: e.keyCode })
}
],
effects: {
print: (state) => console.log(`pressed key: ${state.payload}`)
}
})
WebSockets allow for bidirectional communication between servers and browsers:
const socket = new document.WebSocket('ws://localhost:8081')
app.model({
subscriptions: [
function (send) {
socket.onerror = (e) => send('app:error', { payload: JSON.stringify(e) })
socket.onmessage = (e) => send('app:print', { payload: e.data })
}
],
effects: {
'ws:close': () => socket.close(),
'ws:send': (state, event) => socket.send(JSON.stringify(event.payload)),
'app:error': (state, event_ => console.error(`error: ${event.payload}`)),
'app:print': (state, event) => console.log(`ws: ${event.payload}`)
}
})
This code does not handle reconnects, server timeouts, exponential backoff and
queueing data. You might want to use a package from npm
or write your
own if you're building something for production.
Forms and lists are probably the most used concepts on any page. Together with links they comprise most of what can be done on web pages.
const document = require('global/document')
const choo = require('choo')
const http = require('choo/http')
const app = choo()
function view (params, state, send) {
return choo.view`
<form onsubmit=${onSubmit}>
<fieldset>
<label>username</label>
<input type="text" name="username" autofocus>
</fieldset>
<fieldset>
<label>password</label>
<input type="password" name="password">
</fieldset>
<input type="submit" value="Submit">
</form>
`
function onSubmit (event) {
send('login', { data: new FormData(event.target) })
event.preventDefault()
}
}
app.model({
effects: {
login: (action, state, send) => {
http.post('/login', { body: action.data }, (err, res, body) => {
send('authorize', { payload: body })
})
}
}
})
app.router((route) => [
route('/', view)
])
app.start()
If you want a form element to be selected when it's loaded, add the
autofocus
property.
const view = choo.view`
<form>
<input type="text" autofocus>
</form>
`
In HTML links are represented with the <a href="/some-location">
tag. By
default choo
enables a subscription
for all a
tags on a page. When a link
is clicked, the click event is caught, and the value of href
is passed into
the router causing a state change. If you want to disable this behavior, set
app.start({ href: false })
.
const nav = choo.view`
<a href="/">home</a>
<a href="/first-link">first link</a>
<a href="/second-link">second link</a>
`
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.
Rendering in Node is slightly different than in the browser. First off, to
maintain performance all calls to subscriptions
, effects
, and reducers
are disabled. That means you need to know what the state of your application is
going to be before you render it - no cheating!
Secondly, the send()
method inside router
and view
has been disabled. If
you call it your program will crash. Disabling all these things means that your
program will render O(n)
, which is super neat. Off to 10.000
QPS we go!
To render in Node call the .toString()
method instead of .start()
. The
first argument is the path that should be rendered, the second is the state:
const http = require('http')
const client = require('./client') // path to client entry point
http.createServer(function (req, res) {
const html = client.toString('/', { message: 'hello server!' })
res.setHeader('Content-Type', 'text/html; charset=utf-8')
res.end(html)
})
In order to make our choo
app call app.start()
in the browser and be
require()
-able in Node, we check if module.parent
exists:
const choo = require('choo')
const app = choo()
app.router((route) => [
route('/', (params, state, send) => choo.view`
<h1>${state.message}</h1>
`)
])
if (module.parent) module.exports = app
else document.body.appendChild(app.start())
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.
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, nginx, varnish or a global CDN.
Create a new choo
app
Create a new model. Models modify data and perform IO. Takes the following arguments:
state
,
reducers
and effects
. Also limits actions
called by send()
to
in-namespace only.(action, state)
(action, state, send)
where send
is a reference to
app.send()
Tagged template string HTML builder. See
yo-yo
for full documentation. Views
should be passed to app.router()
Creates a new router. See
sheet-router
for full
documentation. Registered views have a signature of (params, state, send)
,
where params
is URI partials.
Render the application to a string of HTML. Useful for rendering on the server.
First argument is a path that's passed to the router. Second argument is the
state object. When calling .toString()
instead of .start()
, all calls to
send()
are disabled, and subscriptions
, effects
and reducers
aren't
loaded. See rendering in Node for an in-depth guide.
Start the application. Returns a tree of DOM nodes that can be mounted using
document.body.appendChild()
. If a valid id
selector is passed in as the
first argument, the tree will diff against the selected node rather than be
returned. This is useful for rehydration. Opts can contain the
following values:
true
. Enable a subscription
to the browser
history API. e.g. updates the internal state.location
state whenever the
browser "forward" and "backward" buttons are pressed.true
. Handle all relative <a
href="<location>"></a>
clicks and update internal state.location
accordingly.false
. Enable a subscription
to the hash change
event, updating the internal state.location
state whenever the URL hash
changes (eg localhost/#posts/123
). Enabling this option automatically
disables opts.history
and opts.href
.This means that a re-render of the DOM was triggered before the first render
was done. This is usually the case when send()
is called inside a
subscription
before the DOM is done rendering. Instead try listening for a
'DOMContentLoaded'
event:
document.addEventListener('DOMContentLoaded', (e) => send('init'))
This means a send()
event was triggered in Node. In Node, reducers
,
effects
and subscriptions
are disabled for performance reasons, so if
send()
was called to trigger an action it wouldn't work. Try finding where in
the DOM tree send()
is called, and disable it when called from within Node.
choo
is nothing but a formalization of how I've been building my applications
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 (~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.
Because I thought it sounded cute. All these programs talk about being
"performant", "rigid", "robust" - I like programming to be light, fun and
non-scary. choo
embraces that.
Also imagine telling some business people you chose to rewrite something
critical to the company using the choo
framework.
I love small libraries that do one thing well, but when working in a team,
having an undocumented combination of packages often isn't great. choo()
is a
small set of packages that work well together, wrapped in an an architectural
pattern. This means you get all the benefits of small packages, but get to be
productive right from the start.
Ah, so this is where I get to rant. choo
(chugga-chugga-chugga-choo-choo!)
was built because other options didn't quite cut it for me, so instead of
presenting some faux-objective chart with skewed benchmarks and checklists I'll
give you my opinions directly. Ready? Here goes:
react
is kind of big (155kb
was it?). They also
like classes a lot, and enforce a lot of abstractions. It also encourages
the use of JSX
and babel
which break JavaScript, The Language™. And all
that without making clear how code should flow, which is crucial in a team
setting. I don't like complicated things and in my view react
is one of
them. react
is not for me.react
. However it doesn't fix the large dependencies react
seems to use
(e.g. react-router
and friends) and doesn't help at all with architecture.
If react
is your jam, and you will not budge, sitting at 3kb
this is
probably a welcome gift.angular
doesn't tick any box in my book of nice things.TypeScript
and RxJS
definitely hasn't made things simpler. Last I checked
it was ~200kb
in size before including some monstrous extra deps. I guess
angular
and I will just never get along.mercury
is an interesting one. It seemed like a brilliant
idea until I started using it - the abstractions felt heavy, and it took team
members a long time to pick up. In the end I think using mercury
helped
shaped choo
greatly, despite not working out for me.deku
is fun. I even contributed a bit in the early days. It could
probably best be described as "a functional version of react
". The
dependence on JSX
isn't great, but give it a shot if you think it looks
neat.cycle
's pretty good - unlike most frameworks it lays out a clear
architecture which helps with reasoning about it. That said, it's built on
virtual-dom
and RxJS
which are a bit heavy for my taste. choo
works
pretty well for FRP style programming, but something like inu might be
an interesting alternative.cycle
, vue
is pretty good. But it also uses tech that
provides framework lock in, and additionally doesn't have a clean enough
architecture. I appreciate what it does, but don't think it's the answer.yo-yo
send-action
,
xtend
sheet-router
xhr
choo
uses morphdom, which diffs real DOM nodes instead of virtual
nodes. It turns out that browsers are actually ridiculously good at dealing
with DOM nodes, and it has the added benefit of working with
any library that produces valid DOM nodes. So to put a long answer short:
we're using something even better.
choo
really shines when coupled with browserify
transforms. They can do
things like reduce file size, prune dependencies and clean up boilerplate code.
Consider running some of the following:
assert()
statements which reduces file size. Use as a --global
transformconst
,
fat-arrows
and template strings
to older browsers--global
transformprocess.env
values
with plain stringsGenerally for production builds you'll want to run:
$ NODE_ENV=production browserify \
-t envify \
-g unassertify \
-g es2020 \
-g uglifyify \
| uglifyjs
Yup, it's greatly inspired by the elm
architecture. But contrary to elm
,
choo
doesn't introduce a completely new language to build web applications.
Sure.
$ npm install choo