javascriptwebpackes6-modulesbundling-and-minificationsnowpack

Natively import ES module dependencies from npm without bundling/transpiling first-party source


Background

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.

Goal

I want to achieve this kind of workflow:

  1. npm install foo (assume it's an ES module, not CommonJS)
  2. Edit source/index.js and add import { bar } from 'foo'
  3. 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/).
  4. Edit index.html to add <script src="build/vendor.js" type="module"...
  5. I can reload 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.


Solution

  • I figured out how to do this, using Import Maps and Snowpack.

    High-Level Explanation

    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.

    Implementation - Minimal Example

    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.

    Implementation - Full Example

    Check out iandunn/no-build-tools-no-problems/f1bb3052. Here's direct links to the the relevant lines: