Web Components in 437 Bytes
For years now, web developers have depended on bloated component frameworks to make up for the shortcomings of the browser, but no more! Web components are supported by all major browsers.
The two pieces of magic are:
- Custom elements: allows us to define a new custom element, which can be called from HTML
<my-counter></my-counter>. - Shadow DOM: allows us to encapsulate our element by attaching a "shadow" DOM tree to the element.
1// Define custom element
2customElements.define("my-counter", class extends HTMLElement {
3 constructor() {
4 super();
5 // Attach shadow DOM to this element
6 this.shadow = this.attachShadow({ mode: 'open' });
7
8 this.data = {
9 count: 0
10 };
11 }
12
13 update() {
14 this.shadow.innerHTML = `
15 <div>
16 <button id="dec">-</button>
17 <span>${this.data.count}</span>
18 <button id="inc">+</button>
19 </div>
20 `;
21
22 // Bind events
23 this.shadow.getElementById('dec').addEventListener('click', () => {
24 this.data.count--;
25 this.update(); // Force reactivity
26 });
27 this.shadow.getElementById('inc').addEventListener('click', () => {
28 this.data.count++;
29 this.update(); // Force reactivity
30 });
31 }
32
33 // Called each time the element is added to the document
34 // Element setup should happen here, and not in the constructor
35 connectedCallback() {
36 this.update();
37 }
38});
Isn't it beautiful? Well, not really, but its vanilla.
We start by defining our new custom element my-counter with a class that immediately attaches a shadow DOM to the element. This allows us to encapsulate our state.
On update, we set the inner HTML of the element, and bind click events. This works, but our events call update every time they change the state. This is required to have reactivity, so any state changes get reflected in the visible document.
Isn't it ugly that we have to call update every time the state changes? Lets fix that.
1customElements.define("my-counter", class extends HTMLElement {
2 constructor() {
3 super();
4 // Attach shadow DOM to this element
5 this.shadow = this.attachShadow({ mode: 'open' });
6-
7- this.data = {
8- count: 0
9- };
10 }
11
12 update() {
13 this.shadow.innerHTML = `
14 <div>
15 <button id="dec">-</button>
16 <span>${this.data.count}</span>
17 <button id="inc">+</button>
18 </div>
19 `;
20
21 // Bind events
22 this.shadow.getElementById('dec').addEventListener('click', () => {
23 this.data.count--;
24- this.update(); // Force reactivity
25 });
26 this.shadow.getElementById('inc').addEventListener('click', () => {
27 this.data.count++;
28- this.update(); // Force reactivity
29 });
30 }
31
32 connectedCallback() {
33+ // If any data is set, re-render the entire shadow DOM
34+ let data = {
35+ count: 0
36+ };
37+ this.data = new Proxy(data, {
38+ set: (target, prop, value) => {
39+ target[prop] = value;
40+ this.update();
41+ return true;
42+ }
43+ });
44 this.update();
45 }
46});
We removed the need to call update every time the state changes, by adding a proxy to the internal state.
This proxy allows us to intercept each time our data is set. When it is set, we update the value then call our update function to render everything. This provides coarse-grained reactivity to our components for us.
This is simple, but comes with two major drawbacks:
- Inefficient: the entire shadow DOM is rebuilt, and all events need to be bound.
- Lose DOM state: because the shadow DOM is destroyed we lose any DOM state the browser was holding onto. For example, if the user has tabbed to our increment button, and pressed enter. The focus will be lost when the button is destroyed and rebuilt, so they'll have to tab to it again.
Isn't it ugly that we have manually bind events by finding the element by id? Lets fix that:
1customElements.define("my-counter", class extends HTMLElement {
2 constructor() {
3 super();
4 // Attach shadow DOM to this element
5 this.shadow = this.attachShadow({ mode: 'open' });
6 }
7
8 update() {
9 this.shadow.innerHTML = `
10 <div>
11 <button @click="data.count--">-</button>
12 <span>${this.data.count}</span>
13 <button @click="data.count++">+</button>
14 </div>
15 `;
16
17- // Bind events
18- this.shadow.getElementById('dec').addEventListener('click', () => {
19- this.data.count--;
20- });
21- this.shadow.getElementById('inc').addEventListener('click', () => {
22- this.data.count++;
23- });
24
25+ this.shadow.querySelectorAll('*').forEach(selectorElement => {
26+ for (let attr of selectorElement.attributes) {
27+ if (attr.name[0] == '@') {
28+ // Get the event name, and expression
29+ const eventName = attr.name.slice(1); // Remove @ prefix
30+ const expr = attr.value;
31+
32+ // Add the expression to the event - passing in data
33+ selectorElement.addEventListener(eventName, element => {
34+ new Function('data', expr)(this.data);
35+ });
36+ }
37+ }
38+ });
39 }
40
41 connectedCallback() {
42 // If any data is set, re-render the entire shadow DOM
43 let data = {
44 count: 0
45 };
46 this.data = new Proxy(data, {
47 set: (target, prop, value) => {
48 target[prop] = value;
49 this.update();
50 return true;
51 }
52 });
53 this.update();
54 }
55});
Isn't it ugly to use classes, and to have everything hardcoded?
1+ function def(tag, data, render) {
2- customElements.define("my-counter", class extends HTMLElement {
3+ customElements.define(tag, class extends HTMLElement {
4 constructor() {
5 super();
6 // Attach shadow DOM to this element
7 this.shadow = this.attachShadow({ mode: 'open' });
8 }
9
10 update() {
11- this.shadow.innerHTML = `
12- <div>
13- <button @click="data.count--">-</button>
14- <span>${this.data.count}</span>
15- <button @click="data.count++">+</button>
16- </div>
17- `;
18+ this.shadow.innerHTML = render(this.data);
19
20 this.shadow.querySelectorAll('*').forEach(selectorElement => {
21 for (let attr of selectorElement.attributes) {
22 if (attr.name[0] == '@') {
23 // Get the event name, and expression
24 const eventName = attr.name.slice(1); // Remove @ prefix
25 const expr = attr.value;
26
27 // Add the expression to the event - passing in data
28 selectorElement.addEventListener(eventName, element => {
29 new Function('data', expr)(this.data);
30 });
31 }
32 }
33 });
34 }
35
36 connectedCallback() {
37 // If any data is set, re-render the entire shadow DOM
38- let data = {
39- count: 0
40- };
41 this.data = new Proxy(data, {
42 set: (target, prop, value) => {
43 target[prop] = value;
44 this.update();
45 return true;
46 }
47 });
48 this.update();
49 }
50 });
51+}
TODO
- CTA?
- Whats the story?
- How to prevent wall of code?