No bundler? No problem.
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 hopefully some day there will be a nice portals API I can use to make page transitions even fancier/native-feeling while still keeping the simplicity of server-side routing.
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. You can see my tsconfig file here. 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 (well, sometimes I do), 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.
Focus On This - React version - Buildless version
Hopefully you found this post helpful, if you have any questions you can find me on Twitter.
Top comments (3)
I think this is a very good way to go for lots of projects. Not having a build is liberating and my preferred way especially for personal projects, where I don't need to remember all the build-commands after being away for a while. Also your last bit about developing buildless, but having the build-step only for prod is an important point. Especially for things like vendor-prefixing css and the like.
I'd be interested in your thoughts on using WC to structure web-apps in a similar fashion to react-apps. Meaning using WC for "all the things" like React-Components. Of course it works, but lots of times I read that WC was intended to be a low-level-API for creating custom elements for new features, rather than a way of composing parts of an app.
Since writing this the pika web tool has been renamed to snowpack and has production optimization, which is great and my preferred way to build projects at the moment.
In regards to using components for "all the things"... I don't really like to do that. I default to old-school HTML/CSS files for the more "static" pages (landing page, about page - stuff that doesn't have a lot of server calls or need much JS), and then I still use WCs for parts of the page that I want to re-use pieces. And then dynamic pages (with more server calls and JS) I tend to use lit-element to make a component out of the entire page. Which is another advantage of buildless (and server-side routing) is the flexibility to mix and match for different pages.
Aah yes I saw Snowpack, it will probably be part of my next frontend-setup.
With "all the things" I meant doing an entire page as WC. Did you experience any bigger drawbacks compared to React/Vue?
Do you usually keep LitElements Shadow-DOM as it comes, or do you prefer without? I read about problems folks have with Shadow-DOM when it comes to form-handling/labels and css/style-sharing.
Are you using anything special for handling state?
(Sorry for asking so much, just answer if it's really ok. I'm thinking so much about the whole topic currently and it's not a lot of folks writing about that)