Web Components Can Be Simple:
Web Components in Django

Published

Previously we setup a custom element with some interactivity via JavaScript. Now we'll look at how to use this custom element in a Django project.

Part of the reason for moving to Web Components for us was to be able to separate out our monolith CSS and JS bundles. Most of our visitors will only ever see our homepage and a campaign page. But we were shipping CSS and JS for the entire site, most of which is only ever used by our 100 or so campaign organizers. We began by splitting all our code into individual files. We put them in a web-components directory inside our assets directory. We're using esbuild to transpile our code from TypeScript to JavaScript and Sass to CSS. Those files go into a dist directory inside our assets. You can modify this middleware to match your setup.

To solve this, we use a custom middleware to find our custom element tags and then inject <script> and <link> tags into our <head>. Here's how we do it:

Please note this code is provided as an example. You will need to adapt it to your own needs.

from django.conf import settings
from django.templatetags.static import static

COMPONENTS = {}

class WebComponentsMiddleware:
    COMPONENT_PREFIX = "pr"
    ALWAYS_COMPONENTS = [
        "alert",
        "badge",
        "card",
        "pledge-banner",
        "toast",
    ]
    CRITICAL_COMPONENTS = [
        "the-header",
    ]
    CRITICAL_IF_SEEN_COMPONENTS = [
        "alert",
        "card",
    ]

    def __init__(self, get_response):
        self.get_response = get_response

    def __call__(self, request):
        response = self.get_response(request)
        if not hasattr(response, "content"):
            return response
        if b"<!DOCTYPE html>" not in response.content:
            return response
        if not COMPONENTS:
            for path in (settings.BASE_DIR / "assets/web-components").glob("*.ts"):
                COMPONENTS[path.stem] = {
                    "js": static(
                        path.relative_to(settings.BASE_DIR)
                        .as_posix()
                        .replace("assets/", "dist/")
                        .replace(".ts", ".js")
                    )
                }
                if path.with_suffix(".scss").exists():
                    COMPONENTS[path.stem]["css"] = static(
                        path.with_suffix(".css")
                        .relative_to(settings.BASE_DIR)
                        .as_posix()
                        .replace("assets/", "dist/")
                    )
        scripts = []
        styles = []
        preloads = []
        non_critical_styles = False
        content = response.content.decode()
        for tag_name in self.CRITICAL_IF_SEEN_COMPONENTS:
            if f"</{self.COMPONENT_PREFIX}-{tag_name}>" in content:
                self.CRITICAL_COMPONENTS.append(tag_name)
        for tag_name in COMPONENTS:
            if (
                tag_name in self.ALWAYS_COMPONENTS
                or f"</{self.COMPONENT_PREFIX}-{tag_name}>" in content
            ):
                scripts.append(
                    f'<script src="{COMPONENTS[tag_name]["js"]}" async type="module"></script>'
                )
                preloads.append(
                    f'<link rel="modulepreload" href="{COMPONENTS[tag_name]["js"]}">'
                )
                if "css" in COMPONENTS[tag_name]:
                    sheet = f'<link href="{COMPONENTS[tag_name]["css"]}" rel="stylesheet">'
                    if tag_name in self.CRITICAL_COMPONENTS:
                        styles.append(sheet)
                    preload = f'<link rel="preload" href="{COMPONENTS[tag_name]["css"]}" as="style">'
                    if tag_name not in self.CRITICAL_COMPONENTS:
                        preload = preload.replace(">", 'data-swap="css">')
                        non_critical_styles = True
                        preloads.append(f"<noscript>{sheet}</noscript>")
                    preloads.append(preload)
        if styles:
            response.content = response.content.replace(
                b"<!-- component stylesheets -->",
                f"<!-- component stylesheets -->{''.join(sorted(styles))}".encode(),
            )
        if scripts:
            response.content = response.content.replace(
                b"<!-- component scripts -->",
                f"<!-- component scripts -->{''.join(sorted(scripts))}".encode(),
            )
        if preloads:
            response.content = response.content.replace(
                b"<!-- preloads -->",
                f'<!-- preloads -->{"".join(sorted(preloads))}'.encode(),
            )
            if non_critical_styles:
                loader = [
                    "<script>",
                    """document.head.querySelectorAll('link[data-swap="css"]').forEach((link) => {
                        link.addEventListener('load', () => {
                            link.rel = 'stylesheet';
                            link.removeAttribute('as');
                            link.removeAttribute('data-swap');
                        });
                    })""",
                    "</script>",
                ]
                response.content = response.content.replace(
                    b"<!-- stylesheets -->",
                    f"{''.join(loader)}\n<!-- stylesheets -->".encode(),
                )
        return response

Let's break this down.

Setup and configuration #

COMPONENTS = {}

class WebComponentsMiddleware:
    COMPONENT_PREFIX = "pr"
    ALWAYS_COMPONENTS = [
        "alert",
        "badge",
        "card",
        "pledge-banner",
        "toast",
    ]
    CRITICAL_COMPONENTS = [
        "the-header",
    ]
    CRITICAL_IF_SEEN_COMPONENTS = [
        "alert",
        "card",
    ]

    def __init__(self, get_response):
        self.get_response = get_response

The first parts are the configuration. COMPONENTS is a dictionary of all the components we find on the page. This is outside the middleware so that the results can be cached across a number of request.

COMPONENT_PREFIX is the prefix we use for our custom elements. Custom elements must have a dash in them so we use "pr" for Prideraiser.

ALWAYS_COMPONENTS are components that we always want to load. We use these for elements that might not be on a page rendered by the server but could be used by JavaScript. <pr-toast> is a good example of this. We use it to show messages to the user when they interact with a component, but it's never output by the server.

CRITICAL_COMPONENTS are components that we always want to load immediately. The rest we'll use rel="preload" and swap them in when they're loaded.

Finally, CRITICAL_IF_SEEN_COMPONENTS are components that we'll load immediately but only if they're on the page.

Our __init__ is standard Django middleware.

Processing the response #

def __call__(self, request):
    response = self.get_response(request)
    if not hasattr(response, "content"):
        return response
    if b"<!DOCTYPE html>" not in response.content:
        return response

We only want to process HTML responses. So we check for the content attribute and that it contains <!DOCTYPE html>. If not, we return the unchanged response.

Finding our components #

if not COMPONENTS:
    for path in (settings.BASE_DIR / "assets/web-components").glob("*.ts"):
        COMPONENTS[path.stem] = {
            "js": static(
                path.relative_to(settings.BASE_DIR)
                .as_posix()
                .replace("assets/", "dist/")
                .replace(".ts", ".js")
            )
        }
        if path.with_suffix(".scss").exists():
            COMPONENTS[path.stem]["css"] = static(
                path.with_suffix(".css")
                .relative_to(settings.BASE_DIR)
                .as_posix()
                .replace("assets/", "dist/")
            )

We only want to find our components once. So we check if COMPONENTS is empty. If it is, we loop through all the TypeScript files in our assets/web-components directory. We add the JavaScript file to our COMPONENTS dictionary. If there's a Sass file, we add that, too. You might want to look for both .scss and .ts files, but we have scripts for every custom element so we don't need to.

Finding the components on the page #

scripts = []
styles = []
preloads = []
non_critical_styles = False
content = response.content.decode()
for tag_name in self.CRITICAL_IF_SEEN_COMPONENTS:
    if f"</{self.COMPONENT_PREFIX}-{tag_name}>" in content:
        self.CRITICAL_COMPONENTS.append(tag_name)

We start by checking for our CRITICAL_IF_SEEN_COMPONENTS. If any of them are on the page, we add them to our CRITICAL_COMPONENTS list. This is so that we can load them immediately. Note that we're checking for the closing tag for each component. This lets us not need a regex while making sure we don't match a component name that's part of another component name. For example, we have <pr-card> and <pr-card-body> components. If we only checked for <pr-card we'd match both.

for tag_name in COMPONENTS:
    if (
        tag_name in self.ALWAYS_COMPONENTS
        or f"</{self.COMPONENT_PREFIX}-{tag_name}>" in content
    ):
        scripts.append(
            f'<script src="{COMPONENTS[tag_name]["js"]}" async type="module"></script>'
        )
        preloads.append(
            f'<link rel="modulepreload" href="{COMPONENTS[tag_name]["js"]}">'
        )
        if "css" in COMPONENTS[tag_name]:
            sheet = f'<link href="{COMPONENTS[tag_name]["css"]}" rel="stylesheet">'
            if tag_name in self.CRITICAL_COMPONENTS:
                styles.append(sheet)
            preload = f'<link rel="preload" href="{COMPONENTS[tag_name]["css"]}" as="style">'
            if tag_name not in self.CRITICAL_COMPONENTS:
                preload = preload.replace(">", 'data-swap="css">')
                non_critical_styles = True
                preloads.append(f"<noscript>{sheet}</noscript>")
            preloads.append(preload)

Next we loop through all our components. If the component is in our ALWAYS_COMPONENTS list or it's on the page, we add the JavaScript file to our scripts list and the CSS file to our styles list. We also add those to our preloads list, too. Note that we've setup our scripts so that they're all loaded as modules and can be loaded asynchronously and not block rendering.

If the component is in our CRITICAL_COMPONENTS list, we add the CSS to our styles list. Otherwise, we add it to our preloads list. We also add a <noscript> tag to our preloads list. This is so that if JavaScript is disabled, the CSS will still load.

Injecting our tags #

if styles:
    response.content = response.content.replace(
        b"<!-- component stylesheets -->",
        f"<!-- component stylesheets -->{''.join(sorted(styles))}".encode(),
    )
if scripts:
    response.content = response.content.replace(
        b"<!-- component scripts -->",
        f"<!-- component scripts -->{''.join(sorted(scripts))}".encode(),
    )
if preloads:
    response.content = response.content.replace(
        b"<!-- preloads -->",
        f'<!-- preloads -->{"".join(sorted(preloads))}'.encode(),
    )
    if non_critical_styles:
        loader = [
            "<script>",
            """document.head.querySelectorAll('link[data-swap="css"]').forEach((link) => {
                link.addEventListener('load', () => {
                    link.rel = 'stylesheet';
                    link.removeAttribute('as');
                    link.removeAttribute('data-swap');
                });
            })""",
            "</script>",
        ]
        response.content = response.content.replace(
            b"<!-- stylesheets -->",
            f"{''.join(loader)}\n<!-- stylesheets -->".encode(),
        )
return response

Finally, we inject our tags into the response. Initially I tried to magically find the right places (like </head> and </body>) but that got complex with the preloads. So our base.html template has HTML comments where we want to inject our tags. We replace those comments with our tags. If we have any non-critical CSS, we add a script to swap them in when they're loaded. Remember that we added fallbacks for those in <noscript> tags, so we've taken care of users with JavaScript disabled. Then we return our modified response.

Wrapping it all up #

We've created our own custom Web Components, made them interactive, and loaded them on-demand in Django. (And we avoided all the Shadow DOM issues.) For Prideraiser, this dropped about 50% of our non-image page weight on most pages.