📝

Webアプリのパフォーマンス向上についての考察: Web Worker と RxJS

2024/03/01に公開

はじめに

現代の Web アプリでは、ユーザ体験を向上させるために高いパフォーマンスとスムーズなインタラクションが求められます。
しかし、複雑な計算やデータ処理を UI スレッド上で行うと、画面のレンダリングが遅れたり、アプリの応答性が低下することがあります。
このような問題を解決するために以下の検証を行いました。

  • Web Worker を使用して重たい計算処理をバックグラウンドスレッドにオフロードし、UI スレッドの負荷を軽減する
  • RxJS を活用してデータフローを簡潔かつ効率的に管理し、コードの可読性を向上させる

この記事では、Web Worker と RxJS を活用して高いパフォーマンスを実現しつつ、複雑なデータ処理を効率的に管理する方法について、具体的な DEMO とコードスニペットを通じて解説します。

DEMO

MacBook Pro & Chrome で動作確認しています。
カメラとマウスの入力を加工しビジュアライズする DEMO を作りました。

repo も公開していますのでよろしければご覧ください。

背景

カメラから受け取った画像を加工し、色が濃い箇所は赤く、薄い箇所は青、中くらいの色は緑にします。
動きが多いところはピクセルの高さを高くしました。

インジケーター

マウスをクリックしている間、0.8 秒に 10%ずつ満たされていくインジケーターです。

マウス移動履歴

1 秒に 1 回マウスの動きを集計し、発生回数移動量を表示します。
動きがなかった場合は「did not happen...」と表示されます。

通知

アプリ内で起こったイベントを表示します。
通知には Info, Succeed, Failed, Waring の 4 つの区分があり、背景色やアイコンが変わります。

計算タスクの実行場所切り替え

計算タスクを Main Thread で行うか Web Worker で行うか切り替えます。

構成

リアクティブな Web アプリではよく state の共有が取り上げられますが、

  1. 書き込み時点の値と読み込み時点の値が異なる
  2. 一度きり読み込みたい値をいつまでも持っておくか、消す処理をいれる必要がある

といった問題点があります。
そこで値ではなく「出来事を共有する」方が今回の要件に合っていると考えました。

細かい構成については後ほど紹介しますが src/features/* にある要素は互いに依存させないというルールを設けました。
使用したライブラリを選んだ理由は次の通りです。

SolidJS

JSX がすきなので React と比較しました。
今回の作りたいものは canvas 操作がメインとなります。
React であれば useMemouseCallback などによる再レンダリング防止処理が必要ですが SolidJS はそのようなものが不要であることが魅力的に感じました。
canvas 処理自体が副作用の塊ですので必要最低限のレンダリングを行う SolidJS の方が今回の要件に合っていると考えました。

Three.js

他に有力なライブラリを見つけることができなかったというのもありますが、3D ライブラリは Three.jsBabylon.js が候補でした。
今回の検証では WebGL に書き出せればいいので、何度か触ったことがある Three.js を採用しました。

RxJS

Bacon.js や Most.js なども確認しましたが RxJS が最も活発でドキュメントも整備されていました。
また pipe による filter 処理がとても書きやすく理解しやすいことから RxJS を使用することにしました。

プロジェクトの構成

src/app.tsx

entrypoint です。
普段は以下のように dom へ render する処理を main.tsx に、各コンポーネントをまとめた処理を app.tsx に書くことが多いです。

// main.tsx
import { App } from './app'
render(<App />, document.getElementById('root')!)

// app.tsx
export function App() {
  return (
    <Layout>
      <A />
      <B />
      <C />
    </Layout>
  )
}

コードを分割しすぎても分かりにくくなるため、これらのコードを src/app.tsx にまとめました。

src/$/*

RxJS による steram を管理しているフォルダです。
Observable は $ という suffix をつけています。
publish する関数は emit という prefix をつけました。

src/features/*

src/app.tsx で使用される機能群です。
それぞれが stream に発信し、イベントを購読することで、features の兄弟要素は互いに依存しないように考えています。

src/lib/*, src/types/*

プロジェクト全体で使用するものです。
上述した src/$/* と合わせてイベントと型という概念に features が依存するというコンセプトで作りました。

カメラの入力処理から canvas へ書き出すまで

ここからは詳細なコードの解説を行います。
よろしければ repo を clone して動かしながらご覧いただければと思います。

このアプリを開いたとき、画面中央にカメラにアクセスするボタンが表示されています。

src/features/interaction/no-signal.tsx

このボタンが押されたとき src/$/camera.ts にある accessRequested$ が発火します。
src/features/camera/watch-access-requested.ts でその stream を購読しカメラアクセスを行います。
無事カメラアクセスが許可された場合は src/$/camera.ts にある mediaStreamCreated$ が発火します。

src/features/camera/watch-media-stream-created.tsmediaStreamCreated$ を購読し mediaStream を video タグに紐づけ、カメラ画像を canvas 2d に書き込みます。
requestAnimationFrame のタイミングでカメラ画像を吸い上げ src/$/camera.ts にある captured$ に画像を流します。

src/features/heat-map/render-heat-map.tscaptured$ を購読し、Three.js を介して WebGL に書き込みます。
その際、カメラ画像を加工するのですが Web Worker で行うか Main Thread で行うかユーザの選択によって振り分けられます。

src/$/interaction.ts にある calculationProcessLocationUpdated$ イベントを購読します。
locationCalculationProcessLocation という型です。

export type CalculationProcessLocation = 'WebWorker' | 'MainThread'

以下のように switchMap を使用することにより、切り替え前の stream を停止して次の stream を開始することができます。
map は主に加工 Phase で使用します。
今回はカメラ画像を頂点と色へ加工するために使用しています。
tap は副作用を引き起こす際に使用します。
このオペレータはストリームに流れるデータには影響を与えません。

calculationProcessLocationUpdated$
  .pipe(
    switchMap((location) =>
      location === 'WebWorker'
        ? captured$.pipe(
            map((capture) => ({
              capture,
              size,
              height: window.innerHeight,
            })),
            tap((payload) => worker.postMessage(payload))
          )
        : captured$.pipe(
            map((capture) => calculate(capture, size, window.innerHeight)),
            tap(([vertices, colors]) => renderer.render(vertices, colors))
          )
    )
  )
  .subscribe()

vite で Web Worker を使用する

特に vite.config.ts を編集しなくても Web Worker を TypeScript で開発することができます。
ただし import 方法が少し独特で、次のようにしなくてはビルド後に動作しません。

// ?worker をつける
import Worker from './worker?worker'
const worker = new Worker()

Main Thread でも Web Worker でも同じ計算方法を使うため、関数を切り出しました。
そのため Worker 自体はとても単純な作りに収まりました。

import { WorkerPayload } from '~/types/worker-payload'
import { calculate } from './calculate'

self.addEventListener('message', (event: MessageEvent<WorkerPayload>) => {
  const { capture, size, height } = event.data
  const [vertices, colors] = calculate(capture, size, height)
  self.postMessage(
    {
      vertices: vertices.buffer,
      colors: colors.buffer,
    },
    [vertices.buffer, colors.buffer] as any
  )
})

self.postMessage の第 2 引数に参照を引き渡すことにより、高速にデータを配信することができます。

マウス長押しをインジケーターに表示する

chart は apexcharts を使いました。
使ったことがないライブラリでしたが、いい機会ですので採用しました。
chart は ほとんどサンプル通りに実装しています。
recharts に似ていて使いやすかったです。

src/$/mouse.ts にある pressed$released$ をマージして長押しの stream を作ります。
src/features/mouse-pressed-indicator/$.ts をご覧ください。

export const $ = merge(
  pressed$.pipe(map(() => 5)),
  released$.pipe(map(() => 0))
).pipe(
  switchMap((value) =>
    value === 0
      ? [0]
      : interval(800).pipe(
          startWith(value),
          scan((current) => Math.min(current + 10, 100), value - 5),
          takeUntil(released$)
        )
  )
)

押した直後はインジケーターが動かないので最初は 5 をセットするようにしました。
長押しを取得しようとすると難しいコードになりますが、RxJS を使用すると簡単に組み立てることができます。

マウス移動履歴を表示する

1 秒に 1 回マウスの移動距離と mousemove イベントの発生回数を表示します。
src/$/mouse.ts にある moved$ を加工して新しい stream を作成しました。

const initialState: MouseMovement = {
  occurred: 0,
  x: 0,
  y: 0,
  amount: 0,
}

export const $ = moved$.pipe(
  bufferTime(1000),
  map((events) =>
    events.reduce<MouseMovement>(
      (previous, current: MouseMoveEvent) => ({
        occurred: previous.occurred + 1,
        x: Math.abs(previous.x) + Math.abs(current.x),
        y: Math.abs(previous.y) + Math.abs(current.y),
        amount: previous.amount + Math.sqrt(current.x ** 2 + current.y ** 2),
      }),
      initialState
    )
  )
)

この機能では 1000 ミリ秒 buffering して蓄積した events を reduce して集計しています。

通知

これまでマウスやカメラなどイベントを購読してきましたが、それぞれ独立した機能だったため購読する範囲は限定的でした。
しかしこの通知機能はアプリ全体の通知をすべて監視しています。
以下のコードをご覧ください。

export const $: Observable<Notification> = merge(
  camera.accessRequested$.pipe(map(() => info('Camera Access Requested'))),
  camera.permissionUpdated$.pipe(
    filter((status) => status === 'granted'),
    map((_) => succeed('Camera Access Granted'))
  ),
  camera.permissionUpdated$.pipe(
    filter((status) => status === 'denied'),
    map((_) => warning('Camera Access Denied'))
  ),
  camera.unavailable$.pipe(map((cause) => failed('Camera Unavailable', cause))),
  camera.mediaStreamCreated$.pipe(map(() => succeed('MediaStream Created'))),
  camera.captured$.pipe(
    take(1),
    map(() => succeed('Heat Map Rendering Started'))
  ),
  interaction.calculationProcessLocationUpdated$.pipe(
    filter((c) => c === 'MainThread'),
    map((_) => info('Process Location', 'Switched to Main Thread'))
  ),
  interaction.calculationProcessLocationUpdated$.pipe(
    filter((c) => c === 'WebWorker'),
    map((_) => info('Process Location', 'Switched to Web Worker'))
  ),
  background.canvasMounted$.pipe(
    map(() => succeed('Background canvas Created'))
  ),
  mouse.pressed$.pipe(map(() => info('Mouse Pressed'))),
  mouse.released$.pipe(map(() => info('Mouse Released'))),
  window.resized$.pipe(
    throttleTime(1000),
    map(({ width, height }) =>
      info(
        'Window Resized',
        `width: ${width.toLocaleString()}, height: ${height.toLocaleString()}`
      )
    )
  )
)

このように複数の stream を自分の要件に合わせて加工しています。
既存の機能には一切影響を与えません。
RxJS を触っていて一番楽しくなる瞬間です。

emit する側は自分が起こしたことをありのままに伝えればよく、購読する側は pipe にオペレータを入れて自分にとって扱いやすい形に加工して使用します。

おわりに

Web Worker と RxJS の利用は、パフォーマンスの大幅な向上を実現しました。
具体的には、UI スレッドの負荷を軽減し、フレームレートの向上に寄与しました。
特に、Web Worker を使用した場合のフレームレートは、Main Thread のみを使用した場合と比較して、明確な改善が見られました。

  • Main Thread: 平均 30FPS 程度
  • Web Worker 使用時: 60FPS を維持

ただし Main Thread で動かし続けた結果 60FPS まで向上しました。
もしかするとキャッシュのような物があるのかもしれません。

また Retina ディスプレイでは最大 120FPS まで向上しました。
Retina ディスプレイでの比較:Web Worker を使用した場合と Main Thread のみを使用した場合で、それぞれ 120FPS と 60FPS を記録しました。

SolidJS

今回は Web Worker と RxJS がメインでしたので SolidJS にはあまり触れていませんでしたが、とてもすばらしいライブラリと思います。
React を書いていると {nullable && <div>{nullable}</div>} のように書くことがあります。
<Show when={nullable}>{nullable}</Show> と条件を宣言的に書けます。vuev-if ぽくていいですね。
また xs.map(x => <div key={x}>{x}</div>)<For each={xs}> と書けます。
JSX をより気持ちよく書けました。
また immer でおなじみの draft を編集する方法もありました。

setMovements(
  produce((draft) => {
    draft.unshift(movement)
    draft.length > 50 && draft.pop()
  })
)

PR

株式会社 blue で開発に携わっている VOTE は、二択のお題に投票する Web アプリです。
技術的な話題から日常の選択など、多様なお題でお気軽に遊んでみてください。

株式会社blue TechBlog

Discussion