chokidar を Typescriptと仲良くさせる(fromEvent, async/await)


Typescriptの型チェックや、非同期処理を助けるasync/await、ObservableのfromEvent、utilのpromisifyなどにより、最近のライブラリは使いやすくなってきたと思う。

それでも、Javascriptでおなじみのコールバックやイベントエミッタを利用したライブラリとは仲良くしていかないといけない。例えばchokidarは、インスタンス生成後readyイベントまで待ってからでないと、addchangeイベントのリスナを追加できない厄介な性質がある。

正確には追加できるのだが、初期スキャンで全ファイルが「add」扱いにされてしまう。これは大抵の人にとって意図しない動作だと思う。

readyを待ってFSWatcherを返す

初期スキャンの完了(readyイベント発生)してからのウォッチャ(FSWatcher)が欲しければ、以下のようにする。もともとのwatch関数の戻り値を得た後、readyを待ってからresolveで戻り値のFSWatcherを返している。

function createWatcher( paths: string | string[], options: WatchOptions = {} ): Promise<FSWatcher> {
    return new Promise( ( resolve, reject ) => {
        try {
            let watcher = watch( paths, options );
            watcher.once( 'ready', () => resolve( watcher ) );
        } catch( err ) {
            reject( err );
        }
    } );
}

これで、以下のようにready後のFSWatcherが得られるようになる。

let watcher = await createWatcher( <パス> );

Observableを返すようにする

イベントのままだとObservableと仲良くないので、rxjsfromEventを使ってObservable化する。
fromEventを使う前に、いつイベントリスナが登録されるかというところが気になったので検証してみた。

検証: fromEventでリスナが増えるのはいつ?

FSWatcherのインスタンスwatcherに対して以下の操作を行ない、その間のリスナ数を調べてみた。

  1. addイベントを追加
  2. fromEventでaddイベントをObservable化(add$と呼ぶ)
  3. add$を購読(sb1と呼ぶ)
  4. add$を購読(sb2と呼ぶ)
  5. sb1の購読を停止
  6. sb2の購読を停止
// async関数内
let watcher = await createWatcher( <パス> );
console.log( '[0] listener: ' + watcher.listeners('add').length );
watcher.on( 'add', (path) => { console.log( '[on]: ' + path ) } );
console.log( '[1] listener: ' + watcher.listeners('add').length );

const add$ = fromEvent( watcher, 'add' );
console.log( '[2] listener: ' + watcher.listeners('add').length );

add$.subscribe( ( path ) => { 
    console.log( '[subscribe1]: ' + path );
} );
console.log( '[3] listener: ' + watcher.listeners('add').length );

add$.subscribe( ( path ) => {
    console.log( '[subscribe2]: ' + path );
} );
console.log( '[4] listener: ' + watcher.listeners('add').length );

sb1.unsubscribe();
console.log( '[5] listener: ' + watcher.listeners('add').length );

sb2.unsubscribe();
console.log( '[6] listener: ' + watcher.listeners('add').length );

実行結果から、以下のように購読するごとにリスナが増えることが分かった。購読(subscribe)しない限りリスナが増えないというのは便利だ。

[0] listener: 0
[1] listener: 1
[2] listener: 1
[3] listener: 2
[4] listener: 3
[5] listener: 2
[6] listener: 1

検証: fromEventしてshareしたらどうなるか

先ほどの検証で、shareにしたらどうなるかを調べた。

let add$ = fromEvent( watcher, 'add' ).pipe( share() );

実行結果から、購読が増えてもイベントリスナは増えない = shareされていることが分かった。

[0] listener: 0
[1] listener: 1
[2] listener: 1
[3] listener: 2
[4] listener: 2 ← ここで購読が増えたが、リスナは増えない
[5] listener: 2
[6] listener: 1

困ったこと: 余計な情報が付いてくる

イベントリスナの場合、実はパスだけでなくファイルの情報を含んだ配列が生成されていることが分かった。推測だが[ path(paths), stat ]になっていると思う。

fromEventだけだといらない情報が両方ついてきてしまって嬉しくないので、除外する必要がある。とりあえず戻り値の配列の[0]だけ取り出すようにする。

Observableを返す関数

これらを踏まえ、今どきのTypescriptによる型チェックを活用して使いやすくするなら以下のようになると思う。enumにすることで、Eclipseなどで自動補間してくれるようになる。

import { FSWatcher } from 'chokidar';
import { fromEvent } from 'rxjs';

type EventType = 'add' 
               | 'change'
               | 'unlink'
               | 'addDir'
               | 'unlinkDir';

function toObservable( watcher: FSWatcher, event: EventType ): Observable<string> {
    return fromEvent( watcher, event ).pipe( map( info => info[0] ) );
}

備考

FSWatcherインスタンスは、生成後にaddで監視対象を増やすことができる。しかし、addイベントリスナを追加済みの状態で監視対象を増やすと、初回スキャンで既存のファイルを追加したとみなしてしまうらしい。本当の追加と区別がつけられるなら良いが、Observableとは相性が良くなさそうだった。