Asynchronous JavaScript
Over the last couple of chapters, we’ve created a bunch of examples. They all have something in common: they are all synchronous. This means the JavaScript statements are executed line by line, reading from top to bottom:
const x = 5; // executed first
const y = 100; // executed second
add(x, y); // executed third
While working with three.js, we’ll often load assets such as models, animations, textures, and other media. These files can be stored in many different file formats, and loading them over a slow and unreliable internet connection can take some time, or fail for any number of reasons from whale sharks snacking on undersea cables to a mistyped file name. If we take the obvious approach and run a long task like loading a model in the main thread, our entire page will freeze while we wait for the model to load.
In this chapter, we’ll discuss the various methods JavaScript provides for performing long-running tasks such as loading models without causing your app to grind to a halt while the task is running. Collectively, these methods are referred to as asynchronous programming.
Let’s look at what happens when we try to load a model synchronously.
We’re talking about loading things over the internet here. Connections are often slow and unreliable, and loading a model might take a long time or fail completely.
In the above example, the JavaScript engine will reach loadModel(...)
and then pause until the model has loaded, which might take ten seconds or ten minutes. We’ll have to wait until the model has finished loading before the line add(x, y)
will execute. In practical terms, this means your page will freeze while waiting for the model to load, and while that’s happening, your users will have to sit and wait. Or, more likely, they’ll go and find a page that loads faster.
Clearly, synchronous code is not suitable for loading things over a network (or anywhere else, for that matter).
Whenever we need to load something, whether it’s an image, a video, the response from a form a user has submitted, or a 3D model, we’ll switch to an asynchronous code style. There are three main ways to perform asynchronous operations using JavaScript, and we’ll look at each of them in turn here, from the old-school asynchronous callbacks, to modern Promises, and finally, cutting edge async functions.
In this chapter, we’ll explore callback functions, Promises, and async functions. While doing so, we’ll create imaginary loadModel
functions in each of the three styles, although in place of displaying an actual 3D model we’ll simply log a message to the console.
Loading files is not the only use case for asynchronous code. Whenever you want or need to wait a while before executing some code, you’ll switch to an asynchronous code style. When we load a 3D model, we need to wait a while before executing the code to add that model to the scene. Sometimes, you want to wait a while, for example, before displaying a message to a user, in which case, you can use setTimeout
to create an artificial asynchronous operation.
We’ve set up a few examples in the IDE in each of these three styles. In all of them (except 1-synchronous-callback.js) we have used setTimeout
to simulate a model that takes several seconds to load.
Generating Asynchronous Code with setTimeout
To demonstrate asynchronous techniques, we need to perform an asynchronous operation. However, most asynchronous operations are kind of complicated, like loading a model, or submitting a form and waiting for a response from the server.
Fortunately, there’s a function that allows us to perform a very simple asynchronous operation, called
setTimeout
. This method takes two arguments, a
callback function, and the amount of time we want to wait (in milliseconds) before executing the callback function.
Note that we’ll usually wrap the callback in an anonymous arrow function:
We won’t get into the reasons for this here. It’s all about scope and the “this” problem. In any case, you’ll notice we do this a lot when using callback functions.
One final thing about setTimeout
: it’s not accurate. We have passed in 3000 milliseconds to the method above, but we cannot guarantee that exactly 3000 milliseconds will have passed by the time the callback executes. There are two reasons for this.
- The callback we pass to
setTimeout
gets added to a stack of callbacks that need to be executed. If lots of callbacks pile up on the stack, you’ll need to wait until yours gets executed. That can be a few milliseconds later than the time you specified. - Browsers currently reduce the accuracy of their timers to prevent malicious scripts from using time-based attacks or browser fingerprinting. For security, browsers don’t let us measure sub-millisecond time, and a certain amount of jitter is added to the result (usually around one millisecond).
For these reasons, setTimeout
(along with all JavaScript timer functions) is not accurate enough for things like scheduling animation frames. However, it’s perfect for us to simulate a slow model loading since we don’t care when the callback executes.
The callback we pass into setTimeout
is an asynchronous callback function, the first of the three asynchronous techniques we’ll cover in this chapter.
Asynchronous Callback Functions
A callback function is a function that gets passed into another function as an argument. When the parent function is asynchronous, we refer to the callback as an asynchronous callback function.
In old-school JavaScript, before the release of version ES6 sometime around 2015, the only way to write asynchronous code in JavaScript was to use asynchronous callback functions. These are the simplest way of writing asynchronous code, although they do have some drawbacks which mean that we’ll prefer to use other techniques.
We introduced
callback functions earlier, however, aside from the ones we passed into setTimeout
above, all the callbacks we have written so far are synchronous.
There’s nothing different about the callback we passed into setTimeout
. The only difference between a synchronous callback and an asynchronous callback is the context in which we use it. For example, we introduced callbacks using the
array.forEach
method. We can pass the same callback into .forEach
and setTimeout
. In the first case, the callback is synchronous, and in the second, it is asynchronous.
What’s the difference? Without going into much detail, the synchronous callback function is executed immediately and blocks the main thread. This can cause your application to freeze if it takes a long time to complete. By comparison, the asynchronous callback function is put onto something called a task queue which does not block the main thread. However, the callback must wait for its turn in the queue before being executed.
A Synchronous Callback Operation
Rather than use Array.forEach
, we can create a simple synchronous function that takes a callback.
There’s no waiting involved here, the synchronousCallbackOperation
function executes the onComplete
callback immediately. The important thing to note here is the data passed to the callback by the parent function. Here, it’s the string 'Data passed to callback'
. In a real example, this might be a loaded model or the data returned by the server after the user submits a form.
An Asynchronous Callback Operation
We’ll take the synchronousCallbackOperation
and combine it with setTimeout
to turn it into an asynchronous loadModelUsingCallback
function.
Next, we’ll take this and turn it into a fake loadModelUsingCallback
function. Along with the name change, the function now takes an url
argument, and we are using an arrow function as the callback.
function loadModelUsingCallback(url, callback) {
// Wait a few seconds, then execute the callback
// (simulating a model that loads in 3 seconds)
setTimeout(() => {
callback(`Example 2: Model ${url} loaded successfully`);
}, 3000);
}
loadModelUsingCallback('callback_test.file', (result) => {
console.log(result);
});
Error Handling with Callbacks
What happens if loading the model fails? There are lots of reasons why a model might fail to load. For example, you might have typed the model’s name wrong. Or a whale shark might decide to snack on an undersea cable at that exact moment.
To handle errors like these, we need to add a second callback to our loadModelUsingCallback
function.
Then we’ll have two callbacks: one for success which we’ll call onLoad
, and one for failure which we’ll call onError
.
At this point, using setTimeout
to simulate loading a model falls short since there’s no way for this method to fail, or to take a second callback. But here’s what a loadModel
function with both callbacks would look like:
We have named the callbacks onLoad
and onError
, but you can call them whatever you like.
Note: the three.js loaders also take an onProgress
callback which we have skipped here to keep things simple.
Performing Multiple Asynchronous Operations with Callbacks
When using callbacks, loading multiple models is easy. We simply need to run the loadModelUsingCallback
function multiple times with different url
arguments (and perhaps different callbacks).
To add a bit of spice here, for this example, we’re using
Math.random
to add a bit of chaos to our fake model loading function. Now, every model will load in somewhere between zero and five seconds.
Which model will load first? A, B, C, or D?
function loadModelUsingCallback(url, callback) {
// Wait a few seconds, then execute the callback
// (simulating a model that loads in less than 5 seconds)
setTimeout(() => {
callback(`Example 3: Model ${url} loaded successfully`);
}, Math.random() * 5000);
}
const onLoad = (result) => {
console.log(result);
};
loadModelUsingCallback('callback_test_A.file', onLoad);
loadModelUsingCallback('callback_test_B.file', onLoad);
loadModelUsingCallback('callback_test_C.file', onLoad);
loadModelUsingCallback('callback_test_D.file', onLoad);
The answer, of course, is that we have no idea.
When you load a model asynchronously, you no longer have any idea when, if at all, the model will load. This point holds for any asynchronous technique, not just callbacks. It’s kind of the whole point of asynchronous code.
When we load multiple models, we have no idea which one will load first, or whether they will all load successfully. In this example, we’ve set a random time between zero and five seconds for each callback to complete. In the real world, the models might be different sizes, or even located on different servers in different countries. The server in one country might be down (whale sharks again). A 1kb model will probably load faster than a 100mb model even if we start loading the 100mb model first, but you can never be totally sure about that.
The only safe approach: never make any assumptions about when, or if, a block of asynchronous code will run.
Problems with Callbacks
It’s not obvious from these simple examples, but callbacks can become unpleasant to deal with once your app grows in size.
The onLoad
Callback Ends up Stuffed with Functionality
Here’s the first problem: You cannot easily access the loaded model from outside the callback. Everything that you want to do with the model has to be done inside the callback. That’s fine if you simply want to log some data to the console or add the model to your scene, but in the real world, you’ll probably want to so much more than that.
If you are not careful, onLoad
can end up containing almost your entire app.
It’s Hard for Loaded Models to Interact with Each Other
Next, what if you want two or more models to interact with each other in some way? This is a problem, because the other models can only be accessed (easily) from their callback functions.
Suppose models A and B need to interact with each other inside a setupPhysics
function. Where should we put that when loading the models using callbacks?
You can’t put it in model A’s callback because we don’t know if model B has loaded yet. You can’t put it in model B’s callback because we don’t know if model A has loaded yet.
Of course, there are ways around these problems. You can build a complex system that collates the loaded data, keeps it in a central structure somewhere, then, once everything has finished loading, sets up the rest of your app. Sounds complicated though. While callbacks themselves are simple, using them usually means offloading the complexity to another part of your code.
There are other problems with callbacks besides these. We haven’t even touched on callback hell, a problem so notorious it has a website!
Inversion of Control (IoC)
These issues stem from the fact that callbacks force us to use a programming pattern called
inversion of control, so-called because we have passed control from our loadModel
function into the callback function.
Inversion of control is not inherently a bad thing. The problem is being forced into this design choice when in many cases a different design would be better. Callbacks are relatively easy to understand, and for simple applications this way of writing asynchronous code is fine. However, as your app grows in size being forced to design your code around an IoC pattern becomes more and more stifling.
The Perfect Solution
The “perfect” solution would be a loadModel
function that directly returns the loaded model for us to use.
An asynchronous function like this would afford us complete freedom to design our app however we like.
No inversion of control, loadModel
is simply a normal function that returns a value. This code would shine when multiple models need to interact:
Well, that is how using the function would work. Unfortunately, we have to implement the function to use it, and there we run into trouble. This perfect function is not possible, in general. The three.js TextureLoader
does work this way, since it returns a dummy texture for us to use while the real texture is loading.
import { TextureLoader } from 'three';
const loader = new TextureLoader();
const texture = loader.load('kittens.png');
However, this is a special case. Textures are simple image files, but most things we want to load are too complicated to use this approach. In other words, we know what’s in an image before we load it (colored pixels), but we don’t know what is going to be in most other files so we have to wait until they are loaded before can process them.
In general, we’ll never reach this level of beauty and simplicity while asynchronously loading files, but we can get close.
First, Promises will enable us to get out of the IoC pattern, but we’ll still need to use callbacks. Next, async function will take us the rest of the way. Our code will end up looking almost like the “perfect” solution, except there will be a few async
and await
keywords in the mix. Async functions are built on top of promises but have a much nicer API.
As of r116 three.js ships with the
.loadAsync
method that allows us to use async functions directly.
Before we get to those, we’ll continue our exploration of JavaScript’s asynchronous toolkit with Promises.
Promises
Promises are the second asynchronous technique we’ll explore in this chapter. They were added to JavaScript in version ES6. We can create a promise
(small p) using new Promise
(capital P).
Promises are so-called because when we place a Promise in our code, we are promising we’ll get the result of an asynchronous operation back at some point. The result will be either success or failure.
In this section of this chapter of this book, we’ll cover everything you need to know to get started with promises, and also to follow the examples in the book, but this is not a complete promise reference. In the interest of brevity, and of keeping you interested while we cover all this dry theory, we’ll skip quite a few features of promises.
Promises don’t remove callbacks from our code. On the contrary, using promises requires a whole bunch of callbacks. It’s also fair to say that promises are harder to understand than asynchronous callbacks, so you may find yourself wondering what the big deal is. After all, if the code we write is harder to understand and still uses callbacks, we haven’t solved anything, right?
It’s hard to get across just why promises are such an improvement using simple examples like the ones in this chapter. However, once you start using them, their value will become apparent. Also, the biggest advantage of Promises is that they enable us to use async functions, the holy grail of asynchronous JavaScript techniques.
Pending, Fulfilled, Rejected, Settled
Promises are always in one of three states:
- Pending: When we create a
new Promise
(or get a promise back from an API like fetch), it is in pending state, and it will remain there until the asynchronous operation has succeeded or failed. - Fulfilled: If the asynchronous operation completes successfully, the promise will move into fulfilled state.
- Rejected: If the asynchronous operation fails, the promise will move into rejected state.
Another possibility is that the asynchronous operation never completes, in which case the promise will remain in pending state forever, or at least until you refresh the page. In other words, promises don’t have a time limit on how long the operation can take.
There’s a fourth state as well, called settled. This means either fulfilled or rejected, and we can check for settled state when we want to know if the asynchronous operation has completed and we don’t care if it was successful or not.
Promise Based APIs
Usually, you don’t need to create promises yourself, or in other words, you’ll rarely need to type new Promise
. Instead, you’ll use promise-based APIs that create promises for you.
For example, here’s how we can use the Fetch API to load a file from a web server:
fetch
returns a promise instance which we have called filePromise
. Later, if loading the file succeeds, the promise will return any data contained in the file for us to process, and if loading fails, the promise will return an error object with details about the cause of the error.
For more information on how to use the Fetch API, check out the using Fetch page on MDN.
In this section, we’ll look at how to use a generic promise created using new Promise
, but the theory we cover here applies to any promise-based API.
Using Promises
Here’s a complete example of a promise in action:
There’s a lot to unpack here. There are three named callbacks: executorCallback
, resolve
, and reject
, and then there’s .then
and .catch
, each of which takes an anonymous callback of their own. That’s five callbacks! Let’s go over everything now, and hopefully, it will become more manageable.
Note: the above promise will immediately execute resolve('Promise succeeded')
and will never reach reject('Promise failed')
. We’ve included both callbacks for illustration purposes.
Second note: for many people, it takes a while to get a deep understanding of promises. However, using promises is much easier than understanding promises, so if you find yourself struggling with all the callbacks, focus on using promises for now. A deeper understanding will come later.
The Executor Callback
The first callback we encounter when using promises is the executor callback.
You’ll never see executorCallback
explicitly typed out (except in a book). Instead, we’ll write the executor callback inline.
The resolve
and reject
Callbacks
The executor callback itself takes two callbacks, called resolve
and reject
. If the promise succeeds, it will call resolve
, and if it fails it will call reject
. We don’t need to write the resolve
and reject
callbacks ourselves, we simply pass them into the executor callback as arguments.
In other words, you will never do this:
Getting Data Back from a Promise: resolve
If the asynchronous operation succeeds, we use resolve
to get any data from the operation out of the promise. For example, if we are loading a 3D model, then we’ll call resolve(loadedModelData)
. In this chapter, we’ll return the string 'Promise succeeded'
in place of real data.
Return a Useful Error Message on Failure: reject
If the asynchronous operation fails, we use reject
to get information about why it failed.
Once again, we are using a string 'Promise failed'
as a placeholder, but in a real application, you’ll probably get back an object with lots of info. For example, when using Fetch to load files from a web server, errors have codes like 404 (file not found), and 403 (access forbidden). You can use this data to create a helpful message for your users or otherwise handle the error.
Handling a Successful Operation: Promise.then
If the asynchronous operation succeeds, the promise’s state will move from pending to fulfilled, and the resolve
callback will be executed, sending any data into the
Promise.then
callback.
In this example, that means resolve('Promise succeeded')
will execute and .then
will receive the string 'Promise succeeded'
. Here, we simply log that to the console. In a real app, we might add a loaded model to the scene, or do something with a file returned by Fetch
.
The .then
callback is equivalent to the onLoad
callback from our earlier
asynchronous callback example.
Error Handling with Promise.catch
If the asynchronous operation fails, the promise’s state will move from pending to rejected, and the reject
callback will be executed, sending any data into the
Promise.catch
callback.
The .catch
callback is equivalent to our
onError
callback from earlier.
We can test .catch
by making the promise fail immediately. To do that, comment out resolve
in the above example.
Error Handling with .then
Rather than use .catch
, we can pass both callbacks into .then
:
However, using .catch
results in cleaner code and we’ll always prefer to use that rather than passing two callbacks into .then
.
Code that Needs to Run on Success or Failure: Promise.finally
If .then
handles success, and .catch
handles failure, what about code that needs to run in either case? For this case, there’s a third method called
Promise.finally
:
.finally
will run when the promise reaches a settled state, meaning either resolved or rejected.
Promises are Always Asynchronous
The promise example we have created is nearly equivalent to the synchronous callback operation from earlier in the chapter.
… is nearly equivalent to:
However, there are differences. Promises are always asynchronous, so the above code is closer (but still has important differences) to this:
If you test these two example, promise
and asynchronousCallbackOperation
, you’ll find that setTimeout
with a time of zero executes after the promise resolves, even if we call setTimeout
first. What’s going on?
setTimeout
with a time of zero schedules the callback to be executed immediately. This means the callback gets pushed onto the task queue. There may be other tasks already on the queue, so the callback has to wait for its turn to be executed.
When we create a promise and then call resolve
immediately, resolve
is pushed onto a different queue called the
microtask queue. The microtask queue has a higher priority than the standard task queue, so the promise will resolve faster than the setTimeout
callback.
If you want to go deeper into this, see
concurrency model and the event loop on MDN, as well as
this Stackoverflow post on setTimeout(fn,0)
and
this Stackoverflow post on Promise vs setTimeout
.
Loading a File with the Fetch API Using Promises
As a practical example, let’s take a look at how to load a JSON file using the Fetch API.
There’s usually no need to save the promise to a variable, so we can write this even more succinctly:
As you can see, using promises results in clear and simple code. We’ll spare you the horror of the equivalent using XMLHttpRequest
!
Implementing loadModel
Using Promises
Recall our callback-based model loading function from earlier in the chapter:
function loadModelUsingCallback(url, callback) {
// Wait a few seconds, then execute the callback
// (simulating a model that loads in 3 seconds)
setTimeout(() => {
callback(`Example 2: Model ${url} loaded successfully`);
}, 3000);
}
Let’s rewrite this using Promises.
function loadModelUsingPromise(url) {
return new Promise((resolve, reject) => {
// Wait a few seconds, then resolve the promise
// (simulating a model that loads in 4 seconds)
setTimeout(() => {
resolve(`Example 4: Model ${url} loaded successfully`);
}, 4000);
});
}
It’s quite similar. We’re still using setTimeout
to simulate loading a model (this time, one that loads in 4000 milliseconds). The important difference is that the loadModelUsingPromise
function returns a Promise. Let’s see the two versions in action. First, we’ll load a (fake) URL using loadModelUsingCallback
:
loadModelUsingCallback('callback_test.file', (result) => {
console.log(result);
});
Next, we’ll do the same with loadModelUsingPromise
:
loadModelUsingPromise('promises_test.file')
.then((result) => {
console.log(result);
})
.catch((error) => {
console.log(error);
});
This latter example is a bit longer since it includes the .catch
method to handle errors.
Loading Multiple Files with Promises, a First Attempt
Looking at those two examples, there’s no obvious benefit to the version using a promise. Earlier, we claimed that promises shine when it comes to loading multiple models, so let’s try doing that.
Once again, let’s start with our multiple callbacks example from earlier:
const onLoad = (result) => {
console.log(result);
};
loadModelUsingCallback('callback_test_A.file', onLoad);
loadModelUsingCallback('callback_test_B.file', onLoad);
loadModelUsingCallback('callback_test_C.file', onLoad);
loadModelUsingCallback('callback_test_D.file', onLoad);
Next, let’s try the obvious approach to loading model with loadModelUsingPromise
.
One of the major problems with callbacks is that it’s hard for the loaded models to interact with each other. Earlier, we claimed that promises would help with this. Here’s the setupPhysics
method we struggled with earlier:
It doesn’t seem like we have improved anything here. There’s still nowhere for us to put the setupPhysics
function and give it access to all the loaded models. Each model is still being handled in a separate callback, so it doesn’t look like we have solved anything. There is no obvious advantage at all here, we’ve simply renamed onLoad
to onResolve
.
Fortunately, promises give us more options when it comes to handling asynchronously loading files. Let’s try out one called Promise.all
.
Loading Multiple Files with Promise.all
Promise.all allows us to handle multiple loading operations gracefully. Using this method, we even get the results of our operations back in the same order we started them (deterministic ordering), which is kind of a big deal when it comes to asynchronous operations.
Promise.all
takes an array of promises and returns a single promise that will resolve when all the promises are resolved, or reject when one or more of the promises are rejected.
The allResults
argument is an array containing all of the loaded models, so we can process them all at once in a single callback.
We can use the fact that allResults
returns the results in the same order as we loaded them, along with
destructuring assignment, to write very succinct code here.
Promise.all([
loadModelUsingPromise('promise_A.file'),
loadModelUsingPromise('promise_B.file'),
loadModelUsingPromise('promise_C.file'),
loadModelUsingPromise('promise_D.file'),
]).then((results) => {
const [modelA, modelB, modelC, modelD] = results;
console.log(modelA);
console.log(modelB);
console.log(modelC);
console.log(modelD);
});
Finally, we have reached the point where all of the loaded models are in one place, and we have somewhere to put the setupPhysics
method:
Error Handling with Promise.all
No asynchronous operation is complete unless it can handle errors, so let’s make one of our promises fail. We’ll add a second fake model loading function, but this time have it immediately reject.
function loadModelUsingPromise(url) {
return new Promise((resolve, reject) => {
// Wait a few seconds, then execute the callback
// (simulating a model that loads in less than 5 seconds)
setTimeout(() => {
resolve(`Example 6: Model ${url} loaded successfully`);
}, Math.random() * 5000);
});
}
function loadModelUsingPromiseFAIL(url) {
return new Promise((resolve, reject) => {
reject(`Example 6: MODEL ${url} FAILED TO LOAD!`);
});
}
Promise.all([
loadModelUsingPromise('promise_A.file'),
loadModelUsingPromise('promise_B.file'),
loadModelUsingPromiseFAIL('promise_C.file'),
loadModelUsingPromise('promise_D.file'),
])
.then((results) => {
const [modelA, modelB, modelC, modelD] = results;
console.log(modelA);
console.log(modelB);
console.log(modelC);
console.log(modelD);
})
.catch((error) => {
console.error(error);
});
Now model C will immediately reject. Just like when loading a single model, we can use .catch
to handle the error.
Note that Promise.all
will reject if one or more of the promises rejects. In other words, if even one model fails to load none of the models will be returned.
We could use
Promise.allSettled
to get data for successful models even when some fail to load. At the time of writing this chapter (July 2020), browser support for .allSettled
is not great, so we will avoid using it in this book. Here, to keep things simple, we’ll accept this limitation and continue to use Promise.all
. After all, if any of your models fail to load it usually means there’s a problem that needs to be fixed.
Async Functions
Earlier we tried to imagine
how the best possible version of a loadModel
function would work, and came up with this:
This is the “perfect” solution to the problem of loading a model or other data over a slow network such as the internet, but as we noted earlier, aside from some special cases, such a loadModel
function is impossible to implement.
However, async functions get us very close. These are also the newest way of performing asynchronous operations, having been added to JavaScript only recently. Async functions are based on promises, so it will help if you have a basic understanding of those before you start to use async functions.
Loader.loadAsync
Until recently, using async functions in three.js was difficult. Fortunately, as of r116 there is a new
Loader.loadAsync
method that allows us to use them immediately.
The await
Keyword
Async functions introduce two new keywords: async
, which we’ll explain in a moment, and
await
, which we use to tell our program to wait for the result of an asynchronous operation.
Using await
, loading a model becomes as simple as this:
Not a callback in sight!
When an await
is encountered, the JavaScript engine will stop executing the current function until the asynchronous operation has completed. However, the rest of your application will continue to run as normal.
The async
Keyword
To use await
, we need to mark the containing function as async
. await
can only be used inside an async
function. Attempting to use it elsewhere will result in an error. Here, that means we need to create a new function to handle loading the model.
In a real app (a well designed one, at least), you would already have a special function or class method for this purpose, so this shouldn’t disrupt the design of your code too badly.
Implementing loadModel
with Async Functions
As we mentioned earlier, async functions are implemented using
Promises. This means a loadModelAsync
function looks exactly like the
loadModelUsingPromise
function we created earlier.
function loadModelUsingPromise(url) {
return new Promise((resolve, reject) => {
// Wait a few seconds, then execute the callback
// (simulating a model that loads in 5 seconds)
setTimeout(() => {
resolve(`Example 7: Model ${url} loaded successfully`);
}, 5000);
});
}
However, now we’ll use it like this:
… AND THAT’S IT!!!
Sorry for shouting, it’s just such a relief after dealing with all the callbacks from the last few sections. No callbacks!
Loading a File using Fetch, async
/await
Version
OK, there are still some callbacks in the loadModelUsingPromise
function. However, normally you wouldn’t write that function yourself. It’s more common to use a promise based loader that someone else wrote such as the three.js loaders or the Fetch API.
As a real world example, see how easy it is to load a file using the Fetch API and async
/await
:
Now that’s some succinct, beautiful code.
If you are familiar with Fetch, at this point you may be saying “yes, but you also need to decode the file before you can read it, which is a second asynchronous operation”. Fair point. Here’s how to load and decode a JSON file using async functions:
Not one, but two asynchronous operations in a single line of code, and it’s still (fairly) readable. If you’ve spent years working with JavaScript callbacks, this will feel like magic.
Error Handling with Async Functions and .catch
To test error handling, once again,
we’ll make loadModelUsingPromise
fail:
Currently, our code does nothing to handle errors, so when we load the model as before:
… we’ll get an ugly red error message in the console:
There are a few methods we could use to handle errors with async
/await
. For example, we could use a
try...catch
statement. In the interest of keeping this chapter short and
since we covered these already, we’ll use .catch
.
.catch
works a little differently with async functions than with Promises. Previously, we attached .catch
directly to the promise. Here, we’ll attach .catch
to the asynchronous function:
By the way, if you’re thinking this must mean you can use
.then
and
.finally
with async functions, you would be right! .then
will be passed the return value (if any) from the async function, while .finally
runs after all operations have completed, as before.
Be Careful Where You Place the await
Call
The power of async functions lie in the await
keyword, and the fact that promises are objects which we can pass around.
This means that we can start an asynchronous operation early and store the promise.
This is a slightly contrived example since it’s unlikely setting up the scene, camera, and renderer will take long enough for this to make any difference. However, the power of await
will start to shine once you are dealing with a large application with many asynchronous components.
The point being made here is that async functions give us full control over the asynchronous sections of our code. Some of this is simply the result of cleaner and more readable code, but async functions also allow us to structure our code in a way that would simply not be possible with callbacks and Promises
alone.
Loading Multiple Files with Async Functions, First Attempt
In this section, we’re using
console.time
and console.timeEnd
, which time how long code in between those two statements takes to run.
Loading a single model is easy, but what happens when we try to load several at once?
Once again, we’ll use the loadModelUsingPromise
, this time set to resolve in exactly five seconds.
function loadModelUsingPromise(url) {
return new Promise((resolve, reject) => {
// Wait a few seconds, then execute the callback
// (simulating a model that loads in 5 seconds)
setTimeout(() => {
resolve(`Example 7: Model ${url} loaded successfully`);
}, 5000);
});
}
Before we proceed, a quick math quiz: if we load four models, and each model takes five seconds to load, how long will it take to load all four models?
The obvious (but wrong) answer is twenty seconds. However, the real answer is around five seconds. Asynchronous operations don’t happen one by one (sequentially), they happen at the same time (in parallel).
That’s the theory at least. The real world being it’s usual messy self, you may have to deal with busy networks and CPU cores, so the final answer is somewhere between five and twenty seconds. Unless something is wrong though, it should be closer to five than twenty.
Here, we’re using setTimeout
to simulate loading a model in exactly five seconds, so we should get a perfect result of five seconds (to within a couple of milliseconds).
Let’s try it out. Here’s our first attempt, which looks similar to our first attempt to load multiple models with promises from earlier in this chapter:
However, when you check the console you’ll see:
Twenty seconds. Clearly, we’re doing something wrong.
Execution of the
main
function pauses at eachawait
statement until the current asynchronous operation has completed.
Using this approach, we start to load model $A$, wait for five seconds until it has loaded, then move onto model $B$, wait for five seconds, and so on.
This highlights an important difference between Promises and async/await. Our first attempt to load multiple models with Promises from earlier doesn’t suffer from this problem. There, the issue was difficulty in accessing all of the models at once, but otherwise, it was an OK approach. Here, we are flat out wrong. Never use multiple await
statements like this.
In any case, once again, the solution is Promise.all.
Loading Multiple Files with Async Functions using Promise.all
The problem above is that we have four await
statement. Always use a single await statement per async function, unless a subsequent operation relies on the result of an earlier one (see
the Fetch example above where we fetched and decoded a JSON file for an example of this).
With Promise.all
, we can bundle all of the loading operations into a single promise,
just as we did earlier, and then use a single await
to wait for all four promises to complete (
settle):
async function main() {
console.time('Total loading time: ');
const [modelA, modelB, modelC, modelD] = await Promise.all([
loadModelUsingPromise('async_A.file'),
loadModelUsingPromise('async_B.file'),
loadModelUsingPromise('async_C.file'),
loadModelUsingPromise('async_D.file'),
]);
console.timeEnd('Total loading time: ');
console.log(modelA);
console.log(modelB);
console.log(modelC);
console.log(modelD);
}
main().catch((err) => {
console.log(err);
});
This time, if you check the console you’ll see something like:
Everything we said earlier about Promise.all
holds here, with the same caveat that if one model fails, they all fail. The only difference is that we have replaced .then
with an await
statement and moved .catch
onto main
.
Async Functions and the three.js Loaders
As of three.js r116 (May 2020), there is now a
.loadAsync
method available on all three.js loaders which allow us to use async
/await
directly.
This section originally documented the process required to convert the three.js old-school callback-based loaders to modern promise-based loaders. It was nasty.
Thankfully, that’s all behind us now and we live in the glorious, async future. Here, we’ll use the
GLTFLoader
to demonstrate loading a model with .loadAsync
, however, this applies to any three.js loader.
That’s all, three lines of code. Of course, you do need an async
function to wrap it in, so let’s use our main
function once again.
To see the GLTFLoader
in action, check out the chapter on
loading models.
This concludes our whirlwind tour of modern JavaScript. Armed with this knowledge, you can now safely tackle the rest of this book and start to create beautiful creations using WebGL, three.js, and JavaScript.