Sometimes modern web development feels overly complex. When I build a static website I mostly write HTML and CSS, with a little bit of JavaScript. My project structure is straightforward and boring - just the way I like it.
But when I move beyond "static website" into "web app" or Progressive Web App (PWA) territory then things are no longer simple... Babel config files, TypeScript config files, Webpack or Rollup config files. Make a change, wait for it to build, then try and debug one giant bundle.js
file. Surely there's a better way, right?
From Create React App to a "Buildless" PWA
To figure out if building a PWA without any build step is feasible (enjoyable even?) I took an existing PWA that I had written using Create-React-App and converted it to be buildless. The app is rather simple, so I wouldn't say this is an exhaustive experiment, but it was revealing enough to identify some benefits and challenges with building a modern web app without a build step.
Component Framework
The original version of my PWA was written with React using JSX. If I want to go buildless I can't use JSX since browsers can't directly render it. Moving away from JSX is fine with me because nowadays I prefer building web components over using any other component framework (like React). Someday I'll write a post about why I like web components so much, but it boils down to this: in general, I prefer something standards-based over a non-standard library/framework.
Great, so I'm going to build web components... but how? There are many libraries/tools to help build web components, and the ones I'm most familiar with are:
I think any of these are great options. However, Stencil and LWC have compilers to build their web components whereas LitElement is a base class that can run directly in the browser... sounds perfect if I'm going buildless.
// this runs in the browser, no build step needed 🎉🎉🎉
class FotApp extends LitElement {
...
}
Vanilla Web Components
One last comment regarding web components - I try to write any components that are commonly used across projects as standalone packages and build them without any frameworks/libraries/tools. Examples of these commonly used "base components" are side-drawer and wc-menu-button. The reason I like to write base components with vanilla JS is I can build them without any dependencies and be sure they are as small/lean as possible. You might think the development experience is painful using vanilla JS but ideally your base components are mostly just HTML/CSS, with a little bit of JS. Even if that little bit of JS is fairly boilerplate/low-level then I don't mind 🤷♂️.
Routing
My original version was using client-side routing via React Router. I could pull in a different client-side router that works with web components, or I could just simplify and not use client-side routing. No SPA? No problem thanks to service worker pre-caching. My service worker can pre-cache all my routes so when a user navigates to a new page it's instantly pulled from the local cache. And soon I can make page transitions even fancier/native-feeling while still keeping the simplicity of server-side routing thanks to View Transitions.
Type Checking
I love TypeScript and I figured this would be the deal breaker when it came to going buildless. I don't want to write un-typed JavaScript code like some savage 😛. But the .ts
files won't just magically work in the browser, they have to be run through tsc or babel to strip types, right? That's true, but thankfully TypeScript has this really great feature where you can type check your JavaScript files via JSDoc-style comments. Much to my surprise, it all works pretty well (and it keeps getting better)! 💪💪💪
Setup
The first step to setting up type checking in JS code is to create a tsconfig.json
file at my repo root.
As I found out, buildless doesn't necessarily mean you can escape config files 🙃
Then I set allowJs
and checkJs
to true. If I didn't want to type check every file I can set checkJs
to false and then at the top of the files I do want to check I just include the comment // @ts-check
. This is also a great way to slowly move a brownfield project to TypeScript.
Declaring Types
I won't regurgitate everything in the TypeScript handbook, but I will call out a few things I learned.
To declare an object's type I just use a JSDoc-style comment:
/** @type {number} */
const length;
To import type declarations from 3rd party libs in node_modules I just import
them:
/**
* @typedef { import("side-drawer").SideDrawer } SideDrawer
* @typedef { import("lit-element").LitElement } LitElement
*/
In this example I'm defining a
@typedef
so I don't have to keep usingimport("lit-element").LitElement
every time I need that type in code.
To declare that my custom element extends from LitElement I then use that imported typedef:
/** @extends {LitElement} */
class FotDrawer extends LitElement {...}
It's not quite as quick/easy/natural as setting types in .ts
files, but I want type checking without a build step so it works 🚀
Dependencies
As I said earlier, I don't want my own code to be put into one big bundle.js
file. However, I do want each of my app's dependencies to be bundled (not all dependencies as one bundle but one bundle per dependency). I don't have control of the project structure of my dependencies, and my dependencies shouldn't change very often. So how do I bundle my dependencies without a build step?
Using pika web I can have my dependencies bundled as ES modules at install time. I simply set the npm prepare
script in my package.json
"prepare": "pika-web --clean --strict --dest public/web_modules/",
I also declare which dependencies are to be bundled as web modules in package.json
:
"@pika/web": {
"webDependencies": [
"lit-html",
"lit-element",
"side-drawer"
]
}
Now any time I run npm install
my dependencies will be bundled and copied into the web_modules
folder, and then my code can just directly import from the module in that folder:
import { LitElement, html, css } from "../../web_modules/lit-element.js";
Notice how the import uses a relative path to the actual JS file? No fancy module resolution here. I'm all for abstracting things when it makes sense, but I find this form of "module resolution" refreshingly simple 😁.
Pros
There is a lot I like about switching my web app over to be buildless:
- Fewer config files
- Simpler project structure
- Changes are instant during development
- What I develop is exactly what I ship
- I had less conflicts between build tools
Ironically, I am currently having trouble getting the React version of my app to build because one of the dependencies somewhere down in the transitive dependency tree is causing tsc
to error out. It's probably not too hard to figure out what the issue is but it does highlight the fact that, in general, the more dependencies and configurations my apps have, the more potential for issues to crop up.
Cons
It's not all roses, though. Code bundlers weren't created just to add complexity, after all. They have real benefits. As with most things, there is nuance and trade-off. Here are some issues I noticed when switching over to buildless:
- There is no tree-shaking.
- One of my favorite features of Rollup is knowing when I bundle my app all the extra code that might be in my own code or in my dependencies gets shaken out and only what is needed is shipped to my users. Without a build step this doesn't happen 😞.
- In theory the @pika/web tool could fix this, if it provided a way to explicitly say which exports you will use and then it could tree-shake out the rest when it bundles at install time. But that would be pretty tedious to have to declare which exports you plan to use so maybe it's not a great idea, I'm not sure yet.
- I can't leverage templates in HTML.
- Example: All of my pages have the same content in
<head>
but I just have to copy/paste that shared content. - With a build step I could leverage a template library like handlebars.
- Example: All of my pages have the same content in
- I can't leverage workbox build to generate my service worker without a build step.
- I can still hand-write a service worker, but it does get tedious and error-prone to remember to add each individual file to the cache list.
- It's easier/faster for me to just write TypeScript in
.ts
files than having JSDoc comments all over the.js
files.- This might just be a matter of what I'm used to though... and it's not the worst thing to get in the habit of writing JSDoc comments in my
.js
files.
- This might just be a matter of what I'm used to though... and it's not the worst thing to get in the habit of writing JSDoc comments in my
- I can't remove comments or minify without a build step.
- And since I'm using JSDoc comments for type-checking there are a lot of comments. Comments shouldn't affect JS parse time but they do affect download time so that's not great. Ultimately it depends on the app and target audience to figure out if the extra time it takes to pre-cache my files is acceptable.
- I can't support IE11 without a build step to transpile down to pre-ES6.
Final Verdict
I like the developer experience "pros" of not having a build step, but I don't like the production "cons"... particularly around tree-shaking and minifying. I also really missed workbox build.
I think some sort of middle-ground might be what I try next. My thought is I will basically go buildless during development, and then have a build step for staging and production sites. That "build step" will run workbox build and minify each file. I still won't have tree-shaking but if I am thoughtful about what dependencies I pull in (which I should always be thoughtful about that, bundle or no bundle) then maybe it won't be a big deal. I'll have to do some benchmarking to be sure, but this idea feels like a sort of healthy middle ground of simplicity and build-step benefits.
Hopefully you found this post helpful, if you have any questions you can find me on Twitter.
Or from the RSS feed