/el

Minimal JavaScript application framework / WebComponents base class

Primary LanguageJavaScriptMIT LicenseMIT

El

Minimal JavaScript application framework inspired by React, Vue, and lit-element. See a working todo list example and source

Introduction

El is based on Web Components, and provides a friendly interface to these features:

  • Built-in observable store
  • Reactive templates with one-way binding
  • Fast differential DOM updates
  • Scoped styles via shadow DOM
  • CSS preprocessing for implicit nesting and ampersands
  • Watch expressions
  • Component lifecycle methods
  • Just ~150 lines of source code (~2kb gzipped)
  • Minimal surface area with easy learning curve
  • No dependencies on any other libraries
  • No need for build tools like Webpack or Rollup
<my-counter></my-counter>

<script>
  class MyCounter extends El {
    created() {
      this.state = this.$observable({ count: 0 });
    }
    increment() {
      this.state.count += 1;
    }
    render(html) {
      return html`
        <span>Count: ${this.state.count}</span>
        <button onclick=${this.increment}>Increment</button>
      `
    }
  }
  customElements.define('my-counter', MyCounter);
</script>

Installation

El consists of a single source JavaScript file, el.js. You can drop it into your project directly, and import:

<script type="module">
  import { El } from './el.js'
  /* ... */
</script>

Or else install from the npm registry:

npm install @frameable/el

Components

El serves as a base class for custom elements / Web Components. Inherit from El and then register with customElements.define:

<my-element></my-element>

<script>
  class MyElement extends El {
    /* ... */
  }
  customElements.define(MyElement, 'my-element');
</script>

If you are new to custom elements, some tips per the spec:

  • Element tag names must be lowercase with at least one hyphen
  • customElements.define takes the given class and registers with the tag name
  • In the markup, custom elements cannot be self-closing

Lifecycle methods

If lifecycle methods are defined on the component, they will fire at the appropriate time:

  • created() - componenent has been created but not yet mounted
  • mounted() - component has been attached to the DOM
  • unmounted() - component has been removed from the DOM

Observable

Use El.observable to create an observable store which will allow components to update when the store changes. El keeps track of which components depend on which parts of the store, and only performs the necessary updates.

const store = El.observable({ items: [] });

A component may wish to have its own observable state:

class TodoItem extends El {
  created() {
    this.state = this.$observable({
      status: 'new'
    });
  }
}

A component can also subscribe to changes with $watch.

class TodoItems extends El {
  mounted() {
    this.$watch(store.items.length, () => console.log("length changed!"));
  }
}

Observable stores are implemented as recursive proxies. When a component is rendered, as properties are accessed from observable stores, El keeps track of which components are dependent on which properties. When those properties later change, the components that depend on them are rendered again.

Templates

Templates are rendered through the render function, which accepts a html tag function. Element attributes like class names and event handlers can be assigned expressions directly, or interpolated.

class TodoItem extends El {
  render(html) {
    return html`
      <div class="title ${this.done && 'title--done'}">
        ${this.title}
      </div>
      <button onclick=${this.edit}>Edit</button>
    `
  }
}

Component rendering templates are implemented using tag functions. When the html tag function comes across a value to be interpolated, if the value is a complex value like an array or object being passed as a property, or if the value is a function being assigned as an event handler, the tag function stashes the value and interpolates into the template a key that El uses later to refer back to the original complex value.

Looping

Iterate through items with map, and make sure to add a unique key attribute:

class TodoItems extends El {
  render(html) {
    return html`
      <div class="todo-items">
        ${this.items.map, item => html`
          <todo-item item=${item} key=${item.id}></todo-item>
        `}
      </div>
    `
  }
}

Conditional logic

Within a render function, you can use short-circuit (&&) or ternary syntax (condition ? then : else).

class TodoItem extends El {
  render(html) {
    return html`
      <div class="title ${this.done && 'title--done'}">
        ${this.title}
      </div>
      ${this.editable
        ? html`<button onclick=${this.edit}>Edit</button>`
        : html`<span>Archived</span>
      `}
    `
  }
}

Once a template is rendered to html, it then needs to find its way into the DOM. El renders first to a DocumentFragment, then traverses the fragment and its corresponding component in the actual DOM, and selectively alters the real DOM only where the two structures diverge.

Refs

Refer to elements within a component by the name specified by their ref attribute.

class TodoItemDescription extends El {
  save() {
    store.setDescription(this.itemId, this.$refs.descriptionInput.value);
  }
  render(html) {
    return html`
      <input ref="descriptionInput">
      <button onclick=${this.save}>Save</button>`
    `
  }
}

Refs are implemented as a dynamic getter via Proxy. When the property is read, the proxy handler runs a querySelector query on the component root, so these properties will yield "live" results but may not be ideal in a tight loop where performance is critical.

Computed properties

Properties accessed via getters will be computed just once per render cycle. In the following example, the dependents property invokes the getter just once, even though it is referred to multiple times in template.

class TodoItem extends El {

  get dependents() {
    // expensive execution is cached per render
    return store.items.filter(item => item.dependencies.includes(this.item.id));
  }

  render(html) {
    return html`
      <h1>${this.title}</h1>
      <h4>${this.dependents.length} dependent tasks</h4>
      <ul>
        ${this.dependents.map(d => html`
          <li>${d.title}</li>
        `)}
      </ul>
    `
  }
}

Escaping

Interpolated values are HTML-escaped by default. If you have sanitized markup that you would like to include as-is, use html.raw:

class TodoItemDescription extends El {
  render(html) {
    return html`
      <div class="description">
        ${html.raw(this.sanitizedDescriptionHTML)}
      </div>
    `
  }
}

Style

Specify CSS via the styles method and css tag function. Styles are scoped so that they only apply to elements in this component. Neither ancestors nor descendants of this component will be affected by these styles. The built-in preprocessor adds support for implicit nesting and ampersand selectors.

class TodoItem extends El {
  styles(css) {
    return css`
      .item {
        margin: 16px;
        padding: 16px;

        &:hover {
          background: whitesmoke;
        }

        .title {
          font-weight: 500;
        }
      }
    `
  }
  render(html) {
    return html`
      <div class="item">
        <div class="title ${this.done && 'title--done'}">
          ${this.title}
        </div>
      </item>
    `
  }
}

The shadow DOM provides scoped CSS so that styles defined within a component don't leak either up to parents or down to children. By default, global styles will also not be applied within components, which is great when you're building abstract components to be used across projects, but a hinderance when you want different components within a single application to have consistent fonts, colors, spacing, etc. El clones global styles and applies those styles to each component via link tag with a data URI, so components will be affected by application-wide stylesheets.

El runs a stack-based line-by-line zcss source filter on CSS in order to implement nesting CSS and the ampersand selector, popularized by SCSS and other tools, now a W3C working draft CSS Nesting.

Resources

El Starter app template
https://github.com/frameable/el-starter

MDN on Web Components
https://developer.mozilla.org/en-US/docs/Web/Web_Components

Editor syntax highlighting
https://github.com/jonsmithers/vim-html-template-literals (Vim)
https://github.com/0x00000001A/es6-string-html (VS Code)

zcss preprocessor
https://github.com/dchester/zcss.js