A tiny photo of me

Etienne MartinA chronological list of thoughts on web development

Let the Browser Do It for You

February 18, 20229 min read

A little browser lifting weights

Browsers are an incredible feat of engineering. According to wikipedia, the Chromium codebase contains about 35 million lines of source code. It's really impressive to me that they work at all.

Using built-in browser features is not a novel idea. Choosing border-radius over background sprites may seem obvious nowadays, but it was not always the case. There are many things we take for granted that were just hopes and dreams not so long ago.

I've realised that once I found a way of doing something, I tend to stick with it and accept it as the status quo. I often find myself reusing techniques from my old bag of tricks without checking to see if anything changed in the browser landscape.

This post is mostly a reminder to myself that I don't always need to reinvent the wheel and that I can, in most cases, rely on the browser to do the heavy lifting for me.


I need to mention that it's not always possible to use some of the tricks highlighted in this article if you are building an isomorphic application. Some techniques rely on browser features that are not available in the Node.js runtime. But hey, things move fast, I'm looking at you #41749.


URLs

Let's start with the basics. Dealing with URLs is pretty common when developing web applications. It's also quite hard to get right. Better let the browser do the magic.

Parsing URLs

I think we've all been there before. We need to extract data from a URL, so we put together a little function:

// 🚫 Don't do this (keep reading)
const getQueryString = (url) => {
return url.slice(url.indexOf("?"));
};
console.log(getQueryString("https://example.com/?test=1"));

Console:

We start testing our code and realize we forgot to handle the case where the query string is missing completely.

Undeterred, we go back to our function and add a bit of code to handle that case. We do a little more testing and we're confident we nailed it this time. But a couple of days later, our feature starts to break in production because someone somewhere decided to put a fragment at the end of their URL.

Parsing URLs is hard, and doing it by hand is rarely a good idea.

Luckily for us, browsers ship with the URL API which allows us to reliably parse any URL with a single line of code:

const url = new URL("https://example.com:3000/path?test=1#hash");
console.log(url.protocol);
console.log(url.hostname);
console.log(url.pathname);
console.log(url.search);
console.log(url.hash);
// And many others...

Console:

Here's a list of all the properties that are supported: https://developer.mozilla.org/en-US/docs/Web/API/URL#properties

Absolute URLs

Another great thing about the URL constructor is that it can convert relative URLs into absolute URLs, similar to url.resolve in Node.js:

const toAbsoluteUrl = (base, relativeUrl) => {
return new URL(relativeUrl, base).toString();
};
const BASE_URL = "https://base-url/path/";
console.log(toAbsoluteUrl(BASE_URL, "/absolute-path"));
console.log(toAbsoluteUrl(BASE_URL, "relative-path"));
console.log(toAbsoluteUrl(BASE_URL, "../relative-path"));

Console:

Query String Parsing

Browsers also have the ability to parse query strings in a similar fashion to the URL constructor. The API is called URLSearchParams and it's packed with everything we could possibly need when it comes to query parameters.

This example shows how we can extract parameters from a query string:

const params = new URLSearchParams("?query=1&test=Hello%20world");
console.log(params.get("query"));
console.log(params.get("test"));

Console:

Here's a list of all the methods that are supported: https://developer.mozilla.org/en-US/docs/Web/API/URLSearchParams#methods

We can also use a combination of URLSearchParams and Object.fromEntries to turn query strings into plain JavaScript objects:

const parseQueryString = (queryString) => {
return Object.fromEntries(new URLSearchParams(queryString));
};
console.log(parseQueryString("?query=1&test=Hello%20World"));

Console:

Query Params Stringification

Great, we can parse query strings but what about the opposite. What if we have a bunch of values and we want to turn them into a query string?

I've lost count of the number of times I've seen API calls like the one below (disclaimer: I'm guilty of having done this several times).

// 🚫 Don't do this (keep reading)
const fetchProducts = ({ brand, inStock, limit }) => {
return fetch(
`https://api.example.com/products?brand=${encodeURIComponent(
brand
)}&inStock=${inStock}&limit=${limit}`
);
};
await fetchProducts({
brand: "Bang & Olufsen",
inStock: true,
limit: 10,
});

Console:

While this works, there are many things that can be improved.

  • It is hard to read — even more so with prettier doing its best to keep the line length under control.
  • It's fragile — we're one bad keystroke away from messing up our URL.
  • We have to manually encode string parameters with encodeURIComponent.

I've used the qs package in the past to address these issues, but there is no need to increase the size of our JavaScript bundle with yet another package. We can once again rely on URLSearchParams to do the job for us.

I think we can all agree that this is much more readable (with the added bonus that the URL encoding is taken care of):

const fetchProducts = ({ brand, inStock, limit }) => {
return fetch(
`https://api.example.com/products?${new URLSearchParams({
brand,
inStock,
limit,
})}`
);
};
await fetchProducts({
brand: "Bang & Olufsen",
inStock: true,
limit: 10,
});

Console:

Utility Function

If you're feeling nostalgic, you might want to create an abstraction around URLSearchParams that mimics the API of the qs module:

const parse = (queryString) => {
return Object.fromEntries(new URLSearchParams(queryString));
};
const stringify = (queryObject) => {
return new URLSearchParams(queryObject).toString();
};
const qs = {
parse,
stringify,
};
export default qs;

Strings

Doing a check with String.indexOf(...) > -1 should be a thing of the past.

Browsers are making our lives easier with the addition of new String methods such as includes and startsWith.

const startsWith = "Hello world".startsWith("Hello");
const endsWith = "Hello world".endsWith("world");
const includes = "Hello world".includes("world");
console.log(startsWith, endsWith, includes);

Console:

Here's the full list of supported methods: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/String#instance_methods

Removing Accents

Removing diacritics from a string is definitely not something I want to do by hand. I've previously relied on lodash's deburr function to do the job, but I recently found out that it can be done with this one-liner:

const removeAccents = (str) => {
return str.normalize("NFD").replace(/[\u0300-\u036f]/g, "");
};
console.log(removeAccents("Hétérogénéité"));

Console:

HTML Encoding

We sometimes need to sanitize untrusted strings to prevent XSS vulnerabilities. We usually do this by encoding HTML entities so that they are not interpreted as code once rendered in our document.

If we look on the internet for how to HTML-encode a piece of text, we mostly find answers involving regular expressions:

Personally, the farther I stay away from regular expressions the better. Especially when writing code that revolves around security. If there's one thing browsers are good at, it's HTML.

In this example, we leverage the browser's built-in HTML parser to safely encode a piece of untrusted data.

const encodeHtmlEntities = (str) => {
const parent = document.createElement("div");
parent.appendChild(new Text(str));
return parent.innerHTML;
};
console.log(encodeHtmlEntities(`<img src="" onerror="alert('xss')" />`));

Console:

The special ingredient here is the Text constructor. Browsers will properly escape any string you throw at it. You can learn more about it here: https://developer.mozilla.org/en-US/docs/Web/API/Text

Extract Text from Html

Data can be messy sometimes. We might be pulling data from an old API built by someone who thought it was a good idea to store HTML in their database.

The DOMParser API can help us in such a scenario. It provides a way to leverage the browser's built-in DOM parser without interfering with the main document.

const stripHtml = (html) => {
const { documentElement } = new DOMParser().parseFromString(
html,
"text/html"
);
return documentElement.textContent;
};
console.log(stripHtml(`<p>This is some text</p>`));
console.log(stripHtml(`<img src="" onerror="alert('xss')" />`));

Console:

I sincerely hope you never have to, but you can also use the DOMParser API to parse XML documents: https://developer.mozilla.org/en-US/docs/Web/API/DOMParser/parseFromString

Client-side Validations

Client-side validation can be a pain. There is a lot of disagreement about how user inputs should be validated. Most of the debate is over which regex is best for performing a given task. I prefer to look from the sidelines and let the browser do the validation for me when possible.

Validating Email Addresses

As far as I know, there is no built-in function we can call to get a clear yes or no whether an email is syntactically valid, but we can use the Constraint Validation API and a bit of DOM wizardry to achieve just that:

const validateEmail = (email) => {
const input = document.createElement("input");
input.type = "email";
input.required = true;
input.value = email;
return input.checkValidity();
};
console.log(validateEmail("test@example.com"));
console.log(validateEmail("test@localhost"));
console.log(validateEmail("not-an-email"));

Console:

Note: this code won't work server-side as it relies on the DOM but this doesn't mean that we shouldn't validate email addresses on the backend too.

URL Validation

The URL constructor turns out to be useful once again. Here we rely on the fact that it throws an exception if the given URL is invalid in addition to making sure the protocol matches a set of expected values:

const validateUrl = (url) => {
try {
return ["http:", "https:"].includes(new URL(url).protocol);
} catch {
return false;
}
};
console.log(validateUrl(`https://example.com/path/`));
console.log(validateUrl(`wss://example.com/path/`));
console.log(validateUrl(`javascript:alert("XSS")`));
console.log(validateUrl(`not-a-url`));

Console:

Wrapping Up

This is a non-exhaustive list but you get the idea.

As you can see, there's a bit of a trade-off. It's not as convenient as installing a package and importing it into our project. We still have to write a bit of code to leverage these browser features, but the advantage is that we are now relying on code that has been battle-tested and more importantly, we haven't increased the size of our JavaScript bundle.

As always, make sure these features are supported by the browsers you are targeting.

Stay safe ✌️