Skip to content
/ vanilla Public

Vanilla Components - Ergonomic and Widely Reusable

Notifications You must be signed in to change notification settings

websdk/vanilla

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

21 Commits
 
 

Repository files navigation

This is currently under draft and not complete. Feedback welcome on the issue tracker or via twitter

Vanilla

Ergonomic and Widely Reusable Components

image

This is a general pattern for authoring components that gives component developers the ability to create components and not worry that it can reliably reused in a wide range of contexts. This is a concern in an increasingly fragmented application framework landscape, and also with the advent of encapsulated Web Components which further tilts the balance in favour of writing reusable components rather than rewriting them for each framework. These components follow a simple unidirectional model and progressively enhanced by the Web Components specifications, but are not required for them to work.

The primary design goals are to refine the boundaries/seams of components, and maximise the ergonomics of authoring components. This is to separate out the concerns of and enable further customisations and innovations to take place orthogonal to the implementation of components. For example, you may wish to take certain actions before a component renders such as pre-applying a related template or inlining styles or invoking lifecyle hooks or catch diffs after the component has run. The components are framework-agnostic, but not the lowest common denominator at the cost of being a long-term solution.



Authoring

1. JavaScript

/* Define - component.js */
export default function component(node, data){ .. }

Stateless: Components are just plain old functions. node is the DOM node being operated on and data is an object with all the state and data [1] the component requires to render. This makes components agnostic as to how or where the node/data is injected, which simplifies testing and allows frameworks to co-ordinate linking state with elements in different ways [2].

Idempotent: For a given dataset, the component should always result in the same representation. This means components should be written declaratively. node.innerHTML = 'Hi!' is perhaps the simplest example of this, but use of the innerHTML is not the most efficient [3]. A component should not update anything above and beyond it's own scope (node).

Serializable: You should not hold any selections or state within the closure of the component (other than variables that will be used within that the cycle). These components are stamps. They will be applied to all instances of the same type. They may be invoked on the server and the client. They may be streamed over WebSockets. They may be cached in localStorage.

Declarative: Event handlers and component API should update the state object and then call node.draw which will redraw the component. This is in contrast to modifying the DOM directly and greatly simplifies components by disentagling rendering logic from update logic. The node.draw hook can then be extended by frameworks to form their own rendering pipeline or simply stubbed in tests.

Defaults: Default values for state can simply be set using the native ES6 syntax:

function component(node, { color = 'red', focused = false }){ ... }

If you need to default and set initial values on the state object only the first time you can use a helper function. The same technique can be used to idempotently expose your component API.


### 2. Styling
/* Style - component.css */
:host { }

Component styles should be co-located with the component. Additional styles or skins can be provided as a separate file (component-modifier.css or some-feature.css). The styles should be written in the Web Component syntax and should work if they were used accordingly (i.e. within a Shadow DOM). There are various modules that interpret these in different ways as a separate concern: apply conventions, dedupe, compile, scope for non-shadow dom browsers, inline, etc.

You will want a mix of styles to be inherited and not inherited. To make styling robust across different environments, the root element should defensively set all the unwanted styles which may leak into the component. It should then explicitly set those it does wish to inherit by setting their value to inherit, such as font-size: inherit etc. Where possible, it's also recommended to use em to make components responsive to their immediate context.


### 3. Communication
// Communicate
node.addEventListener('event', d => { .. })
node.dispatchEvent(event)

The final aspect is that child components will need to communicate with parent components. This is achieved by emitting events on the host node, as this is the only visible part to the parent (the component implementation may indeed be entirely hidden away in a closed Shadow DOM). You can use the native addEventListener and dispatchEvent for this [4]. If something changes in a parent component that requires the child to update, it should redraw it with the new state (unidirectional architecture).



Using

1. Vanilla Component

The simplest way to invoke a component is:

import { component } from 'component'
component.call(node, data)

This is the pure, low-level, 100% dependency free API. You just require/import the component function and invoke it on an element with some data. Similarly, you can load the styles by just including the stylesheet via <link rel="stylesheet"> or <style>. This API is super-useful for single-pass shallow unit testing or application frameworks to build upon.


### 2. [Ripple](https://github.com/rijs/minimal#minimal)

Ripple allows you to use these components as Web Components:

ripple(require('component')) // load all the resources the component exports

component = document.body.appendChild(document.createElement('component'))
component.state = { ... }
component.draw()

By having a consistent contract across components (set state, then .draw), this makes it easy to compose independent components. For example, you could have the following application/component:

<app-vanilla>

Which expands to:

<app-vanilla>
  <nav-top>
  <nav-left>
  <grid-main>

And each custom element may in turn further expand itself. This leads to a simple unidirectional and fractal architecture [5].

Note: If you use once, it will efficiently create or update elements to match your data and also then redraw them:

once(document.body)
  ('component', [{ .. }, { .. }, { .. }])

### 3. D3

Works very closely with the D3 .each signature (component.call(node, data) vs component(node, data)). You can use a helper function to convert the signature:

const th = fn => function(d){ fn(this, d) }

d3.select(node)
  .datum(data)
  .each(th(component))

### 4. React

You can use React Faux DOM to invoke these components within React:

import { component } from 'component'

const reactComponent = reactify(component)

ReactDOM.render(React.createElement(reactComponent, { .. }), mountNode)

function reactify(fn) {
  return function(state) {
    var el = ReactFauxDOM.createElement('div')
    fn.call(el, state)
    return el.toReact()
  }
}

An alternative approach would be to precompile the functions, similar to how JSX desugars to normal React code.


### 5. Angular

You can create a generic directive to invoke the element with the data from the scope when they change and proxy events.


# Testing

Due to the simplified nature of these components (plain functions of data) it becomes (a) drastically simpler to test (b) free of any library/framework/boilerplate and (c) increases the scope of what we can test. Below is several levels of component testing maturity:

  1. Shallow Unit Tests: Invoke the component function on a node with some data and then check the output. It may be a bit brittle to assert the entire output every time, so you can just pinpoint certain features.

  2. Shallow Functional Tests: Each API or event handler will update some state and then redraw itself. By setting the draw function, and after invoking an API or dispatching an event, you can check the new state/output is as expected.

  3. Universal Tests: The tests can be run on Node or real browsers. This is simply achieved by just importing browserenv which makes window and document available on the server.

  4. Cross-Browser Tests: Whilst running in Node is useful for things like coverage, it is not an alternative to running the tests in the browsers you actually support. To automate this, and get realtime feedback from different browsers/platforms use Popper and add a .popper.yml.

  5. Visual Tests: In addition to verifying output by checking the rendered HTML, you should also pin down the calculated styles using getComputedStyle

  6. CI Tests: You should set up your CI to run your tests on the browsers you support. Popper will soon generate badges from your latest CI logs.

  7. System Testing: Since the entire application at any point in time can be replicated from a given dataset, and all actions simply transition from one state to another, you can conduct high-level tests on your entire application by rendering all components and in the same manner as you test individual components (test rendering with specific data, then test transitions between states, whilst making assertions across components).


# Performance

There is no single way to benchmark performance across different libraries/frameworks for all use cases, but a popular test is the DBMonster Challenge (see here for comparisons). This should be taken with the usual caveats, but is at least useful as a proof-of-concept to confirm how fast this approach can be:


# Example Repo

There are a few example repo's that covers all the above points:


# Footnotes

[1] "State" normally refers to things that are unique to this instance of the component, such as it's scroll position or whether it is focused or not. "Data" is normally used to refer to things that are not unique to this instance of the component. They may be shared with other components and may be injected in from different sources depending on the application it is used in.

[2] There are a few common strategies around persisting state between renders:

  • The state object is held privately in a parent closure and is modified externally via specific accessors. This is the approach outlined in Towards Reusable Charts. In that paradigm, the components in this spec are identical to the inner functions.

  • The state object is managed with the help of some secondary structure that roughly matches the structure of the DOM. This is the virtual DOM approach used by React. In that paradigm, the components in this spec are equivalent to just the render function.

  • The state object for an element is co-located with the element itself (i.e. node.state == state). This is the approach used by Ripple. In that paradigm, by eliminating the need for any closures or secondary structures, a component is just the pure transformation function of data. This also means you can inspect the state of element by checking $0.state.

[3] Instead of .innerHTML, you could use jQuery and control statements (e.g. if) to narrow down which areas to update. But this imperative approach spawns many code paths, quickly leading to spaghetti code. You could use some form of templating to improve on that like Handlebars or JSX, but this comes at the cost of a new syntax and a compilation step.

A better approach is to use once, which combines the best of both (declarative and JS):

  • Once will efficiently generate or update DOM to match what it should be, making as minimal modifications as necessary
  • Once is a small library (not a framework)
  • Once is JavaScript (no new language syntax or compilation step)
  • Once is declarative (no spaghetti code)
  • Once is versatile (arbitrary selectors or even functions)
  • Once is composable (each invocation returns a new selection of parents)
  • Once is one of the fastest approaches to rendering
  • The data driven, D3-inspired API (expressing views as a function of data) is slightly higher level than markup-based templates, React, JSX and Virtual DOM which maps 1:1 to HTML.

You can read more about the evolution of different approaches to rendering here.

[4] The native events API can be a little bit more verbose and restrictive than necessary. If you are using once, you can interchangeably use the more ergonomic emitterify semantics, .on and .emit on any selection it returns. .emit will create and dispatch a CustomEvent with the specified type and detail (or default to the element's state). .on will allow you to register multiple listeners for the same event and to also use namespaces.

once(node)
  .on('event' d => { .. })
  .emit('event', data)

once(node)
  ('li', [1, 2, 3])              // create some elements first
    .on('event.ns1' d => { .. }) // multiple, namespaced handlers
    .on('event.ns2' d => { .. })
    .emit('event', data)

[5] Unidirectional User Interface Architectures

A unidirectional architecture is said to be fractal if subcomponents are structured in the same way as the whole is. In fractal architectures, the whole can be naively packaged as a component to be used in some larger application.

About

Vanilla Components - Ergonomic and Widely Reusable

Resources

Stars

Watchers

Forks

Releases

No releases published

Packages

No packages published