In the last chapter, we talked in great depth about simple ways to create your first Web Component. Specifically, we looked at creating your own custom element and assigning some minimal custom logic so your component acts a certain way. But what if you want your component to act differently depending on what parameters you use to set it up? What if you want your component to be adaptable? Usually, the goal in any platform, language, or framework is to create reusable code that can be simply configured to match the widest range of use cases.
Of course, saying we want to create reusable and configurable Web Components is one thing. It’s almost meaningless unless we can talk about a concrete example!
One of my recent interests is 3D on the web. I’m especially interested in how virtual reality (VR) and augmented reality (AR) are making their way into browsers. Delving into WebGL and Three.js or Babylon is a bit too much to get into here (and off-subject), but we can do something simple to demonstrate reusable and configurable components.
3D has a bit of a content problem. I love experimenting with the 3D web, but I’m definitely not an expert in creating assets with complex 3D software. My favorite thing in VR lately is the explosion of 3D painting and modeling tools. Notably, Google has been doing some awesome things with Blocks and TiltBrush, its VR tools for modeling and painting in 3D. Even better, Google has created a hub that creators can publish to called Poly.
When you go to poly.google.com, you can browse around, search for 3D models, and pick your favorite to use in your application (many are free to use and modify). What’s great for our purposes is that Poly has a REST-based API that we can tap into and use to make a 3D search Web Component of our own! Again, going all in on 3D is a little much, especially for a Web Components book—but the results we get back are all image thumbnails, so we don’t have to get complicated at all in order to search and browse.
As with many services like Poly, we’ll need to get an API key for access. If you’d rather not do this, you’re still welcome to follow along, as I’ll provide a JSON file you can use in its place, and you can run the example from your own server.
First things first. Head over to https://developers.google.com/poly/develop/web and follow the instructions for the API key. Once you have it, put it in a safe place for later.
Let’s now test the service and create an HTTP request in the following listing (in which we search for a parrot).
const url = 'https://poly.googleapis.com/v1/assets?keywords=parrot&format=OBJ&key= <your_api_key>'; 1 const request = new XMLHttpRequest(); 2 request.open( 'GET', url, true ); request.addEventListener( 'load', (event) => { console.log(JSON.parse( 3 event.target.response )); }); request.send();
When running this, you should see all of the results that come back right in your dev tools console. It will also be nicely formatted, given that we turned the raw text of the response back into JSON, as it was intended to be: JSON.parse(event.target.response).
When we look at the console.log output, we’ll see a JSON object returned from the service. Of course, over time, these results will change, but I do see a lot of parrots in the results! Exactly what we specified in the keyword search. If we expand the assets object and look at the array of 3D assets returned in figure 3.1, we see that each asset has a thumbnail object, which we can expand to look at the thumbnail URL. This URL is what we’re after!
There’s certainly lots of other data that you could use, especially if you opened up the “formats” array to reveal actual 3D object links. For our purposes, we’re just going to use and display those thumbnails.
Let’s wrap the HTTP request we just made into a new Web Component that allows us to search for assets by keyword and display the results. We should keep it simple, though. There’s no need to overburden each Web Component to do too much—I like to think that we can be extremely granular with every component, and for bigger pieces of functionality, we can combine two or more components. This is why we’re going to keep the keyword/search input out of the component. Our Web Component will only display search results based on data we pass it from the input.
To make our HTTP request snippet into a Web Component, we can use what we’ve already learned about custom elements and the connectedCallback method of the Web Components API.
<html> <head> <meta charset="UTF-8"> <title>Google Poly Search</title> <script> class PolySearch extends HTMLElement { connectedCallback() { this.doSearch(); 1 } doSearch() { const url = 'https://poly.googleapis.com/v1/assets?keywords=parrot&format=OBJ&key= <your_api_key>'; const request = new XMLHttpRequest(); request.open( 'GET', url, true ); request.addEventListener( 'load', (event) => { console.log(JSON.parse( event.target.response )); }); request.send(); 2 } } customElements.define( 'poly-search', PolySearch); 3 </script> </head> <body> <poly-search></poly-search> 4 </body> </html>
Hopefully, there’s nothing earth-shattering in this listing. I did separate out the actual HTTP request into a doSearch() method. For now, I call it on connectedCallback when the component is added to the DOM. Because I don’t have a big project that involves many components in this one example, I chose a simple element name that reflects the task I’m doing: poly-search. If I were doing multiple components for a large app, maybe I’d name it something like <myappname-poly-search>.
You might notice that our component only searches for parrots right now. I agree, this isn’t incredibly useful. First, however, let’s display our results. Figure 3.2 shows our component reaching out to the Google Poly API and returning an asset list, which our component then renders.
We can start by swapping our console.log(JSON.parse(event.target.response)); with a call to another method that accepts all of the assets we requested:
this.renderResults(JSON.parse( event.target.response ).assets);
Then, inside our class, we’ll add that render method to display all of the thumbnails on our page, as the following listing shows.
renderResults(assets) { 1 let html = ''; for (let c = 0; c < assets.length; c++) { 2 html += '<img src="' + assets[c].thumbnail.url + '" width="200" height="150" />'; 3 } this.innerHTML = html; 4 }
All we’re doing here is looping through our asset array, grabbing the thumbnail URL, making it the source of an image element, and adding that to a long string of HTML. Once finished, we set this long HTML string to our component’s innerHTML.
Of course, there are other ways to do this, rather than constructing strings. We could create a new image element with each loop.
renderResults(assets) { for (let c = 0; c < assets.length; c++) { 1 const img = document.createElement('img'); 2 img.src = assets[c].thumbnail.url; this.appendChild(img); 3 } }
I personally like the string approach for these cases better. You can create a big chunk of HTML and have it hit your DOM at the same time, rather than having one element per loop iteration. Also, HTML is a bit easier to read, especially when we get into template literals later on. A big downside to creating each element one by one in the loop is that with each one, you are causing the browser to re-parse and re-render that entire block. The same would happen if you were adding each image one at a time and setting innerHTML after each. It will likely be better to stick with an HTML string that gets built up over time and then set all at once to innerHTML.
If you run the example now, you’ll see some fairly large images in a vertical list, as figure 3.3 shows. This is not what we necessarily want for a visual results display, so let’s make the images smaller and place them in nice wrapping rows using some CSS, as in the following listing.
<style> poly-search { border-style: solid; 1 border-width: 1px; border-color: #9a9a9a; padding: 10px; 2 background-color: #fafafa; 3 display: inline-block; 4 text-align: center; } poly-search img { margin: 5px; 5 } </style>
For this listing, I’ve simply put the style in our <head> tag, as you would normally do with CSS. Coupling style within the scope of each Web Component is definitely something we’ll get to later on, but we’ll just go simple right now.
Already, though, we are targeting our poly-search element with a CSS selector. This is perfectly valid! When you create your own custom element, you are really creating a custom element that works just like any other element would.
Running the example will give you the best picture of what this style is doing, but figure 3.4 is a visual approximation of what we accomplished, followed by some explanation of what we did with our CSS.
Here’s our entire styled example.
<html> <head> <meta charset="UTF-8"> <title>Google Poly Search</title> <script> class PolySearch extends HTMLElement { 1 connectedCallback() { this.doSearch(); } doSearch() { 2 const url = 'https://poly.googleapis.com/v1/assets?keywords=parrot&format=OBJ&key= <your_api_key>'; const request = new XMLHttpRequest(); request.open( 'GET', url, true ); request.addEventListener( 'load', (event) => { this.renderResults(JSON.parse ( event.target.response ).assets); }); request.send(); } renderResults(assets) { 3 let html = ''; for (let c = 0; c < assets.length; c++) { html += '<img src="' + assets[c].thumbnail.url + '" width="200" height="150" />'; } this.innerHTML = html; } } customElements.define('poly-search', PolySearch); </script> <style> 4 poly-search { border-style: solid; border-width: 1px; border-color: #9a9a9a; padding: 10px; background-color: #fafafa; display: inline-block; text-align: center; } poly-search img { margin: 5px; } </style> </head> <body> <poly-search></poly-search> 5 </body> </html>
The basics are now in place, and we have something that works visually, but it isn’t very useful yet as a search component.
Now, let’s revisit our glaring problem, and the whole point of this chapter. This component isn’t reusable at all. For one, even if I gave you my API key, there’s no way to properly set it in the component. Second, we’re always searching for “parrots.” There’s no way to pass this search term to our component, so if someone on your team used this component you built, they would have to go in and directly modify the URL string:
const url = 'https://poly.googleapis.com/v1/assets?keywords=parrot&format=OBJ&key=<y our_api_key>';
Let’s start by breaking that URL string up a little. We’re going to do this in two different ways, which will eventually complement one another. The first method we’ll explore is to make getters and setters for the API key and search term.
Inside our class, we can add this listing.
set apiKey(value) { 1 this._apiKey = value; this.doSearch(); } set searchTerm(value) { 2 this._searchTerm = value; this.doSearch(); }
Without a matching getter, JS would throw an error if we tried to read, or “get,” the property. However, we could easily create a getter as well:
get searchTerm() { return this._searchTerm; }
So far, though, getters aren’t really necessary; we just need to inject the search term and API key variables into our component, as shown in figure 3.5.
Breaking things up like this makes sense. You’ll likely need to set the API key only once, but as the user keeps searching for different things, the search term will be updated quite a bit.
With the code in listing 3.7 in place, when we set that property from the outside, it will run the function. In this regard, if you didn’t know the code in this class, you’d think you were working with a simple variable, thanks to our setter methods. You also might notice that I’m using underscores (_) in my variable names. This doesn’t mean anything special, but since JS doesn’t have the notion of “private” variables (aside from the exciting new class fields feature in the latest version of Chrome), or variables that you’re not allowed to access from outside your class, I use underscores to indicate that we don’t intend for these variables to be accessed from the outside. Using underscores can be a point of contention for some and is regarded as an older practice. If you’d like to dive deeper on this concept, please refer to the appendix. Regardless, in this case, _searchTerm is our internal variable that we’re using, while searchTerm is the setter for that variable.
By using a setter, we’re not just setting this searchTerm property. When setting it from outside our component class here, that’s just what it looks like to the user of our component’s API. Instead, by using a setter method, we inject some logic to both set that internal property and run our doSearch() method to fire the HTTP request.
Now, if you were to write some JS in your script tag outside the component class, you could write the following to first select your component and then set each property (only after the component has been properly created, of course):
document.querySelector('poly-search').apiKey = '<your_api_key>'; document.querySelector('poly-search').searchTerm = 'parrot';
Of course, if we ran a search without an API key or without a search term, our search would fail, so in the following listing, we can wrap our search method in an if statement to make sure both variables are present before we search.
doSearch() { if (this._apiKey && this._searchTerm) { 1 const url = 'https://poly.googleapis.com/v1/assets?keywords=' + this._searchTerm + '&format=OBJ&key=' + this._apiKey; const request = new XMLHttpRequest(); request.open( 'GET', url, true ); request.addEventListener( 'load', (event) => { this.renderResults(JSON.parse( event.target.response ).assets); }); request.send(); } }
Giving our components an API like this is a good exercise, but for this particular use case, there is another method for passing data: attributes. We use attributes all the time in web development. In fact, that src attribute to set the thumbnail URL in each image is just one example. Even just setting the style of an element using class or the href link for a link tag are attribute examples.
Using attributes on Web Components is so obvious, you might overlook it in favor of the getter/setter approach. We use attributes so often that we might not think of them as something that can be used for the inner workings of your Web Component.
With the getter/setter API approach, there is some complexity involved that isn’t really needed. For one, having to wrap the search method with an if/then to check that the apiKey and searchTerm are set is good practice when a developer forgets to set one or the other, but it would be nice if both properties were immediately available when the component is used as intended.
The other annoyance is having to use JS at all to set these properties. If these properties were attributes on the HTML tag, we wouldn’t have to set the apiKey and searchTerm over two separate lines. In more complex applications, it can be hard to track down where you set these in your code. Also, there may be timing issues with your component. Perhaps your component hasn’t been properly created yet when you happen to call these setters. If this happened, it’s possible that your values would just be lost!
These are definitely manageable concerns—but let’s focus on attributes now.
Let’s change things up a bit. First, let’s get rid of our setters and our JS to use those setters. We don’t need them. Next, we’ll add our attributes to our custom element tag:
<poly-search apiKey="<your_api_key>" searchTerm="parrot"> </poly-search>
Now, we’ll swap in some JS to get our attributes in place of using our variables. Let’s keep the if/then check in the next listing just in case the user of our component forgets to use one attribute or the other.
doSearch() { if (this.getAttribute('apiKey') && this.getAttribute('searchTerm')) { const url = 'https://poly.googleapis.com/v1/assets?keywords=' + this.getAttribute('searchTerm') + '&format=OBJ&key=' + this.getAttribute('apiKey'); 1 const request = new XMLHttpRequest(); request.open( 'GET', url, true ); request.addEventListener( 'load', (event) => { this.renderResults(JSON.parse( event.target.response ).assets); }); request.send(); } }
Lastly, since attributes are available as soon as the element is created, we can do an initial search right away when our component is added to the DOM using connectedCallback:
connectedCallback() { this.doSearch(); }
For brevity’s sake, I’ll leave out our CSS as we look at the current state of our component in the following listing.
<html> <head> <title>Google Poly Search</title> <script> class PolySearch extends HTMLElement { connectedCallback() { 1 this.doSearch(); } doSearch() { if (this.getAttribute('apiKey') && this.getAttribute('searchTerm')) { const url = 'https://poly.googleapis.com/v1/assets?keywords=' + this.getAttribute('searchTerm') + '&format=OBJ&key=' + this.getAttribute('apiKey'); 2 const request = new XMLHttpRequest(); request.open( 'GET', url, true ); request.addEventListener( 'load', (event) => { this.renderResults( JSON.parse( event.target.response ).assets); }); request.send(); 3 } } renderResults(assets) { let html = ''; for (let c = 0; c < assets.length; c++) { html += '<img src="' + assets[c].thumbnail.url + '" width="200" height="150" />'; 4 } this.innerHTML = html; 5 } } customElements.define('poly-search', PolySearch); </script> </head> <body> <poly-search apiKey="<your_api_key>" searchTerm="parrot"> 6 </poly-search> </body> </html>
The component is now pretty functional, but the customization we’ve done only goes so far. That search term will likely change frequently; we’ll need to watch for changes.
Note that while I used an uppercase “K” in apiKey, and an uppercase “T” in searchTerm, attributes themselves are not case-sensitive. We could absolutely rewrite our tag like this, and it wouldn’t affect things at all (though there is a good reason for keeping things all lowercase, which we’ll get to in a bit):
<poly-search apikey="<your_api_key>" searchterm="parrot"> </poly-search>
There’s one remaining problem in regard to our use case, though. It’s true that our API key will likely never change in our web app, but we do want users to input text and search for things. Before we get into solving that problem, let’s create a typical text input that lets a user enter a search term. This aspect is outside of our Web Component, so it’s not a lesson in Web Components per se, just something to help us demonstrate and solve our attribute problem.
With this in mind, let’s change the contents of our <body> tag.
<body> <label>Enter search term: </label> <input type="text" onchange="updatePolySearch(event)" /> <br /><br /> <script> function updatePolySearch(event) { document.querySelector('poly-search').setAttribute('searchterm', event.target.value); } </script> <poly-search apikey="<your_api_key>" searchterm="parrot">
We’ve now added a text input with an onchange event listener. Preceding that, we have a simple label, just to give context in our UI on what that text input is actually doing. I don’t typically have inline JS like this on a tag, but for such a simple demonstration, it’s easier to show it this way. The onchange event occurs only when the user “submits” the text, meaning when they press the Enter key or click off the field.
The function that it calls, updatePolySearch, captures the event that gets sent, which includes the target, or which element sent the event. We can query event.target.value to get the new search term that the user typed in. From there, we can set the searchterm attribute of our Web Component.
Feel free to try this out right now! If you open your browser’s development tools to show the live view of the elements on the page, you can see our <poly-search> searchterm attribute changing in real time after we change our text input.
Unfortunately, just updating the attribute doesn’t cause the search to rerun and update our results. We have to do this ourselves. This brings us to our second Web Component lifecycle method: attributeChangedCallback. Our first lifecycle method, of course, was connectedCallback, but now we’re ready to get a bit deeper.
The attributeChangedCallback method is like any other Web Component lifecycle method. You simply add the method in your class to override HTMLElement’s empty method, and it will be fired when an attribute is changed.
This method accepts three parameters: the name of the attribute that changed, the old value of the attribute, and the new value of the attribute:
attributeChangedCallback(attrName, oldVal, newVal)
Let’s integrate this into our Web Component and see what happens. I’m going to be a little evil here, but warn you up front. We’re going to integrate this, but it’s not going to work because of one missing detail that I’ll explain afterward.
The first thing to do is to get rid of the connectedCallback method in our class. We do this because, in our specific case, our connectedCallback method triggers a search. However, now our attributeChangedCallback will actually do this as well. Technically speaking, our attribute does change from nothing to something when our component starts up, so the attributeChangedCallback triggers. Also, we don’t have any logic to cancel our HTTP request before triggering it again in our component—to keep things simple and bug free when both of these callbacks fire at virtually the same time, let’s just get rid of that connectedCallback.
Next, let’s add our attributeChangedCallback method.
attributeChangedCallback(name, oldval, newval) { if (name === 'searchterm') { this.doSearch(); } }
Our callback here is really simple. If the attribute name being changed is searchterm, then run our search again. This aspect is case-sensitive. The name coming in will always be lowercase. This can be a bit confusing if you write your attribute in HTML in camel case, and then just write the name over here the same way. To avoid confusion, it’s wise to write our attributes in lowercase all the time.
As I was writing this, I accidentally made things a bit more complicated before I caught myself. I initially wrote the following code:
attributeChangedCallback(name, oldval, newval) { if (name === 'searchterm' && oldval !== newval ) { this.doSearch(); } }
I thought that I only wanted to call the search if the old value was different than the new value. There’s no sense in rerunning a search and wasting a network request if the value doesn’t change, right? Well, if the value didn’t change, this method wouldn’t get called in the first place, so doing this extra step is redundant.
Now that we’ve captured attribute changes and taken action when they change, it should work, right? Not yet! This is the part where I left out one little detail of how this method works. Before I explain what this is, let me give a little context and history.
At the start of this chapter, I talked a bit about how common attributes are to everything we do in HTML. Each element has numerous potential attributes it can use that actually mean something. At minimum, elements will likely always have a class element for styling. And, of course, we can make up any attribute we want. With all of these potential attributes everywhere, it could be a huge waste of code execution to call attributeChangedCallback every single time something changes if we don’t care that it changed.
Back in v0 of the Web Components API, the attributeChangedCallback did just that: it was called each and every time something as common as a CSS class attribute changed. Early Web Component adopters thought this was a bit annoying and wasteful. So now, in v1 of the Web Components API, we need to tell our component what specifically to listen for.
static get observedAttributes() { return ['searchterm']; }
If you’re not familiar with the static keyword for a class method, please refer to the appendix. In short, it’s a method called on the class definition, rather than on the created instance.
In this static method, we’ve set our observedAttributes to an array containing searchterm. If we wanted more attributes to be observed, we could simply add more elements to the array:
static get observedAttributes() { return ['searchterm', 'apikey', 'anotherthing', 'yetanotherthing' ]; }
With this last piece added to our example in listing 3.14, our example should run. This new code for watching our searchTerm attribute is depicted in figure 3.6. We now automatically load our results with the first search term of “parrot,” but when the user submits other terms, the results will update.
<html> <head> <title>Google Poly Search</title> <script> class PolySearch extends HTMLElement { 1 static get observedAttributes() { return ['searchterm']; 2 } attributeChangedCallback(name, oldval, newval) { if (name === 'searchterm') { this.doSearch(); 3 } } doSearch() { 4 if (this.getAttribute('apiKey') && this.getAttribute('searchTerm')) { const url = 'https://poly.googleapis.com/v1/assets?keywords=' + this.getAttribute('searchTerm') + '&format=OBJ&key=' + this.getAttribute('apiKey'); const request = new XMLHttpRequest(); request.open( 'GET', url, true ); request.addEventListener( 'load', (event) => { this.renderResults(JSON.parse ( event.target.response ).assets); }); request.send(); } } renderResults(assets) { 5 let html = ''; for (let c = 0; c < assets.length; c++) { html += '<img src="' + assets[c].thumbnail.url + '" width="200" height="150" />'; } this.innerHTML = html; } } customElements.define( 'poly-search', PolySearch); 6 </script> <style> 7 poly-search { border-style: solid; border-width: 1px; border-color: #9a9a9a; padding: 10px; background-color: #fafafa; display: inline-block; text-align: center; } poly-search img { margin: 5px; } input { font-size: 18px; } </style> </head> <body> <label>Enter search term: </label><input type="text" onchange="updatePolySearch(event)" /> 8 <br /><br /> <script> function updatePolySearch(event) { 9 document.querySelector('poly-search').setAttribute('searchTerm', event.target.value); } </script> <poly-search apikey="<your_api_key>" 10 searchterm="parrot"> </poly-search> </body> </html>
With that, we’ve allowed our component to react to changes. It doesn’t really make sense for us to react to API key changes because the API key is typically something that never changes. That search term is going to change all the time, though, so we definitely needed a way to react to it.
Let’s now up our customization game! We can do some small style things, such as set the image size and component background color.
In listing 3.15, I’m being a bit of a lazy developer. I don’t expect that the image sizes or background color will need to change at runtime—only when we’re initially writing the HTML. So, I’m not listening for attribute changes; instead, I’m simply setting these style properties when the component is added to the DOM.
connectedCallback() { if (this.hasAttribute('thumbheight')) { 1 this._thumbheight = this.getAttribute('thumbheight'); this._thumbwidth = (this.getAttribute('thumbheight') * 1.3333 /*aspect ratio*/); } else { 2 this._thumbheight = 150; this._thumbwidth = 200; } if (this.hasAttribute('backgroundcolor')) { 3 this.style.backgroundColor = this.getAttribute('backgroundcolor'); } }
I’m also not forcing the component’s user to have these attributes. Instead, I’m checking if the developer used the attribute in their markup by using hasAttribute and, if so, set these properties. If not, we have fallback values either with JS for the size or using the pre-existing style in CSS for background color.
To use my size properties, I’ve edited the image-rendering method as in the following listing.
renderResults(assets) { let html = ''; for (let c = 0; c < assets.length; c++) { html += '<img src="' + assets[c].thumbnail.url + '" width="' + this._thumbwidth + '" height="' + this._thumbheight + '"/>'; 1 } this.innerHTML = html; }
As we’ve added stylistic customization, you can probably imagine so much more! Certainly, we could customize borders, spacing, and so on. There’s one last thing we’ll customize, and that’s the search endpoint.
This is also the point at which I’m going to make readers who didn’t want to sign up for an API key happy. We’re going to break up the HTTP request URL in the following listing. We’ll do this by separating out the base of the URL as well as the 3D object format for good measure.
doSearch() { if (this.getAttribute('apiKey') && this.getAttribute('searchTerm')) { const url = this.getAttribute('baseuri') + 1 '?keywords=' + this.getAttribute('searchTerm') + '&format=' + this.getAttribute('format') + '&key=' + this.getAttribute('apiKey'); const request = new XMLHttpRequest(); request.open( 'GET', url, true ); request.addEventListener( 'load', (event) => { this.renderResults(JSON.parse( event.target.response ).assets); }); request.send(); } }
With the following tag, we can start using all of our customization options.
<poly-search apikey="<your_api_key>" format="OBJ" thumbheight="50" backgroundcolor="red" baseuri= "https://poly.googleapis.com/v1/assets" 1 searchterm="parrot"> </poly-search>
We can now tweak the baseuri attribute to be something else. Of course, different search services will have different APIs and result formats, but we can test our setup without Google by pointing to a JSON file that we host:
baseuri="http://localhost:8080/assets.json"
This will differ, of course, depending on how you’ve set up your development server (it could be localhost, it could be something else, and port 8080 is common, but it differs wildly depending on your setup).
Because we’ve now covered both getters/setters and attributes for working with data, which one should we use? Really, it’s up to you, but there are some emerging best practices. It’s a bit too early to take these best practices as mandates, but there are some good ideas, especially if you intend to share your components with other people. One resource is an incomplete working draft: https://github.com/webcomponents/gold-standard/wiki. Google has also published some best practices that are further along: https://developers.google.com/web/fundamentals/web-components/best-practices.
Within the Google Web Components guide, there are a few best practices for attributes. One such practice is to not use attributes for rich data such as arrays and objects.
Let’s say, for example, that you have a very complex application, and for some of your Web Components, setup is insanely complex. Perhaps you have 50 or more properties to use for configuration—or your configuration data needs to be represented as a nested structure:
{ Tree: { Branches: [ { branch: { leaves: [ { leaf: "leaf"}, { leaf: "leaf"}, { leaf: "leaf"}, ] } } ] }}
Either way, separating out these properties for individual attributes would be overwhelming or impossible.
We can actually stringify a JSON object and shove it into an attribute on our tag:
<my-element data="{"Tree": {"Branches": [{"branch": {"leaves": [{"leaf": "leaf"},{"leaf": "leaf"},{ "leaf": "leaf"}]}}]}} " my-element>
It’s probably easier to do this through code, however:
myElement.setAttribute('data', JSON.stringify(data));
To pull the data out, you’d then have to serialize that string to JSON:
JSON.parse(this.getAttribute('data'));
In the end, though, when you have this massive, ugly string in your DOM, your development tools get that much harder to read and put up roadblocks for understanding your DOM structure. In this case, perhaps it’s better to use a method or setter to pass your data to your component and avoid rich data attributes.
Another Google-suggested best practice is to do something called reflection for your attributes and properties. Reflection is the practice of using both getters and setters as well as attributes for your data, and always keeping them in sync with each other. Especially when handing your component off to other developers or sharing it with the world, users may expect a consistent component API.
Attributes are generally easier to work with when writing HTML, while with JS code, setting properties on the component is more concise and easier to use. In other words, JS developers will prefer writing yourcomponent.property = 'something'; and likely won’t prefer writing yourcomponent.setAttribute('property', 'something');. At the same time, someone writing HTML would prefer to just set the attribute in the markup.
When these two methods don’t do the same thing, or one is supported and not the other, it can get a bit confusing for your component’s consumer. That’s why, when setting a property through JS, the corresponding attribute should change on the element, and vice versa. When an attribute changes, getting the property after that should reflect the newest value.
One trap that Google has identified with its best practice guide is using attributeChangedCallback to update the setter, which Google is calling re-entrancy; it’s implemented as follows.
// When the [checked] attribute changes, set the checked property to match. attributeChangedCallback(name, oldValue, newValue) { if (name === 'checked') this.checked = newValue; 1 } set checked(value) { const isChecked = Boolean(value); if (isChecked) // OOPS! This will cause an infinite loop because it triggers the // attributeChangedCallback() which then sets this property again. this.setAttribute('checked', ''); 2 else this.removeAttribute('checked'); }
In this example, taken straight from Google’s developer documentation, an infinite loop is caused. The setter is used and sets the attribute, but this causes the attributeChangedCallback to fire, which again uses the setter, which then changes the attribute . . . you get the point—it’s an infinite loop, and the flow can be seen in figure 3.7.
A better way might be to use the attribute as the so-called “source of truth.” I’ve added reflection to the searchTerm property in our Poly search example with just an additional getter and setter, shown in the following listing.
static get observedAttributes() { return ['searchterm']; } get searchTerm() { return this.getAttribute('searchTerm'); 1 } set searchTerm(val) { this.setAttribute('searchTerm', val); 2 } attributeChangedCallback(name, oldval, newval) { if (name === 'searchterm') { this.doSearch(); 3 } }
In this example, our getter simply returns the current attribute, while our setter sets the attribute. There are, of course, additional ways to accomplish reflection, but the important takeaway is that if you want to maximize the developer experience with your component, keep your attributes and properties consistent and synced with each other!
Now that we understand how to work with attributes to make a reusable component, and know about using attribute reflection to our advantage, it’s time to update the slider component from the last chapter to make it interactive and reactive to the attributes we give it or JS properties we set on it. Right now, our component class is pretty slim, especially after moving all of the CSS outside the component into a <style> tag. All it does is render HTML (two <div> tags); the next listing shows the slider minus the lengthy CSS.
<html> <head> <title>Slider</title> <script> class Slider extends HTMLElement { connectedCallback() { this.innerHTML = '<div class="bg-overlay"></div> <div class="thumb"></div>'; } } if (!customElements.get('wcia-slider')) { customElements.define('wcia-slider', Slider); } </script> <style><!—- CSS was here --></style> </head> <body> <wcia-slider></wcia-slider> </body> </html>
Recall that we temporarily used two properties to control some of the component’s functionality, or, in other words, its API. Let’s formalize this API and list those properties here:
With those now defined, we can do two things. The first is to listen for changes to those attributes. We’ll be adding all of these functions right inside the Slider class.
static get observedAttributes() { return ['value', 'backgroundcolor']; 1 } attributeChangedCallback(name, oldVal, newValue) { switch (name) { case 'value': this.refreshSlider(newValue); 2 break; case 'backgroundcolor': this.setColor(newValue); 3 break; } }
The second thing to do is to intertwine those attributes with a proper JS API using reflection, as we’ve just learned. When one of these properties is set through the JS setter, the attribute is updated on the component. Likewise, when the attribute is set on the tag, this value can be retrieved through the matching getter. The next listing shows reflection in our component for these two attributes.
set value(val) { this.setAttribute('value', val); } get value() { return this.getAttribute('value'); } set backgroundcolor(val) { this.setAttribute('backgroundcolor', val); } get backgroundcolor() { return this.getAttribute('backgroundcolor'); }
Remember, with reflection, our attributes are the “source of truth,” so these getters and setters simply set or get the attribute directly.
We’re almost ready to demo the slider for real! Referring back to listing 3.22, which holds the component class definition, remember the attributeChangedCallback. We have two methods that don’t exist yet. When receiving a new slider value, we see
case 'value': this.refreshSlider(newValue); break;
Likewise, with a new background color value, we have
case 'backgroundcolor': this.setColor(newValue); break;
Just so we can start seeing the results of our work, we should create these functions in the component class.
setColor(color) { 1 if (this.querySelector('.bg-overlay')) { this.querySelector('.bg-overlay').style.background = `linear-gradient(to right, ${color} 0%, ${color}00 100%)`; } } refreshSlider(value) { 2 if (this.querySelector('.thumb')) { this.querySelector('.thumb').style.left = (value/100 * this.offsetWidth - this.querySelector('.thumb').offsetWidth/2) + 'px'; } }
Both functions likely need a bit of explanation, even though they are tiny. First, we’re checking to see if the DOM element we’re changing exists. There’s a bit of a timing issue with the attributeChangedCallback. Namely, it will fire first before connectedCallback if there are attributes on the component at the start. So, these DOM elements may not exist yet. Once we update this component to use the Shadow DOM later in the book, this problem won’t exist. This is also the reason we need to add a couple of lines to the connectedCallback, to make sure the initial attributes are acted on:
this.setColor(this.backgroundcolor); this.refreshSlider(this.value);
Next, when setting the color, the color value we get is a hexadecimal value (complete with the hash at the beginning). At the beginning, or 0% stop of the gradient, we can use this color value as normal. In our demo, it’s red, or #ff0000. The second color stop, at 100%, should be the same color but completely transparent. With the exception of Edge, every modern browser supports adding an additional “00” at the end to indicate the transparency to complement the red, green, and blue two-digit values in the larger hexadecimal code. We’ll worry about Edge later!
The refreshSlider function is pretty easy math. We calculate the thumbnail’s horizontal location by taking the fraction (percent divided by 100) of the component’s overall width. The slightly tricky part here is that we don’t actually want to position from the leftmost edge of the thumbnail. Instead, the dead center of the thumbnail should indicate the value. To center it, we need to subtract by half the width of the thumb graphic.
With these last updates, even though we don’t have interactivity, at least our attributes cause updates to the component. We can now load the HTML file and see something that looks like figure 3.8.
What’s cool is that, even if we don’t have interactivity yet, the attributes on the demo can be changed. When the page is refreshed, you’ll see the new color and slide percentage. How about a blue background at 70%?
<wcia-slider backgroundcolor="#0000ff" value="70"></wcia-slider>
We’re almost done! The next step is to make that thumbnail draggable.
Let’s finish our component by adding some mouse listeners to the components. These three listeners can be seen in the next listing.
connectedCallback() { this.innerHTML = '<div class="bg-overlay"></div><div class="thumb"></div>'; document.addEventListener('mousemove', e => this.eventHandler(e)); 1 document.addEventListener('mouseup', e => this.eventHandler(e)); this.addEventListener('mousedown', e => this.eventHandler(e)); this.refreshSlider(this.value); 2 this.setColor(this.backgroundcolor); }
For mouse-down events, we only really care when the user clicks on the slider component. Even when clicking outside the thumbnail, it should snap to the horizontal location in the slider. Mouse-up events need to be caught everywhere on the overall web page. If the user clicks inside the component, but then the mouse drags outside, the user should still be able to release the mouse button, releasing the thumbnail. Likewise, for the mouse-move events, even when our mouse is dragging outside of the component, the thumbnail should still follow (the best it can within the confines of the slider).
All that’s left now is to add some code for our new eventHandler method.
updateX(x) { let hPos = 1 x - this.querySelector('.thumb') .offsetWidth/2; if (hPos > this.offsetWidth) { 2 hPos = this.offsetWidth; } if (hPos < 0) { hPos = 0; } this.value = (hPos / this.offsetWidth) * 100; 3 } eventHandler(e) { const bounds = this.getBoundingClientRect(); const x = e.clientX - bounds.left; 4 switch (e.type) { case 'mousedown': 5 this.isDragging = true; this.updateX(x); this.refreshSlider(this.value); break; case 'mouseup': 6 this.isDragging = false; break; case 'mousemove': 7 if (this.isDragging) { this.updateX(x); this.refreshSlider(this.value); } break; } }
With this last addition, our slider component is fully functional! We can even crack open the dev tools, like in figure 3.9, to watch the value attribute change as we drag the thumbnail.
The slider component isn’t done yet! It’s really not shareable if someone else on your team wanted to use it. This will involve bringing the relevant CSS into the component (as real CSS, not the JS style setting like in the last chapter) and separating out these visual concerns from the main component class.
In this chapter, we’ve expanded our Custom Element API methods repertoire to both connectedCallback and attributeChangedCallback. In the next chapter, we’ll talk through the rest of the Web Component lifecycle in depth and compare it to similar component lifecycles on both the web and beyond. Also in this chapter, you learned