Custom elements as seen through a module system.
Disclaimer: This is a work in progress, as a way for me to learn web components and to find a good answer for them in a module system. So feedback is welcome, it is very possible that I miss important parts of the specs.
- Background
- Perceived issues with Web Components
- Comparison with Polymer and X-Tags
- Element lifecycle background
- Standard Web Component features used
element
loader plugin custom featurestemplate
loader plugin custom features- Example mixins
- Installation
- Usage
- Notes
- TODO
Web components as a concept are a great idea. However, by relying on expressing them primarily using HTML containers, it introduces some mismatches once the developer has a module system. With the coming module system in ECMAScript (ES) 6, this project explores how web components might look like if the developer has a module system available.
This project uses AMD as the module system, but as the ES module system will have very similar capabilities to AMD. So using AMD is a good test bed to work out module-custom element interaction, and it should be easily portable to ES6 when it becomes available.
This is an AMD loader plugin that implements loading of a custom element module via element!custom-element-name
. There is a template
plugin too, that handles scanning HTML snippets for custom elements and loading them as dependencies.
The element.js assumes document.registerElement
is implemented in the browser. Right now it likely only works in Firefox and Chrome. For Firefox, you may need to set dom.webcomponents.enabled
to true
in about:config for it to work.
The hope is to just use document.registerElement
and perhaps the template
element pieces of the Web Components stack in this plugin. Over time, each custom element could use scoped style elements and the Shadow DOM as they become available in browsers.
However, HTML Imports should not be needed with this modular approach, and because module loading in the browser is async, the assumption is that rendering will be always be completed async.
To avoid the FOUC issue, this plugin supports using a template
tag to define the body, and once custom elements are loaded, that template is converted to the real contents of the body element.
Additionally, the template
loader plugin supports using hrefid
and srcid
as attributes in place of href
and src
respectively. The *id
versions allow using a moduleID + '.' + extension
value, which is converted by the loader to a path when the templates are injected, changing them to regular href
and src
values in the process.
More details on those features below, but first, more on the perceived problems with the current state of Web Components, from the point of view of a person using modules.
HTML Imports use paths. This is out of place with a module system, which uses IDs that are converted to paths. IDs work much better as the developer scales up, and allows better use of package managers to install custom elements.
Right now the examples showing custom elements name themselves. This is not so flexible for reuse. In a module system, the end user and other code defines the name for a given module.
HTML Imports need extra mechanics around an ownerDocument. This seems to cause confusion.
HTML Imports creates an addition tension on page load symantics. Some use cases do not want to have a Flash of Unstyled Content (FOUC) while the imports load. In this case might be more accurate to say Unapplied Content. There are talks about trying to allow blocking rendering by some extra signalling either by extra HTML elements or attributes. Modules are always async, and it looks like a template
element could be used to avoid the FOUC problem.
Current examples/base elments of web components are JavaScript-heavy, with just a bit of HTML around them. Modules are great with JavaScript. AMD-based projects have established usage patterns that use loader plugins to load HTML snippets for HTML templating systems. That can be used for custom elements too. If the HTML snippet is small enough, it can just be inlined in the module, and quasi-literals for ES6 open up other possibilities.
Clear optimization strategies are available with the loader plugin. The template and HTML, along with already-parsed dependencies can be inlined in a build step. Try it by going to the tests/basic
directory, run node tools/r.js -o tools/build.js
, and inspect the www-built/app.js
file. There may be optimization strategies in the pipeline for HTML Imports, but with the modular approach, hopefully HTML Imports would not be needed.
Maybe not all HTML cases in the future will be able to use modules, and that some of the decisions in the web component stack, like the ones around HTML Imports, are there for those cases. I wanted to show how a modular approach can streamline some decisions and allow custom elements to be reusable in more ways, in particular by package managers. This fits well with the encapsulation goals of custom elements.
Both Polymer and X-Tags provide extras on top of the base capabilities being specified. While those things may be nice, and some are to feel out what might need to be standardized later, it gets hard to figure out what is custom and what is not, or to only take the custom parts that an app may use.
The goal of this modular plugin approach was to provide a small base to provide basic template, ID-to-path conversion, and document.registration management, then encourage support for additional features as separate modules that can be mixed in to an element module's prototype.
This results in each module for a custom element just needing to export the object properties that will be mixed in to the prototype for the element's constructor function. Example from the tests.
This loader plugin also avoids eval-related issues with CSP because no eval-based approaches are used.
By default, custom elements registered via document.registerElement
can implement standard callbacks for some lifecycle events:
- createdCallback: Called when an instance is created.
- attachedCallback: Called when the element is inserted into document.
- detachedCallback: Called when element is removed from the document.
- attributeChangedCallback: Called when an attribute on the element is added, changed or removed.
Since these are special callbacks, and multiple mixins may want to listen for them, the element
loader plugin allows multiple mixins to listen for these events.
The template
loader plugin hooks into createdCallback
to do the template wiring. The custom element can still have its own createdCallback
on it. See Mixins for Custom Element modules for more information.
The element
and template
loader plugins uses these standard web components features:
but uses a loader plugin to handle document.registerElement, and uses modules for creating the custom element prototypes that is passed to document.registerElement. The template
plugin creates HTML snippets, via the template element, for the interior DOM structure of the body. However for the templates used inside a custom element, via require(template!...)
just use a regular div/element container as a work area.
The element
loader plugin provides these services:
- Takes a mixin of properties, and converts that to an object prototype and calls
document.registerElement
using the module ID referenced in theelement!
dependency reference. - Multiplexes lifecycle events mentioned by the mixins.
- Automatically wires up any attribute value that is set to setting that value on an instance's associated property.
More details follow.
The simplest form of creating a custom element module is to just to return an object literal that is the set a properties that the plugin will mix in to the prototype object for that custom element:
define(function(require) {
return {
createdCallback: function () {},
...
};
});
However, the module can also export an array of objects, and each object's properties will be mixed in to the prototype. If there are overlapping property names, the last one in the array list wins.
define(function(require) {
return [
{
someProp: function () {},
},
{
createdCallback: function () {},
// This someProp definition wins
someProp: function () {}
}
];
});
The only exception to the "last one wins" is if the property name ends in "Callback". Those are all stored and fired in the sequence they are mixed in to the element prototype. This multiplexing of Callback-style properties means that all of the element lifecycle callbacks are multiplexed.
The Callback multiplexing also gives a convention for mixins that want to allow multiple function calls for custom element changes that should be done all in the same turn. This is in contrast to triggering custom events, which can complete asynchronously. Events should be favored if notification of a state change can happen async. This is not necessarily true for some things like the lifecycle callbacks.
element
takes any attributes that were specified on the custom tag and sets those values using JS-equivalent names to the attribute names, to communicate the outside API values to the plugin instance.
For example, for this use of a custom element:
<custom-tag some-attr="foo">
The loader plugin will look for a someAttr
property in the custom element instance, and if it exists, it will call instance.someAttr = 'foo'
. Getters and Setters can be used, see the someSuffix
section in basic-header. Usage here.
Additionally, if an attribute is changed on a custom element instance, the element
plugin listens for attributeChangedCallback
and it will automatically trigger this attribute-to-property name conversion and set the value of that property name.
Notes about this attribute wiring:
- It only applies for setters on the mixed-in prototype generated by the
element
loader plugin. So it does not check for setters that are on prototype objects further up the prototype chain. - It is just a plain property set of a string value from the attribute. It does not try to guess the type of the attribute string. If you want the value to be of a certain type, handle that in the setter.
The template
loader plugin allows specifying an HTML file as the basis to use for the interior DOM structure of an element. It knows how to output a mixin object value that can be used by the element
loader plugin.
In addition to doing the basic DOM construction, it provides these other capabilities:
Since loading custom elements happens asynchronously, because that is how modules load, then there can be a Flash of Unstyled Content (FOUC), where the non-upgraded body of the document is shown before the custom elements are defined and used in the body.
The template
plugin allows you to avoid that flash. If you construct the HTML page by using a template tag with an ID of "body", like so:
<body><template id="body">
Regular body content is put here, and it will
not become the real body content until custom
element loading and registration is complete.
</template></body>
Then the plugin will convert that template to the real body content once it knows all custom elements have loaded that were registered in that template body.
This may mean that any resources for the body, like images, may not start downloading until custom element initialization is done. I think this works out though, because those module elements may also affect layout, so best to have all of the custom module elements loaded first.
Since custom element loading is async, you should not run application code that depends on the custom elements being in the document on window.onload or document DOMContentLoaded. Instead, register a callback with template.ready()
to get notification when custom elements have been loaded and applied:
template.ready(function() {
// All custom elements needed for first page load have
// been loaded and instantiated when this callback is
// executed.
});
Once custom elements are installable via a package manager, knowing the actual paths for items starts to get harder to know. This was simulated in the basic test in this repo, where www/lib
is where all packages would be installed. In the basic test, basic-header
was package directory that had a few resources, and the basic-header.html template wanted to refer to an image in that directory. It did so via srcid
:
<img srcid="./localimage.png">
The template
loader plugin will convert that to a path then replace srcid
with src
before inserting the template in the DOM. The same thing happens with hrefid
to href
.
The template
loader plugin will call templateInsertedCallback
if it is defined on the custom element, after the template has been inserted as the child of the custom element. This callback hook is not part of the standard document.registerElement() lifecycle events, it is a custom one specific to the template
support.
Due to the *Callback mixin capability of the element
loader plugin, it means there can be multiple mixins that can take advantage of this callback. The data-prop and data-event mixins are examples.
These example mixins can be used alongside the template
loader plugin to do some auto-wiring of some internal DOM structure to a custom element instance.
If the template specifies data-prop
as an attribute on a tag, then the element for that tag will be set as the value to that property on the custom element instance. For instance, with this tag in the element's template:
<div data-prop="dialog"></div>
then after the instance of the element is created, that instance can use this.dialog
to refer to that element.
Code is at mixins/data-prop.
Example usage:
// A custom element that mixes in data-prop:
define(function(require) {
return [
require('mixins/data-prop'),
// Main custom element implementation here
{
createdCallback: function () {
// Assuming template specified
// `data-prop="dialog"` on an element,
// the following would work.
console.log(this.dialog.classList);
},
...
}
];
});
You can wire up event handlers by using a data-event
attribute on a tag in the template. The general format is:
<div data-event="[DOM event name]:[property name],[DOM event name]:[property name]..."></div>
Where DOM event name
is an event name like click
, mouseover
, and property name
is the property name on the element that will be used for the call to node.addEventListener
. If [property name]
is omitted, then it is assumed that there is a property on the instance that is the same name as [DOM event name]
.
Examples:
<div data-event="click"> --> node.addEventListener('click', this.click.bind(this));
<div data-event="click:dialogClick"> --> node.addEventListener('click', this.dialogClick.bind(this));
<div data-event="click:dialogClick,mouseover:dialogMouseOver"> -->
node.addEventListener('click', this.dialogClick.bind(this));
node.addEventListener('click', this.dialogMouseOver.bind(this));
Code is at mixins/data-event.
Example usage:
// A custom element that mixes in data-event:
define(function(require) {
return [
require('mixins/data-event'),
// Main custom element implementation here
{
createdCallback: function () {},
// If the template specified
// `data-event="click:onDialogClick"`
// on an element, the following would
// be called if that element was
// clicked.
onDialogClick: function (evt) {},
...
}
];
});
The model
mixin will add a getter and setter for a model
property on the custom element. It will store the model value at this._model
, and it triggers a this.modelChangedCallback
when the model changes.
If the custom element defines a template
method, then on the first set of the model
property, the setter for model
will call the template
method and set the inner contents of the element to that template return value, and trigger a call to templateInsertedCallback
. It will thne call modelChangedCallback
if it is defined on the custom element.
If the model is set again later, then if the custom element defines a modelChangedCallback
method, then it will be called instead of regenerating the template contents.
Code is at mixins/model.
Example usage:
// A custom element that mixes in model:
define(function(require) {
return [
require('mixins/model'),
// Main custom element implementation here
{
// Called if the model changes. Not required
// to be implemented. If not and the model
// changes, then the template for the element
// will be regenerated each time, if the
// custom element has a template method.
modelChangedCallback: function (firstTime) {
// firstTime will be true if this is the first
// time the model has been set.
},
...
}
];
});
They are still under development, so grab them from this repo in raw form:
- element.js or
volo install requirejs/element#element.js
- template.js or
volo install requirejs/element#template.js
Or you can use this example project that shows its usage: jrburke/element-example
If you have volo installed:
volo create localexample jrburke/element-example
You can also look at the tests/basic
directory from this repo.
Once they have more time to bake, the template plugin will move to its own repo and have its own distribution.
In your app's main module, ask for template
as a dependency, and register a ready listener to do work that depends on any custom elements that are in the body of the HTML page:
// app/main module
define(function(require) {
require('template').ready(function () {
// all custom elements referenced in
// the HTML body have been loaded,
// registered, and instantiated.
})
});
When adding new views to the HTML, each view should be constructed as an HTML element. Then, just use the element!
to load those views dynamically:
// this is inside a controller module that
// at some point decides to load a new
// view.
require['element!account-view'], function (AccountView) {
// Create a new AccountView and insert into document
var accountNode = new AccountView();
// Listen to any DOM events in the list that
// would trigger a contoller
// change via listNode.addEventListener()
accountNode.addEventListener('click', function (evt) {
// account view clicked, do something.
}, false);
// Add to the DOM:
document.body.appendChild(accountNode);
});
MIT
jQuery Foundation Code of Conduct.
Given the disconnected require calls done for custom elements found in templates, there is not a clear dependency graph between custom elements. This means it is hard to break cycles, otherwise known as circular dependencies.
I expect circular dependencies in elements will be extremely rare. However, if they show up, you just need to explicitly state the dependency as a require('element!dependency-tag')
dependency in the module, and that should allow for cycle resolution.
May be a bug for Firefox, seems to create some half-formed custom element where the "this" does not have the full set of prototype properties yet for the up-front body.innerHTML scanning and loading/wiring of a component in template.js? Manifests as a "this._element is undefined" error in basic test.
- Show how two way data binding could be added via a selector mixin.
- Show an example that consumes original childNodes.
- shadowDom use?
- CSS: how to load
- expand polymer/xtag comparison?
-
If document.registerElement in browser, when parsing HTML, need to wait for async load of things before starting. A "delay parsing" api, that then is called later to continue? Would allow for img/ Using template tag, but requires special knowledge.
-
:unresolved matches selectors that have not been upgraded, for FOUC handling, but also, could poll until no others for load event?
-
Some "customElementsReady" when all unknown elements resolved?
-
Need to manually do the setPropFromAttr after creation, but should that happen automatically? Same with attibuteChangedCallback.
-
what triggers "all unresolved have been resolved"?
-
is="" attribute, why there, why not just a regular custom element name?
-
events for the lifecycle callbacks?
-
what is the spec around grabbing template children?