Comlinkを使って手軽にWorkerを扱う

アクセシビリティ・エンジニア 黒澤

この記事はミツエーリンクス Advent Calendar 2020 - Adventarの25日目の記事です。

この記事ではComlinkを使って時間のかかる処理を手軽にWorkerへ移した体験を紹介します。

テキスト照合ツール

今回Workerに移した処理は、ミツエーリンクス社内でテキスト照合ツールと呼んでいるツールの一部です。テキスト照合ツールは、原稿のテキストと制作したページのテキストを入力すると2つの異なる部分(差分)をハイライト表示するWebページです。これはテキストの差分を計算するJavaScriptライブラリの1つ、jsdiff(3条項BSDライセンス)サンプルをミツエーリンクス社内で調整したものです。

テキスト照合ツールは原稿のテキストを入力する欄、ページのテキストを入力する欄、比較結果を表示する欄の大きく3つで構成されています。原稿とページの入力欄にテキストを入力すると比較結果がリアルタイムに更新され、差分がハイライト表示されます。

さて、このテキスト照合ツールは社内の制作作業やチェック作業の効率を高めてくれましたが、1つ問題がありました。それは、テキストの組み合わせによっては差分の計算に時間がかかり、その間ページが操作できなくなる(メインスレッドがブロックされる)ことです。厳密に測定したわけではありませんが、テキストの片方は極端に短く、もう片方は極端に長いなどの特定条件で10秒以上操作できなくなっていました。

これは非常にストレスフルでしたので、差分の計算に時間がかかるのは仕方ないにしても、処理中であることは表示したいと考えました。

Comlink

一般に、Workerを使うとメインスレッドをブロックすることなく処理を実行できますので、テキストの差分を計算する処理をWorkerに移すことにしました。

移行前の処理を単純化すると以下のようになります。jsdiffに比較したいテキストを渡しているだけです。

// Workerを使わない場合
import * as Diff from 'diff';

function computeDiff(oldText, newText) {
    return Diff.diffChars(oldText, newText);
}

const diffs = computeDiff('test', 'text');
// 以降、結果表示

この処理をWorkerに移すと次のようになります。

// Workerを呼び出す側
const worker = new Worker('./js/worker.js');

function computeDiff(oldText, newText) {
    return new Promise((resolve) => {
        const myId = generateUniqueId();
        worker.addEventListener('message', function handler({data: {id, payload}}) {
            // messageイベントは自分に対する返信でないものに対しても発生しうるので
            // 自分に対する返信でなければ無視
            if (id !== myId) {
                return;
            }

            worker.removeEventListener('message', handler);
            resolve(payload);
        });
        worker.postMessage({
            id: myId,
            message: 'computeDiff',
            payload: {
                oldText,
                newText,
            },
        });
    });
}

const diffs = await computeDiff('test', 'text');
// 以降、結果表示
// Worker側
import * as Diff from 'diff';

function computeDiff({oldText, newText}) {
    return Diff.diffChars(oldText, newText);
}

self.addEventListener('message', ({data: {id, message, payload}}) => {
    // Workerに処理を何種類書いたとしてもイベントはmessage1種類のみなので
    // 受信内容をもとに処理を分岐
    if (message === 'computeDiff') {
        postMessage({
            id,
            message,
            payload: computeDiff(payload),
        });
    }
});

処理をそのままWorkerに移すと処理の本筋(差分の計算)に関係しないコードが増え、見通しが悪くなるように感じます。

一方、Comlink(Apache-2.0ライセンス)を使うと、Workerに移した処理を通常の非同期処理のように扱うことができます。返信の確認や処理の分岐もComlinkが行ってくれます。

// Workerを呼び出す側
import {wrap} from 'comlink';

const computeDiff = wrap(new Worker('./js/worker.js'));
const diffs = await computeDiff('test', 'text');
// 以降、結果表示
// Worker側
import * as Diff from 'diff';
import {expose} from 'comlink';

function computeDiff(oldText, newText) {
    return Diff.diffChars(oldText, newText);
}

expose(computeDiff);

Comlinkを利用すると処理の本筋(差分の計算)に関係しないコードが減り、コードの見通しが良くなったように感じます。

結果、差分の計算をWorkerに移すことができたので、差分の計算に時間がかかっている場合にも処理中であることを表示できるようになりました(処理中であることの表示にはprogress要素を使っています)。

おまけ:差分計算ライブラリの切り替え

こうして、差分の計算に時間がかかっている場合も処理中であることはわかるようになりましたが、差分の計算に時間がかかる問題自体は残っていました。

この問題は差分の計算をdiff-match-patch(Apache-2.0ライセンス)に切り替えることで解決できました。厳密に測定したわけではありませんが、diff-match-patchに切り替えたところ、これまで差分の計算に10秒以上かかっていたテキストの組み合わせでも0.01秒以内に終わるようになりました。また、差分の計算に指定秒数以上かかる場合は途中で処理を打ち切ることができます(※)。

この切り替えによってテキスト照合ツールで待たされることはまずなくなりました。結果的に処理をWorkerに移した意味はだいぶ薄れてしまいましたが、今はとても快適に使えています。

なお、テキスト照合ツールはミツエーリンクスのGitHubで公開することを検討しています。

diff-match-patchのドキュメントによると処理が途中で打ち切られた場合にも「正しい(valid)」結果が得られるとのことです。簡単に調べたところ、a !== bはすぐに計算できるため、処理を打ち切った場合もa全体とb全体を差分として表示すれば、abが異なることをユーザーに伝えられるという意味で「正しい(valid)」という表現を使っているようです。

まとめ

Comlinkを使うと時間のかかる処理を手軽にWorkerに移すことができます。また、テキストの差分の計算に時間がかかって困るというかたはdiff-match-patchを検討してみてはいかがでしょうか。