👍 实用rxjs学习案例

7,089 阅读7分钟

前言:

我搜集了网络和自己实践中的一些案例,让大家感受一下rxjs处理异步时的优势。

本文主要目的:

1、让一些同学对rxjs对稍微复杂异步的简洁处理感兴趣(不需要懂api,仅仅感受rxjs的优势)

2、让一些缺乏实战案例的同学练手(网上多讲api的,跟业务相关的案例较少,这跟rxjs本身流行程度并不高有关)

3、自己初步学习后的总结

从一个字节面试题开始:控制最大并发数

题目如下:

实现一个批量请求函数 multiRequest(urls, maxNum),要求如下:
• 要求最大并发数 maxNum
• 每当有一个请求返回,就留下一个空位,可以增加新的请求
• 所有请求完成后,结果按照 urls 里面的顺序依次打出

你可以先想想如果不用rxjs,你会怎么做,我们先看用rxjs有多简单

// 假设这是你的http请求函数
function httpGet(url) {
  return new Promise(resolve => setTimeout(() => resolve(`Result: ${url}`), 2000));
}

使用rxjs只需要1行代码就可以解决这个面试题,后面会写一版不用rxjs,可以看看promise实现有多么麻烦。

const array = [
  'https://httpbin.org/ip', 
  'https://httpbin.org/user-agent',
  'https://httpbin.org/delay/3',
];

// mergeMap是专门用来处理并发处理的rxjs操作符
// mergeMap第二个参数2的意思是,from(array)每次并发量是2,只有promise执行结束才接着取array里面的数据
// mergeMap第一个参数httpGet的意思是每次并发,从from(array)中取的数据如何包装,这里是作为httpGet的参数
const source = from(array).pipe(mergeMap(httpGet, 2)).subscribe(val => console.log(val));

在线代码预览:stackblitz.com/edit/rxjs-q… (注意控制台打印顺序)

以下是promise的版本,代码多而且是面向过程的面条代码(如果不用rxjs的化,一般场景建议使用ramda库,用“流”或者函数组合的方式来编写函数,让你的功能模块远离面条代码(面条代码 = 难以维护的面向过程的代码)),文章最后闲聊会讲业务不复杂的场景,怎么使用ramda。

以下是用promise解决上述面试题的思路,可以看到大量的临时变量,while函数,if语句,让代码变得难以维护(并不是拒绝这种代码,毕竟优雅的接口后面很可能是“龌龊的实现”),但如果有工具帮助你直接使用优雅的接口,降低了复杂度,何乐而不用呢

function multiRequest(urls = [], maxNum) {
  // 请求总数量
  const len = urls.length;
  // 根据请求数量创建一个数组来保存请求的结果
  const result = new Array(len).fill(false);
  // 当前完成的数量
  let count = 0;
 
  return new Promise((resolve, reject) => {
    // 请求maxNum个
    while (count < maxNum) {
      next();
    }
    function next() {
      let current = count++;
      // 处理边界条件
      if (current >= len) {
        // 请求全部完成就将promise置为成功状态, 然后将result作为promise值返回
        !result.includes(false) && resolve(result);
        return;
      }
      const url = urls[current];
      console.log(`开始 ${current}`new Date().toLocaleString());
      fetch(url)
        .then((res) => {
          // 保存请求结果
          result[current] = res;
          console.log(`完成 ${current}`new Date().toLocaleString());
          // 请求没有全部完成, 就递归
          if (current < len) {
            next();
          }
        })
        .catch((err) => {
          console.log(`结束 ${current}`new Date().toLocaleString());
          result[current] = err;
          // 请求没有全部完成, 就递归
          if (current < len) {
            next();
          }
        });
    }
  });
}

我们再来一个面试题,这是我自己面腾讯的时候自己遇到的关于ajax请求并发的问题,当时刚转前端回答的不是很好。题目如下:

image.png 再进一步说明问题

按钮A按了之后,ajax请求的数据显示在input type=text框里,B按钮也是。

问题就是如果先按A,此时ajax发出去了,但是数据还没返回来, 我们等不及了,马上按B按钮,结果此时A按钮请求的数据先回来,这就尴尬了,按的B按钮,结果先显示A按钮返回的数据,怎么解决?

这个问题可以在在A按钮按了之后,再按B按钮的时候,取消a按钮发出的请求,这个ajax和fetch都是有方法实现的,ajax原生自带cancel方法,fetch的话要自己写一下,大概思路如下(如何取消fetch)

function abortableFetch(request, opts) {
  const controller = new AbortController();
  const signal = controller.signal;

  return {
    abort: () => controller.abort(),
    ready: fetch(request, { ...opts, signal })
  };
}

别看上面封装的挺不错的,但是用起来还是有点麻烦,而且耦合性有点高,因为我要在B按钮的onClick事件里面去调用A按钮的abort方法。

好了,我们基于rxjs来写一个通用的处理方案(要说函数间的解耦,发布订阅模式有点万能的感觉,rxjs的new Subject也是一样的思想)

import { Subject } from 'rxjs';
import { switchMap } from 'rxjs/operators';

// 假设这是你的http请求函数
function httpGet(url: any): any {
  return new Promise(resolve =>
    setTimeout(() => resolve(`Result: ${url}`), 2000)
  );
}

class abortableFetch {
  search: Subject<any>;
  constructor() {
    this.search = new Subject();
    this.init();
  }
  init() {
    this.search
      .pipe((switchMap as any)((value: any): any => httpGet(value)))
      .subscribe(val => console.log(val));
  }

  trigger(value) {
    this.search.next(value);
  }
}

// 使用方式,非常简单,就一个trigger方法就可以了
const switchFetch = new abortableFetch();

switchFetch.trigger(123);
setTimeout(() => {
  switchFetch.trigger(456);
}, 1000);

请注意此案例控制台输出的是456而不是123,因为456后输出把之前的123覆盖了,相当于取消了之前的请求

在线预览此案例: stackblitz.com/edit/rxjs-z…

好了,上面两个例子可以看出rxjs最大的优点:

1、函数式编程在写一些小功能的时候,解耦非常简单,天然满足高内聚、低耦合

2、rxjs在处理异步(比如网络IO和UI交互)时,写一些小功能时,代码量较少,语义性很强

但是问题也很突出,就是掌握好rxjs,真的不容易,都不说rxjs了,用ramda库或函数式变成的库的前端在我经历里都很少。

接下来,下面就是基于rxjs的一些案例。

1、buffer相关操作符案例

bufferTime: 比如你写一个基于 websocket 的在线聊天室,不可能每次 ws 收到新消息,都立刻渲染出来,这样在很多人同时说话的时候,一般会有渲染性能问题。。

所以你需要收集一段时间的消息,然后把它们一起渲染出来,例如每一秒批量渲染一次。用原生 JS 写的话,你需要维护一个队列池,和一个定时器,收到消息,先放进队列池,然后定时器负责把消息渲染出来,类似:

let messagePool = []
ws.on('message', (message) => {
    messagePool.push(message)
})

setInterval(() => {
    render(messagePool)
    messagePool = []
}, 1000)

这里已经是最简化的代码了,但逻辑依然很破碎,并且还要考虑清理定时器的问题。如果用 RxJS,代码就好看了很多

import { fromEvent } from 'rxjs';
import { switchMap } from 'rxjs/operators';
 fromEvent(ws, 'message')
     .pipe(bufferTime(1000))
    .subscribe(messages => render(messages))

记录鼠标两秒能点击多少次

fromEvent(document,'click').pipe(
    bufferTime(2000),
    map(array=>array.length)
).subscribe(count => {
    console.log("两秒内点击次数", count);
  });

bufferCount: 另外一个例子,比如我们在写一个游戏,当用户连续输入"上上下下左右左右BABA"的时候,就弹出隐藏的彩蛋,用原生 JS 的话也是需要维护一个队列,队列中放入最近12次用户的输入。然后每次按键的时候,都识别是否触发了彩蛋。RxJS 的话就简化了很多,主要是少了维护队列的逻辑:

const code = [
   "ArrowUp",
   "ArrowUp",
   "ArrowDown",
   "ArrowDown",
   "ArrowLeft",
   "ArrowRight",
   "ArrowLeft",
   "ArrowRight",
   "KeyB",
   "KeyA",
   "KeyB",
   "KeyA"
]

fromEvent(document, 'keyup').pipe(
   map(e => e.code),
   bufferCount(12, 1)
).subscribe(last12key => {
        if (_.isEqual(last12key, code)) {
            console.log('隐藏的彩蛋 \(^o^)/~')
        }
    })

当然 RxJS 还可以复杂得多的逻辑,比如要求只有在两秒内连续输入秘籍,才能触发彩蛋,这里该怎么写

import { bufferWhen, filter, fromEvent, tap, interval } from 'rxjs';
import * as _ from 'lodash';
import { bufferCount, map, withLatestFrom } from 'rxjs/operators';

const code = ['KeyA', 'KeyB', 'KeyA'];

const secondSource = interval(1000);
fromEvent(document, 'keyup')
  .pipe(
    map((e) => (e as any).code),
    withLatestFrom(secondSource),
    bufferCount(3, 1),
    filter((last3key) => {
      let arr1 = [];
      let arr2 = [];
      last3key.forEach((v) => {
        arr1.push(v[0]);
        arr2.push(v[1]);
      });
      return _.isEqual(arr1, code) && arr2[2] - arr2[0] <= 2;
    })
  )
  .subscribe((last3key) => {
    console.log('last3key', last3key);
  });

2、简易拖拉

实现内容如下:

1、首先页面上有一個元素(#drag)

2、当鼠标在元素(#drag)上按下左键(mousedown)时,开始监听鼠标移动(mousemove)的位置

3、当鼠标左键释放(mouseup)时,结束监听鼠标的移动

4、当鼠标移动被监听时,跟着修改原件的样式属性

import { of, fromEvent} from 'rxjs'; 
import { map, concatMap, takeUntil, withLatestFrom } from 'rxjs/operators';

 // 样式省略是绝对定位
const dragEle = document.getElementById('drag')
const mouseDown = fromEvent(dragEle, 'mousedown')
const mouseUp = fromEvent(document, 'mouseup')
const mouseMove = fromEvent(document, 'mousemove')

mouseDown.pipe(
  concatMap(e => mouseMove.pipe(takeUntil(mouseUp))),
  withLatestFrom(mouseDown, (move: MouseEvent, down: MouseEvent) => {
        return {
            x: move.clientX - down.offsetX,
            y: move.clientY - down.offsetY
        }
  })
).subscribe(pos => {
        dragEle.style.top = pos.y + 'px';
        dragEle.style.left = pos.x + 'px';
})

在线预览:stackblitz.com/edit/rxjs-s…

3、简易autoComponent功能

实现内容如下:

1、准备 input#search 以及 ul#suggest-list 的 HTML 与 CSS

2、在 input#search 输入文字时,等待 100 毫秒后若无输入,就发送 HTTP Request

3、当 Response 还没回来时,使用者又输入了下一哥文字就舍弃前一次的,并再发送一次新的 Request

4、接受到 Response 之后显示下拉选项

5、鼠标左键选中对应的下拉响,取代 input#search 的文字

import { fromEvent } from "rxjs";
import { map, debounceTime, switchMap } from "rxjs/operators";

const url = 'https://zh.wikipedia.org/w/api.php?action=opensearch&format=json&limit=5&origin=*';

const getSuggestList = (keyword) => fetch(url + '&search=' + keyword, { method: 'GET', mode: 'cors' })
                                    .then(res => res.json())

const searchInput = document.getElementById('search');
const suggestList = document.getElementById('suggest-list');

const keyword = fromEvent(searchInput, 'input');
const selectItem = fromEvent(suggestList, 'click');

const render = (suggestArr = []) => suggestList.innerHTML = suggestArr.map(item => '<li>'+ item +'</li>').join('')

keyword.pipe(
  debounceTime(100),
  switchMap(
    (e: any) => getSuggestList(e.target.value),
    (e, res) => res[1]
  )
).subscribe(list => render(list))
  
 

selectItem.pipe(
  map(e => e.target.innerText)
).subscribe(text => { 
      searchInput.value = text;
      render();
  })

在线预览:stackblitz.com/edit/rxjs-x…

好了,介绍到这里,我个人的感觉是rxjs在处理网络层和ui层的逻辑时,在某些特定场景会非常简单。我在此推荐两个非常好的教程,我看到网上居然没人推荐这两个rxjs学习的教程(上面有个别案例就是从此来的)。

打通rxjs任督二脉: ithelp.ithome.com.tw/users/20020…

30天精通rxjs: ithelp.ithome.com.tw/articles/10…

文章最后,安利另一个函数式编程的库ramdajs,rxjs属于最近才学的,还没用到项目中

ramdajs是自己用了大概3个月了,有一些心得,确实写了之后代码的可维护性变的高很多,原因就是你必须遵守设计模式里的单一职责原则,所有功能能复用的函数都会提取出来(用函数式编程会强行让你养成这个习惯)

附一段我自己项目里ramdajs的代码,完毕。

// 这个函数的意思是,在函数链里面,如果其中一个函数返回是null或者undefined就终止函数链
const pipeWhileNotNil = R.pipeWith((f, res) =>
  R.isNil(res) ? res : f(res),
);

pipeWhileNotNil([
  // checkData用表单校验用的,如果不通过返回null,这个函数链条就终止,不会往下走
  // R__是占位符,被R.curry函数柯里化之后,就可以用R.__充当你函数参数的占位符
  // 数组里的参数不用管,是业务上需要自定义的
  checkData(R.__, ['sdExchangeRate', 'sdEffectDate']),
  // 此函数用来筛选,相当于数组的find方法,format2YYYYMMDD是dayjs用来格式化日期的
  R.find(
      (v) =>
        v?.effectDate === format2YYYYMMDD(sdEffectDate),
  ),
  // pipeP是promise函数流的方法,第一个参数必须是promise函数,后面的函数相当于promise里的then里面的函数
  R.pipeP(
    // 上一个函数执行结果会传给existEqualEffectDate
    // promiseModal是一个弹框组件,询问是否要继续某个操作
    async (existEqualEffectDate) => {
      if (existEqualEffectDate) {
        return await promiseModal({
          title: `生效日期已存在,是否直接修改汇率?`,
        });
      } else {
        return await promiseModal({
          title: `保存后生效日期不可修改,确定保存?`,
        });
      }
    },
    // 最后根据上一个返回的结果是ture还是false进行最后的操作,R.pipe是同步函数链方法,dispatch是redux里的dispatch
    (isGo) => isGo ? R.pipe(R.tail, saveData(dispatch))(data) : dispatch({
      type: 'currencyAndExchange/getExchangePairList',
    });
  ),
])(record);