Awesome
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 mountedmounted()
- component has been attached to the DOMunmounted()
- 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