Upgrade to Pro — share decks privately, control downloads, hide ads and more …

A JavaScript Web App Deconstructed

Alex MacCaw
November 28, 2013

A JavaScript Web App Deconstructed

Deconstructing http://monocle.io

Alex MacCaw

November 28, 2013
Tweet

More Decks by Alex MacCaw

Other Decks in Programming

Transcript

  1. 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
  2. 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
  3. 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
  4. <!DOCTYPE html> <html> <head> <meta charset="utf-8"> <title>Monocle</title> <link rel="stylesheet" type="text/css"

    href="<%= asset_path 'application.css' %>"> <script defer src="<%= asset_path 'application.js' %>"></script> <script defer src="/setup.js"></script> <meta name="referrer" content="always"/> <meta name="fragment" content="!"> <link rel="alternate" type="application/rss+xml" href="/feed" /> </head> <body> </body> </html> index.erb
  5. 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
  6. setup.erb jQuery(function($){ var App = require('app/index'); var options = <%==

    @options.to_json %>; window.app = new App(options); app.$el.appendTo('body'); });
  7. 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
  8. 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
  9. 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
  10. 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
  11. 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
  12. 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
  13. 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
  14. $ = 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
  15. $ = 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
  16. $ = 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
  17. $ = 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
  18. $ = 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
  19. 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
  20. 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
  21. $ = 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
  22. $ = 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
  23. $ = 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
  24. @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
  25. @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
  26. 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
  27. 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
  28. 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
  29. 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
  30. 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
  31. 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
  32. class Sidebar extends Controller constructor: -> super $(window).on('keydown', @keydown) keydown:

    (e) => # ... # Cleanup global events release: => $(window).off('keydown', @keydown) sidebar.module.coffee
  33. 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)
  34. 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)
  35. $ = jQuery current = null activeArea = (e) ->

    current = $(e.currentTarget) $.fn.isActiveArea = -> @is(current) $.fn.activeArea = -> @click(activeArea) jquery.activearea.coffee
  36. # posts.module.coffee class Posts extends Controller constructor: -> super @$el.activeArea()

    # sidebar.module.coffee class Sidebar extends Controller constructor: -> super @$el.activeArea()
  37. isActiveArea: -> @$el.isActiveArea() upKey: (e) => return unless @isActiveArea() e.preventDefault()

    @previousPost() downKey: (e) => return unless @isActiveArea() e.preventDefault() @nextPost() sidebar.module.coffee
  38. “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.”
  39. 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
  40. 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 <pre id="log"> </pre> <script> var es = new EventSource('/subscribe/tick'); es.onmessage = function(e) { log.innerHTML += "\n" + e.data }; </script> Tick example
  41. 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
  42. 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
  43. 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
  44. <html> <head> <meta name="description" content="<%= @post.summary.truncate(150) %>"> <title><%= @post.title %></title>

    </head> <body> <h1><a href="<%= @post.url %>"><%= @post.title %></a></h1> <p> <%= @post.summary %> <a href="<%= @post.url %>">Read more</a> </p> </body> </html>
  45. window.onbeforeunload or= -> if jQuery.active '''There are some pending network

    requests which means closing the page may lose unsaved data.'''
  46. $ = 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
  47. $ = 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