Declaring components are not exactly a new thing. It's just a way for us to agree on a convention to declare what we want from a custom element, using 3 API's that modern platforms already offer: HTMLTemplateElement template, JavaScript modules, ShadowDOM and CSSStyleSheet.
Different libraries or frameworks have tried to solve the same problem. In multiple ways. We want to find a the "special ways" of doing things, and go back to simple and easy concepts.
Before we dig in: this is not a criticism of all the awesome work put behind big frameworks and libraries out there. Everyone is doing their absolute best to solve a difficult problem!
So let's see a few examples:
Vue uses single-file components concept, with an HTML-like syntax.
The "special things" that Vue adds are the :
and @
characters in attribute names, which are not valid in HTML.
There are also "special" attributes like v-if
and v-for
, which are a way to put an entire element behind a template and add logic to define their visibility.
Then we have the #
character for template slots, which is also not valid HTML.
Angular uses Classes and Decorators, and also adds special []
, ()
and [()]
attributes (or was it ([])
??).
I don't even...
React made JSX famous and added more specific syntax as well. Events are inline "things", with special brackets around values and all that. I could never really memorize all that.
These add again very specific syntax to their mental model, which is not easy to remember.
From their docs: Alpine is a collection of 15 attributes, 6 properties, and 2 methods.
We can choose a predictable mental model instead of the specifics of each library. But we must also keep things flexible, so that we can make additions as needed.
Components have a few common needs:
So let's agree on some principles:
- Data flow is predictable: only
props
down and onlyevents
up. No two-way communication!
- The only way a component can change from the outside is with a change in its properties.
- any change inside a component is propagated up as an event.
- any string in a property binding or event handler is valid Javascript and local to the current context, never global
With that in mind, we still need something to add life to HTML.
Let's start with only TWO conventions for our components: event names start with on-
and properties with prop-
.
Now we can declare what we want:
<custom-element on-name="reaction()" prop-name="value"></custom-element>
We still need to sprinkle some Javascript into our component to make it useful. Let's look at other Web API's we can use to expand our component convention.
We have quite a few things to put together before we can fully use the web platform. Here are some of the API's we can explore:
We will also build on top of concepts previously popularized by projects like Angular, VueJS, SolidJS and React.
Historically, a web page has a shared Javascript execution context, sometimes called "global scope", where all parts of a page must coexist. When pages were mostly text, with barely any Javascript, this was okay.
But the Web evolved, and with it, more complex structures started to emerge. It became harder and harder to maintain with a global context.
We need something in a page to create local variables, declare local event handlers, load modules and manage state.
We should also compose with pieces of logic anda data without assigning values to the window
object.
Another problem area is styling: we don't want global styles applied everywhere. Sometimes styles must be scoped to a single component.
Custom Elements are one of the building blocks we need to achieve just that.
We declare a custom <any-name>
tag and let the platform initialize it for us. Inside that context, we can import modules, load stylesheets and run our business completely isolated from the global state.
The <template>
element provides an API to include HTML in a webpage without rendering it.
This is very useful for us: we can load components just like any other HTML content, then read their content and "hydrate" the HTML with the help of Javascript, of course.
From a template
element we use .contents.cloneNode()
API to clone the entire template content without modifying the original nodes.
Another important aspect of templates is that scripts and styles are not active.
We can include a <script>
tag inside a template and use that as our component source.
That's possible because of modules.
<script setup>
and <template component>
attributeOK, now that we agreed upon attributes for components, let's add TWO other convetions:
- add a
component
attribute to a<template>
to create a custom element with a name
<template component="ui-card">~</template>
- add a
<script>
tag inside that template to declare the component behaviour
Now, a component can be authored in plain HTML, and even inserted into a webpage directly into the source.
This is also not new. We can also use a Declarative Shadow DOM to achieve that. In that case, we don't reuse anything, and all content is inline.
Here's what we introduce on top of the standard API:
shadow-dom
attribute to specify Shadow DOM API options.<template component="ui-card" shadow-dom="open"></template>
<script setup>
tag to write the component logic.
Use import to load @li3/web
and export a setup function:<script setup>
import { defineEvent, defineProp, signal, effect } from '@li3/web';
export default function () {
/* ... */
}
</script>
OK, let's take one step back and recap:
<template component>
for declarative component<script setup>
for declarative component behaviour<custom-element prop-* on-*>
for props and events with local scope.Let's see all together in a ui-card
component:
<template component="ui-card" shadow-dom="open">
<div class="card">
<span class="card-title">{{ title }}</span>
<slot></slot>
</div>
<script setup>
import { defineProp } from '@li3/web';
export default function uiCard() {
defineProp('title', '');
}
</script>
<style>
.card {
padding: 1rem;
margin: 1rem auto;
border-radius: 0.5rem;
border: 1px solid #ccc;
background-color: white;
}
.card-title {
color: #999;
text-transform: uppercase;
font-size: 0.75rem;
display: inline-block;
padding: 0 0 1rem 0;
}
</style>
</template>