Role a tight WebComponent
I was trying to build a SPA with WebComponents. There was a lot of trial and error, but in the end I settled on some ground rules that worked for me
constructor()
Only use the constructor
to attach the shadowRoot
(if you want to use it), set the template
and bind function. That is, the constructor
would look something like this:
export default class Component extends HTMLElement {
constructor() {
super();
this.attachShadow({mode: 'open'});
this.shadowRoot.innerHTML = `...some markup...`;
this.handleEvent = this.handleEvent.bind(this)
}
handleEvent(event) {}
}
window.customElements.define('x-component', Component);
Because this Component has probable not been imported into DOM tree when the constructor runs, it's better not to do any fancy stuff in the constructor.
connectedCallback
This method runs when the component is connected/imported into DOM tree. This is the perfect place to set up any
event listeners. Also, if the user has defined attributes on the component, this is the first place there
the WebComponent is exposed to them. I therefor use the connectedCallback
to check for attributes and set
default values if attributes are not defined.
export default class Component extends HTMLElement {
connectedCallback() {
this.hasAttribute('property') && this.setAttribute('property', 'value');
this.shadowRoot.querySelector('[data-button]').addEventListener('click', this.handleEvent);
}
}
I really like this short circuit syntax for checking if the attribute has been set.
When querying the shadowRoot for element, I tend to use data-*
rather than CSS class
names or id
s. CSS classes should
only really be used for applying CSS rules, and you should be able to rename them without event listeners
breaking. Even though id
attributes will be local to only this shadowRoot and not exposed to other parts of the DOM, you
can't query multiple elements on a single ID:
this.shadowRoot.querySelectorAll('#id')
This wouldn't work. Therefor I use data-*
to define any JS interaction with shadowDOM elements.
disconnectedCallback
While I haven't tested this out to the n-th degree, I think it's quite important to clean up after yourself when the component is removed. If not, the component could not be garbage-collected and you end up with an over-blown heap and a memory leak.
Obviously, if you attach an event to an element outside of the component, remove it:
export default class Component extends HTMLElement {
handleEvent(event) {}
connectedCallback() {
window.addEventListener('resize', this.handleEvent);
}
disconnectedCallback() {
window.removeEventListener('resize', this.handleEvent);
}
}
But also any references to the internals of the component should be disconnected
export default class Component extends HTMLElement {
animationFrame;
animation() {
// ...some animation stuff...
this.animationFrame = requestAnimationFrame(this.animation);
}
connectedCallback() {
this.animation();
}
disconnectedCallback() {
cancelAnimationFrame(this.animationFrame);
}
}
Without having tested this out completely, what I think will happen is that since requestAnimationFrame
is a method on window
,
even though our WebComponent is removed from the DOM tree, there is still a reference to the Component.animate
method and
the Node will not be garbaged-collected.
attributeChangedCallback
If you want to listen to any attribute changes to the WebComponent, you need to set up the observedAttributes
and
attributeChangedCallback
methods. This is where all state changes should be defined for the component.
export default class Component extends HTMLElement {
static get observedAttributes() { return ['property']; }
attributeChangedCallback(name, oldValue, newValue) {
switch (name) {
case 'property':
//...
break;
}
}
}
Just remember that attributeChangedCallback
will be called for any attribute defined on the WebComponent when it is inserted into
the document
<html>
<head></head>
<body>
<x-component property="value"></x-component>
<script type="module">
class Component extends HTMLElement {
constructor() {
super();
this.attachShadow({mode: 'open'});
this.shadowRoot.innerHTML = '<h1>hello world</h1>'
}
static get observedAttributes() { return ['property']; }
attributeChangedCallback(name, oldValue, newValue) {
switch (name) {
case 'property':
console.log(name, newValue)
break;
}
}
}
window.customElements.define('x-component', Component);
</script>
</body>
</html>
If you copy/paste this into a file and run in a browser, the console will print out property hallo
. But if we don't define
any attribute on the <x-component />
, nothing will happen. That's why it is such a good idea to initialize any attributes
in the connectedCallback
method.
But, don't do this
export default class Component extends HTMLElement {
static get observedAttributes() { return ['color']; }
connectedCallback() {
this.shadowRoot.querySelector('[data-element]').style.backgroundColor = this.hasAttribute('color')
? this.getAttribute('color')
: 'pink' ; // don't do this
}
attributeChangedCallback(name, oldValue, newValue) {
switch (name) {
case 'color':
// ... nothing
break;
}
}
}
it's far better to do this:
export default class Component extends HTMLElement {
connectedCallback() {
this.hasAttribute('color') && this.setAttribute('color', 'pink');
}
attributeChangedCallback(name, oldValue, newValue) {
switch (name) {
case 'color':
this.shadowRoot.querySelector('[data-element]').style.backgroundColor = newValue; // do this
break;
}
}
}
This makes connectedCallback
really clean and you know that the meat of the component is defined in one place:
attributeChangedCallback
Getters and Setters.
Documentations from WebComponents go into great lengths to tell you that you should have a get/set pair for every attribute you define.
export default class Component extends HTMLElement {
static get observedAttributes() { return ['property']; }
get property () {}
set property (value) {}
}
This is so you can use both types of API
const component = document.querySelector('x-component');
// this:
component.setAttrubute('property', 'value');
// or this
component.property = 'value';
And I guess that's a good idea. Just be consistent. HTML Attributes can only deal with string so your get/set should
also only accept/return strings. The get/set pair should therefor only be an alias for setAttribute(value)
/getAttribute()
export default class Component extends HTMLElement {
get property () {
return this.setAttribute('property');
}
set property (value) {
this.setAttribute('property', String(value));
}
}
Again, because attributeChangedCallback
defines all the state-changes, there is no need to do any complicated things in the setter.
The attribute and the getter for href
's anchor
tag don't return the same thing:
const anchor = document.querySelector('a');
console.log(anchor.href)
// http://domain.com/document.html
console.log(anchor.getAttribute('href'))
// /document.html
Don't be that guy :)
If you are coming from the world of React where you can pass in complex objects to component as attributes:
const obj = {
property: 'value';
};
<Component attr={obj} />
you might find it limiting to be able to only pass in strings as attributes. For that you do have the get/set methods. But then don't try to marry up the two things. Just treat them separate.
export default class Component extends HTMLElement {
_property = {};
get property () {
return this._property;
}
set property (value) {
this._property = value;
}
static get observedAttributes() { return ['property']; }
attributeChangedCallback(name, oldValue, newValue) {
switch (name) {
case 'property':
this._property = JSON.parse(newValue);
break;
}
}
}
In this example the attribute and the private _property will get out of sync.
<html>
<head></head>
<body>
<x-component property='{"one": 1}'></x-component>
<script>
const component = document.querySelector('x-component');
console.log(component.getAttribute('property'));
// prints the string '{"one": 1}'
console.log(component.property);
// prints the object {one: 1}
component.setAttribute('property', '{"two": 2}');
console.log(component.property);
// prints the object {two: 2}
component.property = {three: 3};
console.log(component.property);
//prints the object {three: 3}
console.log(component.getAttribute('property'));
// prints the string '{"two": 2}'
</script>
</body>
</html>
It would be better to not use the _property at all and store the value as a string in the attribute mechanism with in the HTMLElement itself.
class Component extends HTMLElement {
constructor() {
super();
this.attachShadow({mode: 'open'});
this.shadowRoot.innerHTML = '<h1>hello world</h1>'
}
get property () {
return JSON.parse(this.getAttribute('property'));
}
set property (value) {
this.setAttribute('property', JSON.stringify(value));
}
}
That fixes the sync issue, but what remains and can't be fixed is that the getter returns an object but the
getAttribute
returns a string.
The best thing is to not have private _property and the attribute be associated in any way.