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>
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
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 {};
Include it in your index.html
:
<script type="module" src="./index.js"></script>
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";
or for their exports
import { add, multiply } "./my-math-lib.js";
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";
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
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"]
}
Now run
npm run tsc --watch -p jsconfig.json
and make a type error in your code. The TypeScript compiler should complain:
/** @type {number} **/
const num = "hello";
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>
Now we can simply import this package with
import {} from "superstruct"
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
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/",
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";
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/*"]
},
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);
}
Now create a file example.test.js
.
import { test, assert } from "@/test.js";
test("1+1", () => {
assert(1 + 1 === 2);
});
And a file where you import all your tests:
import "./example.test.js";
console.log("This should only show up when running tests");
Run this on page load:
await import("@/test/index.js"); // file that imports all tests
(await import("@/test.js")).runAllTests();
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)
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).
Bonus: you can even generate coverage without setting up complicated build tooling. It is built into Chrome: developer.chrome.com/docs/devtools...