Slide 1

Slide 1 text

Alex MacCaw November 2013 Monocle A JavaScript Web App Deconstructed

Slide 2

Slide 2 text

What is Monocle?

Slide 3

Slide 3 text

What is Monocle? monocle.io

Slide 4

Slide 4 text

No content

Slide 5

Slide 5 text

No content

Slide 6

Slide 6 text

github.com/maccman/monocle

Slide 7

Slide 7 text

git clone https://github.com/maccman/monocle.git github.com/maccman/monocle

Slide 8

Slide 8 text

No content

Slide 9

Slide 9 text

No content

Slide 10

Slide 10 text

No content

Slide 11

Slide 11 text

class App < Sinatra::Application use Rack::Deflater use Rack::CSRF use Brisk::Routes::Static use Brisk::Routes::Users use Brisk::Routes::Posts use Brisk::Routes::Comments use Brisk::Routes::Client end app.rb

Slide 12

Slide 12 text

class App < Sinatra::Application use Rack::Deflater use Rack::CSRF use Brisk::Routes::Static use Brisk::Routes::Users use Brisk::Routes::Posts use Brisk::Routes::Comments use Brisk::Routes::Client end app.rb

Slide 13

Slide 13 text

client.rb get /\A((\/\Z)|\/posts)/ do if mobile? erb :mobile else erb :index end end helpers do def mobile? request.user_agent =~ /iPhone|iPod/ end end

Slide 14

Slide 14 text

Monocle index.erb

Slide 15

Slide 15 text

get '/assets/*' do env['PATH_INFO'].sub!(%r{^/assets}, '') settings.assets.call(env) end get '/setup.js' do content_type :javascript posts = Post.published.popular.limit(25).all @options = { environment: settings.environment, csrfToken: csrf_token, user: current_user, posts: posts } erb :setup end client.rb helpers do set :assets, Sprockets::Environment.new(settings.root) end

Slide 16

Slide 16 text

setup.erb jQuery(function($){ var App = require('app/index'); var options = <%== @options.to_json %>; window.app = new App(options); app.$el.appendTo('body'); });

Slide 17

Slide 17 text

1. Browser navigates to / 2. Calls Client#/ route 3. Returns index.erb 4. Browser fetches CSS/JS 5. Browser fetches setup.js 6. Browser instantiated Bootstrapping steps

Slide 18

Slide 18 text

Frontend structure

Slide 19

Slide 19 text

No content

Slide 20

Slide 20 text

No content

Slide 21

Slide 21 text

App Controller (index.module.coffee)

Slide 22

Slide 22 text

Sidebar Controller (sidebar.module.coffee)

Slide 23

Slide 23 text

Posts Controller (posts.module.coffee)

Slide 24

Slide 24 text

Details controller (posts/details.module.coffee)

Slide 25

Slide 25 text

Comments controller (comments.module.coffee)

Slide 26

Slide 26 text

CommonJS modules

Slide 27

Slide 27 text

CommonJS modules Gives you `require` and `exports`

Slide 28

Slide 28 text

gem 'sprockets' gem 'sprockets-commonjs' Gemfile

Slide 29

Slide 29 text

posts.module.coffee Controller = require('controller') class Posts extends Controller constructor: -> # ... module.exports = Posts

Slide 30

Slide 30 text

Controller = require('controller') Session = require('session') Sidebar = require('app/controllers/sidebar') Posts = require('app/controllers/posts') Post = require('app/models/post') User = require('app/models/user') State = require('app/state') Router = require('app/router') KeyBinding = require('app/key_binding') class App extends Controller className: 'app' constructor: (options = {}) -> super State.set(environment: options.environment) State.set(user: options.user and new User(options.user)) Session.setCSRFToken(options.csrfToken) Post.popular.add(options.posts) @append(@sidebar = new Sidebar) @append(@posts = new Posts) # Trigger a route (@router = new Router).change() # Add key bindings @keyBinding = new KeyBinding module.exports = App index.module.coffee

Slide 31

Slide 31 text

Controller = require('controller') Session = require('session') Sidebar = require('app/controllers/sidebar') Posts = require('app/controllers/posts') Post = require('app/models/post') User = require('app/models/user') State = require('app/state') Router = require('app/router') KeyBinding = require('app/key_binding') class App extends Controller className: 'app' constructor: (options = {}) -> super State.set(environment: options.environment) State.set(user: options.user and new User(options.user)) Session.setCSRFToken(options.csrfToken) Post.popular.add(options.posts) @append(@sidebar = new Sidebar) @append(@posts = new Posts) # Trigger a route (@router = new Router).change() # Add key bindings @keyBinding = new KeyBinding module.exports = App index.module.coffee

Slide 32

Slide 32 text

Controller = require('controller') Session = require('session') Sidebar = require('app/controllers/sidebar') Posts = require('app/controllers/posts') Post = require('app/models/post') User = require('app/models/user') State = require('app/state') Router = require('app/router') KeyBinding = require('app/key_binding') class App extends Controller className: 'app' constructor: (options = {}) -> super State.set(environment: options.environment) State.set(user: options.user and new User(options.user)) Session.setCSRFToken(options.csrfToken) Post.popular.add(options.posts) @append(@sidebar = new Sidebar) @append(@posts = new Posts) # Trigger a route (@router = new Router).change() # Add key bindings @keyBinding = new KeyBinding module.exports = App index.module.coffee

Slide 33

Slide 33 text

Controller = require('controller') Session = require('session') Sidebar = require('app/controllers/sidebar') Posts = require('app/controllers/posts') Post = require('app/models/post') User = require('app/models/user') State = require('app/state') Router = require('app/router') KeyBinding = require('app/key_binding') class App extends Controller className: 'app' constructor: (options = {}) -> super State.set(environment: options.environment) State.set(user: options.user and new User(options.user)) Session.setCSRFToken(options.csrfToken) Post.popular.add(options.posts) @append(@sidebar = new Sidebar) @append(@posts = new Posts) # Trigger a route (@router = new Router).change() # Add key bindings @keyBinding = new KeyBinding module.exports = App index.module.coffee

Slide 34

Slide 34 text

Controller = require('controller') Session = require('session') Sidebar = require('app/controllers/sidebar') Posts = require('app/controllers/posts') Post = require('app/models/post') User = require('app/models/user') State = require('app/state') Router = require('app/router') KeyBinding = require('app/key_binding') class App extends Controller className: 'app' constructor: (options = {}) -> super State.set(environment: options.environment) State.set(user: options.user and new User(options.user)) Session.setCSRFToken(options.csrfToken) Post.popular.add(options.posts) @append(@sidebar = new Sidebar) @append(@posts = new Posts) # Trigger a route (@router = new Router).change() # Add key bindings @keyBinding = new KeyBinding module.exports = App index.module.coffee

Slide 35

Slide 35 text

Controller = require('controller') State = require('app/state') Comments = require('app/controllers/comments') Details = require('app/controllers/posts/details') Landing = require('app/controllers/posts/landing') class Posts extends Controller className: 'posts-show' constructor: -> super State.observeKey 'post', => @render(State.get('post')) render: (@post) => @$el.empty() if @post @append(@details = new Details(post: @post)) @append(@comments = new Comments(post: @post)) else @append(@landing = new Landing) posts.module.coffee

Slide 36

Slide 36 text

Controllers

Slide 37

Slide 37 text

$ = jQuery class Controller tag: 'div' helpers: {} constructor: (@options = {}) -> @el = @el or @options.el or document.createElement(@tag) @$el = $(@el) @$el.addClass(@className) @on('removed', @release) $: (sel) -> $(sel, @$el) on: -> @$el.on(arguments...) append: (controller) -> @$el.append(controller.el or controller) html: (controller) -> @$el.html(controller.el or controller) view: (name) => (context = {}) => context.view = @view context.helpers = @helpers @template(name)(context) template: (name) -> JST["app/views/#{name}"] release: => module.exports = Controller

Slide 38

Slide 38 text

$ = jQuery class Controller tag: 'div' helpers: {} constructor: (@options = {}) -> @el = @el or @options.el or document.createElement(@tag) @$el = $(@el) @$el.addClass(@className) @on('removed', @release) $: (sel) -> $(sel, @$el) on: -> @$el.on(arguments...) append: (controller) -> @$el.append(controller.el or controller) html: (controller) -> @$el.html(controller.el or controller) view: (name) => (context = {}) => context.view = @view context.helpers = @helpers @template(name)(context) template: (name) -> JST["app/views/#{name}"] release: => module.exports = Controller

Slide 39

Slide 39 text

$ = jQuery class Controller tag: 'div' helpers: {} constructor: (@options = {}) -> @el = @el or @options.el or document.createElement(@tag) @$el = $(@el) @$el.addClass(@className) @on('removed', @release) $: (sel) -> $(sel, @$el) on: -> @$el.on(arguments...) append: (controller) -> @$el.append(controller.el or controller) html: (controller) -> @$el.html(controller.el or controller) view: (name) => (context = {}) => context.view = @view context.helpers = @helpers @template(name)(context) template: (name) -> JST["app/views/#{name}"] release: => module.exports = Controller

Slide 40

Slide 40 text

$ = jQuery class Controller tag: 'div' helpers: {} constructor: (@options = {}) -> @el = @el or @options.el or document.createElement(@tag) @$el = $(@el) @$el.addClass(@className) @on('removed', @release) $: (sel) -> $(sel, @$el) on: -> @$el.on(arguments...) append: (controller) -> @$el.append(controller.el or controller) html: (controller) -> @$el.html(controller.el or controller) view: (name) => (context = {}) => context.view = @view context.helpers = @helpers @template(name)(context) template: (name) -> JST["app/views/#{name}"] release: => module.exports = Controller

Slide 41

Slide 41 text

render: => rendered = @view('post')(@post) @$el.html(rendered)

Slide 42

Slide 42 text

$ = jQuery class Controller tag: 'div' helpers: {} constructor: (@options = {}) -> @el = @el or @options.el or document.createElement(@tag) @$el = $(@el) @$el.addClass(@className) @on('removed', @release) $: (sel) -> $(sel, @$el) on: -> @$el.on(arguments...) append: (controller) -> @$el.append(controller.el or controller) html: (controller) -> @$el.html(controller.el or controller) view: (name) => (context = {}) => context.view = @view context.helpers = @helpers @template(name)(context) template: (name) -> JST["app/views/#{name}"] release: => module.exports = Controller

Slide 43

Slide 43 text

jQuery.event.special.removed = { remove: (e) -> e.handler?() } jquery.event.removed.coffee

Slide 44

Slide 44 text

Controller = require('controller') State = require('app/state') Comments = require('app/controllers/comments') Details = require('app/controllers/posts/details') Landing = require('app/controllers/posts/landing') class Posts extends Controller className: 'posts-show' constructor: -> super State.observeKey 'post', => @render(State.get('post')) render: (@post) => @$el.empty() if @post @append(@details = new Details(post: @post)) @append(@comments = new Comments(post: @post)) else @append(@landing = new Landing) posts.module.coffee

Slide 45

Slide 45 text

Controller = require('controller') State = require('app/state') Comments = require('app/controllers/comments') Details = require('app/controllers/posts/details') Landing = require('app/controllers/posts/landing') class Posts extends Controller className: 'posts-show' constructor: -> super State.observeKey 'post', => @render(State.get('post')) render: (@post) => @$el.empty() if @post @append(@details = new Details(post: @post)) @append(@comments = new Comments(post: @post)) else @append(@landing = new Landing) posts.module.coffee

Slide 46

Slide 46 text

Model = require('model') class State extends Model module.exports = new State state.module.coffee

Slide 47

Slide 47 text

Model = require('model') class State extends Model module.exports = new State state.module.coffee

Slide 48

Slide 48 text

No content

Slide 49

Slide 49 text

$ = jQuery Model = require('model') Collection = require('collection') PaginatedCollection = require('collection.paginated') helpers = require('app/helpers') class Post extends Model @key 'title', String @key 'url', String @key 'user_id', String @key 'user_handle', String @url '/v1/posts' @popular: new PaginatedCollection( model: this, all: (model, options = {}) -> $.post(model.uri('popular'), options.data) comparator: (a, b) -> b.get('score') - a.get('score') ) post.module.coffee

Slide 50

Slide 50 text

Scoping CSS

Slide 51

Slide 51 text

$ = jQuery Controller = require('controller') class Landing extends Controller className: 'posts-landing' constructor: -> super @render() render: => @html(@view('posts/landing')(this)) module.exports = Landing landing.module.coffee

Slide 52

Slide 52 text

$ = jQuery Controller = require('controller') class Landing extends Controller className: 'posts-landing' constructor: -> super @render() render: => @html(@view('posts/landing')(this)) module.exports = Landing landing.module.coffee

Slide 53

Slide 53 text

@import '../mixins' .app .posts-landing { background: #F8FAFB position: relative height: 100% box-shadow: inset 0 0px 1px rgba(0, 0,0, 0.1) // ... } posts_landing.css.styl

Slide 54

Slide 54 text

@import '../mixins' .app .posts-landing { background: #F8FAFB position: relative height: 100% box-shadow: inset 0 0px 1px rgba(0, 0,0, 0.1) > nav { // ... } } posts_landing.css.styl

Slide 55

Slide 55 text

Creating a scrollable list

Slide 56

Slide 56 text

Sidebar Controller (sidebar.module.coffee)

Slide 57

Slide 57 text

Sidebar Controller (sidebar.module.coffee)

Slide 58

Slide 58 text

Controller = require('controller') State = require('app/state') PostItem = require('app/controllers/posts/item') class PostList extends Controller className: 'list posts-list' constructor: (options = {}) -> super @render() render: => @reset() @addAll() # Private reset: => @$el.empty() addOne: (post) => @append new PostItem(post: post) addAll: => @collection.each(@addOne) module.exports = PostList sidebar/post_list.module.coffee

Slide 59

Slide 59 text

Controller = require('controller') State = require('app/state') PostItem = require('app/controllers/posts/item') class PostList extends Controller className: 'list posts-list' constructor: (options = {}) -> super @render() render: => @reset() @addAll() # Private reset: => @$el.empty() addOne: (post) => @append new PostItem(post: post) addAll: => @collection.each(@addOne) module.exports = PostList sidebar/post_list.module.coffee

Slide 60

Slide 60 text

Controller = require('controller') State = require('app/state') PostItem = require('app/controllers/posts/item') class PostList extends Controller className: 'list posts-list' constructor: (options = {}) -> super @render() render: => @reset() @addAll() # Private reset: => @$el.empty() addOne: (post) => @append new PostItem(post: post) addAll: => @collection.each(@addOne) module.exports = PostList sidebar/post_list.module.coffee

Slide 61

Slide 61 text

Controller = require('controller') State = require('app/state') PostItem = require('app/controllers/posts/item') class PostList extends Controller className: 'list posts-list' constructor: (options = {}) -> super @render() render: => @reset() @addAll() # Private reset: => @$el.empty() addOne: (post) => @append new PostItem(post: post) addAll: => @collection.each(@addOne) module.exports = PostList sidebar/post_list.module.coffee

Slide 62

Slide 62 text

State = require('app/state') class PostList extends Controller className: 'list posts-list' constructor: (options = {}) -> super # Set active state whenever someone # goes to a specific post State.change('post', @setPost) @render() # ... sidebar/post_list.module.coffee

Slide 63

Slide 63 text

setPost: (@post) => @setActive() setActive: => @$('.item').removeClass('active') if @post $active = @$(".item[data-id=#{@post.getID()}]") $active.addClass('active') @$('.item.active').scrollIntoViewIfNeeded() sidebar/post_list.module.coffee

Slide 64

Slide 64 text

scrollIntoViewIfNeeded() https://gist.github.com/hsablonniere/2581101

Slide 65

Slide 65 text

up / down arrow key bindings

Slide 66

Slide 66 text

class Sidebar extends Controller constructor: -> super $(window).on('keydown', @keydown) keydown: (e) => # ... # Cleanup global events release: => $(window).off('keydown', @keydown) sidebar.module.coffee

Slide 67

Slide 67 text

nextPost: => $active = @$('.item.active:visible:first') $active.next().click() previousPost: => $active = @$('.item.active:visible:first') $active.prev().click() # Keybindings upKey: (e) => e.preventDefault() @previousPost() downKey: (e) => e.preventDefault() @nextPost() keyMapping: 38: 'upKey' 40: 'downKey' keydown: (e) => # Return if input return if 'value' of e.target # Are we listening for this key? mapping = @[@keyMapping[e.which]] return unless mapping mapping(e)

Slide 68

Slide 68 text

nextPost: => $active = @$('.item.active:visible:first') $active.next().click() previousPost: => $active = @$('.item.active:visible:first') $active.prev().click() # Keybindings upKey: (e) => e.preventDefault() @previousPost() downKey: (e) => e.preventDefault() @nextPost() keyMapping: 38: 'upKey' 40: 'downKey' keydown: (e) => # Return if input return if 'value' of e.target # Are we listening for this key? mapping = @[@keyMapping[e.which]] return unless mapping mapping(e)

Slide 69

Slide 69 text

Scrolling conflict!

Slide 70

Slide 70 text

Sidebar Controller (sidebar.module.coffee) 1. 2.

Slide 71

Slide 71 text

$ = jQuery current = null activeArea = (e) -> current = $(e.currentTarget) $.fn.isActiveArea = -> @is(current) $.fn.activeArea = -> @click(activeArea) jquery.activearea.coffee

Slide 72

Slide 72 text

# posts.module.coffee class Posts extends Controller constructor: -> super @$el.activeArea() # sidebar.module.coffee class Sidebar extends Controller constructor: -> super @$el.activeArea()

Slide 73

Slide 73 text

isActiveArea: -> @$el.isActiveArea() upKey: (e) => return unless @isActiveArea() e.preventDefault() @previousPost() downKey: (e) => return unless @isActiveArea() e.preventDefault() @nextPost() sidebar.module.coffee

Slide 74

Slide 74 text

Dynamic pagination

Slide 75

Slide 75 text

Dynamic pagination

Slide 76

Slide 76 text

What do other people do?

Slide 77

Slide 77 text

“Send the server the IDs of the posts you already have when paginating. Sort posts by score, limit to your pagination offset, and omit those supplied IDs from the returned collection.”

Slide 78

Slide 78 text

post '/v1/posts/popular' do posts = Post.published.popular posts = posts.exclude(id: params[:ignore]) posts = posts.limit(30).all json posts end posts.rb

Slide 79

Slide 79 text

Realtime streaming

Slide 80

Slide 80 text

Server Sent Events

Slide 81

Slide 81 text

streams = [] get '/subscribe', provides: 'text/event-stream' do stream :keep_open do |out| streams << out out.callback { streams.delete(out) } end end post '/publish' do streams.each do |out| out << "data: #{params[:msg]}\n\n" end 204 end SSE example

Slide 82

Slide 82 text

require 'eventmachine' require 'sinatra' require 'sinatra/pubsub' register Sinatra::PubSub Sinatra::PubSub.set( cors: true, origin: 'http://monocle.io' ) sinatra-pubsub example

Slide 83

Slide 83 text

register Sinatra::PubSub EventMachine.next_tick do EventMachine::PeriodicTimer.new(1) do Sinatra::PubSub.publish('tick', 'tock') end end get '/' do erb :stream end __END__ @@ stream


var es = new EventSource('/subscribe/tick');
es.onmessage = function(e) { log.innerHTML += "\n" + e.data };

Tick example

              

Slide 84

Slide 84 text

sinatra-pubsub-tick.herokuapp.com

Slide 85

Slide 85 text

Events = require('events') class Stream @::[k] = v for k,v of Events url: 'http://stream.monocle.io/subscribe' constructor: (@url = @url) -> @source = new EventSource(@url) @source.addEventListener('message', @message, false) message: (e) => msg = JSON.parse(e.data) @trigger('message', msg) @trigger(msg.type, msg.data) module.exports = Stream stream.module.coffee

Slide 86

Slide 86 text

post '/v1/posts/:id/vote' do post = Post.first!(id: params[:id]) post.vote!(current_user) publish [:posts, :vote], id: post.id json post end posts.rb

Slide 87

Slide 87 text

post '/v1/posts/:id/vote' do post = Post.first!(id: params[:id]) post.vote!(current_user) publish [:posts, :vote], id: post.id json post end posts.rb

Slide 88

Slide 88 text

Stream = require('stream') Post = require('app/models/post') class Stream constructor: -> super @on 'posts:vote', (msg) -> Post.find(msg.id).refresh() module.exports = Stream stream.module.coffee

Slide 89

Slide 89 text

SEO & JavaScript

Slide 90

Slide 90 text

/?_escaped_fragment_ monocle.io

Slide 91

Slide 91 text

No content

Slide 92

Slide 92 text

index.erb

Slide 93

Slide 93 text

get '/posts/:slug' do if params.has_key?('_escaped_fragment_') @post = Post.first!(slug: params[:slug]) erb :spider_page else # Render normal JS client end end posts.rb

Slide 94

Slide 94 text

get '/posts/:slug' do if params.has_key?('_escaped_fragment_') @post = Post.first!(slug: params[:slug]) erb :spider_page else # Render normal JS client end end posts.rb

Slide 95

Slide 95 text

configure do set :spider do |enabled| condition do params.has_key?('_escaped_fragment_') end end end get '/posts/:slug', spider: true do @post = Post.first!(slug: params[:slug]) erb :spider_page end posts.rb

Slide 96

Slide 96 text

<%= @post.title %>

<%= @post.title %>

<%= @post.summary %> Read more

Slide 97

Slide 97 text

site:monocle.io

Slide 98

Slide 98 text

‘onbeforeunload’ event

Slide 99

Slide 99 text

window.onbeforeunload or= -> if jQuery.active '''There are some pending network requests which means closing the page may lose unsaved data.'''

Slide 100

Slide 100 text

$ = jQuery $.activeTransforms = 0 addHandler = -> window.onbeforeunload or= -> '''There are some pending network requests which means closing the page may lose unsaved data.''' removeHandler = -> window.onbeforeunload = null $(document).ajaxSend (e, xhr, settings) -> return unless settings.warn $.activeTransforms += 1 addHandler() if $.activeTransforms $(document).ajaxComplete (e, xhr, settings) -> return unless settings.warn $.activeTransforms -= 1 removeHandler() unless $.activeTransforms jquery.onclose.coffee

Slide 101

Slide 101 text

‘wake’ event

Slide 102

Slide 102 text

$ = jQuery TIMEOUT = 20000 lastTime = (new Date()).getTime() setInterval -> currentTime = (new Date()).getTime() if currentTime > (lastTime + TIMEOUT + 2000) $(document).trigger('wake') lastTime = currentTime , TIMEOUT jquery.wake.js

Slide 103

Slide 103 text

$(document).on('wake', Post.refresh) index.module.coffee

Slide 104

Slide 104 text

alexmaccaw.com @maccaw onbeforeunload