Going build-free with native JavaScript modules
For the last decade and more, we've been bundling CSS and JavaScript files. These build tools allowed us to utilize new browser capabilities in CSS and JS while still supporting older browsers. They also helped with client-side network performance, minimizing the content to be as small as possible and combining files into one large bundle to reduce network handshakes. We've gone through a lot of build tools iterations in the process; from Grunt (2012) to Gulp (2013) to Webpack (2014) to Parcel (2017) to esbuild (2020) and Vite (2020).
And with modern browser technologies there is less need for these build tools.
- Modern CSS supports many of the features natively that the build tools were created for. CSS nesting to organize code, variables, @supports for feature detection.
- JavaScript ES6 / ES2015 was a big step forward, and the language has been progressing steadily ever since. It now has native module support with the import / export keywords
- Meanwhile, with HTTP/2 performance improvements, parallel requests can be made over the same connection, removing the constraints of the HTTP/1.x protocol.
These build processes are complex, particularly for beginners to Django. The tools and associated best practices move quickly. There is a lot to learn and you need to understand how to utilize them with your Django project. You can build a workflow that stores the build results in your static folder, but there is no core Django support for a build pipeline, so this largely requires selecting from a number of third party packages and integrating them into your project.
The benefit this complexity adds is no longer as clear cut, especially for beginners. There are still advantages to build tools, but you can can create professional results without having to use or learn any build processes.
Build-free JavaScript tutorial
To demonstrate modern capabilities, let's expand Django’s polls tutorial with some newer JavaScript. We’ll use modern JS modules and we won’t require a build system.
To give us a reason to need JS let's add a new requirement to the polls; to allow our users to add their own suggestions, instead of only being able to vote on the existing options. We update our form to have a new option under the selection code:
or add your own <input type="text" name="choice_text" maxlength="200" />
Now our users can add their own options to polls if the existing ones don't fit. We can update the voting view to handle this new option. We add a new choice_text input, and if there is no vote selection we will potentially handle adding the new option, while still providing an error message if neither is supplied. We also provide an error if both are selected.
def vote(request, question_id):
if request.POST['choice'] and request.POST['choice_text']:
return render(request, 'polls/detail.html', {
'question': question,
'error_message': "You can't vote and provide a new option.",
})
question = get_object_or_404(Question, pk=question_id)
try:
selected_choice = question.choice_set.get(pk=request.POST['choice'])
except (KeyError, Choice.DoesNotExist):
if request.POST['choice_text']:
selected_choice = Choice.objects.create(
question=question,
choice_text=request.POST['choice_text'],
)
else:
return render(request, 'polls/detail.html', {
'question': question,
'error_message': "You didn't select a choice or provide a new one.",
})
selected_choice.votes += 1
selected_choice.save()
return HttpResponseRedirect(reverse('polls:results', args=(question.id,)))
Now that our logic is a bit more complex it would be nicer if we had some JavaScript to do this. We can build a script that handles some of the form validation for us.
function noChoices(choices, choice_text) {
return (
Array.from(choices).some((radio) => radio.checked) ||
(choice_text[0] && choice_text[0].value.trim() !== "")
);
}
function allChoices(choices, choice_text) {
return (
!Array.from(choices).some((radio) => radio.checked) &&
choice_text[0] &&
choice_text[0].value.trim() !== ""
);
}
export default function initFormValidation() {
document.getElementById("polls").addEventListener("submit", function (e) {
const choices = this.querySelectorAll('input[name="choice"]');
const choice_text = this.querySelectorAll('input[name="choice_text"]');
if (!noChoices(choices, choice_text)) {
e.preventDefault();
alert("You didn't select a choice or provide a new one.");
}
if (!allChoices(choices, choice_text)) {
e.preventDefault();
alert("You can't select a choice and also provide a new option.");
}
});
}
Note how we use export default in the above code. This means form_validation.js is a JavaScript module. When we create our main.js file, we can import it with the import statement:
import initFormValidation from "./form_validation.js";
initFormValidation();
Lastly, we add the script to the bottom of our details.html file, using Django’s usual static template tag. Note the type="module" this is needed to tell the browser we will be using import/export statements.
<script type="module" src="{% static 'polls/js/main.js' %}"></script>
That’s it! We got the modularity benefits of modern JavaScript without needing any build process. The browser handles the module loading for us. And thanks to parallel requests since HTTP/2, this can scale to many modules without a performance hit.
In production
To deploy, all we need is Django's support for collecting static files into one place and its support for adding hashes to filenames. In production it is a good idea to use ManifestStaticFilesStorage storage backend. It stores the file names it handles by appending the MD5 hash of the file’s content to the filename. This allows you to set far future cache expiries, which is good for performance, while still guaranteeing new versions of the file will make it to users’ browsers.
This backend is also able to update the reference to form_validation.js in the import statement, with its new versioned file name.
Future work
ManifestStaticFilesStorage works, but a lot of its implementation details get in the way. It could be easier to use as a developer.
- The support for
import/exportwith hashed files is not very robust. - Comments in CSS with references to files can lead to errors during collectstatic.
- Circular dependencies in CSS/JS can not be processed.
- Errors during collectstatic when files are missing are not always clear.
- Differences between implementation of StaticFilesStorage and ManifestStaticFilesStorage can lead to errors in production that don't show up in development (like #26329, about leading slashes).
- Configuring common options means subclassing the storage when we could use the existing OPTIONS dict.
- Collecting static files could be faster if it used parallelization (pull request: #19935 Used a threadpool to parallelise collectstatic)
We discussed those possible improvements at the Django on the Med 🏖️ sprints and I’m hopeful we can make progress.
I built django-manifeststaticfiles-enhanced to attempt to fix all these. The core work is to switch to a lexer for CSS and JS, based on Ned Batchelder’s JsLex that was used in Django previously. It was expanded to cover modern JS and CSS by working with Claude Code to do the grunt work of covering the syntax.
It also switches to using a topological sort to find dependencies, whereas before we used a more brute force approach of repeated processing until we saw no more changes, which lead to more work, particularly on storages that used the network. It also meant we couldn't handle circular dependencies.
To validate it works, I ran a performance benchmark on 50+ projects, it’s been tested issues and with similar (often improved) performance. On average, it’s about 30% faster.
While those improvements would be welcome, do go ahead with trying build-free JavaScript and CSS in your Django projects today! Modern browsers make it possible to create great frontend experiences without the complexity.