Rxjs初體驗:製作語音測試工具

lazy 幅川發表於2019-02-11

學習Rxjs是兩個月前的事了,但沒有用到在一個實際需求上。近日收到一個需求,正好是一個可以抽象為由資料流驅動的應用,於是我欣然用Rxjs結合React寫之。

需求說明

需求原始說明為:

60個英文單詞被分為三個組系,每個組系包括2-6個單詞一組的五組單詞。在測試中,測試單詞以1詞/秒的速率依次呈現在電腦螢幕上,被試大聲朗讀螢幕上的單詞,當一組單詞呈現結束時,螢幕上出現與該組數目相等的問號,提示被試用剛才呈現的單詞造句。 兩個一組時,螢幕上以1詞/秒的速率依次出現“price”,“week”,兩個單詞後會出現??,受試用price造句,造完句後點下滑鼠,螢幕上出現一個問號,受試用week造句,之後點滑鼠進入三個一組,螢幕上以1詞/秒的速率依次出現“bird”,“game”,“ star”,三個單詞後會出現???,受試用bird造句,造完句後點下滑鼠, 螢幕上出現兩個問號,受試用game造句,再點下滑鼠, 螢幕上出現一個問號,受試用star造句,之後點滑鼠進入四個一組…以此類推。

全程錄音,計算機記錄受試每個單詞造句所用時間。

需求原始說明可能有些不易看明,經過討論,我歸總如下:

  1. 資料格式為:(word.組系序號.組單詞數.組內序號)

    Test1 Test2 Test3
    word.1.2.1 word.2.2.1 word.3.2.1
    word.1.2.2 word.2.2.2 word.3.2.2
    word.1.3.1 word.2.3.1 word.3.3.1
    word.1.3.2 word.2.3.2 word.3.3.2
    word.1.3.3 word.2.3.3 word.3.3.3
    ... ... ...
    word.1.6.6 word.2.6.6 word.3.6.6

    由此,抽象為以下資料型別:

    // 代表一個組系
    interface TestItem {
      name: string; // 組系名,如:Test1, Test2, Test3
      /*
        二維陣列表示的組系內資料項
        [word.1.2.1, word.1.2.2]
        [word.1.3.1, word.1.3.2, word.1.3.3]
        ...
      */
      wordGroups: string[][]; 
    }
    複製程式碼

    並將資料放入全域性store:

    class Store {
      // 組系
      items: TestItem[] = [];
      // 每組最小單詞數
      minWordsCount = 2;
      // 每組最大單詞數
      maxWordsCount = 6;
      // 實驗開始前倒數計時秒數
      countdown = 5; 
    }
    複製程式碼
  2. 流程:

    1. 倒數計時

    2. 對每個組系:

      1. 對每一組:

        1. 依次顯示單詞,每個單詞顯示一秒
        2. 對每個單詞:
          1. 顯示此組單詞數 - i個問號
          2. 如果點選事件發生,子流程結束
        3. 子流程結束
      2. 子流程結束

    3. 流程結束

讀者有意,可以試想一下普通做法如何實現。

資料流抽象

為了方便一些操作,我封裝了一個immediateInterval,因為interval不是即時開始發射資料的:

function immediateInterval(
  period?: number | undefined,
  scheduler?: SchedulerLike | undefined
): Observable<number> {
  return new Observable(subscriber => {
    subscriber.next(0);
    const interval$ = interval(period, scheduler);
    const next = subscriber.next; 
    subscriber.next = function(this: Subscriber<number>, i: number) {
      next.bind(this)(i + 1);
    }.bind(subscriber);
    interval$.subscribe(subscriber);
  });
}
複製程式碼

由上節的流程,得到如下資料流:

  1. 倒數計時:使用immediateInterval發出store.countdown個值,每個值攜帶當前倒數計時數,然後

  2. 迭代item of store.items

    1. 迭代group of item.wordGroups:

      1. 顯示單詞:使用immediateInterval發出group.length個值,每個值攜帶當前單詞,然後

      2. 迭代for(let i = 0; i < group.length; i++)

        1. 發出值,攜帶group.length - i(問號數)
        2. 訂閱全域性event busclick事件,當click激發時,continue此迭代

資料流實現

以下程式碼的一些設計點:

  • 使用event busemit實現流程鉤子,用來向外報告進行到了哪一步
  • 使用async await語法實現非同步迭代:
    async () => {
      for(...){
        await new Promise(async (resolve) => {
          someAsyncCode(
            // 結束此Promise,以continue該for迴圈
            resolve(); 
          )
        })
      }
    }
    複製程式碼
// event bus
const bus = new EventEmitter();

const stream$ = new Observable<
  | { type: "倒數計時"; count: number }
  | {
      type: "顯示單詞";
      word: string;
    }
  | {
      type: "顯示問號";
      count: number;
    }
>(subscriber => {
  immediateInterval(1000)
    .pipe(take(store.countdown + 1))
    .subscribe(
      i => {
        if (i !== store.countdown)
          subscriber.next({ type: "倒數計時", count: store.countdown - i });
      },
      console.error,
      async () => {
        bus.emit("start", { type: "full" }); // 全程開始

        for (let item of store.items) {
          await new Promise(async (resolve, reject) => {
            bus.emit("start", { type: "item", item }); // 一組系開始
            for (
              let groupIndex = 0;
              groupIndex < item.wordGroups.length;
              groupIndex++
            ) {
              const group = item.wordGroups[groupIndex];
              // 一組開始
              bus.emit("start", { type: "group", item, groupIndex }); // 一組開始
              await new Promise(async (resolve, reject) => {
                // 顯示單詞
                immediateInterval(1000)
                  .pipe(take(group.length))
                  .subscribe(
                    i => {
                      subscriber.next({ type: "顯示單詞", word: group[i] });
                    },
                    console.error,
                    () => {
                      // 顯示問號
                      rxjs.timer(1000).subscribe(async () => {
                        for (
                          let i = group.length, wordIndex = 0;
                          i > 0;
                          i--, wordIndex++
                        ) {
                          // 開始造句
                          bus.emit("start", {
                            type: "sentence",
                            index: wordIndex,
                            item,
                            groupIndex
                          });
                          await new Promise(async (resolve, reject) => {
                            subscriber.next({ type: "顯示問號", count: i });
                            const onClick = () => {
                              resolve(); // next 問號
                              bus.removeListener("click", onClick);
                            };
                            bus.addListener("click", onClick);
                          });
                          // 造句結束
                          bus.emit("end", {
                            type: "sentence",
                            index: wordIndex,
                            item,
                            groupIndex
                          });
                        }
                        resolve(); // next group
                      });
                    }
                  );
              });
              bus.emit("end", { type: "group", item, groupIndex }); // 一組結束
            }

            bus.emit("end", { type: "item", item }); // 一組繫結束
            resolve(); // next test
          });
        }

        bus.emit("end", { type: "full" }); // 全程結束
        subscriber.complete();
      }
    );
});
複製程式碼

連線到React元件(Hooks):

const Play = () => {
  const action = useObservable(() => stream$);

  let comp = null;

  if (!action) {
    comp = null;
  } else if (action.type === "倒數計時") {
    comp = ...;
  } else if (action.type === "顯示單詞") {
    comp = ...;
  } else if (action.type === "顯示問號") {
    comp = ...;
  }

  return ...; 
};
複製程式碼

總結

Rxjs使得一些事情更容易(:

涉及到的一些庫

rxjs

rxjs-hooks(rxjs與react hooks的結合庫)

wolfy87-eventemitter(瀏覽器端的高效的EventEmitter)

相關文章