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.