头图

前端的“Race Condition”


theme: qklhk-chocolate

关于 Race Condition

关于 Race Condition,维基上有具体介绍(英文版的更详细):
image.png

举个例子,大概就是两个线程去修改全局资源,理想的情况:
image.png

但在缺少同步锁的情况下,实际的情况可能是这样:
image.png

如何去解决:
image.png

大意是大部分语言都提供了资源锁/同步锁这种东西,根据不同的语言选择不同的方法去处理这个问题

在前端的表现形式

javascript 是单线程的,理应不会出现上面的情况。但是在异步渲染的时候,还是会出现渲染的时序问题,表现形式,大概是,一个详情组件,watch/useEffect传进来的id,然后根据id向后端发送请求,然后异步的渲染。

因为是异步的,你没法保证先发出的请求就一定是最先返回的,就会出现了页面展示的id和详情对不上的情况:
image.png

运用同步锁的概念,可以定义一个blocked的变量,在请求时,阻止后续请求的发送:
image.png
看似解决了渲染的时序问题,但仔细观察会发现,这样处理会导致新的问题产生:

  1. 整体的渲染周期变长了很多
  2. 前端是重交互和UI的,这种“锁”也会导致用户的操作被阻塞,对用户的使用影响也是不好的
  3. 场景太过单一,打个比方,如果处理的是输入框智能提示的时序问题,你不可能在前一个请求未返回前阻止用户继续输入
请求1有话说
image.png

现实中的案例

登陆/切换账户场景

  1. 用户未登陆的状况下,点击登陆,在登陆成功前点了取消,请求是异步的,取消是同步的,会导致,即使用户点击取消,用户仍然登陆成功了。
  2. 用户切换账号,场景类似,就是切换成功前取消切换,但是切换成功后的操作还是会执行的,如果用户感知不到账户切换,会出现比较大的 bug。

切换 tab/搜索

点击什么字母就会返回什么字母,这块对接口做了处理,早先的请求响应更慢,当你连续点击a -> ab -> abc,会出现:先展示abc->ab->a,搜索的场景同理
image.png
结果:
image.png

原因

image.png
产生这种时序问题的原因很多,简单概括包含以下几点:

  1. 当前所处的网络环境差,不稳定,没法保证请求返回的稳定性
  2. 后端的处理逻辑不同。打个比方,不同的两个接口都能触发组件的更新,但后端对这两个接口的处理策略不同,或者这两个接口访问的数据量不同,就会导致请求的处理周期不同,也就没法保证时序
  3. 此时的用户是个倒霉蛋,第1个请求就是比第2个请求返回慢

如何去解决

测试案例

image.png

image.png

一个简单的Vue组件,根据输入的内容展示不同的结果,这块的接口做了处理,先发送的请求依然是响应最慢,会出现搜索和结果不匹配的情况

方案1:从最底层出发,“取消”请求

目前的请求方式大概两种:XMLHttpRequest + Fetch,目前主流的方案还是XMLHttpRequestFetch因为兼容性的问题使用的还是不多,基于XMLHttpRequest,用的最多的大概是axios,这种一般都会把取消请求的方法封装好了

image.png
我们还是以Fetch为例子。Fetch还是比较尴尬,本身就有兼容性的问题,对于请求控制的 AbortController 的兼容性相比更差,这块先不考虑这些。关于AbortControllerMDN上有详细说明:
image.png
按照官方的例子这样处理就好了:

async handleSearch() {
  try {
    this.isCanceled = false;
    if (this.controller) {
      this.controller.abort();
      this.isCanceled = true;
    }
    this.controller = new AbortController();
    const { result } = await fetch(
      `http://localhost:3000/list?search=${this.text}`,
      {
        signal: this.controller.signal,
      }
    ).then((response) => response.json());
    this.result = result;

    console.log("result", result);
  } catch (err) {
    console.log("err", err);
    // this.controller.signal.aborted
    if (this.isCanceled) {
      console.log("aborted");
    } else {
      this.$message("请求出错了");
    }
  }
}

⚠️需要注意的点:

  1. 取消的请求会走到catch,会和一些异常场景耦合,所以需要单独处理
  2. 这块每次都去生成新的实例,我没有找到相对应的reset方法
  3. error拿不到取消请求的信息,controller.signal.aborted能够判断请求是否aborted,但因为每次生成新实例的原因,只能用变量去控制

image.png

不会吧不会吧,难道真有人会去取消请求的?
  1. 百度,只会保留最新的请求,前面的请求都会被取消:

image.png

  1. 谷歌,谷歌会保留最大4个的并行请求,然后取消前面的所有请求:

image.png

奇怪的是,都没有做防抖处理

取消 Promise

取消Promise,其实就是让Promise提前resolved或者rejected。关于取消的具体姿势,可以看下how-to-cancel-your-promise

就是下面几点:

  1. Pure Promises
  2. Switch to generators
  3. Note on async/await

简单写法:

const request = (...arg) => {
  let cancel;
  const promise = new Promise((resolve, reject) => {
    cancel = () => reject("aborted");
    fetch(...arg).then(resolve, reject);
  });

  return [promise, cancel];
};
// ...
async handleSearch() {
  try {
    if (this.cancel) {
      this.cancel();
    }
    const [promise, cancel] = request(
      `http://localhost:3000/list?search=${this.text}`
    );
    this.cancel = cancel;
    const result = (await promise.then((response) => response.json()))
      .result;
    this.result = result;

    console.log("result", result);
  } catch (err) {
    if (err === "aborted") {
      console.log(err);
    } else {
      this.$message("请求出错了");
    }
  }
}

image.png

匹配请求

只有当前处理的是请求匹配时才处理,否则不管,这里分为两种情况:

  1. 有唯一key区分的,例如商品详情:

    // 存在 id
    async handleSearch() {
      try {
        const detail = await fetch(`xx/${this.id}`);
        if (detail.id === this.id) {
            this.detail = detail;
        }
      } catch (err) {
        this.$message("请求出错了");
      }
    }
  2. 不存在唯一key,记录最后Promise引用,再匹配

    async handleSearch() {
      try {
        const curPromise = fetch(`xx/${this.id}`);
        this.promiseRef = curPromise;
        
        const detail = await curPromise;
        
        if (this.promiseRef === curPromise) {
            this.detail = detail;
        }
      } catch (err) {
        this.$message("请求出错了");
      }
    }

我用过的库

redux-saga

redux-saga,我以前使用React的时候喜欢用,是Redux的一个中间件,主要就是处理副作用的,即请求。感觉这个库实现了个小型的IO系统,这块内容感兴趣的同学自行了解,我只说下解决方法,redux-saga提供了TakeLatest的辅助辅助函数去处理这种问题:

function* loadStarwarsHeroSaga() {
  yield* takeLatest(
    'LOAD_STARWARS_HERO',
    function* loadStarwarsHero({ payload }) {
      try {
        const hero = yield call(fetchStarwarsHero, [
          payload.id,
        ]);
        yield put({
          type: 'LOAD_STARWARS_HERO_SUCCESS',
          hero,
        });
      } catch (err) {
        yield put({
          type: 'LOAD_STARWARS_HERO_FAILURE',
          err,
        });
      }
    },
  );
}

rx-js

rx-js是一个响应式的库,官方说了,算是异步的lodash。把所有的数据封装成流的形式进行处理。用到的操作方法主要就是SwitchMap

import { Subject, merge, of } from "rxjs";
import { ajax } from "rxjs/ajax";
import { switchMap, catchError, tap } from "rxjs/operators";

export default {
  name: "HelloWorld",
  data() {
    return {
      text: "",
      result: "holder",
    };
  },
  mounted() {
    this.subject = new Subject();

    this.subject
      .pipe(
        tap(() => {
          console.log("text:", this.text);
        }),
        switchMap((str) =>
          ajax(`http://localhost:3000/list?search=${this.text}`)
        ),
        catchError((err, caught$) => {
          return merge(of({ err }), caught$);
        })
      )
      .subscribe((response) => {
        if (response.err) {
          this.$message("请求失败");
        } else {
          const result = response.response.result;
          console.log("result:", result);
          this.result = result;
        }
      });
  },
  beforeDestroy() {
    this.subject.unsubscribe();
  },
  methods: {
    handleSearch() {
      this.subject.next();
    },
  },
};

image.png
因为把数据当作流去处理,避免了时序的问题:

image.png

结束语

我整理的大概这么多,解决方式不止这些,还有像GraphQL等,了解的不多,就没写了。“竞态”问题出现在一些简单应用中的概率相对小很多,但在一些复杂应用中就会比较容易出现,自从我从B端项目切换到活动页以后,就再也没有碰到这种问题了(活动页赛高),只是我朋友碰到了这个问题,所以就简单整理了下,大概这么多,谢谢阅读。


我大EOI前端
[链接] 虽然公司尚小,但是志向不小

立志做一个好厨师!!!

425 声望
24 粉丝
0 条评论
推荐阅读
Vue微信公众号开发踩坑记录
JS-SDK需要向服务端获取签名,且获取签名中需要的参数包括所在页面的url,但由于单页应用的路由特殊,其中涉及到iOS和android微信客户端浏览器内核的差异性导致的兼容问题

imwty132阅读 67.6k评论 81

从零搭建 Node.js 企业级 Web 服务器(零):静态服务
过去 5 年,我前后在菜鸟网络和蚂蚁金服做开发工作,一方面支撑业务团队开发各类业务系统,另一方面在自己的技术团队做基础技术建设。期间借着 Node.js 的锋芒做了不少 Web 系统,有的至今生气蓬勃、有的早已夭折...

乌柏木172阅读 13.8k评论 10

手把手教你写一份优质的前端技术简历
不知不觉一年一度的秋招又来了,你收获了哪些大厂的面试邀约,又拿了多少offer呢?你身边是不是有挺多人技术比你差,但是却拿到了很多大厂的offer呢?其实,要想面试拿offer,首先要过得了简历那一关。如果一份简...

tonychen152阅读 17.7k评论 5

封面图
正则表达式实例
收集在业务中经常使用的正则表达式实例,方便以后进行查找,减少工作量。常用正则表达式实例1. 校验基本日期格式 {代码...} {代码...} 2. 校验密码强度密码的强度必须是包含大小写字母和数字的组合,不能使用特殊...

寒青56阅读 8.4k评论 11

JavaScript有用的代码片段和trick
平时工作过程中可以用到的实用代码集棉。判断对象否为空 {代码...} 浮点数取整 {代码...} 注意:前三种方法只适用于32个位整数,对于负数的处理上和Math.floor是不同的。 {代码...} 生成6位数字验证码 {代码...} ...

jenemy48阅读 6.9k评论 12

从零搭建 Node.js 企业级 Web 服务器(十五):总结与展望
总结截止到本章 “从零搭建 Node.js 企业级 Web 服务器” 主题共计 16 章内容就更新完毕了,回顾第零章曾写道:搭建一个 Node.js 企业级 Web 服务器并非难事,只是必须做好几个关键事项这几件必须做好的关键事项就...

乌柏木75阅读 7k评论 16

再也不学AJAX了!(二)使用AJAX ① XMLHttpRequest
「再也不学 AJAX 了」是一个以 AJAX 为主题的系列文章,希望读者通过阅读本系列文章,能够对 AJAX 技术有更加深入的认识和理解,从此能够再也不用专门学习 AJAX。本篇文章为该系列的第二篇,最近更新于 2023 年 1...

libinfs42阅读 6.8k评论 12

封面图

立志做一个好厨师!!!

425 声望
24 粉丝
宣传栏