chaplinjs / chaplin

HTML5 application architecture using Backbone.js
http://chaplinjs.org
Other
2.85k stars 231 forks source link

Plugin / Extension architecture #345

Open mehcode opened 11 years ago

mehcode commented 11 years ago

We need to discuss a plugin / extension / component architecture for Chaplin. I

First to talk of extensions. An plugin or extension is something that adds properties or behaviors to one of chaplin's components directly. A Chaplin version of Backbone.stickit would be a good example (as it provides and utilizes a bindings hash).

I would think we don't want people to extend Chaplin.View directly. I'm thinking of something along the lines of Chaplin.mixin or Chaplin.use (parallels _.mixin) in which it would take a library say if the library was like AwesomeChaplin.View then that class would be mixed in with Chaplin.View, etc intelligently via function composition. What this means is that if such an extension had AwesomeChaplin.View#initialize then having it automatically invoked when Chaplin.View#initialize is invoked would be interesting. Perhaps a method in each chaplin component such as Chaplin.View#plugin that plugin authors would use when creating their AwesomeChaplin.View that disallows super calls etc and just composes their methods.

In the modular world the end-user (who wanted to make use of extensions) would do something like follows:

# src/scripts/lib/chaplin.coffee
define(require) ->
  Chaplin     = require '../components/chaplin/amd/chaplin'
  Chaplin.mixin require 'chaplin-forms'
  Chaplin.mixin require 'chaplin-data'

  # And maybe allow for
  Chaplin.View.mixin require('chaplin-cool-views').View
  Chaplin

Thoughts? Opinions? Better ideas?

Having something like this formalized and documented will help spur the community to develop awesome stuff with Chaplin.


Another thing is simple components or modules. Like an auto-complete box that uses a Chaplin View and Collection View for its components or a login page. Things like this can distributed as modules but the stylesheets must be CSS and the templates must be pre-compiled. Doing this can allow for re-usable modules. We also need to talk about including a modules routes inside of the main applications routes.

Like if a login page had match 'login', 'login#show' then our main app would have:

match 'account/', include('modules/login')

And 'account/login' would match the login#show action from the login module.

Just throwing down ideas to spark this discussion. We'll see what comes of it.

anowak commented 11 years ago

In our Chaplin app we've created 'traits' (name is not quite appropriate, but we got used to it) to manage cross-cutting concerns and facade utility libraries/plugins (e.g. keymaster).

module.exports = class ViewTrait
  constructor: (options = {}) ->
    _(@).extend options

  apply: (view) ->
    unless (view instanceof View) or (view instanceof CollectionView)
      throw new Error 'ViewTrait can only be applied to View or CollectionView'

    @_setTraitsAttribute view, this
    # configure view here

  _setTraitsAttribute: (view, trait) ->
    return if trait.constructor.name is 'ViewTrait'
    if view.traits
      view.traits.push trait.constructor.name
    else
      view.traits = [trait.constructor.name]
    @_setTraitsAttribute view, trait.constructor.__super__

  _addFunction: (view, name, f = @[name]) ->
    originalFunction = view[name]
    if originalFunction and not _(originalFunction).isFunction()
      throw new Error "Property #{name} is not a function in #{view}"

    f = _(originalFunction).wrap(f)
    view[name] = _(f).bind(view, this)

The whole point of having the trait object passed to the mixed methods is to keep any trait-specific properties out of the view (or whatever) object to achieve separation of concerns and avoid name collision. So we are in fact creating a companion object for each trait application. We keep an array of traits to detect any clashes between them or unmet dependencies that cannot be enforced via trait inheritance.

The traits compose their function with existing ones, so we can wrap an existing dispose function:

module.exports = class Focusable extends ViewTrait

  apply: (view) ->
    super

    @_addFunction(view, 'beforeFocusLost')
    @_addFunction(view, '_focusGained')
    @_addFunction(view, 'onFocusLost')
    @_addFunction(view, 'dispose')
    view.delegate 'click', -> mediator.setFocus view

  beforeFocusLost: (originalFunction, trait, deferred) ->
    if originalFunction
      originalFunction.call(this, deferred)
    else
      deferred.resolve(true)

  _focusGained: (originalFunction, trait) ->
    originalFunction.call(this) if originalFunction
    mediator.currentFocus = this

  dispose: (originalFunction) ->
    originalFunction.apply(this) if originalFunction

    # if currently focused view is being destroyed then set focus to heirView
    mediator.setFocus mediator.heirView if mediator.currentFocus.cid is @cid

We also added some syntactic sugar to base View that lets us say just @enable Focusable:

enable: (trait, args...) ->
  (new trait(args...)).apply(this)

To sum up, it works pretty well for us, but requires some attention while writing traits to make sure there will be no 'dissonances' in composition, i.e. if the originalFunction is invoked at right moment etc.

Rendez commented 11 years ago

I personally don't like Traits. It's a pattern that never fits quite well in JS, due to language constraints.

I'd rather have it the other way around. For me a plugin should be responsible of enhancing a certain component, such a View or Model. Much like @mehcode suggested but instead of creating the mixins from outside, having a Core plugin manager, which would initialize/hook those modules that choose to register as plugins, and make use of mixins from inside:

define ['plugins/core/core'], (CorePlugin) ->

  class ChaplinData extends CorePlugin

    name: "Data Manager",
    dev: "@Rendez",
    alone: true,
    type: Chaplin.GENERAL,

    ###
     Standard Extension functionality
    ###
    init: () ->
       [...]

    register: (defaults) ->
       Chaplin.mixin {...}
mehcode commented 11 years ago

Here are my thoughts laid out in code.

First from the point-of-view of the user using chaplin and an extension.

# file: lib/chaplin
# requirejs path configuration states that chaplin == lib/chaplin (this file)
Chaplin = require 'vendor/chaplin'
Chaplin.mixin Chaplin.SyncMachine

# Apply all extensions from the following
Chaplin.mixin require 'chaplin-auth'
Chaplin.mixin require 'chaplin-data'
Chaplin.mixin require 'chaplin-forms'

# Apply only the view extension from this because 
# we don't like the others
Chaplin.mixin require('chaplin-crazier').View

module.exports = Chaplin

This Chaplin.mixin call will either take the passed object and apply it (if it is an extension) or else it will enumerate its properties and apply all found extensions.

A Chaplin.noConflict call would be added to reset the internal state of Chaplin back to no-plugins.

Next from the point-of-view of the extension writer.

# Extension
module.exports = class SyncMachine extends Chaplin.Extension

  #! What this extension get applied to:
  #! Chaplin[extends[..]] so the following would apply to both
  #! Chaplin.Model and Chaplin.Collection
  extends: [
    'Model'
    'Collection'
  ]

  # or extends could be like follows for most cases:
  # extends: 'View'

  #! What extensions, if any, this requires.
  requires: [
  ]

  #! Anything added here is added as an attribute to the applied class 
  #! like `class::NAME = extension::attributes.NAME` 
  attributes:
    # This becomes Chaplin.Model.state
    state: 'unsynced'

  #! Anything in here is ensured to be called before the method in question in 
  #! a pipeline style keeping the order of the @use calls by the chaplin user
  methods:
    sync: (options) ->
      # Facilitate the sync machine
      # ... snipped ...
      super options

    dispose: ->
      # Facilitate the sync machine
      # ... snipped ...
      super

  initialize: ->
    # Do what you want, have fun I suppose

  register: ->
    # This is more of a hook to do anything esoteric.
    super  # Does all of the above comments, etc
    # Now anything just weird can be done

I'm not fond of adding metadata like name or author inside the extension defintion though.

Discuss.


I'll get to actually implementing Chaplin.Extension and friends after I hear some of your thoughts. I don't want to get ahead of myself.

//cc @molily @Rendez @paulmillr