Lets Build Web Components! Part 2: The Polyfills
This post originally appeared on dev.to
Component-based UI is all the rage these days. Did you know that the web has its own native component module that doesn't require the use of any libraries? True story! You can write, publish, and reuse single-file components that will work in any* good browser and in any framework (if that's your bag).
In our last post, we learned about the four web standards that let us write web components: <template>
, custom elements, shadow DOM, and JavaScript modules.
Today, we'll learn a little bit about the webcomponentsjs polyfills which let us write web component based apps that run on browsers which don't support the specs.
- Overview
- Loading the Polyfills
- Writing Custom Elements that Work with the ShadyCSS Polyfill
- Custom Elements Polyfill
Overview
Web components are truly awesome. And if you're my favourite brand of nerd, the promise of cross-browser, reusable, interoperable components is heady stuff. It's a no-brainer that web component-based libraries and apps are going to quickly grow in popularity, since as of late October of 2018, web components will be natively supported in the latest versions of Chrome, Firefox and Safari. Sweet!
But web developers who have been in this joint for longer than a minute know that it's not always that simple. Sometimes it feels like the cooler the web platform feature (I'm looking at you, scroll-snap!), the less likely it is to be widely supported.
But fear not, friends! You can dive in to the web components world today without fear of leaving users on older browsers behind. The good people at Google's web components team had you in mind when they created the webcomponentsjs polyfills, which let you target your apps to IE11, which I'm sure is the reason you wake up in the morning. The polyfills will also work on older versions of Chrome and Firefox, and on Microsoft Edge, until they wake up and implement the two most popular tickets on their uservoice board
So don't just sit there, read on! We'll learn together how to load the polyfills, how to write custom elements that leverage them correctly, and how to avoid known issues and pitfalls with the polyfills.
Loading the Polyfills
For most users, the easiest thing to do is pop a script tag sourcing the webcomponents-loader.js
script into your page's head
, before loading any component files. This script checks the users browser's <abbr title="user agent">UA</abbr> string, and only loads the polyfill or set of polyfills that are needed.
<head>
<!-- Load the polyfills first -->
<script src="https://unpkg.com/@webcomponents/webcomponentsjs/webcomponents-loader.js"></script>
<!-- Then afterwards, load components -->
<script type="module" src="./superlative-input.js"></script>
</head>
You can load the scripts via CDN as we've done above, or you can bundle them with the rest of your app code by installing to your project:
npm install --save @webcomponents/webcomponentsjs
<head>
<!-- ... -->
<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-loader.js"></script>;
</head>
<body>
<script type="module">
import './superlative-input.js'
const template = html`<superlative-input label="š„"></superlative-input>`;
// ...
</script>
</body>
<aside>By the way, I just discovered that there's a falafel emoji š„, which I think technically brings the world one step closer to perfection.</aside>
Advanced Loading Scenarios
You can also load specific polyfills individually if you know exactly what you need:
<!-- Load all polyfills, including template, Promise, etc. -->
<!-- Useful when supporting IE11 -->
<script src="https://unpkg.com/@webcomponents/webcomponentsjs/webcomponents-bundle.js"></script>
<!-- Load only the Shadow-DOM and Custom Elements polyfills -->
<!-- Useful to support Firefox <63 -->
<script src="https://unpkg.com/@webcomponents/webcomponentsjs/entrypoints/webcomponents-sd-ce-index.js"></script>
<!-- Load only the Shadow-DOM polyfills -->
<script src="https://unpkg.com/@webcomponents/webcomponentsjs/entrypoints/webcomponents-sd-index.js"></script>
<!-- Load only the Custom Elements polyfills -->
<script src="https://unpkg.com/@webcomponents/webcomponentsjs/entrypoints/webcomponents-ce-index.js"></script>
You might choose to bite the bullet and load the bundle or sd-ce
polyfills in all cases, which would save your users a round-trip to the server, which is a popular choice in production environments. In most simple cases, you'll probably just want to use the webcomponents-loader.js
script.
The full bundle adds 94kb to your critical loading path, whereas the loader only adds 5kb. You should balance the needs of the likely minority of your users on old browsers with the convenience of the likely majority on evergreen browsers.
Asynchronous Loading
In most cases, you'll want to load the webcomponents-loader.js
script as a render blocking resource at the top of your head
. But there will be times you'll want to load it asynchronously. For example: if your app implements a static app-shell to give users the illusion of performance, you'll want that static HTML and CSS to load as quickly as possible, which means eliminating render-blocking resources. In those cases, you'll need to use the window.WebComponents.waitFor
method to ensure your components load after the polyfills. Here's a gratuitously lifted slightly-modified example from the webcomponentsjs
README:
<!-- Note that because of the "defer" attr, "loader" will load these async -->
<script defer src="node_modules/@webcomponents/webcomponentsjs/webcomponents-loader.js"></script>
<!-- Load a custom element definitions in `waitFor` and return a promise -->
<!-- Note that all modules are deferred -->
<script type="module">
WebComponents.waitFor(() =>
// At this point we are guaranteed that all required polyfills have
// loaded, and can use web components API's.
// The standard pattern is to load element definitions that call
// `customElements.define` here.
// Note: returning the import's promise causes the custom elements
// polyfill to wait until all definitions are loaded and then upgrade
// the document in one batch, for better performance.
Promise.all([
import('./my-element.js'),
import('/node_modules/bob-elements/bobs-input.js'),
import('https://unpkg.com/@power-elements/lazy-image/lazy-image.js?module'),
])
);
</script>
<!-- Use the custom elements -->
<my-element>
<bobs-input label="Paste image url" onchange="e => lazy.src = e.target.value"></bobs-input>
<lazy-image id="lazy"></lazy-image>
</my-element>
Or an example more typical of a static-app-shell pattern:
<head>
<script defer src="https://unpkg.com/@webcomponents/webcomponentsjs/webcomponents-loader.js"></script>
<style>
/* critical static-app-shell styles here */
</style>
</head>
<body>
<script type="module">
// app-shell.js in turn imports its own dependencies
WebComponents.waitFor(() => import('./app-shell.js'))
</script>
<app-shell loading>
<header id="static-header">
<span id="static-hamburger"></span>
<span id="static-user"></span>
</header>
<main>
<div id="static-spinner"></div>
</main>
<footer id="static-footer"></footer>
</app-shell>
</body>
Writing Custom Elements that Work with the Polyfills
If you're using a web component library like Polymer, LitElement, or hybrids (among others) to write your components (something we'll cover in a later post), your components will work with the polyfills out-of-the-box, since those libraries are specifically written to use the polyfills. Your job is done. Have a beer.
But if you're writing your components without using a library (first of all, good for you), you'll need to jump through a few hoops to make sure that your components render correctly for as many users as possible.
Eagle-eyed readers may have noticed a few tricky lines of JavaScript peppered into one of the examples that we used in the last post:
const template = document.createElement('template')
template.innerHTML = /*...*/
// Let's give the polyfill a leg-up
window.ShadyCSS &&
window.ShadyCSS.prepareTemplate(template, 'awesome-button')
customElements.define('awesome-button', class AwesomeButton extends HTMLElement {
constructor() {
super()
this.onclick = () => report('Clicked on Shadow DOM')
}
connectedCallback() {
// Let's give the polyfill a leg-up
window.ShadyCSS &&
window.ShadyCSS.styleElement(this)
const root = this.attachShadow({mode: 'open'});
root.appendChild(template.content.cloneNode(true))
}
})
See that ShadyCSS
reference? That's the part of the polyfills which emulates the style-scoping of shadow DOM in browsers which don't support it. In order for your styles to be scoped properly, there are a few rules to follow:
ShadyCSS Rules:
- Styles should be defined in a
<style>
element which is a direct child of a<template>
element. - That
<style>
tag should be the only one in that template. - Before your element attaches, associate its template with it's tag name with
ShadyCSS.prepareTemplate(templateElement, tagName)
- After your custom element attaches to the document, but before the shadow root is created, call
ShadyCSS.styleElement
on your custom element to calculate its styles.
prepareTemplate
parses the rules in your style tag into an abstract syntax tree, and then prepends generated parent selectors to them to simulate scoping.
button {/*...*/}
becomes...
.style-scope .awesome-button button {/*..*/}
styleElement
applies the scoping classes to your element and it's "shady" children.
<awesome-button>
#shadow-root
<button></button>
</awesome-button>
becomes...
<awesome-button>
<button class="style-scope awesome-button"></button>
</awesome-button>
ShadyCSS will also shim CSS Custom Properties (var(--foo)
) if the browser doesn't support them.
Dynamic Styles
Because of the way the ShadyCSS polyfill works, web component authors that need to support Edge or worse are advised not to use dynamically generated CSS such as:
const getTemplate = ({disabled}) => `
<style>
button {
background-color: ${disabled ? 'grey' : 'white'};
}
</style>
`
class AwesomeButton extends HTMLElement {
set disabled(disabled) {
this.render()
}
connectedCallback() {
this.attachShadow({mode: 'open'})
this.render()
}
render() {
this.shadowRoot.innerHTML = getTemplate(this.disabled)
}
}
Instead of that example (which is poorly conceived for many different reasons, not just ShadyCSS compatibility), use CSS Custom Properties, and whenever a dynamic update occurs, use ShadyCSS.styleSubTree
or ShadyCSS.styleDocument
:
const template = document.createElement('template')
template.innerHTML = `
<style>
button {
background-color: var(--awesome-button-background, white);
}
</style>
<button></button>
`;
class AwesomeButton extends HTMLElement {
static get observedAttributes() {
return ['disabled']
}
connectedCallback() {
this.attachShadow({mode: 'open'})
this.shadowRoot.appendChild(template.content.cloneNode(true))
}
attributesChangedCallback(name, oldVal, newVal) {
name === 'disabled' &&
ShadyCSS &&
ShadyCSS.styleDocument({
'--awesome-button-background' : newVal ? 'grey' : 'white',
});
}
}
Those are contrived examples. In the real world you're more likely to solve the problem entirely with CSS like:
:host { background: white; }
:host([disabled]) { background: grey; }
But if you wanted to, say, rotate a hue based on touch events or transform an element based on websocket updates, CSS Custom Properties are the way to go.
ShadyCSS provides some other features like a shim for the now-deprecated @apply
CSS syntax, but we're not going to cover them because that spec is dead in the water.
There are also some known limitations to the ShadyCSS polyfill. Spoilers:
- Since ShadyCSS removes all
<slot>
elements, you can't select them directly, so you have to use some context wrapper like.context ::slotted(*)
. - Document styles can leak down into your shady trees, since the polyfill only simulates encapsulation.
For the low-down and dirty on known-limitations, see the README.
ShadyCSS tl;dr:
So basically, your elements will work as intended even on older browsers and Edge as long as you
- Define your element's styles in it's
<template>
element; - Factor your element's shadow slots with the polyfill in mind;
Make the appropriate incantations in your element'sconnectedCallback
; And - Dynamically update CSS Custom Properties with
ShadyCSS.styleDocument
orShadyCSS.styleSubTree
, or avoid the problem by using some other CSS-based solution.
Custom Elements Polyfill
The custom elements polyfill patches several DOM constructors with APIs from the custom elements spec:
HTMLElement
gets custom element callbacks likeconnectedCallback
andattributeChangedCallback
(which we'll discuss in the next post in more detail). on its prototype.Element
getsattachShadow
, and methods likesetAttribute
and theinnerHTML
setter are patched to work with the polyfilled custom element callbacks.- DOM APIs on
Node
likeappendChild
are similarly patched - The
Document#createElement
et al. get similar treatment.
It also exposes the customElements
object on the window
, so you can register your components.
The polyfill upgrades custom elements after DOMContentLoaded
, then initializes a MutationObserver
to upgrade any custom elements that are subsequently attached with JavaScript.
Supporting IE11
āļø How I feel when someone tells me they need to support IE11.
<rant>
The polyfills support IE11, but it's not all sunshine and rainbows. IE11 is no longer developed by MS, which means it should not be used. Deciding to support IE11 means added development time, added complexity, added surface area for bugs, and exposing users to a buggy, outdated browser. Any time IE11 support is raised as a requirement, it has to be carefully evaluated. Don't just lump it in as a "nice to have". It's not nice to have. If it's not an absolute requirement based on unavoidable circumstances, better to not support it at all.
</rant>
phew. Ok, on with the show.
Per spec, custom elements must be defined with JavaScript class
es, but IE11 will never support that feature of ES6. So we have to transpile our classes to ES5 with babel or some such tool. If you're using the Polymer CLI, there's an option to transpile JS to ES5.
In an ideal world, you would build two or more versions of your site:
- Written using
class
keyword and es2015+ features for evergreen/modern browsers - Transpiled to ES5 using
function
keyword classes - And any other shades in-between you want to support.
You would then differentially serve your app, sending fast, light, modern code to capable user agents, and slow, transpiled, legacy code to old browsers.
But this is not always an option. If you have simple static hosting and need to build a single bundle for all browsers, you will be forced to transpile to ES5, which is not compatible with the native customElements
implementation.
For cases like that, the polyfill provides a shim for the native customElements implementation which supports ES5-style function
keyword elements Make sure to include it in your build (don't transpile this file!) if you're targeting old and new browsers with the same bundle.
<script src="/node_modules/@webcomponents/webcomponentsjs/entrypoints/custom-elements-es5-adapter-index.js"></script>
<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-loader.js"></script>
Active web-components community member @ruphin suggests a neat trick you can use to provide a sort of differential serving even on a static host is to leverage the browser's nomodule
feature:
<!-- This loads the app as a module on Chrome, Edge, Firefox, and Safari -->
<!-- Modules are always nonblocking, and they load after regular scripts, so we can put them first -->
<script type="module" src="/index.js"></script>
<!-- This loads the app on IE11 -->
<script nomodule src="https://cdnjs.cloudflare.com/ajax/libs/babel-polyfill/6.26.0/polyfill.min.js"></script>
<!-- Take a look at rollup.config.js to see how to build this guy -->
<script nomodule src="./index.nomodule.js"></script>
Check out his light-weight web-components framework, gluonjs
https://github.com/ruphin/gluonjs
Conclusion
The webcomponentsjs polyfills let you run your webcomponents in older browsers. True, there are some hoops you have to jump through to make it work, but if you're using a web component helper library to define your elements, that will mostly be taken care of for you.
In our next post, God-willing, we'll explore writing web components with vanilla browser APIs for maximum control and interoperability.
Errata
- A previous version of this article recommended importing the polyfill in a module like so:
import '@webcomponents/webcomponentsjs/webcomponents-loader.js';
Don't do this. Instead, the polyfills should be loaded in the documenthead
, before any other modules are loaded. The article has been corrected with an updated example. - A previous version of this article recommended against loading specific polyfills. The current version provides more depth on why and when you might choose to do so.
- A previous version of this article used
this.shadowRoot.append
, which works on supporting browsers. It's preferable to usethis.shadowRoot.appendChild
, which works with the polyfills as well.
ng here...