DOM-Native: A Native Web Component TypeScript Optimized Utility
dom-native
is a minimalistic utility (<7kb gzip) embracing the DOM Native Web Components model to build simple to big applications the DOM way. #LearnWhatMatters #SimpleScaleBetter
THE DOM IS THE FRAMEWORK
QUICK DEMO
YouTube tutorials: Native Web Components | Quickstart introduction
Key features and approach:
-
REAL DOM (i.e., No Virtual DOM): Fully embrace native DOM customElement and Web Components.
-
Modern (i.e., ZERO IE TAX): Only target modern browsers (e.g., Chrome, Edge Chromium, Firefox, and Safari). NO Polyfill or Shiming.
-
JUST A LIB NOT a framework, the Browser is the Framework (uses the native DOM customElement / webcomponent as the framework).
-
SMALL <7kb gzipped (< 18kb minimized) and ZERO dependency!
-
SIMPLE base class providing expressive lifecycle by hooking to native DOM custom elements
- e.g.,
BaseHMLElement extends HTMLElement
with .init
.preDisplay
.postDisplay
.
-
O(1) event binding support by fully utilizing DOM event bubbling with optional namespacing
on(containerEl, 'pointerup', '.my-div', (evt) => {
console.log('clicked')
}, {ns: 'some_namespace'});
off(containerEl, {ns: 'some_namespace'});
- TYPED for expressiveness and robustness with some minimalistic but powerfull TS decorators
import {customElement, BaseHTMLElement, onEvent} from 'dom-native';
@customElement('my-element')
class MyElement extends BaseHTMLElement{
@onEvent('pointerup', '.big-button')
bigButtonClick(evt) { ... }
@onDoc('pointerup', '.main-menu')
mainMenuClicked(evt) { ... }
}
-
LIGHT and expressive DOM API wrappers (e.g., first(el, selector)
all(el, selector)
)
- e.g.,
const itemsEl = first(el, 'ul.items'); all(itemsEl,'li').map(liEl => console.log(liEl))
-
PUB/SUB - Unleash state management with a Minimalistic pub/sub api (see below)
-
AGNOSTIC - NO templating included. Feel free to use template literals, handlebars, or [lit-html](https://github.com/Polymer/lit-html, or even vuejs).
IN SHORT - Simple Scales Better - Learn what matters - favor pattern over frameworks - The DOM is the Framework! - Real DOM is Back!!!
Hello World
npm install dom-native
BaseHTMLElement
is a simple class that extends the browser native HTMLElement
and provides expressive binding mechanisms and lifecycle methods.
Here an example with some TypeScript decorators (can be used without TypeScript as well)
import { BaseHTMLElement, onEvent } from 'dom-native';
@customElement('hello-world')
class HelloComponent extends BaseHTMLElement{
get name() { return this.getAttribute('name') || 'World'}
@onEvent('click', 'strong')
onStrongClick(){
console.log(`${this.name} has been clicked`);
}
init(){
this.innerHTML = `Hello <strong>${this.name}</strong>`;
}
preDisplay() {
}
postDisplay(){
}
}
document.body.innerHTML = '<hello-world name="John"></hello-world>';
HTML will be:
<body><hello-world name="John">Hello <strong>John</strong></hello-world></body>
Fully based on Native Web Component (customElements) with a lightweight but powerful BaseHTMLElement extends HTMLElement
base class with some simple and highly productive DOM APIs allowing to unparallel productivity to build simple to big Web frontends.
dom-native is designed to scale from the get-go and, therefore, fully embrace TypeScript types and makes a lightweight, expressive, and optional use of TypeScript decorators (JS decorator standard is still way off). However, all functionalities are available in pure JS as well.
Full BaseHTMLElement lifecycle and typescript decorators
dom-native BaseHTMLElement is just a base class that extends DOM native custom element class HTMLElement
and add some minimalistic but expressive lifecycle methods as well as few typescript decorators (or properties for pure JS) to allow safe event bindings (i.e., which will get unbound appropriately).
import { BaseHTMLElement, onEvent, onDoc, onWin, onHub } from 'dom-native';
@customElement('full-component')
class FullComponent extends BaseHTMLElement{
@onEvent('click', 'strong')
onStrongClick(){
console.log('World has been clicked');
}
@onDoc('click', '.logoff')
onDocumentLogoff(){
console.log('.logoff element was clicked somewhere in the document');
}
@onWin('resize')
onDocumentLogoff(){
console.log('Window was resized');
}
@onHub('HUB_NAME', 'TOPIC_1', 'LABEL_A')
onPubSubTopic1LabelAEvent(message: any, info: HubEventInfo){
console.log(`hub event message: ${message}, for topic: ${info.topic}, label: ${info.label}`);
}
init(){
super.init();
this.innerHTML = 'Hello <strong>World</strong>';
}
preDisplay(){
console.log('before first paint');
}
async postDisplay(){
}
disconnectedCallback(){
super.disconnectedCallback();
}
attributeChangedCallback(attrName: string, oldVal: any, newVal: any){
}
}
To fully understand BaseHTMLElement
lifecycle, which is just an extension of the browser native HTMLElement
one, it is essential to understand the nuances of the native HTMLElement lifecycle.
Understanding DOM customElement lifecycle
See MDN documation about custom element lifecycle callbacks
In short, there are
- First you associate a class which extends
HTMLElement
with a tag name using customElements.define('my-comp', 'MyComponent')
dom-native
provides an optional convenient @customElement('my-comp')
TypeScript decorator
- Then, the class can define the following methods:
connectedCallback
: Invoked each time the custom element is appended into a document-connected element. Each time the node is moved, this may happen before the element's contents have been fully parsed.
disconnectedCallback
: Invoked each time the custom element is disconnected from the document's DOM.
attributeChangedCallback
: Invoked each time one of the custom element's attributes is added, removed, or changed. Which attributes to notice change for is specified in a static get observedAttributes method
- There is a
adoptedCallback
, but it is used mostly in the iframe case, which is not common.
With this in mind, here are the BaseHTMLElement
extended lifecycle methods based on the native HTMLELement ones.
@customElement('my-comp')
class MyComponent extends BaseHTMLElement{
private _data?: string;
set data(data: string){ this._data = data; }
get data(){ return this._data }
constructor(){
super();
console.log('-- constructor', this.data);
}
init(){
super.init();
console.log('-- init ', this.data);
}
preDisplay(){
console.log('-- preDisplay', this.data);
}
postDisplay(){
console.log('-- postDisplay', this.data);
}
}
const el = document.createElement('my-comp');
document.body.appendChild(el);
el.customData = 'test-data-1';
document.addEventListener('DOMContentLoaded', function () {
const el2 = document.createElement('my-comp');
el2.data = 'test-data-2';
});
requireAnimationFrame(function(){
requireAnimationFrame(function(){
});
});
NOTE - element upgrade
There is an essential detail on when the component class MyComponent
gets associated with its tag my-comp
, and it follows the following "element upgrade" rule.
- When using
document.createElement('my-comp')
if the MyComponent
associated class was defined before, then it will get immediately 'upgraded', meaning that the returned value will be of type MyComponent
- However, if when using
document.createElement('my-comp')
the MyComponent
was not defined, or if the <my-comp></my-comp>
was created via a template (i.e. in a document fragment), then the MyComponent
will get instantiated and associated to the tag my-comp
when the element is added to the document.body
DOM tree. This is important if the MyComponent
has some setters/getters, as they won't be defined until after the DOM is added to the body.
Best Practices
Here are three typical rendering scenarios:
1) Attribute / Content Rendering
If the component can infer its content soly from its declaration (i.e., attributes and content), then, set the innerHTML
or call appendChild
in the init()
method. Favor this.innerHTML
or one this.appendChild
call (e.g., using the convenient frag('<some-html>text</some-html>)
dom-native DocumentFragment builder function)
@customElement('ami-happy')
class AmIHappy extends BaseHTMLElement{
init(){
super.init();
const happy = this.hasAttribute('happy');
this.innerHTML = `I am ${happy ? '<strong>NOT</strong>' : ''} happy`;
}
}
const el = document.createElement('ami-happy');
document.body.appendChild(el);
2) Data Initialization Rendering
If the component needs more complex data structure to render itself, but those data do not require any async, adding the component to the document to instantiate the component, and then, calling the data initializers will allow the preDisplay()
to render those data before first paint.
@customElement('whos-happy')
class WhosHappy extends BaseHTMLElement{
data?: {happy: string[], not: string[]}
preDisplay(){
if (this.data){
this.innerHTML = `Happy people: ${this.happy.join(', ')} <br />
Not happy: ${this.not.join(', ')}`;
}
}
}
const el = document.createElement('whos-happy');
const whosHappyEl = document.body.appendChild(el) as WhosHappy;
whosHappyEl.data = {happy: ['John', 'Jen'], not: ['Mike']};
3) Async Rendering
When a component needs to get data asynchronously, then, async postDisplay()
method is a good place to put this logic, and usually the component HTML structure gets assigned at the init()
method.
@customElement('happy-message')
class HappyMessage extends BaseHTMLElement{
init(){
super.init();
this.innerHTML = '<c-ico>happy-face</c-ico><h1>loading...</h1><p>loading...</p>';
}
async postDisplay(){
const msg: {title: string, text: string} = await httpGet('/happy-message-of-the-day');
first(this,'h1')!.textContent = msg.title;
first(this,'p')!.textContent = msg.text;
}
}
const el = document.createElement('happy-message');
Note: init()
and preDisplay()
could be marked as async
but it would not change the lifecycle of the component as async calls will always be resolved after first paint anyway. Use init()
and preDisplay()
synchronous component initialization, and have the async work done in the postDisplay()
.
constructor()
v.s. init()
: Many Web Component tutorials show how to create/attach ShadowDom
at the constructor, but calling this.innerHTML
at the constructor is not permitted. init()
get called at the first connectedCallback
and is a safe place to set this.innerHTML
value. This allows to decouple the ShadowDom requirements from the component model, making it optional.
4) ShadowDOM
ShadowDom
is now, since Safari supports cssParts
a robust concept to follow. It allows to high component internals while exposing part of the component structure that should be visibile to the rest of the application.
See Dialog Box with Native Web Components - Part 1 and Part 2 for best practices.
APIs
DOM Navigation & Manipulation APIs Overview
import {on, off, all, first, prev, next, append, frag, attr, style } from 'dom-native';
on(els, types, listener);
on(els, types, selector, listener);
on(els, types, selector, listener, {ns,ctx});
off(els, type, [selector,] listener)
off(els, type[, selector])
off(els, {ns})
trigger(els, "MyCustomEvent", {detail: "cool", cancelable: false});
const els = all(el, selector);
const els = all(selector);
const el = first(el, selector);
const el = first(selector);
const el = first(el);
const el = next(el[, selector]);
const el = prev(el[, selector]);
const newEl = append(refEl, newEl);
const newEl = append(refEl, newEl, "first");
const newEl = append(refEl, newEl, "last");
const newEl = append(refEl, newEl, "empty");
const newEl = append(refEl, newEl, "after");
const newEl = append(refEl, newEl, "before");
const htmlFrag = html`<div>any</div><td>html</td>`;
const div = elem('div');
const [div, myComp] = elem('div', 'my-comp');
const cssObj = css`
:host{ border: solid 1px red}
`;
adoptStyleSheets(this, cssObj);
Pub / Sub APIs overview
import { hub } from 'dom-native';
const myHub = hub("myHub");
myHub.sub(topics, [labels,] handler[, opts]);
myHub.pub(topic, [label,] data);
myHub.unsub(opts.ns);
Dom Data eXchange (push/pull)
push
and pull
provides a simple and extensible way to extract or inject data from and to a DOM subtree.
push(el, [selector,] data);
const data = pull(el[, selector]);
pusher(selector, pusherFn(value){this });
puller(selector, pullerFn(){this });
push(el, [selector,] data);
Will inject data to the matching selector (default ".dx") elements. By default, selector is ".dx".
pull(el[, selector]);
Will extract the data from the matching elements (default selector ".dx")
Example
<div id="myEl">
<fieldset>
<input class="dx" name="firstName" value="Mike">
<input class="dx" name="lastName" value="Donavan">
</fieldset>
<div>
<div class="dx dx-address-street">123 Main Street</div>
<div class="dx" data-dx="address.city">San Francisco</div>
</div>
</div>
import {first, push, pull} from 'dom-native';
const myEl = first("#myEl");
const data = pull(myEl);
const updateData = {address: {street: "124 Second Street"}};
push(myEl, updateData)
Anim
anim(callback, duration, ease?)
is a very simple but convenient method to make animation based on requestAnimationFrame
anim((ntime) => {
}, 200);
import { easeBounceOut } from 'd3-ease';
anim((ntime) => {
}, 200, easeBounceOut);
changlogs
Other notes
dom-native
formerly named mvdom as been rename to dom-native
as it came closer to 1.0 release. While a little longer, I felt it was punchier and more representative of the library's intent.