DEV Community

Cover image for Resizing images client-side with vanilla JS
Taylor Beeston
Taylor Beeston

Posted on

Resizing images client-side with vanilla JS

Table of Contents

Backstory

I recently came upon a need to optimize images client-side prior to uploading them to a back end (in this case AWS S3). Ordinarily, this would be done on the back end (i.e. your front end sends a request containing an unoptimized image to the back end, which then optimizes that image before saving it), but for this project I really wanted to do this on the client.

Code

All the code for this can be found here.

Getting to work

The canvas element

Overview

It turns out that the best way (in this case) to create an image with javascript is by using a canvas element! How do we do that? By creating a 2d context, drawing our image in it, then calling the toBlob method.

Code

For this particular project, I am working with images as File Objects, obtained, for example, by using a function such as

(e) => e.target.files[0];
Enter fullscreen mode Exit fullscreen mode

on an HTML file input element's onchange event.

Because of this, let's write the helper function readPhoto, which creates and returns a canvas element containing the image given to it. The code for this function is as follows:

const readPhoto = async (photo) => {
  const canvas = document.createElement('canvas');
  const img = document.createElement('img');

  // create img element from File object
  img.src = await new Promise((resolve) => {
    const reader = new FileReader();
    reader.onload = (e) => resolve(e.target.result);
    reader.readAsDataURL(photo);
  });
  await new Promise((resolve) => {
    img.onload = resolve;
  });

  // draw image in canvas element
  canvas.width = img.width;
  canvas.height = img.height;
  canvas.getContext('2d').drawImage(img, 0, 0, canvas.width, canvas.height);

  return canvas;
};
Enter fullscreen mode Exit fullscreen mode

Let's break down what this code is doing.

First we create two HTML elements, an img and a canvas.

Why do we need the img? Because the drawImage method we will be using expects a CanvasImageSource as one of its parameters, and an HTMLImageElement is going to be the most convenient for us to create.

Next we read the photo into the img element using the readAsDataURL method and a cute little promisify trick.

After that, we make sure we wait for the img to load using the promisify trick again with the following:

await new Promise((resolve) => {
  img.onload = resolve;
});
Enter fullscreen mode Exit fullscreen mode

Once we have our photo into img, and img has loaded, we draw it onto our canvas and return.

// draw image in canvas element
canvas.width = img.width;
canvas.height = img.height;
canvas.getContext('2d').drawImage(img, 0, 0, canvas.width, canvas.height);

return canvas;
Enter fullscreen mode Exit fullscreen mode

Overall Structure

Overview

Okay, so we now know how to get a File into a canvas. Great! Now, let's talk about what we're going to do with it by looking at the function optimizePhoto, the canonical main of our little helper file.

Basically, what we're doing is taking our image, shrinking it to a maximum width that is set via an environment variable (or really any way you'd like to set this!), and then returning it as a Blob.

To add a little bit of complexity, I have found that it's best to first keep shrinking our image in half until we need to use bilinear interpolation (aka scaling by a factor not divisible by 2) to finish the job. This is a very quick and easy thing to do, so we'll go ahead and add it to this function.

Code

The function looks like this:

export default async (photo) => {
  let canvas = await readPhoto(photo);

  while (canvas.width >= 2 * MAX_WIDTH) {
    canvas = scaleCanvas(canvas, .5);
  }

  if (canvas.width > MAX_WIDTH) {
    canvas = scaleCanvas(canvas, MAX_WIDTH / canvas.width);
  }

  return new Promise((resolve) => {
    canvas.toBlob(resolve, 'image/jpeg', QUALITY);
  });
};
Enter fullscreen mode Exit fullscreen mode

Nothing too crazy (besides maybe the use of our little promisify trick), but we're going to need to talk about one new function that this function depends on: scaleCanvas.

Scaling a canvas

Overview

Scaling a canvas actually turns out to be pretty simple, as we can reuse that drawImage method, just using a canvas as input instead of an img as input.

To do this, we simply make a new canvas, set its width and height to our desired dimensions, then call drawImage with the new width/height.

Code

The code for this is as follows:

const scaleCanvas = (canvas, scale) => {
  const scaledCanvas = document.createElement('canvas');
  scaledCanvas.width = canvas.width * scale;
  scaledCanvas.height = canvas.height * scale;

  scaledCanvas
    .getContext('2d')
    .drawImage(canvas, 0, 0, scaledCanvas.width, scaledCanvas.height);

  return scaledCanvas;
};
Enter fullscreen mode Exit fullscreen mode

Conclusion

And that is it! Now we can simply pass an image to optimizePhoto and get a resized photo.

For example, assuming the following HTML

<input id="file-input" type="file" multiple />
Enter fullscreen mode Exit fullscreen mode

We can generate upload resized photos with the following javascript:

const input = document.getElementById('file-input');

const input.onChange = (e) => {
  e.target.files.forEach(async (photo) => {
    const resizedPhoto = await optimizePhoto(photo);
    await uploadPhoto(resizedPhoto); // or do whatever
  });
}
Enter fullscreen mode Exit fullscreen mode

Please Note

The algorithm used to resize photos by a factor other than 2 is not necessarily bilinear interpolation. At least as far as I've been able to find. From my own personal testing, it seems as though Firefox and Chrome will both user bilinear interpolation, which looks just fine in most cases. However, it is possible to manually bilinearly interpolate an image, which I may make another post about. If you happen to have a need for it, this also applies to using another scaling algorithm such as nearest neighbor or bicubic interpolation.

Promisify?

I wrote about this cute little trick right here.

Basically, you create a new Promise that wraps around a function that relies on callbacks, then simply use resolve in the callback to magically 'promisify' that function!

Top comments (0)