UNPKG

20.6 kBMarkdownView Raw
1# batman.js
2
3[batman.js](http://batmanjs.org/) is a framework for building rich single-page browser applications. It is written in [CoffeeScript](http://jashkenas.github.com/coffee-script/) and its API is developed with CoffeeScript in mind, but of course you can use plain old JavaScript too.
4
5#### It's got:
6
7* a stateful MVC architecture
8* a powerful binding system
9* routable controller actions
10* pure HTML views
11* toolchain support built on [node.js](http://nodejs.org) and [cake](http://jashkenas.github.com/coffee-script/#cake)
12
13The APIs are heavily inspired by [Rails](http://rubyonrails.org/) and designed to make Rails devs feel right at home.
14
15We're targeting Chrome, Safari 4+, Firefox 3+, and IE 7+ for compatibility, although some of those require you to include [es5shim](https://github.com/kriskowal/es5-shim).
16
17#### Here's some code:
18
19```coffeescript
20class Shopify extends Batman.App
21 @root 'products#index'
22 @resources 'products'
23
24class Shopify.Product extends Batman.Model
25 @persist Batman.RestStorage
26
27class Shopify.ProductsController extends Batman.Model
28 index: ->
29 @redirect action: 'show', id: 1
30
31 show: (params) ->
32 @product = Shopify.Product.find params.id
33```
34
35#### views/products/index.html
36
37```html
38<ul id="products">
39 <li data-foreach-product="Product.all" data-mixin="animation">
40 <a data-route="product" data-bind="product.name">name will go here</a>
41 </li>
42
43 <li><span data-bind="products.length"></span> <span data-bind="'products' | pluralize products.length"></span></li>
44</ul>
45```
46
47## Installation
48
49If you haven't already, you'll need to install [node.js](http://nodejs.org) and [npm](http://npmjs.org/). Then:
50
51 npm install -g batman
52
53Generate a new batman.js app somewhere, called my_app:
54
55 cd ~/code
56 batman new my_app
57
58Fire it up:
59
60 cd my_app
61 batman server # (or just "batman s")
62
63Now visit [http://localhost:1047](http://localhost:1047) and start playing around!
64
65## The Basics
66
67Most of the classes you work with in your app code will descend from `Batman.Object`, which gives you some nice things that are used extensively by the rest of the system.
68
69### Events
70
71If you want to define observable events on your objects, just wrap a function with the `@event` macro in a class definition:
72
73```coffeescript
74class BatBelt.Gadget extends Batman.Object
75 constructor: -> @usesLeft = 5
76 use: @event (times) ->
77 return false unless (@usesLeft - times) >= 0
78 @usesLeft -= times
79```
80
81You can observe the event with some callback, and fire it by just calling the event function directly. The observer callback gets whichever arguments were passed into the event function. But if the even function returns `false`, then the observers won't fire:
82
83```coffeescript
84gadget.observe 'use', (times) ->
85 console.log "gadget was used #{times} times!"
86gadget.use(2)
87# console output: "gadget was used 2 times!"
88gadget.use(6)
89# nothing happened!
90```
91
92### Observable Properties
93
94The `observe` function is also used to observe changes to properties. This forms the basis of the binding system. Here's a simple example:
95
96```coffeescript
97gadget.observe 'name', (newVal, oldVal) ->
98 console.log "name changed from #{oldVal} to #{newVal}!"
99gadget.set 'name', 'Batarang'
100# console output: "name changed from undefined to Batarang!"
101```
102
103You can also `get` properties to return their values, and if you want to remove them completely then you can `unset` them:
104
105```coffeescript
106gadget.get 'name'
107# returns: 'Batarang'
108gadget.unset 'name'
109# console output: "name changed from Batarang to undefined!"
110```
111
112By default, these properties are stored like plain old JavaScript properties: that is, `gadget.name` would return "Batarang" just like you'd expect. But if you set the gadget's name with `gadget.name = 'Shark Spray'`, then the observer function you set on `gadget` will not fire. So when you're working with batman.js properties, use `get`/`set`/`unset` to read/write/delete properties.
113
114
115### Custom Accessors
116
117What's the point of using `gadget.get 'name'` instead of just `gadget.name`? Well, a Batman properties doesn't need to correspond with a vanilla JS property. Let's write a `Box` class with a custom getter for its volume:
118
119```coffeescript
120class Box extends Batman.Object
121 constructor: (@length, @width, @height) ->
122 @accessor 'volume',
123 get: (key) -> @get('length') * @get('width') * @get('height')
124
125box = new Box(16,16,12)
126box.get 'volume'
127# returns 3072
128```
129
130The really cool thing about this is that, because we used `@get` to access the component properties of `volume`, batman.js can keep track of those dependencies and let us observe the `volume` directly:
131
132```coffeescript
133box.observe 'volume', (newVal, oldVal) ->
134 console.log "volume changed from #{oldVal} to #{newVal}!"
135box.set 'height', 6
136# console output: "volume changed from 3072 to 1536!"
137```
138
139The box's `volume` is a read-only attribute here, because we only provided a getter in the accessor we defined. Here's a `Person` class with a (rather naive) read-write accessor for their name:
140
141```coffeescript
142class Person extends Batman.Object
143 constructor: (name) -> @set 'name', name
144 @accessor 'name',
145 get: (key) -> [@get('firstName'), @get('lastName')].join(' ')
146 set: (key, val) ->
147 [first, last] = val.split(' ')
148 @set 'firstName', first
149 @set 'lastName', last
150 unset: (key) ->
151 @unset 'firstName'
152 @unset 'lastName'
153```
154
155### Keypaths
156
157If you want to get at properties of properties, use keypaths:
158
159```coffeescript
160employee.get 'team.manager.name'
161```
162
163This does what you expect and is pretty much the same as `employee.get('team').get('manager').get('name')`. If you want to observe a deep keypath for changes, go ahead:
164
165```coffeescript
166employee.observe 'team.manager.name', (newVal, oldVal) ->
167 console.log "you now answer to #{newVal || 'nobody'}!"
168manager = employee.get 'team.manager'
169manager.set 'name', 'Bill'
170# console output: "you now answer to Bill!"
171```
172
173If any component of the keypath is set to something that would change the overall value, then observers will fire:
174
175```coffeescript
176employee.set 'team', larrysTeam
177# console output: "you now answer to Larry!"
178employee.team.unset 'manager'
179# console output: "you now answer to nobody!"
180employee.set 'team', jessicasTeam
181# console output: "you now answer to Jessica!"
182```
183
184batman.js's dependency tracking system makes sure that no matter how weird your object graph gets, your observers will fire exactly when they should.
185
186## Architecture
187
188The MVC architecture of batman.js fits together like this:
189
190* Controllers are persistent objects which render the views and give them mediated access to the model layer.
191* Views are written in pure HTML, and use `data-*` attributes to create bindings with model data and event handlers exposed by the controllers.
192* Models have validations, lifecycle events, a built-in identity map, and can use arbitrary storage backends (`Batman.LocalStorage` and `Batman.RestStorage` are included).
193
194A batman.js application is served up in one page load, followed by asynchronous requests for various resources as the user interacts with the app. Navigation within the app is handled via [hash-bang fragment identifers](http://www.w3.org/QA/2011/05/hash_uris.html), with [pushState](https://developer.mozilla.org/en/DOM/Manipulating_the_browser_history#Adding_and_modifying_history_entries) support forthcoming.
195
196
197### The App Class
198
199Sitting in front of everything else is a subclass of `Batman.App` which represents your application as a whole and acts as a namespace for your other app classes. The app class never gets instantiated; your main interactions with it are using macros in its class definition, and calling `run()` on it when it's time to fire up your app.
200
201Here's a simple app class:
202
203```coffeescript
204class BatBelt extends Batman.App
205 @global yes
206
207 @controller 'app', 'gadgets'
208 @model 'gadget'
209
210 @root 'app#index'
211 @route 'faq/:questionID', 'app#faq'
212 @resources 'gadgets'
213```
214
215The `@global yes` declaration just makes the class global on the browser's `window` object.
216
217The calls to `@controller` and `@model` load external app classes with XHRs. For the controllers, this ends up fetching `/controllers/app_controller.coffee` and `/controllers/gadgets_controller.coffee`. The gadget model gets loaded from `/models/gadget.coffee`.
218
219#### Routes
220
221Routes are defined in a few different ways.
222
223`@route` takes two strings, one representing a path pattern and the other representing a controller action. In the above example, `'faq/:questionID'` matches any path starting with "/faq/" and having one other segment. That segment is then passed as a named param to the controller action function specified by the second string argument.
224
225For the FAQ route, `'app#faq'` specifies the `faq` function on `BatBelt.AppController`, which should take a `params` argument and do something sensible with `params.questionID`.
226
227`@root 'app#index'` is just a shorthand for `@route '/', 'app#index'`.
228
229The `@resources` macro takes a resource name which should ideally be the underscored-pluralized name of one of your models. It sets up three routes, as if you'd used the `@route` macro like so:
230
231```coffeescript
232@route 'gadgets', 'gadgets#index'
233@route 'gadgets/:id', 'gadgets#show'
234@route 'gadgets/:id/edit', 'gadgets#edit'
235```
236
237In addition to setting up these routes, the call to `@resources` keeps track of the fact that the `Gadget` model can be accessed in these ways. This lets you load these routes in your controllers or views by using model instances and classes on their own:
238
239```coffeescript
240class BatBelt.GadgetsController extends Batman.Controller
241 someEventHandler: (node, event) ->
242 @redirect BatBelt.Gadget.find(1) # redirects to "/gadgets/1"
243 someOtherHandler: (node, event) ->
244 @redirect BatBelt.Gadget # redirects to "/gadgets"
245```
246
247### Controllers
248
249batman.js controllers are singleton classes with one or more instance methods that can serve as routable actions. Because they're singletons, instance variables persist as long as the app is running.
250
251```coffeescript
252class BatBelt.AppController extends Batman.Controller
253 index: ->
254 faq: (params) ->
255 @question = @questions.get(params.questionID)
256```
257
258Now when you navigate to `/#!/faq/what-is-art`, the dispatcher runs this `faq` action with `{questionID: "what-is-art"}`. It also makes an implicit call to `@render`, which by default will look for a view at `/views/app/faq.html`. The view is rendered within the main content container of the page, which is designated by setting `data-yield="main"` on some tag in the layout's HTML.
259
260Controllers are also a fine place to put event handlers used by your views. Here's one that uses [jQuery](http://jquery.com/) to toggle a CSS class on a button:
261
262```coffeescript
263class MyApp.BigRedButtonController extends Batman.Controller
264 index: ->
265
266 buttonWasClicked: (node, event) ->
267 $(node).toggleClass('activated')
268```
269
270If you want to redirect to some route, you can use `@redirect`:
271
272```coffeescript
273buttonWasClicked: (node, event) ->
274 $(node).toggleClass('activated')
275 @redirect '/apocalypse/'
276```
277
278### Views
279
280You write views in plain HTML. These aren't templates in the usual sense: the HTML is rendered in the page as-is, and you use `data-*` attributes to specify how different parts of the view bind to your app's data. Here's a very small view which displays a user's name and avatar:
281
282```html
283<div class="user">
284 <img data-bind-src="user.avatarURL" />
285 <p data-bind="user.name"></p>
286</div>
287```
288
289The `data-bind` attribute on the `<p>` tag sets up a binding between the user's `name` property and the content of the tag. The `data-bind-src` attribute on the `<img>` tag binds the user's `avatarURL` property to the `src` attribute of the tag. You can do the same thing for arbitrary attribute names, so for example `data-bind-href` would bind to the `href` attribute.
290
291batman.js uses a bunch of these data attributes for different things:
292
293#### Binding properties
294
295* `data-bind="foo.bar"`: for most tags, this defines a one-way binding with the contents of the node: when the given property `foo.bar` changes, the contents of the node are set to that value. When `data-bind` is set on a form input tag, a _two-way_ binding is defined with the _value_ of the node, such that any changes from the user will update the property in realtime.
296
297* `data-bind-foo="bar.baz"`: defines a one-way binding from the given property `bar.baz` to any attribute `foo` on the node.
298
299* `data-foreach-bar="foo.bars"`: used to render a collection of zero or more items. If the collection descends from `Batman.Set`, then the DOM will be updated when items are added, removed. If it's a descendent of `Batman.SortableSet`, then its current sort.
300
301#### Handling DOM events
302
303* `data-event-click="foo.bar"`: when this node is clicked, the function specified by the keypath `foo.bar` is called with the node object as the first argument, and the click event as the second argument.
304
305* `data-event-change="foo.bar"`: like `data-event-click`, but fires on change events.
306
307* `data-event-submit="foo.bar"`: like `data-event-click`, but fires either when a form is submitted (in the case of `<form>` nodes) or when a user hits the enter key when an `<input>` or `<textarea>` has focus.
308
309#### Managing contexts
310
311* `data-context="foo.bar"`: pushes a new context onto the context stack for children of this node. If the context is `foo.bar`, then children of this node may access properties on `foo.bar` directly, as if they were properties of the controller.
312
313#### Rendering Views
314
315* `data-yield="identifier"`: used in your layout to specify the locations that other views get rendered into when they are rendered. By default, a controller action renders each whole view into whichever node is set up to yield `"main"`. If you want some content in a view to be rendered into a different `data-yield` node, you can use `data-contentfor`.
316
317* `data-contentfor="identifier"`: when the view is rendered into your layout, the contents of this node will be rendered into whichever node has `data-yield="identifier"`. For example, if your layout has `"main"` and `"sidebar"` yields, then you may put a `data-contentfor="sidebar"` node in a view and it will be rendered in the sidebar instead of the main content area.
318
319* `data-partial="/views/shared/sidebar"`: renders the view at the path `/views/shared/sidebar.html` within this node.
320
321* `data-route="/some/path"` or `data-route="some.model"`: loads a route when this node is clicked. The route can either be specified by a path beginning with a slash "/", or by a property leading to either a model instance (resulting in a resource's "show" action) or a model class (for the resource's "index" action).
322
323
324### Models
325
326batman.js models:
327
328* can persist to various storage backends
329* only serialize a defined subset of their properties as JSON
330* use a state machine to expose lifecycle events
331* can validate with synchronous or asynchronous operations
332
333#### Attributes
334
335A model object may have arbitrary properties set on it, just like any JS object. Only some of those properties are serialized and persisted to its storage backends, however. You define persisted attributes on a model with the `encode` macro:
336
337```coffeescript
338 class Article extends Batman.Model
339 @encode 'body_html', 'title', 'author', 'summary_html', 'blog_id', 'id', 'user_id'
340 @encode 'created_at', 'updated_at', 'published_at',
341 encode: (time) -> time.toISOString()
342 decode: (timeString) -> Date.parse(timeString)
343 @encode 'tags',
344 encode: (tagSet) -> tagSet.toArray().join(', ')
345 decode: (tagString) -> new Batman.Set(tagString.split(', ')...)
346```
347
348Given one or more strings as arguments, `@encode` will register these properties as persisted attributes of the model, to be serialized in the model's `toJSON()` output and extracted in its `fromJSON()`. Properties that aren't specified with `@encode` will be ignored for both serialization and deserialization. If an optional coder object is provided as the last argument, its `encode` and `decode` functions will be used by the model for serialization and deserialization, respectively.
349
350By default, a model's primary key (the unchanging property which uniquely indexes its instances) is its `id` property. If you want your model to have a different primary key, specify it with the `@id` macro:
351
352```coffeescript
353class User extends Batman.Model
354 @encode 'handle', 'email'
355 @id 'handle'
356```
357
358#### States
359
360* `empty`: a new model instance remains in this state until some persisted attribute is set on it.
361* `loading`: entered when the model instance's `load()` method is called.
362* `loaded`: entered after the model's storage adapter has completed loading updated attributes for the instance. Immediately transitions to the `clean` state.
363* `dirty`: entered when one of the model's persisted attributes changes.
364* `validating`: entered when the validation process has started.
365* `validated`: entered when the validation process has completed. Immediately after entering this state, the model instance transitions back to either the `dirty` or `clean` state.
366* `saving`: entered when the storage adapter has begun saving the model.
367* `saved`: entered after the model's storage adapter has completed saving the model. Immediately transitions to the `clean` state.
368* `clean`: indicates that none of an instance's attributes have been changed since the model was `saved` or `loaded`.
369* `destroying`: entered when the model instance's `destroy()` method is called.
370* `destroyed`: indicates that the storage adapter has completed destroying this instance.
371
372#### Validation
373
374Before models are saved to persistent storage, they run through any validations you've defined and the save is cancelled if any errors were added to the model during that process.
375
376Validations are defined with the `@validate` macro by passing it the properties to be validated and an options object representing the particular validations to perform:
377
378```coffeescript
379class User extends Batman.Model
380 @encode 'login', 'password'
381 @validate 'login', presence: yes, maxLength: 16
382 @validate 'password', 'passwordConfirmation', presence: yes, lengthWithin: [6,255]
383```
384
385The options get their meaning from subclasses of `Batman.Validator` which have been registered by adding them to the `Batman.Validators` array. For example, the `maxLength` and `lengthWithin` options are used by `Batman.LengthValidator`.
386
387
388#### Persistence
389
390To specify a storage adapter for persisting a model, use the `@persist` macro in its class definition:
391
392```coffeescript
393class Product extends Batman.Model
394 @persist Batman.LocalStorage
395```
396
397Now when you call `save()` or `load()` on a product, it will use the browser window's [localStorage](https://developer.mozilla.org/en/dom/storage) to retrieve or store the serialized data.
398
399If you have a REST backend you want to connect to, `Batman.RestStorage` is a simple storage adapter which can be subclassed and extended to suit your needs. By default, it will assume your CamelCased-singular `Product` model is accessible at the underscored-pluralized "/products" path, with instances of the resource accessible at `/products/:id`. You can override these path defaults by assigning either a string or a function-returning-a-string to the `url` property of your model class (for the collection path) or to the prototype (for the member path). For example:
400
401```coffeescript
402class Product extends Batman.Model
403 @persist Batman.RestStorage
404 @url = "/admin/products"
405 url: -> "/admin/products/#{@id}"
406```
407
408# Contributing
409
410Well-tested contributions are always welcome! Here's what you should do:
411
412#### 1. Clone the repo
413
414 git clone https://github.com/Shopify/batman.git
415
416#### 2. Run the tests
417
418You can test batman.js locally either on the command line or in the browser and both should work. Tests are written in Coffeescript using [QUnit](http://docs.jquery.com/QUnit#API_documentation).
419
420To run on the command line, run the following command from the project root:
421
422 cake test
423
424To run in the browser, start a web server to serve up the tests:
425
426 batman server
427
428...then visit `http://localhost:1047/test/batman/test.html`.
429
430#### 3. Write some test-driven code
431
432The tests are in `tests/batman`. You'll need to source any new test files in `tests/batman/test.html`.
433
434#### 4. Create a pull request
435
436If it's good code that fits with the goals of the project, we'll merge it in!
437
438# License
439
440batman.js is copyright 2011 by [Shopify](http://www.shopify.com), released under the MIT License (see LICENSE for details).