Skip to content

Eliminate asynchronous contagiousness

About 1183 wordsAbout 4 min

javascript

2023-04-07

Async infectivity means that when a function uses async/await, its caller also needs to use async/await to handle asynchronous operations. This causes the entire call chain associated with it to become asynchronous. This situation can cause the code to become complicated and difficult to maintain.

Overview

In a previous interview, I happened to encounter this problem:

Note

I personally dislike this type of interview. It will only become a essay in the interview, and people will memorize the answers. But in most development scenarios, this is not done, and even the answer to this question itself may be It will also be listed in the prohibition rules of the development specification, so that it cannot pass the code-review.

Although this problem can be solved using the features of JavaScript itself, who would write code like this most of the time? I don't support the abuse of language features like this. Maybe when you are building some libraries or frameworks, you have to If you do this, then I support it, but for most business development, writing this way is to bury other people's pit.

Eliminate asynchronous infectivity in the following code and print the correct result by calling synchronously.

async function getData() {
  return fetch('/api/data').then((res) => res.json())
}

async function foo() {
  // other work
  return await getData()
}

async function bar() {
  // other work
  return await foo()
}

async function main() {
  // other work
  const data = await bar()

  console.log(data)
}

main()

The desired result is:

function main() {
  // other work
  const data = bar()

  console.log(data) // Print correct result
}

At first glance, the source of data is obtained from getData(), calling the service interface, and you need to wait for the server to respond before you can Get data, so how can I achieve synchronous execution and get results?

Solution

In the code, we can see that the initial asynchronous is initiated from fetch() in the getData() function. To solve this problem, you need to Start here.

Just imagine, fetch() must require waiting time to get data results unless it immediately throws an error:

async function getData() {
  const res = fetch('/api/data').then((res) => res.json())
  throw new Error('error')
  return res // For demonstration, the code will not be written like this in actual situation, because it will never be executed here
}

At this point, if we do not catch the error manually, the program will be interrupted. However, it can be seen that the thrown error is executed immediately and meets the requirements of synchronous execution.

Then we can follow this idea and continue to try.

We can manually catch this error through try {} catch {} and then continue to execute other code. But what kind of errors should be caught? What do we want? If it is just throwing a normal request error, or an execution error, this will be of no use to our problem. Because they can't tell us that the data is ready.

So, who can tell us that the data is ready? Or is it only the Promise returned to us by fetch() can tell us that the data is ready. Since that's the case, wouldn't we just throw this Promise as an error and then recapture it?

async function getData() {
  const res = fetch('/api/data').then((res) => res.json())
  throw res
  return res // For demonstration, the code will not be written like this in actual situation, because it will never be executed here
}

function main() {
  // other work
  try {
    const data = getData()
    console.log(data)
  } catch (err) {
    if (err instanceof Promise) {
      // do something
    }
  }
}

Next, we can check whether there is a return result in the catch block. This way we can know if the data is ready.

But, knowing that the data is ready, then what? The main() method has been executed, and the console.log(data) has not been executed. This creates the following problems:

  • How to make console.log(data) execute?
  • Where should I get the data when I am ready?

Re-execution requires re-calling the main() function, but re-calling the main() function will inevitably re-title an error. Therefore, it is also necessary to let getData() know when to throw an error and when there is no need to throw an error.

We can add a caching mechanism to cache the result when the Promise state changes from pending. Then in getData(), determine whether there is a cache, and if there is a cache, the cache result will be returned directly. Of course, there is another most important step. After changing the Promise status from pending, the main() function needs to be called again.

//Cached data
let cache = null

function getData() {
  // Return the cache immediately after discovering cached data
  if (cache) {
    if (cache.status === 'fulfilled') {
      return cache.data
    } else {
      throw new Error(cache.reason)
    }
  }
  const res = fetch('/api/data').then((res) => res.json())
  throw res
}

function main() {
  // other work
  try {
    const data = getData()
    console.log(data)
  } catch (err) {
    //Catch the error thrown by `getData` and determine whether err is `Promise`
    if (err instanceof Promise) {
      // Cache the results
      err
        .then(
          (data) => {
            cache = {
              status: 'fulfilled',
              data,
            }
          },
          (reason) => {
            cache = {
              status: 'rejected',
              Reason,
            }
          },
        )
        // Recall `main()`
        .finally(() => main())
    }
  }
}

Note

This cannot be the final answer, because it makes too many changes to getData() and main(). The actual answer should encapsulate this part of the rewritten logic into a separate function.

I won't give an example of implementation code here, why not try it yourself?

Replace the fetch section with a fake data interface for testing:

function sleep(num, func) {
  return new Promise((resolve) => {
    setTimeout(() => {
      func?.()
      resolve()
    }, num || 0)
  })
}

async function mockData() {
  await sleep(100)
  return { a: 1 }
}

// Cache data
let cache = null

function getData() {
  // Return the cache immediately after discovering cached data
  if (cache) {
    if (cache.status === 'fulfilled') {
      return cache.data
    } else {
      throw new Error(cache.reason)
    }
  }
  const res = mockData()
  throw res
}

function main() {
  // other work
  try {
    const data = getData()
    console.log(data)
  } catch (err) {
    // Judgment Is the error `Promise`
    if (err instanceof Promise) {
      // Cache the results
      err
        .then(
          (data) => {
            cache = {
              status: 'fulfilled',
              data,
            }
          },
          (reason) => {
            cache = {
              status: 'rejected',
              Reason,
            }
          },
        )
        // Recall `main()`
        .finally(main)
    }
  }
}

main()

Tips

You can copy this code directly to the browser's console and run it directly to see the results.

Console
{ a: 1 }

Summarize

From a code perspective, in the process of obtaining results of synchronous execution, throwing an error will block the execution of subsequent code. Then catch the error, wait for the asynchronous result in catch and cache it, and finally call the main() function again to directly return the cached result.

Achieve the seemingly synchronous execution results. In fact, the main() function is executed twice, which has side effects.

If the main() function content contains code that affects the external scope in the code before the getData() call, Then two executions of main() may have unexpected effects.

Therefore, this requires that the functions on the entire call chain of main() should be pure functions and should not have side effects.