Web Components are gaining momentum in the web platform space, and frankly, it’s bittersweet for us ASP.NET Core developers. On the one hand, you can use Web Components in your current ASP.NET Core applications, as I demonstrated in a previous post, but it isn’t the best experience it could be. You’ll have flashes of unstyled content, layout shifts, JavaScript code that blocks the critical rendering path, and you’ll not get the server-side rendering (SSR) goodness developed exclusively for the JavaScript ecosystem.
In his post “THE GOOD, THE BAD, THE WEB COMPONENTS”, Zach Leatherman wrote about the ideal way to deliver web components to avoid some of the issues I described. In this blog post, we’ll see how we can use ASP.NET Core’s Tag Helpers to deliver the best experience to users when using Web Components.
The ideal Web Component delivery method
Web Components are a mix of JavaScript and HTML. Nesting your HTML within a custom element tag is best for delivering a
progressively enhanced and seamless experience. Let’s take a look at building a Counter
component. First, let’s see
what the custom element would look like on your page.
<my-counter>
<button class="btn btn-primary">1</button>
</my-counter>
and the corresponding JavaScript would be as follows.
class Counter extends HTMLElement {
connectedCallback() {
const button = this.querySelector("button");
button.addEventListener("click", () => {
button.innerText = (parseInt(button.innerText) + 1).toString();
});
}
}
customElements.define("my-counter", Counter);
This allows the client to render the button
element and its CSS styles in place before the JavaScript attaches the
event listener. That’s awesome, but this technique breaks down quickly once you want to use the counter multiple times.
<my-counter>
<button class="btn btn-primary">1</button>
</my-counter>
<my-counter>
<button class="btn btn-primary">2</button>
</my-counter>
<my-counter>
<button class="btn btn-primary">3</button>
</my-counter>
This is especially painful if the internal HTML is complex with multiple nodes.
Shadow Dom and Templates
With Web Components, you can avoid repeating yourself by utilizing the template
element. This template can be used in
JavaScript to clone repeated elements within an instance of a component. Let’s look at the HTML.
<template id="my-counter-template">
<style>
button {
background-color: orange;
color: black;
padding: .375rem .75rem;
border-radius: 0.25rem;
border: 1px solid transparent;
line-height: 1.5rem;
cursor: pointer;
&:hover {
/* make color darker */
filter: brightness(75%);
}
}
</style>
<button>
<slot></slot>
</button>
</template>
<my-counter>1</my-counter>
<my-counter>1</my-counter>
<my-counter>2</my-counter>
<my-counter>3</my-counter>
This is great for reducing repetition but comes at the expense of additional front-end processing. Let’s look at the updated JavaScript that now uses the ShadowDom.
class Counter extends HTMLElement {
connectedCallback() {
if (this.dataset.template) {
const template = document.getElementById("my-counter-template");
const shadowRoot = this.attachShadow({mode: "open"});
shadowRoot.appendChild(template.content.cloneNode(true));
const button = shadowRoot.querySelector("button");
button.addEventListener("click", () => {
console.log(button);
this.innerText = (parseInt(this.innerText) + 1).toString();
});
}
}
}
customElements.define("my-counter", Counter);
You can probably tell a few things changed in the JavaScript as well. This is a bit more complex, and we lost the ability to target the elements in our components with external styles from a CSS framework like Bootstrap.
What if we could get the benefits of the first implementation with the style of the second? Well, luckily, with ASP.NET Core, we can!
ASP.NET Core TagHelpers to the rescue!
TagHelpers can do almost anything to the elements within its content, and I mean anything. Let’s look at how we might
implement a WebComponentOptimizer
tag helper in our ASP.NET Core Razor pages.
<web-component-optimizer>
<template name="my-counter">
<button class="btn btn-danger">
<slot></slot>
</button>
</template>
<my-counter>1</my-counter>
<my-counter>2</my-counter>
<my-counter>3</my-counter>
</web-component-optimizer>
When we render the page, we will get the following HTML.
<my-counter>
<button class="btn btn-danger">
1
</button>
</my-counter>
<my-counter>
<button class="btn btn-danger">
2
</button>
</my-counter>
<my-counter>
<button class="btn btn-danger">
3
</button>
</my-counter>
The template is processed from the HTML output, and you get some fast and snappy web components on the initial page load. That’s awesome, right?!
Well, here’s the tag helper to help you do that. Note: this is a proof of concept. Before going to a production environment, you’ll want to test and adapt this for your needs.
using AngleSharp;
using AngleSharp.Dom;
using AngleSharp.Html;
using AngleSharp.Html.Dom;
using AngleSharp.Html.Parser;
using Microsoft.AspNetCore.Razor.TagHelpers;
namespace WebComponentsWithTagHelpers;
public class WebComponentOptimizer : TagHelper
{
public override async Task ProcessAsync(TagHelperContext context, TagHelperOutput output)
{
var children = await output.GetChildContentAsync();
var parser = new HtmlParser();
var document = await parser.ParseDocumentAsync(children.GetContent());
// clear the output
output.Content.Clear();
output.TagName = "";
var template = (IHtmlTemplateElement)document.GetElementsByTagName("template")[0];
var webComponentName = template.GetAttribute("name");
if (webComponentName is { })
{
var outputBuilder = new StringWriter();
var formatter = new HtmlMarkupFormatter();
var webComponents = document.GetElementsByTagName(webComponentName);
foreach (var webComponent in webComponents)
{
var clone = template.Content.Clone();
var slot = clone.FindDescendant<IHtmlSlotElement>()!;
slot.OuterHtml = webComponent.InnerHtml;
slot.RemoveFromParent();
webComponent.InnerHtml = clone.ToHtml();
webComponent.ToHtml(outputBuilder,formatter); ;
}
output.Content.SetHtmlContent(outputBuilder.ToString());
}
}
}
There are a few neat tag helper tricks here.
- Setting the
TagName
to an empty string removes the parent tag. - AngleSharp is a great way to parse the existing DOM
- You can use a formatter to optimize the final output.
If you want to try this for yourself, head to GitHub to see the whole sample.
Stretch Goals: Other Web Component Frameworks
While not shown here, I’d love someone to use the SSR support in their favorite frontend frameworks to integrate with
Razor views. This could improve the initial user experience while allowing developers to use frameworks like Angular,
Vue, and React. Since ProcessAsync
could call out to an NPM process and retrieve the starting HTML from a library.
Conclusion
Web Components are fantastic, and they have so much potential to make the user experience great for users without long pauses while the UI gets its act together. On the .NET side, ASP.NET Core could be a great option to boost the web platform with Razor’s strength. This blog post only begins to scratch the surface of what could be possible. I hope you consider this when building your following UI in ASP.NET Core as an option for delivering world-class experiences.
Thanks for reading and sharing my posts with friends and colleagues. Cheers.