Sunday, 5 November 2017

Using web component polyfills with template tags

I've been playing around with using <template> tags and how well they work with the current Web Component (Custom Elements) polyfills.

My main motivation for going for Web Components instead of something like React or Angular is that I'm currently developing a chrome extension. I wanted the code base to be as small so that it didn't slow down devtools and increase the frequency of hands. Plus I think it's going to be the natural progression from the current React/Angular/etc components - especially with HTTP 2.0's server push of dependant files removing the need for tools like webpack by allow all dependant files to be automatically sent in response to one request.

I immediately hit problems using custom elements in a chrome extension as they're disabled by default. So in order to use them I had to forcefully polyfill the existing API, it took a bit of fiddling  but now works with both libraries I looked at.

Next, using template tags an import link html file, seemed to be creating me a bit of grief. Templates are a key part of making web components easy to build. The contents of a template tag is parsed but it's not considered to be part of the document. This means that when the web components are defined, they are not instantiated as for any tags that are defined inside the template tags until they are attached to the document tree.

There are also 2 types of components:
  • Autonomous custom element
    These are just basically any tag that only extends HTMLElement or a parent class that does. All behaviour and rendering needs to be done by the implementer. They are defined in html as &ltmy-tag>&lt/my-tag>
  • Customized built-in element
    These are components that extend existing elements such as a button, adding to existing functionality. They are defined in html as <button is="my-button"></button>

Importing the elements

In the process of getting the polyfill working in my chrome extension, I came across 2 different ways of adding nodes from an external document. Both were recommend.
clondeNode
This gave me issues with document-register-element which I had to patch to get working until I found the other suggested way of doing it. cloneNode creates a new copy of the node that isn't attached to any document until it is append to a tree.
var link = document.querySelector('link[rel="import"]');
var template = link.import.querySelector('#my-template');
var dest = document.getElementById("insertion-point");

// uses import node
var instance = document.importNode(template.content, true);
dest.appendChild(instance);
importNode
Using this made the polyfills behave a bit better. ImportNode creates a copy of the nodes which are attached to the document (but not placed on the tree).
// ....
// uses cloneNode
var instance = template.content.cloneNode(true);
dest.appendChild(instance);

Libraries compared

webcomponents.js

This is one promoted by polymer / Google's developer site as being the polyfill to use. However I discovered these don't support customized built-in elements. I don't have an immediate need for them but as I get more familiar with using them I'm sure I'll be wanting to use them.
One benefit that this library did have was that when I did a forced polyfill inside the chrome extension's content scripts I was able to use the basic custom elements correctly (but not built-in extensions).

document-register-element

This is a more lightweight implementation and the best to use in other than a chrome extension. Both types of components are supported and the callback methods are called in the right place.
However, using the forced polyfill, the constructors and callback methods were called in the next event loop. This means that at the moment you insert them you can't use them and definitely shouldn't be setting any properties on them as they would overwrite the functionality that hasn't been applied yet.

The comparison

I did a comparison using chrome only as that was my target browser. The files were just locally hosted from my workspace using node express / connect.

Chrome 62.0.3202
customized built-in element
autonomous custom element
Library constructor called createdCallback called connectedCallback called constructor called createdCallback called connectedCallback called
document-register-element.js v1.7.0 After import After constructor After append After import NO After append
document-register-element.js - force polyfill After event loop After constructor After callback After event loop After constructor After callback
webcomponents.js v1.0.17 NO NO NO After import NO After append
webcomponents.js - forced polyfill NO NO NO After import NO After append

So in summary:
  • Use document-register-element for most cases
  • If your forcing the polyfill use webcomponents.js instead at the sacrifice of being able to extend the built-in elements

The test code

<!DOCTYPE html>
<html>
<head>
    <script>
        window.module = {}; // for pony version of document-register-element.js
        // uncomment below for forcing webcomponentsjs polyfill
        //if (window.customElements) window.customElements.forcePolyfill = true;
    </script>
    <script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>

    <!-- enable below for document-register-element -->
    <!--<script src="/node_modules/document-register-element/pony/index.js"></script>-->
    <script>
      // force polyfill
      //window.module.exports(window, 'force-all');
      // apply with defaults
      //window.module.exports(window);
    </script>

    <script>
        class MyButton extends HTMLButtonElement {
            constructor() {
                super();
                console.log("MyButton:init  -- customized built-in element");
            }
            createdCallback() {
                console.log("MyButton:createdCallback -- customized built-in element");
                this.textContent = 'button';
            }
            connectedCallback() {
                console.log("MyButton:connectedCallback -- customized built-in element");
            }
            customMethod() {
            }
        }
        // customized built-in element
        customElements.define('my-button', MyButton, {extends: 'button'});

        class MyDiv extends HTMLElement {
           constructor() {
              super();
              console.log("MyDiv:init");
           }
           createdCallback() {
              console.log("MyDiv:createdCallback");
              this.innerHTML = 'button';
           }
           connectedCallback() {
              console.log("MyDiv:connectedCallback");
           }
           customMethod() {
           }
        }
        // autonomous custom element
        customElements.define('my-div', MyDiv);
    </script>
  <!--<link rel="import" href="template-tag-import.html"/>-->
</head>

<body>
  <p>
      There should be a "button" text inside the button.
  </p>
  <div id="insertion-point">
  </div>

  <template id="my-template">
      <button is="my-button">Something</button>
      <my-div>Something</my-div>
  </template>

  <script>
      var template = document.querySelector('#my-template');
      // if you want to try out the linked document try these statements instead:
      // var link = document.querySelector('link[rel="import"]');
      // var template = link.import.querySelector('#my-template');


      console.log("************* Import node");
      var instance = document.importNode(template.content, true);

      var dest = document.getElementById("insertion-point");
      console.log("************* Appending child");
      dest.appendChild(instance);
      console.log("*************");

      window.setTimeout(function() {
        var myButton = dest.querySelector("button");
        console.log("\n\nmyButton has customMethod %s -- customized built-in element", !!myButton.customMethod);
        console.log(myButton);

        var myDiv = dest.querySelector("my-div");
        console.log("\n\nmyDiv constructor == MyDiv %s -- autonomous custom element", !!myDiv.customMethod);
        console.log(myDiv);
      }, 1);
  </script>
</body>
</html>