Let the Browser Do It for You
February 18, 2022 • 9 min read
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 ✌️