乐趣区

关于javascript:前端的Race-Condition


theme: qklhk-chocolate

对于 Race Condition

对于 Race Condition,维基上有具体介绍(英文版的更具体):

举个例子,大略就是两个线程去批改全局资源,现实的状况:

但在短少同步锁的状况下,理论的状况可能是这样:

如何去解决:

粗心是大部分语言都提供了 资源锁 / 同步锁 这种货色,依据不同的语言选择不同的办法去解决这个问题

在前端的表现形式

javascript 是单线程的,理当不会呈现下面的状况。然而在异步渲染的时候,还是会呈现渲染的时序问题,表现形式,大略是,一个详情组件,watch/useEffect传进来的 id,而后依据id 向后端发送申请,而后异步的渲染。

因为是异步的,你没法保障先收回的申请就肯定是最先返回的,就会呈现了页面展现的 id 和详情对不上的状况:

使用同步锁的概念,能够定义一个 blocked 的变量,在申请时,阻止后续申请的发送:

看似解决了渲染的时序问题,但仔细观察会发现,这样解决会导致新的问题产生:

  1. 整体的渲染周期变长了很多
  2. 前端是重交互和 UI 的,这种“锁”也会导致用户的操作被阻塞,对用户的应用影响也是不好的
  3. 场景太过繁多,打个比方,如果解决的是输入框智能提醒的时序问题,你不可能在前一个申请未返回前阻止用户持续输出

申请 1 有话说

事实中的案例

登陆 / 切换账户场景

  1. 用户未登陆的情况下,点击登陆,在登陆胜利前点了勾销,申请是异步的,勾销是同步的,会导致,即便用户点击勾销,用户依然登陆胜利了。
  2. 用户切换账号,场景相似,就是切换胜利前勾销切换,然而切换胜利后的操作还是会执行的,如果用户感知不到账户切换,会呈现比拟大的 bug。

切换 tab/ 搜寻

点击什么字母就会返回什么字母,这块对接口做了解决,新近的申请响应更慢,当你间断点击 a -> ab -> abc,会呈现:先展现abc->ab->a,搜寻的场景同理

后果:

起因


产生这种时序问题的起因很多,简略概括蕴含以下几点:

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

如何去解决

测试案例

一个简略的 Vue 组件,依据输出的内容展现不同的后果,这块的接口做了解决,先发送的申请仍然是响应最慢,会呈现搜寻和后果不匹配的状况

计划 1:从最底层登程,“勾销”申请

目前的申请形式大略两种:XMLHttpRequest + Fetch,目前支流的计划还是 XMLHttpRequestFetch 因为兼容性的问题应用的还是不多,基于XMLHttpRequest,用的最多的大略是 axios,这种个别都会把勾销申请的办法封装好了


咱们还是以 Fetch 为例子。Fetch还是比拟难堪,自身就有兼容性的问题,对于申请管制的 AbortController 的兼容性相比更差,这块先不思考这些。对于 AbortControllerMDN 上有具体阐明:

依照官网的例子这样解决就好了:

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,但因为每次生成新实例的起因,只能用变量去管制

不会吧不会吧,难道真有人会去勾销申请的?

  1. 百度,只会保留最新的申请,后面的申请都会被勾销:

  1. 谷歌,谷歌会保留最大 4 个的并行申请,而后勾销后面的所有申请:

奇怪的是,都没有做防抖解决

勾销 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("申请出错了");
    }
  }
}

匹配申请

只有以后解决的是申请匹配时才解决,否则不论,这里分为两种状况:

  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();
    },
  },
};


因为把数据当作流去解决,防止了时序的问题:

结束语

我整顿的大略这么多,解决形式不止这些,还有像 GraphQL 等,理解的不多,就没写了。“竞态”问题呈现在一些简略利用中的概率绝对小很多,但在一些简单利用中就会比拟容易呈现,自从我从 B 端我的项目切换到流动页当前,就再也没有碰到这种问题了(流动页赛高),只是我敌人碰到了这个问题,所以就简略整顿了下,大略这么多,谢谢浏览。

退出移动版