Comlink を使えば、Reactでwebworkerを扱うときに生じる問題が簡単に解決、さらに Redux を使っているとなおさら React と worker のやりとりに苦慮しなくて済むようになります
React で webworker を扱うのは困難
React のコンポーネントで webworker を扱うのは非常に厄介です。
理由は2つあります
- React は web イベントと相性が悪い
- worker インスタンスは React の二度呼出仕様で保持が困難
理由1についてです。
webworker はmessage
イベントとposrMessage
を駆使することでメインスレッドとワーカーがやり取りできます。
一方 React は web イベントも React の理の外で、つまり副作用です。
つまり React コンポーネントで worker を扱おうと考えるとイベントリスナをいかにして React と連携させるかという問題に直面します。
なぜこれが問題かというと、
React はイベントリスナのコールバック関数内はさっぱり更新してくれないからです。
React がイベントリスナと相性が悪い理由
React はイベントリスナのコールバック関数内はさっぱり更新してくれない理由は簡単で、イベントリスナは React の機能ではないからです。
そのため関数コンポーネントの再呼び出し時(再レンダリング時)にイベントリスナのコールバック内のリアクティブな値は無視されます。
コールバック関数内で setState 関数を呼び出すようにしておいて、コールバック関数を useEffect 内でイベントリスナへアタッチしても
関数コンポーネントが再レンダリングされたら、もはやコールバック関数内の setState 関数は stale に(再レンダリング前の値に)なっています。
const myComponent = () => {
const [data, setData] = useState<iDataState[]>([]);
const agent = useRef<Worker>();
// Attach message event on mount.
useEffect(() => {
if (window.Worker && agent.current === undefined) {
agent.current = new Worker(
new URL('/src/worker/your.worker.ts', import.meta.url),
{ type: 'module' }
);
agent.current.addEventListener('message', handleWorkerMessage);
}
return () => {
if (window.Worker && agent.current) {
agent.current.removeEventListener(
'message',
handleWorkerMessage
);
agent.current.terminate();
}
};
}, []);
// レンダリング後はもはや`setState`はレンダリング前の`setState`を指している
const handleWorkerMessage = (e: MessageEvent<yourMessageType>) => {
const { data } = e.data.payload;
setData(data);
};
const requestFetchData = () => {
if (agent.current !== undefined) {
agent.current!.postMessage({"get data"});
}
};
return (
// ...
);
};
となるとどうするべきかといえば、
レンダリングのたびにイベントリスナに再度コールバック関数をアタッチしなおさなくてはならなくなります
const myComponent = () => {
const [data, setData] = useState<iDataState[]>([]);
const agent = useRef<Worker>();
// Attach message event on mount.
useEffect(() => {
if (window.Worker && agent.current === undefined) {
agent.current = new Worker(
new URL('/src/worker/your.worker.ts', import.meta.url),
{ type: 'module' }
);
agent.current.addEventListener('message', handleWorkerMessage);
}
return () => {
if (window.Worker && agent.current) {
agent.current.removeEventListener(
'message',
handleWorkerMessage
);
agent.current.terminate();
}
};
}, []);
+ // 毎レンダリングイベントリスナのコールバック関数を再アタッチする
+ useEffect(() => {
+ if (window.Worker && agent.current === undefined) {
+ agent.current.addEventListener('message', handleWorkerMessage);
+ }
+ return () => {
+ if (window.Worker && agent.current) {
+ agent.current.removeEventListener(
+ 'message',
+ handleWorkerMessage
+ );
+ }
+ };
+ });
const handleWorkerMessage = (e: MessageEvent<yourMessageType>) => {
const { data } = e.data.payload;
setData(data);
};
const requestFetchData = () => {
if (agent.current !== undefined) {
agent.current!.postMessage({"get data"});
}
};
return (
// ...
);
};
毎レンダリング時に副作用を呼び出すことになるので、
非常に無駄な処理が発生しワーカースレッドを使う恩恵を減らしているような気がしてなりません。
このように React で生で webworker を扱おうとしたら大変です。
Comlink はメッセージイベントを丸投げできる
Comlink は、React で worker を扱うのが困難であるという先の理由1を解決します。
なぜかといえば、
メッセージのやり取りを一切 Comlink に丸投げできるので使う側は message イベントを管理しなくてよくなる点です。
メッセージイベントを管理しなくてよくなるので、上記の message イベントの管理問題が一気に解決されます。
// Comlinkを導入した例
import * as Comlink from 'comlink';
import type { ExposedAPI } from '../worker/your.worker.ts';
const myComponent = () => {
const [data, setData] = useState<iDataState[]>([]);
const worker = useRef<Worker|undefined>();
const agent = useRef<Comlink.Remote<ExposedAPI>>();
// イベントリスナを呼び出さなくてよくなった
// なのでコールバック関数の再アタッチなどしなくてよくなった
useEffect(() => {
if (window.Worker && agent.current === undefined && worker.current === undefined) {
worker.current = new Worker(
new URL('../worker/fetchLibs.worker.ts', import.meta.url),
{
type: 'module',
}
);
agent.current = Comlink.wrap<ExposedApi>(worker);
}
return () => {
if (window.Worker && agent.current !== undefined && worker.current !== undefined) {
agent[Comlink.releaseProxy]();
worker.current.terminate();
worker.current = undefined;
}
};
}, []);
// もはや不要に。
// const handleWorkerMessage = (e: MessageEvent<yourMessageType>) => {
// const { data } = e.data.payload;
// setData(data);
// };
const requestFetchData = () => {
if (agent.current !== undefined) {
// もはや不要に。
// agent.current!.postMessage({"get data"});
// `setData`がもはやstaleじゃない!!
agent.current.getData().then((data) => setData(data));
}
};
return (
// ...
);
};
Comlink を使えば、worker をあたかもモジュールのように扱うことができるので、イベントリスナを呼び出す必要がなくなり、
イベントリスナがなくなったのでリアクティブな値は stale になってしまうことを気にする必要もなくなりました。
worker インスタンスも Comlink で生成されたオブジェクトも明示的にターミネイトしなくてはならないので
両方インスタンスを参照できるようにしておきます。
React では worker のインスタンスの保持が困難
React で webworker を扱うのは困難の理由2についてです。
useRef
で worker のインスタンスを保持すればいいだけでは?
実はそうではありません。
React はStrictMode
だと関数コンポーネントを二度呼び出す仕様となっております。
そのため関数コンポーネントで worker のインスタンスを保持するには、毎度の二度呼び出しに耐えられるように工夫しないとなりません。
単純にuseRef
で参照するだけではうまくいきません。
拙作の記事より、なんで React で worker インスタンスの保持useRef
だけでは不十分なわけについて
リンク先の記事では worker インスタンスを参照している ref は terminate した後必ず undefined を渡さないと正常に機能してくれません。
この辺は原因がはっきりしておりません。
const myComponent = () => {
const [data, setData] = useState<iDataState[]>([]);
const worker = useRef<Worker|undefined>();
useEffect(() => {
// workerインスタンスがundefinedであることを確認する!
if (window.Worker && worker.current === undefined) {
worker.current = new Worker(
new URL('../worker/fetchLibs.worker.ts', import.meta.url),
{
type: 'module',
}
);
}
return () => {
// workerインスタンスがundefinedになっていないか確認する!
if (window.Worker && worker.current !== undefined) {
worker.current.terminate();
// terminateしたらundefinedを渡す!
worker.current = undefined;
}
};
}, []);
// ...
return (
// ...
);
};
これを行っておかないと、worker スレッドが何個も生成されてしまったり、
worker へ送信したメッセージが永遠に届かなかったりします(おそらく生成したワーカとそのワーカが扱うはずのスレッドとがちぐはぐになってしまっているから?)
上記の通り、Reactでそのままworkerを扱う場合
- worker を生成する際は ref.current が undefined であることを確認する
- worker を terminate する前に worker が undefined でないことを確認する
- worker を terminate したら ref.current を undefined にしておく
を守ると React の再レンダリング仕様を耐えられます。
Redux の Thunk アクションクリエータとの連携
...をするともはや React で worker とやり取りする必要がなくなる...という一例です。
worker のメソッドの呼び出しが Redux の store に関連するならば直接 Redux が worker を管理すればよくなります。
例
(無駄な)負荷の高い計算(expensiveCalcurate
)を実施してくれるworker
と、そのworker
と連携する Thunk アクションクリエータ。
import { createSlice, createAsyncThunk } from '@reduxjs/toolkit';
import type { PayloadAction } from '@reduxjs/toolkit';
import * as Comlink from 'comlink';
import type { RootState } from '../store';
import type { ExposedApi } from '../worker/your.worker';
interface iState {
status: 'loading' | 'idle' | 'failed';
number: number;
}
const worker = new Worker(
new URL('../worker/your.worker.ts', import.meta.url),
{
type: 'module',
}
);
const agent = Comlink.wrap<ExposedApi>(worker);
export const requestExpensiveCalcuration = createAsyncThunk(
'worker/expCalc',
async (arg: number, thunkApi) => {
return agent.expensiveCalcurate(arg);
}
);
const initialState: iState = {
status: 'idle',
number: 0,
};
const workerSlice = createSlice({
name: 'worker',
initialState,
reducers: {
sum: (state, action: PayloadAction<number>) => {
state.number += action.payload;
},
},
extraReducers: (builder) => {
builder
.addCase(requestExpensiveCalcuration.pending, (state) => {
state.status = 'loading';
})
.addCase(requestExpensiveCalcuration.fulfilled, (state, action) => {
state.status = 'idle';
state.number = action.payload;
caches.set(String(action.payload), action.payload);
})
.addCase(requestExpensiveCalcuration.rejected, (state, action) => {
state.status = 'failed';
});
},
});
export const workerActions = workerSlice.actions;
export const selectWorker = (state: RootState) => state.worker;
export default workerSlice.reducer;
slice ファイルの中の worker インスタンスは、React コンポーネントで扱う場合と異なり
インスタンスを再レンダリング時にまたがって保持する工夫を施す必要がなくなります。
とくに React の「二度関数を呼び出す」という純粋関数を使ってますよね仕様で worker の生成が台無しになったりしません。
なので上記の通り、インスタンスを一度生成してしまえば React の理の外でずっと保持してくれるので
worker の取り扱いがずっと楽になります。
ほかのメリット
worker インスタンスの保持が楽になるという点以外にもメリットがあります。
Thunk アクションクリエータを使って worker と連携させれば React は一切 worker とやり取りしないで更新された state を享受できます。
そのため、React は UI の更新に専念して余計な副作用を管理しなくてよくなります。
残る課題
React で worker インスタンスを持たなくてよくなった代わりに、worker のインスタンスを terminate するタイミングが難しくなりました。
この点は解決できていません。
最後に
Reactでworkerを扱うにはどうすればいいねんという人の参考にできたら幸いです。