Web Workerは Web 開発の重要な要素として注目を集めている。そしてComlinkは、Web Worker を使った開発を楽しめるように GoogleChrome チームによって作成された JavaScript ライブラリだ。Comlink は Web Worker で定義された API と UI スレッドが簡単に通信できるようにする。
この記事では、Angular CLI で作成した Angular アプリケーションに Comlink を組み込む方法を説明する。Comlink を使うことで、重い処理をメインスレッドから簡単に切り離すことができ、コードを分離することで JavaScript のバンドルを小さくすることができる。
アプリケーションの準備
Angular CLIを使って、サンプルアプリケーションを作成する。この記事で使用する Angular CLI のバージョンは v12 を前提としている。
$ ng new angular-comlink-example --defaults
アプリケーションのセットアップが終わったあと、 comlink
パッケージをインストールする。Comlink は TypeScript 型定義も同梱しているため型定義を追加でインストールする必要はない。
$ cd angular-comlink-example
$ yarn add comlink # or npm install comlink
Worker モジュールの実装
例として、Markdown テキストを HTML へ変換するアプリケーションを作成する。 まずはじめにmarked
と@types/marked
の 2 つのパッケージをインストールする。
$ yarn add marked && yarn add --dev @types/marked
次に、 ng generate webworker
コマンドを使い、 worker/markdown.worker.ts
ファイルを作成する。このコマンドは TypeScript ファイルだけでなく、Web Worker 用の tsconfig.worker.json
も同時に生成し、そのファイルを参照するように angular.json
ファイルも更新される。
$ ng generate webWorker worker/markdown
CREATE tsconfig.worker.json (289 bytes)
CREATE src/app/worker/markdown.worker.ts (157 bytes)
UPDATE angular.json (3204 bytes)
生成された markdown.worker.ts
ファイルの内容はすべて削除し、次のように Markdown テキストを HTML へコンパイルする API を記述する。
// worker/markdown.worker.ts
import * as marked from 'marked';
export const api = {
compileMarkdown(source: string) {
return new Promise<string>((resolve, reject) => {
marked(source, (err, result) => {
if (err) {
reject(err);
return;
}
resolve(result);
});
});
},
};
compileMmarkdown
関数をこのワーカーの API として公開するため、Comlink.expose
関数を呼び出し、api
オブジェクトを渡す。
import * as marked from 'marked';
import { expose } from 'comlink';
export const api = {
compileMarkdown(source: string) {
// ...
},
};
expose(api); // Expose as worker's API
ワーカー側の作業はこれで終わりとなる。
ワーカーの公開 API を呼び出す
続いて、Comlink を通してワーカーの compileMarkdown
関数を UI スレッド側から呼び出す。 まずは Angular サービスとして MarkdownService
クラスを作成し、コンポーネントから呼び出し可能な状態にする。
$ ng generate service service/markdown
MarkdownService
クラスには引数に Markdown テキストを受け取りコンパイル結果を Promise で返す compile
メソッドを定義しておく。
import { Injectable } from '@angular/core';
@Injectable({
providedIn: 'root',
})
export class MarkdownService {
async compile(source: string): Promise<string> {}
}
worker/markdown.worker.ts
を呼び出すため、Comlink をインポートして wrap
関数でワーカーインスタンスをプロキシする。 ワーカーはnew Worker(new URL('../worker/markdown.worker', import.meta.url)
でインスタンス化される。この記述形式はwebpack の Web Worker サポートで定義されたものである。
import { wrap } from 'comlink';
async function compileMarkdown(source: string): Promise<string> {
const worker = wrap<typeof import('../worker/markdown.worker').api>(
new Worker(new URL('../worker/markdown.worker', import.meta.url)),
);
return await worker.compileMarkdown(source);
}
ここで worker
変数は Comlink.expose
で公開された関数を持っており、その型も保持されている。
api
の型を取り出すために、 typeof import('../worker/markdown.worker).api
を wrap<T>
のジェネリクスに渡している。この import
は ES モジュールのインポートではなく TypeScript の型定義だけをインポートしている。そのため TypeScript のコンパイル後には除去され、静的な参照は残らず JavaScript のバンドルを分割できる。
以下はservice/markdown.service.ts
の最終的な例だ。環境が window.Worker
をサポートしていない場合は、動的 import()
を使用してメインスレッドでの処理にフォールバックする。この場合でも自動的にコードは分割され、モジュールは遅延読み込みされる。
import { Injectable } from '@angular/core';
import { wrap } from 'comlink';
async function compileMarkdown(source: string): Promise<string> {
if (window.Worker) {
const worker = wrap<typeof import('../worker/markdown.worker').api>(
new Worker(new URL('../worker/markdown.worker', import.meta.url)),
);
return await worker.compileMarkdown(source);
} else {
// Fallback to main thread with dynamic imports
const worker = await import('../worker/markdown.worker').then((m) => m.api);
return await worker.compileMarkdown(source);
}
}
@Injectable({
providedIn: 'root',
})
export class MarkdownService {
async compile(source: string): Promise<string> {
return await compileMarkdown(source);
}
}
アプリケーションの実行
アプリケーションを完成させよう。ここでは、AppComponent
を MarkdownService
を使用し、その結果を表示するように変更する。
import { MarkdownService } from './service/markdown.service';
import { Component } from '@angular/core';
@Component({
selector: 'app-root',
template: `
<button (click)="compileMarkdown()">compile</button>
<div>{{ result }}</div>
`,
})
export class AppComponent {
result: string = '';
constructor(private markdown: MarkdownService) {}
async compileMarkdown() {
this.result = await this.markdown.compile(`## Hello Comlink`);
}
}
ng serve
を使ってアプリをサーブし、ブラウザの開発者ツールを開いてみよう。Network タブを開いて“compile”ボタンをクリックすると、メインのバンドルからは分離された JavaScript ファイルがロードされ、Web Worker として実行される。
まとめ
このように、Comlink と Angular CLI によって、UI スレッドがブロックされることを簡単に防ぐことができる。 また、webpack 5 ベースになった Angular CLI により、Web Worker コードを遅延ロード可能なバンドルとしてビルドすることも簡単になった。 読み込みだけでなく読み込み後の実行パフォーマンスにも課題があるアプリケーションで導入すると効果を発揮するだろう。
サンプルコードの全体は GitHub で確認できる。