I'm trying to create a "buildless" JavaScript app, one where I don't need a watch
task running to transpile JSX, re-bundle code, etc every time I save any source file.
It works fine with just first-party code, but I'm stuck when I try to import
dependencies from npm.
I want to achieve this kind of workflow:
npm install foo
(assume it's an ES module, not CommonJS)source/index.js
and add import { bar } from 'foo'
npm run build
. Something (webpack, rollup, a custom script, whatever) runs, and bundles foo
and its dependencies into ./build/vendor.js
(without anything from source/
).index.html
to add <script src="build/vendor.js" type="module"...
source/index.js
in my browser, and bar
will be available. I won't have to run npm run build
until the next time I add/remove a dependency.I've gotten webpack to split dependencies into a separate file, but to import
from that file in a buildless context, I'd have to import { bar } from './build/vendor.js
. At that point webpack will no longer bundle bar
, since it's not a relative import.
I've also tried Snowpack, which is closer to what I want conceptually, but I still couldn't configure it to achieve the above workflow.
I could just write a simple script to copy files from node_modules
to build/
, but I'd like to use a bundled in order to get tree shaking, etc. It's hard to find something that supports this workflow, though.
I figured out how to do this, using Import Maps and Snowpack.
I used Import Maps to translate bare module specifiers like import { v4 } from 'uuid'
into a URL. They're currently just a drafted standard, but are supported in Chrome behind an experimental flag, and have a shim.
With that, you can use bare import
statements in your code, so that a bundler understands them and can work correctly, do tree-shaking, etc. When the browser parses the import, though, it'll see it as import { v4 } from 'http://example.org/vendor/uuid.js'
, and download it like a normal ES module.
Once those are setup, you can use any bundler to install the packages, but it needs to be configured to build individual bundles, instead of combining all packages into one. Snowpack does a really good job at this, because it's designed for an unbundled development workflow. It uses esbuild under the hood, which is 10x faster than Webpack, because it avoids unnecessarily re-building packages that haven't changed. It still does tree-shaking, etc.
index.html
<!doctype html>
<!-- either use "defer" or load this polyfill after the scripts below-->
<script defer src="es-module-shims.js"></script>
<script type="importmap-shim">
{
"imports": {
"uuid": "https://example.org/build/uuid.js"
}
}
</script>
<script type="module-shim">
import { v4 } from "uuid";
console.log(v4);
</script>
snowpack.config.js
module.exports = {
packageOptions: {
source: 'remote',
},
};
packageOptions.source = remote
tells Snowpack to handle dependencies itself, rather than expecting npm to do it.
Run npx snowpack add {module slug - e.g., 'uuid'}
to register a dependency in the snowpack.deps.json
file, and install it in the build
folder.
package.json
"scripts": {
"build": "snowpack build"
}
Call this script whenever you add/remove/update dependencies. There's no need for a watch
script.
Check out iandunn/no-build-tools-no-problems/f1bb3052
. Here's direct links to the the relevant lines:
snowpack.config.js
snowpack.deps.json
package.json
core.php
outputs the shimplugin.php
- outputs the import mappassphrase-generator.js
- imports the modules. (They're commented out in this example, for reasons outside the scope of this answer, just uncomment them, run the bundle
script, and they'll work).