DEV Community

Bart Louwers
Bart Louwers

Posted on

Web Development Without (Build) Tooling

When starting a new web project where JavaScript will be used, often the first thing we do is set up build and developer tooling. For example, Vite, which is a popular these days. You might not be aware that complicated build tooling it not needed for all JavaScript (web) projects. In fact, it is now easier to go without than ever before as I will show in this article.

Create a new project with an index.html file.

<!doctype html>
<html>
  <head>
  </head>
  <body>
    <p>Hello world</p>
  </body>
</html>
Enter fullscreen mode Exit fullscreen mode

If you are using VS Code, install the Live Preview extension. Run it. This is a simple file server with live reload. You can use any file server, Python comes with one built in:

python3 -m http.server
Enter fullscreen mode Exit fullscreen mode

I like Live Preview, because it automatically refreshes the page after making changes to a file.

You should now be able to access your index.html file from the browser and see "Hello world".

Next, create an index.js file:

console.log("Hello world");

export {};
Enter fullscreen mode Exit fullscreen mode

Include it in your index.html:

<script type="module" src="./index.js"></script>
Enter fullscreen mode Exit fullscreen mode

Open the developer console in your browser. If you see "Hello world", you know that it is loading properly.

Browsers support ECMAScript modules now. You can import other files for their side effects:

import "./some-other-script.js";
Enter fullscreen mode Exit fullscreen mode

or for their exports

import { add, multiply } "./my-math-lib.js";
Enter fullscreen mode Exit fullscreen mode

Pretty cool right? Refer to the MDN guide above for more information.

Packages

You probably don't want to re-invent the wheel, so your project will probably use some third-party packages. That doesn't mean you now need to start using a package manager.

Say we want to use superstruct for data validation. We can can not just load modules from our own (local) file server, but from any URL. esm.sh conveniently provides modules for almost all packages available on npm.

When you visit https://esm.sh/superstruct you can see that you are re-directed to the latest version. You can include this package as follows in your code:

import { assert } from "https://esm.sh/superstruct";
Enter fullscreen mode Exit fullscreen mode

If you want to be on the safe side, you can pin versions.

Types

I don't know about you, but TypeScript spoiled me (and made me lazy). Writing plain JavaScript without help from the type checker feels like writing over a tightrope. Luckily, we don't have to forgo type checking either.

It is time to bust out npm (even though we won't ship any code it provides).

npm init --yes
npm install typescript
Enter fullscreen mode Exit fullscreen mode

You can use the TypeScript compiler on JavaScript code just fine! There is first-class support for it. Create a jsconfig.json:

{
  "compilerOptions": {
    "strict": true,
    "checkJs": true,
    "allowJs": true,
    "noImplicitAny": true,
    "lib": ["ES2022", "DOM"],
    "module": "ES2022",
    "target": "ES2022"
  },
  "include": ["**/*.js"],
  "exclude": ["node_modules"]
}
Enter fullscreen mode Exit fullscreen mode

Now run

npm run tsc --watch -p jsconfig.json
Enter fullscreen mode Exit fullscreen mode

and make a type error in your code. The TypeScript compiler should complain:

/** @type {number} **/
const num = "hello";
Enter fullscreen mode Exit fullscreen mode

By the way, the comment you see above is JSDoc. You can annotate your JavaScript with types this way. While it is a little bit more verbose than using TypeScript, and you get used to it pretty quickly. It is also very powerful, as long as you are not writing crazy types (which you should not for most projects) you should be fine.

If you do need a complicated type (helper), you can always add some TypeScript in a .d.ts file.

Is JSDoc just a stepping stone for people stuck with large JavaScript projects to be able to gradually migrate gradually to TypeScript? I don't think so! The TypeScript team also continues to add great features to JSDoc + TypeScript, such as in the upcoming TypeScript release. Auto-completion also works great in VS Code.

Import maps

We learned how to add external packages to our project without a build tool. However, if you split your code in a lot of modules, writing out the complete URL over and over again might be a bit verbose.

We can add an import map to the head section of our index.html:

<script type="importmap">
  {
    "imports": {
      "superstruct": "https://esm.sh/superstruct@1.0.4"
    }
  }
</script>
Enter fullscreen mode Exit fullscreen mode

Now we can simply import this package with

import {} from "superstruct"
Enter fullscreen mode Exit fullscreen mode

Like a 'normal' project. Another benefit is that completion and recognition of types will work as expected if you install the package locally.

npm install --save-dev superstruct
Enter fullscreen mode Exit fullscreen mode

Note that the version in your node_modules directory will not be used. You can remove it, and your project will continue to run.

A trick I like to use is to add:

      "cdn/": "https://esm.sh/",
Enter fullscreen mode Exit fullscreen mode

To my import map. Then any project available through esm.sh can be used by simply importing it. E.g.:

import Peer from "cdn/peerjs";
Enter fullscreen mode Exit fullscreen mode

If you want to pull types from node_modules for development for this type of import as well, you need to add the following to the compilerOptions of your jsconfig.json:

    "paths": {
      "cdn/*": ["./node_modules/*", "./node_modules/@types/*"]
    },
Enter fullscreen mode Exit fullscreen mode

Deployment

To deploy your project, copy all the files to a static file host and you are done! If you have ever worked on a legacy JavaScript project, you know the pain of getting build tooling updated that is not even 1-2 years old. Your will not suffer the same fate with this project setup.

Testing

If your JavaScript does not depend on browser APIs, you could just use the test runner that comes bundled with Node.js. But why not write your own test runner that runs right in the browser?

/** @type {[string, () => Promise<void> | void][]} */
const tests = [];

/**
 *
 * @param {string} description
 * @param {() => Promise<void> | void} testFunc
 */
export async function test(description, testFunc) {
  tests.push([description, testFunc]);
}

export async function runAllTests() {
  const main = document.querySelector("main");
  if (!(main instanceof HTMLElement)) throw new Error();
  main.innerHTML = "";

  for (const [description, testFunc] of tests) {
    const newSpan = document.createElement("p");

    try {
      await testFunc();
      newSpan.textContent = `✅ ${description}`;
    } catch (err) {
      const errorMessage =
        err instanceof Error && err.message ? ` - ${err.message}` : "";
      newSpan.textContent = `❌ ${description}${errorMessage}`;
    }
    main.appendChild(newSpan);
  }
}

/**
 * @param {any} val
 */
export function assert(val, message = "") {
  if (!val) throw new Error(message);
}
Enter fullscreen mode Exit fullscreen mode

Now create a file example.test.js.

import { test, assert } from "@/test.js";

test("1+1", () => {
  assert(1 + 1 === 2);
});
Enter fullscreen mode Exit fullscreen mode

And a file where you import all your tests:

import "./example.test.js";

console.log("This should only show up when running tests");
Enter fullscreen mode Exit fullscreen mode

Run this on page load:

await import("@/test/index.js"); // file that imports all tests
(await import("@/test.js")).runAllTests();
Enter fullscreen mode Exit fullscreen mode

And you got a perfect TDD setup. To run only a section of the tests you can comment out a few .test.js import, but test execution speed should only start to become a problem when you have accumulated a lot of tests.

Benefits

Why would you do this? Well, using fewer layers of abstraction makes your project easier to debug. There is also the credo to "use the platform". The skills you learn will transfer better to other projects. Another advantage is, when you return to a project built like this in 10 years, it will still just work and you don't need to do archeology to try to revive a build tool that has been defunct for 8 years. An experience many web developers that worked on legacy projects will be familiar with.

See plainvanillaweb.com for some more ideas.

Top comments (2)

Collapse
 
greenersoft profile image
GreenerSoft

Very good article.
ESM modules can simplify a lot of things.
In addition, a link element and the "modulepreload" attribute, which preloads, parse and compile the module asynchronously, is extremely efficient with the HTTP/2 protocol (the "preload" attribute only preloads).

Collapse
 
louwers profile image
Bart Louwers • Edited

Bonus: you can even generate coverage without setting up complicated build tooling. It is built into Chrome: developer.chrome.com/docs/devtools...