解决前端常见问题:竞态条件

12,227 阅读3分钟

​| 导语 竞态条件一词翻译自英语 "race conditions"。当我们在开发前端 web 时,最常见的逻辑就是从后台服务器获取并处理数据然后渲染到浏览器页面上,过程中有不少的细节需要注意,其中一个就是数据竞态条件问题,本文会基于 React 并结合一个小 demo 来解释何为竞态条件,以及循序渐进地介绍解决竞态条件方法。框架不同解决的方式会不一样,但不影响理解竞态条件。

获取数据

下面是一个小 demo:前端获取文章数据,并渲染到页面上

App.tsx

import React from 'react';
import { Routes, Route } from 'react-router-dom';
import Article from './Article';
 
function App() {
  return (
    <Routes>
      <Route path="/articles/:articleId" element={<Article />} />
    </Routes>
  );
}
 
export default App;

Article.tsx

import React from 'react';
import useArticleLoading from './useArticleLoading';
 
const Article = () => {
  const { article, isLoading } = useArticleLoading();
 
  if (!article || isLoading) {
    return<div>Loading...</div>;
  }
 
  return (
    <div>
      <p>{article.id}</p>
      <p>{article.title}</p>
      <p>{article.body}</p>
    </div>
  );
};
 
export default Article;

在上述的 Article 组件中,我们把相关的数据请求封装到了自定义 hook "useArticleLoading" 中,为了页面的使用体验,我们要么显示获取的数据,要么显示加载中。这里加上了加载态的判断。

useArticleLoading.tsx

import { useParams } from 'react-router-dom';
import { useEffect, useState } from 'react';
 
interface Article {
  id: number;
  title: string;
  body: string;
}
 
function useArticleLoading() {
  const { articleId } = useParams<{ articleId: string }>();
  const [isLoading, setIsLoading] = useState(false);
  const [article, setArticle] = useState<Article | null>(null);
 
  useEffect(() => {
    setIsLoading(true);
    fetch(`https://get.a.article.com/articles/${articleId}`)
      .then((response) => {
        if (response.ok) {
          return response.json();
        }
        return Promise.reject();
      })
      .then((fetchedArticle: Article) => {
        setArticle(fetchedArticle);
      })
      .finally(() => {
        setIsLoading(false);
      });
  }, [articleId]);
 
  return {
    article,
    isLoading,
  };
}
 
export default useArticleLoading;

在这个自定义 hook 中,我们管理了加载态以及数据请求

当我们 url 访问 /articles/1 时,会发出 get 请求获取对应 articleId 为 1 的文章内容

竞态条件出现场景

上面是我们非常常见的获取数据的方法,但是让我们考虑以下情况(时间顺序):

  • 访问 articles/1 查看第一个文章内容
    • 浏览器开始请求后台服务器,获取文章 1 的内容
    • 网络连接出现问题
    • articles/1 请求未响应,数据未渲染到页面中
  • 不等待 articles/1 了,访问 articles/2
    • 浏览器开始请求后台服务器,获取文章 2 的内容
    • 网络连接没有问题
    • articles/2 请求立即响应了,数据渲染到页面中
  • articles/1 的请求响应了
    • 通过 setArticles (fetchedArticles) 覆盖了当前的文章内容
    • 当前 url 应该显示 articles/2,却显示了 articles/1

需要理解的一点就是,网络请求的过程是复杂的,且响应时间是不确定的,访问同一个目的地址,请求经过的网络链路不一定是一样的路径。所以先发出的请求不一定先响应,如果前端以先发请求先响应的规则来开发的话,那么就可能会导致错误的数据使用,这就是竞态条件问题。

解决

解决方法也很简单,当收到响应后,只要判断当前数据是否需要,如果不是则忽略即可。

在 React 中可以很巧妙的通过 useEffect 的执行机制来简洁、方便地做到这点:

useArticlesLoading.tsx

useEffect(() => {
  let didCancel = false;
 
  setIsLoading(true);
  fetch(`https://get.a.article.com/articles/${articleId}`)
    .then((response) => {
      if (response.ok) {
        return response.json();
      }
      return Promise.reject();
    })
    .then((fetchedArticle: Article) => {
      if (!didCancel) {
        setArticle(fetchedArticle);
      }
    })
    .finally(() => {
      setIsLoading(false);
    });
 
  return () => {
    didCancel = true;
  }
}, [articleId]);

根据 hook 的执行机制:每次切换获取新文章时,执行 useEffect 返回的函数,然后再重新执行 hook,重新渲染。

现在 bug 不会再出现了:

  • 访问 articles/1 查看第一个文章内容
    • 浏览器开始请求后台服务器,获取文章 1 的内容
    • 网络连接出现问题
    • articles/1 请求未响应,数据未渲染到页面中
  • 不等待 articles/1 了,访问 articles/2
    • useArticleLoading 重新渲染执行,重新渲染前执行了上一次的 useEffect 返回函数,把 didCancel 设置为 true
    • 网络连接没有问题
    • articles/2 请求立即响应了,数据渲染到页面中
  • articles/1 的请求响应了
    • 由于 didCancel 变量,setArticles (fetchedArticles) 没有执行。

处理完后,当我们再次切换文章时,didCancel 为 true,就不会再处理上一个文章的数据,以及 setArticles。

AbortController 解决

虽然上述通过变量的解决方案解决了问题,但它并不是最优的。浏览器仍然等待请求完成,但忽略其结果。这样仍然浪费占用着资源。为了改进这一点,我们可以使用 AbortController。

通过 AbortController,我们可以中止一个或多个请求。使用方法很简单,创建 AbortController 实例,并在发出请求时使用它:

useEffect(() => {
  const abortController = new AbortController();
 
  setIsLoading(true);
  fetch(`https://get.a.rticle.com/articles/${articleId}`, {
    signal: abortController.signal,
  })
    .then((response) => {
      if (response.ok) {
        return response.json();
      }
      return Promise.reject();
    })
    .then((fetchedArticle: Article) => {
      setArticle(fetchedArticle);
    })
    .finally(() => {
      setIsLoading(false);
    });
 
  return () => {
    abortController.abort();
  };
}, [articleId]);

通过传递 abortController.signal,我们可以很容易的使用 abortController.abort() 来终止请求(也可以使用相同的 signal 传递给多个请求,这样可以终止多个请求)

使用 abortController 后,再来看看效果:

  • 访问 articles/1
    • 请求服务器获取 articles/1 数据
  • 不等待响应,再访问 articles/2
    • 重新渲染 hook,useEffect 执行返回函数,执行 abortController.abort ()
    • 请求服务器获取 articles/2 数据
    • 获取到 articles/2 数据并渲染到页面上
  • 第一个文章从未完成加载,因为我们手动终止了请求

可以在开发工具中查看手动中断的请求:

调用 abortController.abort () 有一个问题,就是其会导致 promise 被拒绝,可能会导致未捕获的错误:

为了避免,我们可以加个捕获错误处理:\

useEffect(() => {
  const abortController = new AbortController();
 
  setIsLoading(true);
  fetch(`https://get.a.article.com/articles/${articleId}`, {
    signal: abortController.signal,
  })
    .then((response) => {
      if (response.ok) {
        return response.json();
      }
      return Promise.reject();
    })
    .then((fetchedArticle: Article) => {
      setArticle(fetchedArticle);
    })
    .catch(() => {
      if (abortController.signal.aborted) {
        console.log('The user aborted the request');
      } else {
        console.error('The request failed');
      }
    })
    .finally(() => {
      setIsLoading(false);
    });
 
  return () => {
    abortController.abort();
  };
}, [articleId]);

停止其他 promises

AbortController 不止可以停止异步请求,在函数中也是可以使用的:

function wait(time: number) {
  return new Promise<void>((resolve) => {
    setTimeout(() => {
      resolve();
    }, time);
  });
}
wait(5000).then(() => {
 console.log('5 seconds passed');
});
function wait(time: number, signal?: AbortSignal) {
  return new Promise<void>((resolve, reject) => {
    const timeoutId = setTimeout(() => {
      resolve();
    }, time);
    signal?.addEventListener('abort', () => {
      clearTimeout(timeoutId);
      reject();
    });
  });
}
const abortController = new AbortController();
 
setTimeout(() => {
  abortController.abort();
}, 1000);
 
wait(5000, abortController.signal)
  .then(() => {
    console.log('5 seconds passed');
  })
  .catch(() => {
    console.log('Waiting was interrupted');
  });

传递 signal 给 wait 来终止 promise。

其他

关于 AbortController 兼容性:

除了 IE,其他可以放心使用。

总结

本文讨论了 React 中的竞态条件,解释了竞态条件问题。为了解决这个问题,我们学习了 AbortController 背后的思想,并扩展了解决方案。除此之外,我们还学习了如何将 AbortController 用于其他目的。它需要我们更深入地挖掘并更好地理解 AbortController 是如何工作的。对于前端,可以选择自己最合适的解决方案。