I wrote this article to record a lecture on event loops that I had seen on YouTube, which dissected event loops, polling mechanisms, RAF and microtasks, in the form of a tutorial, which also included some of my insights into event loops.

1. The event loop

1.1 concept

Before we look at event polling, we need to look at the event loop mechanism in JS. Let’s look at the following code:

document.body.appendChild(el)
el.style.display = 'none'
Copy the code

We want to add an element to the page that should be hidden at the beginning of the page. But if you feel bad looking, you don’t know if the user will see the flash before the element is hidden.

So in many cases you’ll change it to something like this:

el.style.display = 'none'
document.body.appendChild(el)
Copy the code

It looks so much better, doesn’t it?

In fact, the two methods are the same. There is no competition between them. There are defined and defined periods of time in the browser when JS runs and pages are rendered, and these things are executed in the same thread.

There is a main thread in the web page, where a lot of events happen, where JS runs, where the web page starts rendering, and where the DOM resides. This means that most of the activity on the page is in a deterministic order.

However, this also means that if the task on the main thread takes a long time, such as 200ms, which is a long time in terms of the user interaction experience, the user will notice the page loading, rendering, and interaction.

Therefore, other threads are needed in the web page to handle things like network requests, encoding and decoding, encryption, and monitoring input devices. Once these threads have actions that require a page response, they need to notify the main thread to handle those responses, and the event ring is responsible for coordinating all of those response activities.

1.2 deep

Now, if you don’t know the event loop mechanism, take setTimeout() for example, have you ever wondered how this function works? Now let’s write a new standard for it:

setTimeoutMethod, run the following steps at invocation time:

  1. Wait for specified time
  2. Trigger the callback function

However, the setTimeout method and its callback function are run on the same thread. The JS code running on the main thread essentially requires the main thread to wait a specified time to continue running, which will prevent other activities on the main thread.

So, we changed it to run both steps simultaneously:

setTimeoutMethod, run the following steps at invocation time:

  1. Run the following steps
    1. Wait for specified time
    2. Trigger the callback function

In other words, we let the task leave the current thread and run it at the same time.

However, there is a new problem. We now trigger the callback outside the main thread. The result is a lot of JS code running in parallel, compiling the same DOM, and then having a competing relationship, which is obviously not feasible either.

So, how can we not delay the execution of subsequent tasks in the current thread, but also not open another thread to operate on the same object?

All we need to do is create a task to add to the task queue so that at some point we can return to the main thread to continue execution.

setTimeoutMethod, run the following steps at invocation time:

  1. Run the following steps
    1. Wait for specified time
    2. Create a task to run the following steps and add the task to the current task queue
      1. Trigger the callback function

This way, we can call the code in the callback function in the same thread as the current code (start a new thread to wait, and then add it to the current thread), which is exactly what setTimeout used to do.

Thus, all of our responses on the page, such as clicking on DOM elements, requesting response data, and sending messages from the page, can be done by adding new tasks to the task queue. The task loop focuses first on the task queue, which is the oldest part of the task loop.

Note: The task loop and task queue are the event loop and event queue.

2. Polling mechanism

2.1 Simple Execution

For now, just for ordinary tasks, the event loop is idle when no task needs to be executed, and whenever a task is added, it is added to the task queue for later execution.

Now we add two callback functions to the task queue using setTimeout:

setTimeout(callback1, 1000)
setTimeout(callback2, 1000)
Copy the code

When we add a task to the task queue, the event loop will leave the idle state, enter the execution phase of the event, execute the first callback function, and then come back around to execute the second callback function

As shown above, this is the task queue that performs ordinary events

2.2 Rendering Stage

This makes sense if you just consider ordinary event ring polling. However, things get complicated when we consider browsers rendering web pages.

As we know, browsers update the display of web pages by rendering. The rendering step involves three stages:

  • Style (S) : Responsible for style calculation, collecting all CSS, computer styles applied to elements
  • Layout (L) : Create a render tree that finds all the content on the page and the location of the elements
  • Draw (P) : Create the actual pixel data and draw the content onto the page

Now, the render phase branches out of the event loop, and when rendering is needed, the browser lets the event loop do the rendering the next time, as shown above.

2.3 Conflict

2.3.1 Synchronization Loop

We create a button on the page that executes an infinite loop when we click the button:

button.addEventListener('click'.e= > {
    while (true) {
        // do nothing}})Copy the code

When this button is clicked, we will notice that the page stops momentarily. That’s right, no matter what you do, it doesn’t work. Animated giFs don’t respond and text can’t be selected, as if all the action is blocked.For the event loop, it looks like this: when a button is clicked, a task is added to the task queue, and the event loop polls to execute the task, but the event never ends, so the event loop stays there to execute the newly added task.

The reason why there is no GIF is because it is an image that is not moving. Yes, this is because the event loop is blocked by an infinite loop, and nothing will continue to run until the blockage is resolved.

When the page needs to update the render again, such as updating the dynamic effects of a GIF, the event ring is notified, and the event ring opens the render task entry, and the user tries to select the text, which involves getting the click, which involves viewing the DOM text, and which adds a new task to the task queue.

At this point, however, because the event ring is doing an infinite loop of events, neither the tasks requested by the browser nor the new tasks we add to the event ring will take effect. So there is the effect that we see when the page stops.

Ok, let’s look at the original code again:

document.body.appendChild(el)
el.style.display = 'none'
Copy the code

We always think that this element will flash, but it doesn’t actually happen, because the code is executed in a task, and the browser doesn’t render until the task is finished, and the event loop ensures that the task is completed before the next page rendering.

2.3.2 Asynchronous loops

Since using something like while(true) prevents the loop, why should we use this one?

function loop() {
    setTimeout(loop, 0)
}
loop()
Copy the code

This is an infinite loop of code, isn’t it? However, when we clicked the button again, everything was fine. We didn’t feel any changes on the page, and the GIF responded and selected text.

This code doesn’t work, right? Obviously not. On the event loop you actually do the same thing over and over again, but this time it’s different. We add a task to the task queue, execute it around the event loop, and then add another task to the task queue, and wait for the event loop to come back and execute the added task again, and so on.

As we can see, only one task can be performed at a time, and when it processes one task, it must loop around the event to receive the next task. This means that at some point when the browser tells the event loop to update the GIF, it can go around the render side to do the rendering, so the infinite loop done by setTimeout won’t stop the rendering.

Note: There is also a Promise implementation loop for loops, which is not covered here in microtasks.

3.RAF

3.1 concept

If we want to execute code related to rendering, do not put it in the JS task queue, because the task is on the other side of rendering, we need to put the code responsible for rendering before the rendering stage.In a browser, userequestAnimationFrameMethod, where functions (RAF callbacks) occur as part of the rendering step.

Now we use code to move a box forward one pixel at a time, and then use requestAnimationFrame to create a loop:

function callback() {
    move()
    requestAnimationFrame(callback)
}
callback()
Copy the code

This code runs as expected, moving forward one pixel at a time, at the browser’s render rate, because this function is called when the browser is in the render phase and is executed according to the browser’s render rate.

So what if we change requestAnimationFrame to setTimeout?

function callback() {
    move()
    setTimeout(callback, 0)
}
callback()
Copy the code

If you test it, the box below is faster, and about 3.5 times faster, which means the move() callback is called more often. That’s not a good thing, because it’s probably not what you’d expect the user to see the box move one pixel at a time, right?

Previously we knew that rendering might be performed between tasks, but this does not mean that rendering happens immediately when a new task is added to the task queue. The event loop closes the entry point for rendering events and renders only at fixed intervals (depending on the browser’s rendering frequency). The render phase then takes over, perhaps opening the render phase entry and updating the page after several events are executed.

It is up to the browser to decide when to render and to render as efficiently as possible, only if the values have been updated and not if they have not changed. If the browser runs in the background and doesn’t display, the browser doesn’t render because it doesn’t make any sense.

In most cases, the page updates at a fixed rate of 60 times per second, depending on the screen, but generally 60 times per second. If we change the page style 1000 times per second, instead of rendering 1000 times, the browser will synchronize with the display and only render as often as the display can reach, otherwise it’s a waste of time because you’re rendering something extra that the user can’t see.

As we can see, the setTimeout in the previous example is an unnecessary style change. The box moves faster because it is called more times, and the user sees more than the browser can display, so the browser moves not just one pixel, but multiple pixels each time it renders. However, the setTimeout in our example does not actually set the delay time to 0. Even if we set the delay time to 0, the browser will choose any number as the delay time. The delay time is usually 4.7ms, which means that it actually looks like this:

function callback() {
    move()
    setTimeout(callback, 4.7)
}
callback()
Copy the code

If we compress the delay again, it looks more like the box is teleporting, because there are so many calls that it looks like the box is in a random location.

As we said, rendering can happen between tasks, and tens of thousands of tasks can be performed between renders.

3.2 animation frames

We call the phase between the browser’s rendering a frame, and the browser’s rendering takes place at the beginning of each frame, including style calculation, layout, and drawing, depending on what actually needs to be updated, but that’s not the point. The focus is on tasks, which can appear anywhere, in no order in terms of the time period within the frame.

Again, in the example above, we passsetTimeoutThree or four tasks are set for each frame. For the previous example, this means that three quarters of the tasks are unnecessary because they are not rendered by the browser at all.

Maybe you’ve thought about doing this before:

function callback() {
    move()
    setTimeout(callback, 1000 / 60)
}
callback()
Copy the code

Wouldn’t it be enough to use a millisecond value for a function that executes 60 times a second, you think. However, this is where you have to make sure that the user’s current screen is a 60 Hz screen and that it is sufficiently accurate. More often than not, it’s a necessity becausesetTimeoutFunctions are not designed to animate, and this would cause drift due to imprecision,It is possible to display no task execution in one frame and two tasks in the next.

This is not good for the user experience. At the same time,If a certain time takes too long, the browser will also delay rendering because they’re all running on the same thread,This breaks established procedure.

If we userequestAnimationFrameSince it is executed during the render phase, it looks more like this:

Even if some render takes too long, the result will be rendered in one frame and everything will fall into place.

Of course, it is impossible to avoid tasks that appear on the other side of the rendering phase, such as clicking events that are passed through the task, and we usually expect the browser to complete the task as soon as possible. But if there is something like a timer or a response from the network, using requestAnimationFrame to wrap up the animation work is definitely a good option. Especially if you already have an animation running, as this saves a lot of rework.

3.3 Execution Period

One more detail, requestAnimationFrame’s callback is run before the CSS is processed in the render phase and before the draw operation, so whatever we do in it, the browser will only render the final result. Something like this:

button.addEventListener('click'.(e) = >{
    box.style.display = 'none'
    box.style.display = 'block'
    box.style.display = 'none'
    box.style.display = 'block'
    box.style.display = 'none'
    box.style.display = 'block'
    box.style.display = 'none'
    box.style.display = 'block'
    box.style.display = 'none'
})
Copy the code

It looks like we’re hiding and showing a box multiple times, which should be a lot of overhead, but the browser doesn’t do anything about it yet, because during the task phase, the browser doesn’t take into account CSS changes until the actual rendering is done.

Now, we have another box, and we want to move its position from 0 to 1000px to 500px.

button.addEventListener('click'.(e) = >{
    box.style.display = 'translateX(1000px)'
    box.style.display = 'transform 1s ease-in-out'
    box.style.display = 'translateX(500px)'
})
Copy the code

The code above will immediately move to 500px for the same reason. Now we put the second part of the animation in the requestAnimationFrame:

button.addEventListener('click'.(e) = >{
    box.style.display = 'translateX(1000px)'
    box.style.display = 'transform 1s ease-in-out'
    requestAnimationFrame(() = > {
        box.style.display = 'translateX(500px)'})})Copy the code

But! It will still go directly from 0 to 500px. Why is that? Let’s break it down:

  • First, a task is added to the task queue when the button is clicked, all events are executed in the task, and the last step is to add an animation frame in the render phase

  • Then, the event loop reaches the render phase and begins the animation frame task, where we set the end of the move

  • The end point of the later Settings (rAF) directly overwrites the previous Settings, and the browser does not take into account the changes in the middle styles until it enters the S-zone

That’s it, the final position changes were made before the CSS was actually computed, so we didn’t end up with the desired result.

So, how do we make this animation change the way we want it to? To render this animation, we need to use two RequestAnimationFrames, like this:

button.addEventListener('click'.(e) = >{
    box.style.display = 'translateX(1000px)'
    box.style.display = 'transform 1s ease-in-out'
    requestAnimationFrame(() = > {
        requestAnimationFrame(() = > {
            box.style.display = 'translateX(500px)'})})})Copy the code

This way, we loop to the next render phase to perform the second change, and we can see the dynamic effects.

An alternative, by the way, is to use a method like getComputedStyle that requires only one of the attributes to be accessed, which forces the browser to evaluate the style earlier and makes it record everything that was set up before. However, you need to be careful when using this method, because doing so can cause the browser to do extra work in a single frame and can ruin what we really want.

However, it should be said that neither of the above is the final solution. The final solution is to use the Web Animation API, which makes it easy to specify the desired actions, but it is currently only supported by Chrome, so we will not mention it. Also, in Edge (older versions) and Safari, requestAnimationFrame may not be executed right now before rendering CSS, which means that it is difficult to batch update pages, users may delay seeing the page until the next frame, and the screen displays with a significant delay. Note that these are not web compliant and we expect them to change later.

4. Macro versus micro tasks

We already know about the event loop, the task queue, and what happens when the event loop polls the queue. Now, let’s dig a little deeper, split the task queue, and look at the specific distribution in the task queue.

First, we need to make it clear that JS is single-threaded, and the event loop is the only event loop that this thread has. In a thread, the event loop is unique, but the task queue can have multiple, task queue subdivision is mainly divided into macro-task queue and micro-task queue (RAF callback queue can be regarded as a special queue, subdivision should belong to macro task queue. In the latest standard, they are called Task and Jobs, respectively

The main module of a macro task

  • setTimeout
  • setInterval
  • I/O
  • The script code block

The main module of microtasks

  • nextTick
  • callback
  • Promise
  • process.nextTick
  • Object.observe
  • MutationObserver

You may not know that there are microtasks in the task queue, which is probably the least known part of the event loop. Most of us now just associate it with promises, but that’s not what microtasks were originally created for.

Is at the very beginning, we hope that when the browser to add a lot of events, like the RAF, we want the browser to temporarily don’t do any processing, but wait until a suitable time to produce an event or other things to represent all of the changes, the solution for the use of an observer, the observer will create a new task queue, This queue is called the microtask queue.

There is a specific place in the event loop where microtasks are handled, and this particular place is dynamic and can appear at any time. It may be executed after JS, or it may be executed as part of RAF during rendering. Microtasks can be performed anywhere JS is running, so to speak.

And why did Promise use microtasks? When JS execution ends, the Promise callback function is executed. This means that when the Promise callback executes, you can be sure that there are no JS executions in between, and that the Promise callback is at the bottom of the stack, which is why Promise uses microtasks.

4.1 Handling Mechanism

Now we create another infinite loop, this time using a Promise, just like setTimeout did before:

function loop() {
    Promise.resolve().then(loop)
}
loop()
Copy the code

The effect may not be what you think, but this time the GIF will not respond and the user will not be able to select text, just like using a synchronous loop.

The point to make here is that promises will generate an asynchronous function, but there are different kinds of asynchronous functions that can be added to macro tasks and microtasks depending on the type. All asynchronous does not mean that it has to yield to any particular part of the render event loop.

So far, we have mentioned three different queues: task queues (macro task queues), RAF callback queues, and micro-task queues. The three queues are handled slightly differently:

  • Task queue: Only one task is executed at a time. If another task is added, it is added to the end of the queue.
  • RAF callback queue: All tasks in the callback queue are executed until the queue is complete, and if there are animation callbacks inside the animation callback, they are executed in the next frame.
  • Microtask queue: Again, the execution continues until the queue is empty, but if a new microtask is added during the processing of the microtask and is added faster than the execution speed, the microtask will be executed forever.

Given the microtask-processing nature above, we can see why using Promise for an infinite loop can cause event loops to clog and prevent rendering.

4.2 Execution Sequence

The order of the event loop determines the execution order of the JS code.A block of code is a macro task.After entering the overall code (macro task), the first loop begins. Then perform all the microtasks. Then start from the macro task again, find one of the task queues to complete, and then execute all the microtasks. The diagram looks like this:

Take a look at the following code:

button.addEventListener('click'.() = > {
    Promise.resolve().then(() = > console.log('Microtask 1'))
    console.log('Listener 1')
})

button.addEventListener('click'.() = > {
    Promise.resolve().then(() = > console.log('Microtask 2'))
    console.log('Listener 2')})Copy the code

We bind two click events to the same button. When the user clicks the button, the console prints four outputs. What is the exact sequence?

If you take a closer look at the previous macro task definition, you’ll get the answer pretty quickly. The printing sequence on the console is Listener 1, Microtask 1, Listener 2, and Microtask 2.

Now to explain: first, our first click-listener executes, the JS stack is cleared after printing the first output, and now it’s time for the Microtask execution. We’ll execute the Promise processing, outputting Microtask 1, and then the second listener executes.

Now, instead of using listeners to listen for user clicks, we’re using JS code to make this button click, and wonder if the result will change?

button.addEventListener('click'.() = > {
    Promise.resolve().then(() = > console.log('Microtask 1'))
    console.log('Listener 1')
})

button.addEventListener('click'.() = > {
    Promise.resolve().then(() = > console.log('Microtask 2'))
    console.log('Listener 2')
})

button.click()
Copy the code

Listener 1, Listener 2, Microtask 1, and Microtask 2 are displayed. Why are the results of the two click events inconsistent? In fact, it is still in the execution scope of macro tasks and micro tasks.

First, schedule the event, first allowing Listener 1, and then queue up a microtask. By the definition of macro tasks, should we now perform microtasks? Not yet, unlike the user click, our JS stack is not empty now because we are still executing the click() method, so we will continue to perform another Listener, print Listener 2, and queue up a microtask, that’s the difference. After the second click listener, the JS stack request, is now the time to perform the microtask.

To reinforce the understanding:

const nextClick = new Promise(resolve= > {
    link.addEventListener('click', resolve, { once:true })
})

nextClick.then(e= > {
    e.preventDefault()
    // Handle event
})
Copy the code

We have a Promise that represents the next click on a particular link, we can use that Promise and call e.preventDefault(), then will we miss a method that blocks the default event for this click? According to our previous understanding, when we click the link, the JS stack is already empty, then clicking the link will trigger the default blocking event immediately, the link will not jump.

const nextClick = new Promise(resolve= > {
    link.addEventListener('click', resolve, { once:true })
})

nextClick.then(e= > {
    e.preventDefault()
    // Handle event
})

link.click()
Copy the code

But when you click with JS code, because the JS stack is not cleared at this point, the microtask will not be executed until the click event ends, you cannot prevent the default event, the page jump, and that’s it.

In general, microtasks perform differently depending on the JS stack.

reference

  • JavaScript Event Queue – macro and micro tasks