Messin’ around with web components. Also—JavaScript, generally
Table of contents
I’ve been enjoying Robin Rendle’s new email newsletter called The Cascade. He’s been kicking the tires on web components (also known as custom elements) and last week posted some thoughts about why you might want to use them.
Now this week he went a little deeper into some code for one of these bad boys and expressed some mild frustration and confusion about how it all works.
He’s trying to set up a custom element like this:
<thanksgiving-button></thanksgiving-button>
With some script like:
const template = document.createElement('template')
template.innerHTML = `<button>Happy Thanksgiving!</button>`
class ThanksGivingButton extends HTMLElement {
constructor() {
super()
this._shadowRoot = this.attachShadow({ mode: 'open' })
this._shadowRoot.appendChild(template.content.cloneNode(true))
}
}
customElements.define('thanksgiving-button', ThanksGivingButton)
Then, he says this:
I think I have to use the constructor() here since I’m setting
this
. But also there are no good blog posts out there explaining any of this stuff and so I challenge you, nay dare you, to really explain all this to me
Ha, challenge accepted.
Objects, Classes and “this”
To understand all of this (and, ahem, this
), we have to step way, way back into first principles.
(Almost) everything in JavaScript is an object
Although this is simplifying some, in JavaScript if something is not a string ("hello"
), number (42
), or the programming-y bits like true
, false
, or undefined
, it’s an object.
Think of objects like a basket of stuff. They have properties (bits of data on the object), and methods (functions attached to the object). For example, here is a simple object, stored as the variable ultimateQuestion
:
const ultimateQuestion = {
answer: 42,
getAnswer: function () {
return `The answer to life and so forth is 42`
},
}
// accessing property by name
// ultimateQuestion['answer'] is equivalent
ultimateQuestion.answer
// --> 42
// calling a method
ultimateQuestion.getAnswer()
// --> "The answer to life and so forth is 42"
So in the above, answer
is a property, and getAnswer
is a method (function) on that object.
You’re probably familiar with some of the big important objects in a browser’s environment like document
or window
, but under the covers, basically everything is an object. Arrays are a special kind of object with numbers for properties and special methods like .forEach()
; Date
s are objects; even functions are, deep down, objects with properties and methods of their own.
What is "this"?
So, fair warning up front: the this
keyword in JavaScript is one of the most confusing parts of the language.
You may have noticed on my ultimateQuestion
that I repeated 42
, once in the answer property, then again in what I’m logging in the getAnswer
method:
const ultimateQuestion = {
answer: 42,
getAnswer: function () {
return `The answer to life and so forth is 42`
},
}
That kinda stinks. Like our answer
is some data we’re storing on the object, and it’d be great if getAnswer
could refer to that data, yah?
Enter this
:
const ultimateQuestion = {
answer: 42,
getAnswer: function () {
return `The answer to life and so forth is ${this.answer}`
},
}
Lookit, now we’re using this
in getAnswer
. When we call that method…
ultimateQuestion.getAnswer()
// --> "The answer to life and so forth is 42"
… it works just like before, but now it’s referring to the answer
property on the ultimateQuestion
object (represented in the method by this
)!
Should I change the answer
property on the ultimateQuestion
object, calling getAnswer()
later on will do the right thing:
ultimateQuestion.answer = 1
ultimateQuestion.getAnswer()
// --> "The answer to life and so forth is 1"
So, we’ve arrived at one of the things that this
can be: this
is the context in which a method is called. With ultimateQuestion.getAnswer()
, we are invoking the getAnswer
method, but it’s attached to the ultimateQuestion
object, so this
within the method is that specific ultimateQuestion
object.
In short, to know what this
actually is, you have to look at what object the function is attached to when it is getting called.
// ↙️ to know what "this" is in here…
ultimateQuestion.getAnswer()
// ↖️ …you have to look back here
Now, I am glossing over MANY oddities in the language, like you can redefine what this
is on the fly with the .call()
, .apply()
, and .bind()
methods on functions, or if you were to try to peel off getAnswer()
and call it separated from ultimateQuestion
, it wouldn’t do the same thing…
// with .call, you're invoking a method with a different "this"
ultimateQuestion.getAnswer.call({ answer: 'strippers & blackjack' })
// --> "The answer to life and so forth is strippers & blackjack"
const getAnswer = ultimateQuestion.getAnswer
getAnswer()
// --> actually what happens here depends on if you're in strict mode or not lolololol
JavaScript is bonkers sometimes! But let’s move on.
Creating new objects with constructor functions
So my little ultimateQuestion
object is pretty great, but you might be thinking, “Golly it’d be great to create my own questions, with different answers!”
More power to you! This is where constructors come in!
A “constructor” is a fancy name for a function that returns a new object. That’s all they are!
But they’re, uh, a little weird. Let’s make a constructor function:
function UltimateQuestion(answer) {
this.answer = answer
this.getAnswer = function () {
return `The answer to life and so forth is ${this.answer}`
// btw don't do this in real life
// I am trying very hard not to say "prototype" in this article
}
}
(Constructors function names are capitalized by convention, but they don’t have to be.)
Wait, we’re using this
here now? Hang in there!
You use a constructor function like so, with the new
keyword:
const myQuestion = new UltimateQuestion(42)
myQuestion.getAnswer()
// --> "The answer to life and so forth is 42"
The super important part is the new
keyword before calling the function (as in new UltimateQuestion(42)
), which leads us to the second special thing that this
can be: in a (constructor) function called with new
, this
is the new object being created.
So my UltimateQuestion
function is sticking a property and method onto this
, which is a new object every time someone calls new UltimateQuestion()
.
Now because we have a dedicated function for building them, we can make a bunch of UltimateQuestion
s, each with their own stored answer
property and getAnswer()
method that refers to that property.
const bendersQuestion = new UltimateQuestion('strippers & blackjack')
bendersQuestion.getAnswer()
// --> "The answer to life and so forth is strippers & blackjack"
You might have noticed that I didn’t have to do something like return this
at the end of the constructor; that’s assumed when you use new
.
You might be thinking, “Golly, this seems maybe needlessly complicated? If I wanted a function that makes an object for me, couldn’t I just… make and return a new object?”
function makeUltimateQuestion(answer) {
return {
answer,
getAnswer: function () {
return `The answer to life and forth is ${this.answer}`
},
}
}
const myQuestion = makeUltimateQuestion(42)
const bendersQuestion = makeUltimateQuestion('strippers & blackjack')
bendersQuestion.answer
// --> "strippers & blackjack"
That works! I’d probably do that!
But what constructor functions and new
let you do is check what kind of object you have with instanceof
.
const ultimateQuestion = new UltimateQuestion(42)
ultimateQuestion instanceof UltimateQuestion
// --> true
ultimateQuestion instanceof HTMLElement
// --> false
const imposterQuestion = { answer: 42 }
imposterQuestion instanceof UltimateQuestion
// --> false
What instanceof
does is tells you if the thing on the left sprang forth in any way from a constructor on the right.
This is… well in many years of writing JavaScript I rarely used instanceof
, but it’s a low-key superpower in TypeScript.
Remember: almost everything is an object, so being able to narrow down what specific kind of object something is — and, accordingly, gaining some understanding of what properties and methods will be on that object — can be very useful.
someRandomObject.answer
// ❌ TypeScript error; it isn't sure "answer" is a property on this object
if (someRandomObject instanceof UltimateQuestion) {
// ✅ TypeScript understands that "answer" is a property on this object
someRandomObject.answer
}
A more practical real-world use is verifying that a given element is a specific HTML element, each of which has a dedicated constructor like HTMLImageElement
or HTMLParagraphElement
:
if (someElement instanceof HTMLImageElement) {
// now TS knows the someElement could have <img />-specific properties
// like "src", "width", "height"
}
Class is in session
Alright, now you can make your own new questions with whatever answer you want, but the constructor function…
function UltimateQuestion(answer) {
this.answer = answer
this.getAnswer = function () {
return `The answer to life and so forth is ${this.answer}`
}
}
… is kinda gross-looking, yeah? You have to keep referring to this
to add properties and methods to our newly-created object. Also, you kinda have to know that UltimateQuestion
is a special constructor function — either because of the (not-required!) capitalized name or because it uses this
inside.
Enter class
, which is (really!) just a different, arguably nicer syntax for constructor functions. My original ultimateQuestion
would look like this as a class:
class UltimateQuestion {
// property
answer = 42
// method
getAnswer() {
return `The answer to life and so forth is ${this.answer}`
}
}
const myQuestion = new UltimateQuestion()
myQuestion.answer
// --> 42
myQuestion.getAnswer()
// --> "The answer to life and so forth is 42"
(Like with constructor functions, it’s conventional to capitalize class names.)
There’s some weird stuff now! The property answer
looks more like a variable assignment (answer = 42
), instead of like an object property assignment (answer: 42,
) for some reason!
Also, the class UltimateQuestion
that we’re creating is just a template for objects. If you tried to do UltimateQuestion.answer
, it’ll be undefined
. You don’t actually make a real object with your properties and methods until you call it like a function with new UltimateQuestion()
— just like constructor functions.
We’ve reverted a bit here; the UltimateQuestion
class above doesn’t let you set any old answer.
If we wanted folks to define their own answers again, classes can have a special method called constructor()
. When you go to actually make a new object from a class with new
, the constructor
method gets called.
Again, this is just like our “regular function” constructors, but with a class it’s now explicitly called constructor
. Also just like before when we were calling a regular function with new
, within the constructor()
method the value of this
is the new object that is being created.
class UltimateQuestion {
constructor(answer) {
// "this" is the new object we will be making
this.answer = answer
}
getAnswer() {
return `The answer to life and so forth is ${this.answer}`
}
}
const goodQuestion = new UltimateQuestion(42)
const worseQuestion = new UltimateQuestion(43)
Here’s where I can see the appeal of this syntax:
- With
class
, we’re making it clear that we’re defining the template for an object. - Anything required for initialization or setup can be done in the
constructor
, so that has a nice, dedicated purpose and a clear(ish) name. - You can define the various methods on your object separately from the
constructor
, so they’re not cluttering up the constructor code.
Oh, and also you can build on existing classes by…
Extending classes
Another neat thing about class
is its close buddy extends
. What extends
lets you do is, well, extend existing classes. When you extend
an existing class (or constructor), you are building on top of what the original does for free, without having to duplicate that code.
Here’s an example where I make a new class that adds additional methods:
class QuestionLogger extends UltimateQuestion {
whisperAnswer() {
console.log(`[mumbles softly] ${this.getAnswer()}`)
}
yellAnswer() {
console.log(`THE ANSWER TO LIFE AND SO FORTH IS ` + this.answer + '!!!!!!')
}
}
Above, QuestionLogger
is the child or subclass, and it is building on top of what happens in UltimateQuestion
, the parent class.
Check out how I can access and reuse the properties and methods of UltimateQuestion
, via this
in each of those methods. Those bits from UltimateQuestion
come along for free because I am extending it.
Maybe we could add additional methods that let you change existing properties in a controlled way:
class CounterQuestion extends UltimateQuestion {
increment() {
this.answer = this.answer + 1
}
decrement() {
this.answer = this.answer - 1
}
}
const counterQuestion = new CounterQuestion(42)
counterQuestion.getAnswer()
// --> "The answer to life and so forth is 42"
counterQuestion.increment()
counterQuestion.increment()
counterQuestion.increment()
counterQuestion.decrement()
counterQuestion.getAnswer()
// --> "The answer to life and so forth is 44"
See how it’s totally fine to access and even change the properties on this
in the child methods?
Child classes can even override a method on the parent class:
class LouderUltimateQuestion extends UltimateQuestion {
getAnswer() {
return `THE ANSWER TO LIFE AND SO FORTH IS ${this.answer}!!!!!!`
}
}
new LouderUltimateQuestion(42).getAnswer()
// --> "THE ANSWER TO LIFE AND SO FORTH IS 42!!!!!!"
Note also that I am not defining a constructor()
method in the child classes. If I leave that off, they’ll just (kinda sorta) reuse the same one as the parent class.
But if you do want to override that constructor…
What is “super()”?
So when a child class is extending a parent and has to do more stuff in its constructor
, that’s where super()
comes into play.
Maybe a child class of UltimateQuestion
wants let you pass in a template to control what gets returned when you call getAnswer()
, so that’s an additional argument in the constructor:
class TemplatedAnswerQuestion extends UltimateQuestion {
constructor(answer, template) {
super(answer)
this.template = template
}
getAnswer() {
return this.template.replace('{{ANSWER}}', this.answer)
}
}
const theQuestion = new TemplatedAnswerQuestion(300, 'The answer is {{ANSWER}}')
theQuestion.getAnswer()
// --> "The answer is 300"
Because my child TemplatedAnswerQuestion
needs to store more internal data (the template
property when it gets created), it needs its own constructor
method to store it somewhere when we make one of these.
But in a child’s constructor()
, you must call the special super()
function, which then creates the new (parent) object first. Once that happens, you can access this
(the new object) in the child’s constructor, and it’ll have all the properties and methods of the parent class.
So if you need to store additional properties on this
or do more custom setup, you’d do that in the constructor()
, after calling super()
.
If you’re not doing any of that, you don’t need to define the child’s constructor()
method, and you also don’t need to worry about super()
at all.
Why do you have to call super()
manually? If you want to do something like logging or futzing with the arguments or what-have-you before the parent object gets created, you can do that:
class TemplatedAnswerQuestion extends UltimateQuestion {
constructor(answer, template) {
// stuff like logging before super() is fine
console.log('creating a TemplatedAnswerQuestion with arguments', { answer, template })
// you must call super() before accessing "this" here
super(answer)
// now you can access "this"
this.template = template
}
}
So you have some control over when super()
gets called, but you must do it at some point and you must do it before using this
in the child’s constructor()
. It’s the law! Just kidding but you will get a runtime error if you don’t.
Parent classes might defer to their children
One last thing before we actually talk about web components, I promise.
Up to this point we’ve had a class (UltimateQuestion
) and extended it to adjust or overwrite some parts of the resulting object. But the UltimateQuestion
was fine on its own, honestly. The extensions were just for funsies.
What if we designed a class with the expectation that it will be extended?
Like (and OK yes I know this is super contrived) our parent class sets up a listener so that whenever you click on the document, we’ll do… something…
class QuestionDocumentClicker {
constructor(answer) {
this.answer = answer
document.addEventListener('click', () => {
if (this.documentClickedCallback) {
this.documentClickedCallback()
}
})
}
}
But look, we didn’t actually define that documentClickedCallback()
method that gets called when you click.
What we’re doing here is not defining that method in the parent, and assuming that any child classes that are extend
ing this class will define it for us. That way, the parent doesn’t particularly have to care about what happens; that’s a concern for the child class. The parent just stores the answer
and sets up the event handler.
A child class then might look like this, where all it has to do is provide the “missing” documentClickedCallback()
method:
class MyClicker extends QuestionDocumentClicker {
documentClickedCallback() {
console.log(`My super special answer is ${this.answer}`)
}
}
const myClicker = new MyClicker(42)
// (click the document)
// --> [logs] "My super special answer is 42"
If you’re wondering, “Huh? Why are we using ‘callback’ in the name for the method?” you don’t have to! That’s just a sorta-conventional way of describing a function that will get called at some future time, typically in response to an event like a click or a request completing or a timer finishing up.
We’ll see methods like this — where it’s on the child class to define what happens when certain events happen — in action with web components.
Phew! That’s a whirlwind tour of objects, constructors, this
, class
, and extending classes.
(I am papering over a lot, like how objects in JavaScript use “prototype” inheritance so this is really all a facade to make it look like other “class”-based languages but, god, don’t go looking into that too deeply.)
The HTMLElement class
OK that went longer than I thought, so let’s tie all this back to web components.
When you’re defining a custom element’s class, you do so (typically!) by creating a new class
that extends the HTMLElement
class, which is the common-denominator shared class for all elements in HTML. Like here’s Robin’s example:
class ThanksGivingButton extends HTMLElement {
constructor() {
super()
this._shadowRoot = this.attachShadow({ mode: 'open' })
// some other things use this._shadowRoot…
}
}
So, put another way given what we now understand about classes, we’re defining the setup code to make a new thing (ThanksGivingButton
), and it is building on top of HTMLElement
.
Now in that snippet above, Robin is using a constructor()
for this new child thing.
Finally, finally I’m circling back to his initial point of confusion, but the first point is that for web components…
(Probably) Don’t use a constructor on custom elements
Like, you can, but you don’t have to, probably.
You probably want to do stuff when the element is fully “in” the page anyway — that’s when you can look for children of your custom element or replace the innards with templates AKA 99% of what you’d do in a web component. You should do that work in the connectedCallback()
method.
In the same manner as our extremely contrived example QuestionDocumentClicker
above, the HTMLElement
parent class knows to call a child class’s connectedCallback
method when the custom elements lands on the document.
A common misconception I see — and it appears that this might be where Robin is coming from — is that you can only access this
in a constructor function, but as I’ve shown in my whirlwind tour of objects and constructors above, that’s not true at all: Any method on an object (or class) can access this
!
Accordingly, when the connectedCallback
method runs, you have access to this
, and in that context this
is the now-extended HTMLElement
element that is currently on the page.
Because we’re extending the HTMLElement
class, this
has all the bits and bobs you find on other elements: you can set this.innerHTML
, you can look for descendent elements with this.querySelector()
, set up a shadow DOM with this.addShadow()
, add an event listener with this.addEventListener()
, and so forth.
Anyway, for Robin’s case, all his logic that’s in the constructor can (and probably should, in my opinion) move to the connectedCallback
method:
class ThanksGivingButton extends HTMLElement {
connectedCallback() {
this._shadowRoot = this.attachShadow({ mode: 'open' })
this._shadowRoot.appendChild(template.content.cloneNode(true))
}
}
No more constructor, no more super()
. And you can use this
in there just fine.
Avoiding constructor()
in web components comes right from the spec, BTW:
In general, work should be deferred to connectedCallback as much as possible — especially work involving fetching resources or rendering.
Now you might be wondering, “Hang on, why isn’t the constructor()
the thing that gets run when inserting the element? Why is there also this connectedCallback
?”
Good question! Typically we think about these things as custom HTML elements that you author in markup like…
<thanksgiving-button></thanksgiving-button>
The real magic with these web component class
es is that the browser is doing so much stuff for you. After you’ve registered a custom element, when the browser sees that markup in the page, it’ll effectively run that custom element’s constructor()
function, then also immediately call connectedCallback()
on your behalf.
So in general, you’re not writing scripts that makes these things like new ThanksGivingButton()
; you just write the setup code and then use HTML markup to place them wherever you want in the page.
But, if you wanted to, you can create new elements in JavaScript manually, and insert them into the page later, if ever, which separates the initialization (constructor
time) from the mounting (connectedCallback
time):
const myThanksgivingButton = new ThanksGivingButton()
// ^ constructor() fires
// do some stuff, wait a bit, whatever
// insert that element into the body
document.body.appendChild(myThanksgivingButton)
// ^ connectedCallback() fires
So that’s why connectedCallback()
is its own thing, and it’s also arguably the most important thing: probably most of what you want to do with a web component should happen there.
Update: For some more nuance on connectedCallback
, see this follow-on article where I build a functional web component and hit some issues with it.
(Probably) You don’t need to store a separate reference to the shadowRoot
Another thing Robin does (I’ve seen this in many other examples) is store a reference to the custom element’s shadowRoot on this
, like this._shadowRoot
:
Here’s just the stuff I moved out of his constructor and into connectedCallback again:
connectedCallback() {
this._shadowRoot = this.attachShadow({ mode: 'open' })
this._shadowRoot.appendChild(template.content.cloneNode(true))
}
So, you don’t really need to store your own reference to the element’s shadowRoot.
Calling this.attachShadow()
will automatically stick a property shadowRoot
on this
for you like ✨magic✨ — but you know now that it’s not really magic because object methods can fiddle with this
— so we can simplify things:
class ThanksGivingButton extends HTMLElement {
connectedCallback() {
this.attachShadow({ mode: 'open' })
// now you have this.shadowRoot automatically.
// you don't need to store it again as this._shadowRoot
// go ahead and interact with this.shadowRoot now
this.shadowRoot.appendChild(template.content.cloneNode(true))
}
}
If you define other standard callback methods or custom methods, they’ll also have access to this.shadowRoot
, assuming they’re getting called after this.attachShadow({ mode: 'open' })
happens.
Actually, attaching a shadowRoot very early is one of the rare good reasons to use a constructor()
for web components, because that will happen before anything else. Again, the spec calls this out!
In practice, you probably don’t need to worry about this, but if you wanted to be extra safe and separate out that step, that’d look like:
class ThanksGivingButton extends HTMLElement {
constructor() {
super()
this.attachShadow({ mode: 'open' })
}
// you probably don't want use your template until the element is fully "in" the document
// so that’s broken out into the connectedCallback() method
connectedCallback() {
// the constructor called this.attachShadow()
// so this.shadowRoot has been set up and you can just access it directly
// storing and accessing this._shadowRoot is not necessary
this.shadowRoot.appendChild(template.content.cloneNode(true))
}
}
Alright, so, more than 3,000 words later that’s the speedrun of extending classes to make custom elements by way of explaining how objects work (kinda sorta) 😅.
If you want to know more, this 2019 article about the web component API by Danny Moerkerke does a great job taking it from here (tip o’ the cap to @asuh@mastodon.social for sharing this with me).
Let’s look at something else from Robin’s post.
Why “:host”?
A bit later Robin starts exploring encapsulated elements and styles with the Shadow DOM. Using Shadow DOM is a whole other thing with web components, but the idea is the innards of your component (elements and styles) can be isolated from the surrounding document. Like if your component has styles for a button that’s in its Shadow DOM, those button
styles only apply to buttons within your web component and won’t affect other buttons on the page.
So Robin was fussing with that, and he tried sticking a <style>
tag into the template like so:
template.innerHTML = `
<style>thanksgiving-button button { background: red; }</style>
<button>Happy Thanksgiving!</button>
`
But that doesn’t work! As he says:
We can’t select
thanksgiving-button
like this and instead we have to use a strange CSS selector called:host
Here’s his working code after that adjustment:
template.innerHTML = `
<style>:host button { background: red; }</style>
<button>Happy Thanksgiving!</button>
`
What’s the deal with :host
? Why can’t you refer to the element by its selector, thanksgiving-button
, like you would a div
or a p
?
Well, the answer becomes clearer when you define
the custom element:
class ThanksGivingButton extends HTMLElement {
// etc
}
// ⬇️ this is the tag name
customElements.define('thanksgiving-button', ThanksGivingButton)
// ⬆️ this is the class to use
That customElements.define
method is what lets you finally write these doohickeys as HTML like <thanksgiving-button></thanksgiving-button>
.
The first string argument is what defines that new tag name you’ll use. But that tag name could be anything (as long as it has a hyphen — that’s a hard requirement of the spec)!
When you’re playing around with these or using them just for yourself, you usually just have a specific element name in mind, but for libraries or sharing it’s typical to let folks use whatever name they want. Maybe they just don’t like your choice, or maybe they already have a <thanksgiving-button>
in their codebase and need to use some other name to try out this one (you can’t have two custom elements with the same name).
So! Because you can’t truly know what tag name your custom element will have, that’s what the :host
selector in Shadow’d CSS is for. It’s a special selector that says, "whatever the heck my tag name ends up being, target that".
So Robin has to do :host { /* styles for the outer element */}
to style the wrapper, whether it’s thanksgiving-button
or turkey-day-button
or whatever.
Can you do encapsulated styles without Shadow DOM?
I’m not going to get into all the ins and outs of the Shadow DOM and style encapsulation, but here Robin is expressing a very real frustration:
I do want to write my web component like this…
<thanksgiving-button> <button>Happy Thanksgiving</button> </thanksgiving-button>
…and yet have all those styles isolated in some way. I don’t want button styles leaking in, nor do I want button styles inside leaking out. If I could simply encapsulate styles here then I would barely ever need the complexity of all this template stuff and writing HTML with JS and Shadow DOM which feels sickly to me anywho and overly complex.
I’ve already rambled enough about the script side of things to talk about web component styling today, but here’s some of the things smart people have written about this:
- Nolan Lawson has a good post about some common ways to encapsulate styles and allow people to override your choices.
- This post on lamplightdev lays out what styles your component will inherit, even when you’re using a Shadow DOM (this stuff is CRAZY MAKING!)
- Zach Leatherman has a great rundown of ways to try to encapsulate styles in web components. He calls out the new CSS
@scope
rule as a possibility for the future, but it’s brand-new now. - Here’s more on
@scope
.
Probably the “cleanest” method available now is defining components with Zach Leatherman’s Webc
language and it’ll spit out mostly-encapsulated styles for you without using a Shadow DOM. Looks promising! But that’ll introduce a build step and additional tooling.
I was dinking around with some of this on my own and came up with this godawful hack that:
- defines styles in a string of CSS, using
:host
- looks up the tag name at runtime (that’s the
this.localName
property) - Inserts a stylesheet into the document, replacing
:host
with the now-known tag name so the selectors work:
class ThanksgivingButton extends HTMLElement {
// oh god I didn't even get into static class properties…
static stylesDefined = false
connectedCallback() {
if (ThanksgivingButton.stylesDefined) {
// bail. we already have the styles
return
}
const styles = `
:host { display: block; }
:host {
padding: 30px;
background: orange;
}
:host button {
/* reset all inherited styling */
all: unset;
color: red;
}
`
// replace all the instances of :host with the runtime tagname eg "thanksgiving-button"
const myStyles = styles.replaceAll(':host', this.localName)
// insert that stylesheet into the document
const styleEl = document.createElement('style')
styleEl.innerHTML = myStyles
document.head.appendChild(styleEl)
// make sure we don't insert the styles again
ThanksgivingButton.stylesDefined = true
}
}
customElements.define('thanksgiving-button', ThanksgivingButton)
That gives you some rudimentary style encapsulation without the shadow DOM and we’re not introducing any libraries. There’s probably something deeply wrong with this. Like WebC does something similar for styles, but it generates a unique CSS class on the fly, which is better. Don’t do this.
That said, here’s a real rudimentary CodePen of that in action.
Anyway, it’s fun to see people experimenting with these more. I’m happy to pile on!
List of updates
- Added a section about using "instanceof"; big overhaul of the examples
- Added a link to an article by Danny Moerkerke about web components for further reading
- Linked to my follow-on article for select-allosaurus
~^~^~^~^~^~^~^~^~^~^~^~^~^~^~^~^~^~^~