Rendering Image Previews Using Object URLs vs. Base64 Data URIs In AngularJS
In the past, I've used base64-encoded data URIs to show client-side image previews. However, in a recent revamp of InVision App, Jonathan Rowny started rendering image previews using a relatively new browser technology - Object URLs. Rather than reading the image into memory and converting it into another format (base64), Object URLs allow us to link directly to the file on the user's local system. This has both memory and performance considerations. And, since I've never played with this myself, I wanted to put together a quick little demo.
Run this demo in my JavaScript Demos project on GitHub.
In the following demo, I've created a component directive that provides a dropzone. When you drop images onto the dropzone, the image previews are rendered in a list. The directive can render the image using either the older base64 data URI approach or the newer Object URL approach. In fact, the difference in access patterns is so minimal, it can be easily encapsulated behind the promise API.
That said, the URL.createObjectURL() method is actually synchronous. Since it's not really doing any processing - just providing a local URL - it can return immediately. The base64 data URI, on the other hand, needs to read the file into memory and then encode it as a string. As such, it is necessarily an asynchronous promise. To keep things uniform, however, I am putting both access patterns behind a promise such that the consuming code can assume async access for both approaches.
Once an Object URL is created using URL.createObjectURL(), it is recommend that you subsequently deallocate the url, when you no longer need it, using URL.revokeObjectURL(). Apparently this is for memory management; however, when using the Chrome DevTools Profiler, I wasn't seeing anything concerning when I ran the demo without explicit deallocation. That said, I'm all for best practices; so, in the following code, I keep track of the URLs as I generate them and then deallocate them in the $destroy event.
With all that said, let's take a look at the code. The first instance of the component directive uses Object URLs (as per the preview-method="url" attribute) and the second instance of the component directive uses base64 data URIs (as per the preview-method="base64" attribute):
<!doctype html>
<html ng-app="Demo">
<head>
<meta charset="utf-8" />
<title>
Rendering Image Previews Using Object URLs vs. Base64 Data URIs In AngularJS
</title>
<link rel="stylesheet" type="text/css" href="./demo.css"></link>
</head>
<body ng-controller="AppController as app">
<h1>
Rendering Image Previews Using Object URLs vs. Base64 Data URIs In AngularJS
</h1>
<p>
<a ng-click="app.toggleDemo()">Toggle Demos</a> (to clear them)
</p>
<!-- I show image previews using the URL.createObjectUrl() method. -->
<h2>
Rendering Previews Using URL.createObjectURL()
</h2>
<bn-demo ng-if="app.isShowingDemo" preview-method="url">
<ul>
<li ng-repeat="image in demo.images track by image.id">
<figure ng-style="{ 'background-image': 'url(' + image.src + ')' }">
<figcaption>
{{ image.name }}
</figcaption>
</figure>
</li>
</ul>
</bn-demo>
<!-- I show image previews using the base64 method. -->
<h2>
Rendering Previews Using .getAsDataURL()
</h2>
<bn-demo ng-if="app.isShowingDemo" preview-method="base64">
<ul>
<li ng-repeat="image in demo.images track by image.id">
<figure ng-style="{ 'background-image': 'url(' + image.src + ')' }">
<figcaption>
{{ image.name }}
</figcaption>
</figure>
</li>
</ul>
</bn-demo>
<!-- Load scripts. -->
<script type="text/javascript" src="../../vendor/angularjs/angular-1.4.7.min.js"></script>
<script type="text/javascript" src="../../vendor/plupload/2.1.8/moxie.js"></script>
<script type="text/javascript">
// Create an application module for our demo.
angular.module( "Demo", [] );
// --------------------------------------------------------------------------- //
// --------------------------------------------------------------------------- //
// I control the root of the application.
angular.module( "Demo" ).controller(
"AppController",
function AppController( $scope ) {
var vm = this;
// I determine if the demo widgets are being shown.
vm.isShowingDemo = true;
// Expose public methods.
vm.toggleDemo = toggleDemo;
// ---
// PUBLIC METHODS.
// ---
// I toggle the display of the demo widgets.
function toggleDemo() {
vm.isShowingDemo = ! vm.isShowingDemo;
}
}
);
// --------------------------------------------------------------------------- //
// --------------------------------------------------------------------------- //
// I provide a drag-n-drop image preview widget.
angular.module( "Demo" ).directive(
"bnDemo",
function bnDemoDirective( $document, $q ) {
// Return the directive definition object.
return({
controller: DemoController,
controllerAs: "demo",
link: link,
restrict: "E"
});
// I bind the JavaScript events to thew view-model.
function link( scope, element, attributes, controller ) {
// I am the means by which the image data will be rendered for this
// demo. Either as a local URL or as a base64-encoded data URI.
var previewMethod = ( attributes.previewMethod || "url" );
// Set up our dropzone to accept image file types.
var dropzone = new mOxie.FileDrop({
drop_zone: element[ 0 ],
accept: [
{
title: "Images",
extensions: "jpg,jpeg,gif,png"
}
]
});
dropzone.ondrop = handleDrop;
dropzone.init();
// When we render image previews using the URL service, it is
// recommended that the URLs be deallocated at some point to free up
// memory. As such, we're going to keep track of them and deallocate
// them in the $destroy event.
// --
// NOTE: These URLs are also deallocated (automatically) when the
// current window is destroyed.
var localUrlsToDestroy = [];
// Listen for the destroy event to teardown the component.
scope.$on( "$destroy", handleDestroy );
// ---
// PRIVATE METHODS.
// ---
// I take the given image file and return a promise that resolves to
// the base64-based data URI.
function getPreviewData( file ) {
var deferred = $q.defer();
// In this approach, we're using the mOxie Image class to
// asynchronously load the image into the browser memory and read
// the image as a base64 URI.
var preloader = new mOxie.Image();
preloader.onload = handleLoad;
preloader.load( file );
return( deferred.promise );
function handleLoad() {
deferred.resolve( preloader.getAsDataURL() );
preloader.destroy();
}
}
// I take the given image file and return a promise that resolves to
// the local Object URL.
// --
// NOTE: While accessing the Object URL is a SYNCHRONOUS process, I
// am wrapping it in a promise to make the preview access symmetric
// between this version and the base64 version (above).
function getPreviewUrl( file ) {
// In this approach, instead of reading the image into memory,
// we're going to get a URL that actually points to the image
// file in the local file system.
var localUrl = ( URL || webkitURL ).createObjectURL( file.getSource() );
// Since it is recommended that we deallocate these object URLs,
// let's track it so that we can destroy it later.
localUrlsToDestroy.push( localUrl );
return( $q.when( localUrl ) );
}
// I tear down the component.
function handleDestroy() {
// Destroy the dropzone to prevent memory leaks.
// --
// NOTE: This does not automatically destroy the dropped files -
// those have to be destroyed individually (see below).
dropzone.destroy();
// While Object URLs are automatically destroyed when the
// window is destroyed, we can manually deallocate them when the
// component is destroyed since we no longer need them.
localUrlsToDestroy.forEach(
function iterator( localUrl ) {
( URL || webkitURL ).revokeObjectURL( localUrl );
}
);
}
// I handle the dropping of an image object.
function handleDrop( event ) {
this.files.forEach(
function iterator( file ) {
// Read the image preview using the demo-specific method.
var promise = ( previewMethod === "url" )
? getPreviewUrl( file )
: getPreviewData( file )
;
// Once the image preview is available, add the image to
// the demo.
// --
// NOTE: $q is already taking care of the digest; hence,
// no need to call $apply() when changing the view-model.
promise.then(
function handleResolve( previewUrl ) {
controller.addImage( file.name, previewUrl );
// Destroy the file to prevent memory leaks.
file.destroy();
}
);
}
);
}
}
// I control the demo widget.
function DemoController( $scope ) {
var vm = this;
// I keep track of the auto-incrementing ID attached to each image.
var primaryKey = 0;
// I hold the image records to render.
vm.images = [];
// Expose public methods.
vm.addImage = addImage;
// ---
// PUBLIC METHODS.
// ---
// I add an image with the given name and preview src value. The
// image is added to the front of the collection.
function addImage( name, src ) {
vm.images.unshift({
id: ++primaryKey,
name: name,
src: src
});
}
}
}
);
// Turn off debugging for m0xie related functionality (quites the console).
MXI_DEBUG = false;
</script>
</body>
</html>
As you can see, in either approach, the link() function takes care of managing the dropzone and extracting the image preview URL. Then, regardless of the preview technique, the link() function lets the controller know about the new image to render as part of the view-model. And, when we run this code, you can see what a local Object URL looks like:
If you watch the video, or you play around with demo, you'll likely see that the Object URL approach is faster than the base64 approach. This difference is more pronounced in some browsers (Firefox) and less pronounced in other browsers (Safari). But, the support is pretty good (IE10+).
This is some pretty cool stuff. And, with decent support. Since the difference in workflow between Object URLs and base64 data URIs is insignificant, I don't see any reason to not try to use Object URLs by default and then fallback to base64 data URIs in slightly older versions of IE. It wouldn't even be a lot of code - throw it behind a promise and you're ready to rock!
Want to use code from this post? Check out the license.
Reader Comments
Nice. Thanks also for drawing my attention to Plupload (www.bennadel.com/blog/2652-using-plupload-to-upload-files-in-angularjs.htm) as I can't imagine how I'd handle that part myself.
Fun to see you componentizing your A1. Migrating it to A2 shouldn't be too hard when the time comes.
@Ward,
Plupload is awesome. I've been using it for years and it's been really great. Not everyone at work loves it; but, we keep using it because anytime someone tries to look at some other library, they find reasons that Plupload has some feature of known quantity that makes it the uploader of choice. One of the things that I love about it is that you can have multiple dropzones that are easy to pipe into the main uploader.
@Ben,
Yea Plupload is helpful, I Just wish Plupload it was managed better and the licensed as MIT so we could add improvements like PUTs to S3 without it taking years. The jumbled monolithic code, the license, and the slow management I think turn off opensource contributions that could make it really great.
@Jonathan,
I also find the code very hard to follow internally. The way it creates a "Runtime" and then connects and has to disconnect each "Runtime Client" with a runtime, is hard to understand, unless you wrote the code (presumably). This also makes debugging memory leaks pretty hard. You have to *destroy all the things*.
But, still, I do love it :D
Thanks for this explanation. While angular probably doesn't mind, this is a big deal for react, as huge base64-encoded strings thrown around while doing virtual dom diffing doesn't work so fast. So there's another significant win, although that's somewhere else :D
@Bastian,
Ah, very cool perspective. Glad to know this has some hidden benefits as well.