This series consists of seven chapters. Check out Github addresseshere, please refer to the original addresshere.

Run time other than setTimeout

This chapter explains different ways to execute asynchronous code on the browser side. You will learn about the difference between event loops and timing technologies like setTimeout and Promises.

Asynchronously executing code

Most people are probably familiar with asynchronously executing code such as Promise, process.Nexttick (), setTimeout, and perhaps requestAnimationFrame. They both use event loops internally, but when it comes to precise timing, they behave very differently.

This article will explain the differences and give you an idea of how to implement a modern framework such as NX’s timing system. Instead of reinventing the wheel, we will use a native event loop to achieve our goals.

Event loop

Event loops are not mentioned in the ES6 specification. JavaScript itself only has the concept of tasks and task queues. The concept of more complex event loops is defined in the NodeJS and HTML5 specifications, respectively. Because this series is about the front end, I’ll cover the latter here.

An event loop is called a loop for a reason. It loops endlessly and looks for new tasks to perform. A single iteration of the loop is called a tick. The code that executes during a TICK is called a task.

while (eventLoop.waitForTask()) {
	eventLoop.processNextTask()
}
Copy the code

A task is a piece of synchronized code that can schedule other tasks in a loop. A simple programming method for scheduling new tasks is setTimeout(taskFn).

However, tasks can come from several other sources, such as user events, network requests, or DOM operations.

Task queue

To complicate matters further, an event loop can have multiple task queues. The only two limitations are that events from the same task source must belong to the same task queue, and all tasks in each queue must be executed in the order they were inserted. Other than that, the user agent is free to do anything. For example, it can determine the next task queue to execute.

While (eventLoop.waitForTask()) {
	const taskQueue = eventLoop.selectTaskQueue()
	if (taskQueue.hasNextTask()) {
    taskQueue.processNextTask()
	}
}
Copy the code

According to this model, we lose precise control of time. Before executing the task we planned with setTimeout(), the browser may decide to fully process several other task queues.

Microtask queue

Fortunately, the event loop also has a single queue called the microtask queue. Within each tick, the microtask queue is completely emptied each time the current task completes.

while (eventLoop.waitForTask()) {
  const taskQueue = eventLoop.selectTaskQueue()
  if (taskQueue.hasNextTask()) {
    taskQueue.processNextTask()
  }
  
  const microtaskQueue = eventLoop.microTaskQueue
  while (microtaskQueue.hasNextMicrotask()) {
    microtaskQueue.processNextMicrotask()
  }
}
Copy the code

The easiest way to set up a microtask is promise.resolve ().then(microtaskFn). The microtasks are executed in the order they are inserted, and since there is only one microtask queue, the user agent cannot interfere with us this time.

In addition, microservices can set new microservices to be inserted into the same microservice queue and executed in the same TICK.

Apply colours to a drawing

The last thing to note is the rendering schedule. Unlike event handling or parsing, rendering is not done by a separate background task. This is an algorithm that can run at the end of each cycle tick.

The user agent again has a lot of freedom of choice: it may render after each task, but it may decide to perform hundreds of tasks without rendering.

Fortunately, there is a requestAnimationFrame function, which will execute the incoming function immediately before the next rendering. Our final event cycle model looks like this:

while (eventLoop.waitForTask()) {
  const taskQueue = eventLoop.selectTaskQueue()
  if (taskQueue.hasNextTask()) {
    taskQueue.processNextTask()
  }
  
  const microtaskQueue = eventLoop.microTaskQueue
  while (microtaskQueue.hasNextMicrotask()) {
    microtaskQueue.processNextMicrotask()
  }
  
  if (shouldRender()) {
    applyScrollResizeAndCSS()
    runAnimationFrames()
    render()
  }
}
Copy the code

Now let’s use all this knowledge to build a timing system!

Use event loops

Like most modern frameworks, NX handles DOM manipulation and data binding in the background. It operates in batches and executes them asynchronously to improve performance. For these operations to execute properly, it relies on Promises, MutationObservers, and requestAnimationFrame().

The proposed timing system is as follows:

  • Developer code
  • NX provides data binding and responds to DOM operations
  • Developer custom hooks
  • User agent rendering

Step 1

NX has used ES6 Proxies to log object changes as well as MutationObserver to log DOM changes (more on this in the next chapter). It delays the response as the second step of the microtask to improve performance. Deferred response object changes are implemented using promise.resolve ().then(reaction), which is then handled automatically by the MutationObserver because of the internal use of microtasks in the MutationObserver.

Step 2

The developer’s code (task) is finished. The microtask response registered by the NX begins execution. Microtasks are performed sequentially. Notice that we are still in the same loop tick.

Step 3

NX uses requestAnimationFrame(hook) to run hooks passed by the developer. This may happen later in the cycle tick. Importantly, the hooks here will run before the next render and after all data, DOM, and CSS changes have been made.

Step 4

The browser renders the next view. This may happen later in the tick cycle, but it never happens before the first few steps of a tick.

Matters needing attention

We just implemented a simple but usable timing system on top of a native event loop. It should work fine in theory, but timing is a tricky thing, and a tiny error can lead to some very strange bugs.

In a complex system, it is important to set some rules about timing and follow them later. Nx laid down the following rules.

  • Never use this in internal operationssetTimeout(fn, 0)
  • Register microtasks in the same way
  • Reserve microtasks for internal operations only
  • Don’t use anything else to contaminate the developer’s hook execution window

Rules 1 and 2

Responses to data and DOM operations should be executed in the order in which the operations occurred. It is desirable to delay their execution as long as their execution order is not confused. Confusing the order of execution can make things unpredictable and hard to figure out.

SetTimeout (fn, 0) is completely unpredictable. Registering microtasks in different ways can also lead to confusion about the order of execution. For example, in the example below, MicroTask2 will mistakenly execute before MicroTask1.

Promise.resolve().then().then(microtask1)
Promise.resolve().then(microtask2)
Copy the code

Rules 3 and 4

It is important to separate developer code execution Windows from internal operations. Confusing the two leads to seemingly unpredictable behavior, and ultimately it forces developers to learn the inner workings of the framework. I think a lot of front-end developers have had similar experiences.

conclusion

An event loop traversal is called a TICK, and the code executed in it is called a task. A TICK can contain only one microtask queue, and a TICK can contain multiple task queues.

Events generated by the same task source must be in the same task queue, and all tasks in each task queue must be executed in the order they were inserted. The microtasks in the microtask queue are also executed in the order they are inserted.

Microtasks start when the current task in a tick finishes executing.

Github address please refer to here, the original address please refer to here, the next code to run the sandbox.