Skip to content
This repository has been archived by the owner on Sep 3, 2021. It is now read-only.

[3.0] New plugin architecture and state flow #107

Closed
milesj opened this issue Mar 12, 2015 · 2 comments
Closed

[3.0] New plugin architecture and state flow #107

milesj opened this issue Mar 12, 2015 · 2 comments

Comments

@milesj
Copy link
Member

milesj commented Mar 12, 2015

In 3.0, we will be rewriting the entire JS layer using ES6. This will include the removal of the current class system and the requirement of a new class system (based on ES6 classes). The following diagrams are our current thoughts on how this new plugin class will work. Ideas are taken from React and Flux.

At its core, we would have the parent Plugin class, which both Components and Behaviors will extend. The Plugin should provide shared functionality and a common instantiation process.

Components

Components are a type of plugin that add pre-built functionality to an element, known as the "primary" element. Components can be interacted with from the JS layer to trigger certain events or actions.

There are 3 types of components, based on specific use cases. They are:

  • Embedded - Primary elements already exist in the DOM
  • Rendered - Primary elements are rendered on demand using a template as a source and then mounted in the DOM
  • Composite - A container for multiple child components, either embedded or rendered

A few examples of embedded components are accordion, carousel, off canvas, and rendered components are type ahead, toast, and finally composite components are modal, tooltip, flyout.

class Component extends Plugin {}
class EmbeddedComponent extends Component {}
class RenderedComponent extends Component {}
class CompositeComponent extends Component {}

Behaviors

Behaviors are a type of plugin that add unique behavior to a collection of elements, but ultimately don't have a "primary" element. Once initialized, they usually never need to be interacted with.

A few examples of behaviors are: lazy load, stalker, and drag and drop.

class Behavior extends Plugin {}

Initialization

The plugin class constructor will look something like the following (using pseudo code). The constructor will need to initialize members in a specific order: options -> element -> props -> binds. We want the constructor to be generic enough that it can be re-used by all plugins (and sub-classes) without need for overriding or super calls -- but still could if need be.

class Plugin {
    constructor(selector, options = {}) {
        initialize(selector, options)
        enable()
        startup()
    }

    initialize(selector, options) {
        this.selector = selector;

        initOptions(options)
        initElement(selector)
        initProperties()
        initBinds()
        emit('init')
    }
}

The initialize() method contains a handful of other methods used in the initialization of the class. These methods (and the order of operation) are as follows:

  1. initOptions() will take the custom options from the constructor argument, merge them with the static options for the plugin, and set them to this.options.
  2. initElement() will take the selector (a string) from the constructor argument and either find or create an element based on the type of plugin. The element will be set to this.element.
    • Embedded components will find and use the element from the DOM.
    • Rendered components will render an element based on a template and mount it in the DOM.
    • Composite components work similar to rendered components but only create a container element.
    • Behaviors will find all elements that match the selector.
  3. initProperties() will set class properties, for example, this.headers and this.sections from the accordion. This will need to be ran after options and the element are set, as certain properties will require those fields.
  4. initBinds() will set a mapping of events and their callbacks to be bound to the element found in step 2. This will also require the options and element to be set.
  5. emit() will dispatch an init event.

Once initialization is complete, the plugin will be enabled using enable(). This method will bind DOM events and set the enabled flag to true.

Once enabled, the startup() method is called, which is used by sub-classes to bootstrap the plugin by setting its initial state (more information below). For example, a carousel will need to start cycle timers, the matrix will need to position tiles, etc.

A very rudimentary example of this in action may look something like.

class EmbeddedComponent extends Component {
    initElement(selector) {
        var element = dom.query(selector);

        this.setElement(element);
        this.setOptions(this.extractOptionsFromDataAttributes(element));
    }

    initBinds() {
        this.setBinds({
            ['click @element ' + this.selector]: 'onDelegate',
            'click @element': 'onChange',
            'resize window': 'onResize'
        });
    }
}

class Carousel extends EmbeddedComponent {
    initProperties() {
        this.name = 'Carousel';
        this.version = '1.2.3';
        this.items = dom.query('[data-carousel-items]', this.element);
    }

    startup() {
        this.startTimer();
        this.setState({
            index: 0
        });
    }
}

var carousel = new Carousel('#carousel', { itemsToShow: 6 });

This approach makes it much easier to initialize a class as each class can define which init* methods they want to customize. This also gets around ES6's lack of class properties.

The entire flow will look like the following.

+-----------------------+
| initialize()          |
+-----------------------+
            |
            +-----------+
                        V
            +-----------------------+       +-----------------------+
            | initOptions()         | ----> | setOptions()          |
            +-----------------------+       +-----------------------+
                        |
                        V
            +-----------------------+       +-----------------------+       +-----------------------+
            | initElement()         | --+-> | Embedded:             | --+-> | setElement()          |
            +-----------------------+   |   | findElement()         |   |   +-----------------------+
                        |               |   +-----------------------+   |   +-----------------------+
                        |               |                               +-> | setOptions()          |
                        |               OR                                  +-----------------------+
                        |               |               
                        |               |   +-----------------------+       +-----------------------+
                        |               |-> | Rendered:             | ----> | setElement()          |
                        |               |   | renderTemplate()      |       +-----------------------+
                        |               |   +-----------------------+
                        |               OR                      
                        |               |   +-----------------------+       +-----------------------+
                        |               +-> | Composite:            | ----> | setElement()          |
                        |                   | renderTemplate()      |       +-----------------------+
                        |                   +-----------------------+
                        |
                        V
            +-----------------------+       +-----------------------+
            | initProperties()      | ----> | setProperties()       |
            +-----------------------+       +-----------------------+
                        |
                        V
            +-----------------------+       +-----------------------+
            | initBinds()           | ----> | setBinds()            |
            +-----------------------+       +-----------------------+
                        |
                        V
            +-----------------------+
            | emit('init')          |
            +-----------------------+

+-----------------------+       +-----------------------+
| enable()              | ----> | bindEvents()          |
+-----------------------+       +-----------------------+

+-----------------------+
| startup()             |
+-----------------------+

State

A plugin and its DOM must represent a single state at any given time. The current state is represented as an object on this.state and can be modified using setState(). The setState() method accepts either an object, or a function that returns an object, and will compare the new state to the old state and determine whether to call render() or not.

The render() method must be defined in the child class as this method is used for modifying the state, whether in the current DOM, or rendering a new state and inserting into the DOM.

  • Embedded components will modify the DOM (add/remove classes, show/hide elements, etc).
  • Rendered components will render a new element based on a template and replace the old element.
  • Composite components will find the child element that the state is interacting with and pass the state down.
  • Behaviors don't have a state.

To better understand how state works, let's use the carousel component again. What does a carousel's state look like? Well, we have the index for the item currently being displayed, a flag on whether it's stopped or not, the current width and height dimensions, and the size to cycle width. All of these can be modified when calling setState(), which in turn will call render() which modifies the DOM accordingly.

Another rudimentary example may be.

class Carousel extends EmbeddedComponent {
    // ...

    render() {
        // Update the active state tabs using `this.state.index`
        // Animate the items to the new `this.state.index`
        // Toggle the display of next and previous arrows
        // etc
    }

    prev() {
        this.setState({ index: this.state.index - 1 });
    }

    next() {
        this.setState({ index: this.state.index + 1 });
    }
}

This state functionality is very similar to React's state.

The flow.

+-----------------------+
| setState()            | 
+-----------------------+
            |
 ( If state has changed )
            |
            |   +-----------------------+
            |-> | Embedded:             |
            |   | render()              |
            |   +-----------------------+
            OR
            |   +-----------------------+
            |-> | Rendered:             |       +-----------------------+       +-----------------------+
            |   | render()              | ----> | renderTemplate()      | ----> | replaceElement()      |
            |   +-----------------------+       +-----------------------+       +-----------------------+
            OR
            |   +-----------------------+       
            +-> | Composite:            |       +-----------------------+       +-----------------------+
                | render()              | ----> | findChild()           | ----> | setChildState()       |
                +-----------------------+       +-----------------------+       +-----------------------+

Destroying

Destroying a plugin will include the removal of DOM elements (if not embedded), unbinding of events, removing the class instance, and any other clean up tasks. This can be achieved using the destroy() method (same as 2.0 and below versions).

The destroy() method should be common amongst all plugins, similar to how the constructor is handled above. The pseudo code may look like the following.

class Plugin {
    destroy() {
        emit('destroying')
        shutdown()
        disable()
        unmount()
        emit('destroyed')
    }
}

And information on the steps. These usually have to happen in order, hence the reason this method should be shared.

  1. emit() will dispatch a destroying event.
  2. shutdown() is a custom method used in the cleanup of the plugin (similar to startup() but does the opposite).
  3. disable() will disable the plugin by unbinding events and setting the enabled flag to false.
  4. unmount() will remove the element from the DOM if need be.
  5. emit() will dispatch a destroyed event.

Continuing on our carousel example, the shutdown() may look like the following. This pseudo code is taken from the current 2.1 implementation.

class Carousel extends EmbeddedComponent {
    // ...

    shutdown() {
        this.stop();
        this.setState({ index: 0 });
        this.removeClones();
    }
}   

The flow.

+-----------------------+
| destroy()             |
+-----------------------+
            |
            V
+-----------------------+
| emit('destroying')    |
+-----------------------+
            |
            V
+-----------------------+
| shutdown()            | 
+-----------------------+
            |
            V
+-----------------------+       +-----------------------+
| disable()             | ----> | unbindEvents()        |
+-----------------------+       +-----------------------+
            |
            V
+-----------------------+
| unmount()             |
+-----------------------+
            |
            V
+-----------------------+
| emit('destroyed')     |
+-----------------------+

Rendering

Rendering, depending on the context, can mean one of two things. The first being the mutation of the DOM whenever the state of a plugin has changed, usually through Plugin.render(). The second being the conversion (parsing and rendering) of a logic based templating language, usually represented by template strings, into a set of unmounted DOM nodes, usually through Toolkit.renderTemplate().

Templates

The default templating language within Toolkit will be a logic based custom implementation, with syntax loosely based off of Mustache and Handlebars. Basing the syntax on those libraries will allow for easy drop-in support if Handlebars or Mustache is being used.

The language should support basic conditional blocks, loops, and variable interpolation. The syntax would look like the following.

If/else conditionals.

<div class="post">
    {{#if post.deleted}}
        Post is deleted.
    {{#elif post.hidden}}
        Post is hidden.
    {{/if}}
</div>

Variable interpolations. Escaped and unescaped.

<h1>{{title}}</h1>
<h2>{{{subtitle}}}</h2>

Object and array iteration.

<ul>
    {{#each people}}
        <li>{{firstName}} {{lastName}}</li>
    {{/each}}
</ul>   

The renderer can be customized or overridden by changing the Toolkit.renderTemplate() method.

Toolkit.renderTemplate = function() {
    // Return rendered template as a string
};

Events & Listeners

The old hook system from the previous Toolkit versions is now known as the listener system... since that's what they are. The old "event" system will now be referred to as event bindings (below). Listeners can be subscribed to an event by calling the on() method on the plugin, and the reverse for unsubscribing by calling off().

plugin.on('event', function() {});
plugin.on('event', [
    function() {},
    function() {}
]);

plugin.off('event', func);

Events can be dispatched (emitted, triggered, whatever) using the emit() method (known as fireEvent() in previous versions). This method will loop through and notify all listeners, while passing an optional list of arguments.

plugin.emit('event');
plugin.emit('event', [foo, bar]);

DOM listeners attached to the primary element of a plugin will also be triggered. Since these events are bound outside the context of the plugin, they must use the event naming convention of <event>.toolkit.<component>.

var plugin = new Component('#foo');

document.getElementByID('foo').addEventListener('event.toolkit.plugin', function(event) {});

plugin.emit('event');

Event Bindings

The old event system from the previous Toolkit versions is now known as the binds system, or event bindings. The naming has changed because the old system was rather confusing as the term "event" was rather ambiguous. Was this binding events? Dispatching them? Subscribing listeners? In 3.0, this should be much more straight forward as event bindings will now refer to the mapping of events to bind to the primary element when a plugin is instantiated.

Bindings will be mapped during initialization when the initBinds() method is called, and subsequently the setBinds() method. The setBinds() method accepts an object of key-value pairs, with the key being an event to bind to, and the value being the function callback. The key should include the event name, the context, and an optional delegation context. An example implementation:

class Carousel extends EmbeddedComponent {
    initBinds() {
        this.setBinds({
            'resize window': 'onResize',            // Window events
            'ready document': 'onReady',            // Document events
            '{mode} document': 'onMode',            // Mode based events based on options.mode (either "click" or "hover")
            'click document {selector}': 'onClick', // Document events with delegation and selector
            'click element': debounce(this.func),   // Property events
            'click element [data-foo]': 'onDel'     // Delegated property events
        });
    }
}

The previous example is a bit contrived but it simply showcases many of the different patterns for bindings. For a better understanding, here is the binding diagram pattern.

PATTERN
    EVENT CONTEXT[ DELEGATE]: FUNCTION

EVENT
    The name of the event. If "ready" is used, it will be set as "DOMContentLoaded". If "{mode}" is used, it will be replaced with "options.mode".

CONTEXT
    The context (or element) in which to add listeners to. If "window" or "document" is used, the respective objects will be used, else it will attempt to use a property on the plugin that matches by name.

DELEGATE
    An optional CSS selector to apply delegation to within the CONTEXT. All values after the CONTEXT will be used as a delegation selector. If "{selector}" is used, it will be replaced with the "selector" passed to the constructor.

FUNCTION
    The function to bind as an event listener. If a string is passed, it will attempt to find a method on the plugin that matches by name. If a literal function is passed, it will be used directly.
@milesj milesj added this to the 3.0 milestone Mar 12, 2015
@milesj
Copy link
Member Author

milesj commented Mar 17, 2015

Implementation progress on this can be found here: https://github.com/titon/toolkit/blob/3.0/js-es6/plugin.js

@milesj
Copy link
Member Author

milesj commented Feb 12, 2016

After a thorough implementation and trial process, I have decided to port Toolkit to React instead.

@milesj milesj closed this as completed Feb 12, 2016
@milesj milesj removed this from the 3.0 milestone Feb 12, 2016
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.
Projects
None yet
Development

No branches or pull requests

1 participant