zone.js と async / await を併用してみる

zone.js と async / await を併用すると面倒な非同期処理を楽して書けそう... ということで、ここ最近これらについて試してたことをメモっておく。

リポジトリ:https://github.com/cyokodog/zone_study

async / await

async / await は、Promise による非同期処理を同期処理っぽく簡潔に書ける機能。なにがどう簡潔になったのかは以下の動画が分かりやすい。Callback -> Promise -> async / await 順でそれぞれの書き方がどう変化するかを表現してる。

async / await は、Chrome 55 から、Node.js では v7.6.0 から利用可能(ただしメモリリークが激しいとかいう噂がある?)で、これより古いバージョンで利用する場合は Babel による変換が必要。

以下設定で試してみた。

package.json

{
  "devDependencies": {
    "babel-cli": "6.24.1",
    "babel-plugin-transform-runtime": "6.23.0",
    "babel-preset-es2015": "6.24.1",
    "babel-preset-stage-3": "6.24.1"
  }
}

.babelrc

{
  "presets": ["es2015","stage-3"],
  "plugins": [
    [
      "transform-runtime",
      {
        "polyfill": false,
        "regenerator": true
      }
    ]
  ]
}

transform-runtime の指定をしないと実行時に「regeneratorRuntime is not defined」とかいうエラーがでる。この regeneratorRuntime とかいう関数は、Babel が生成するもので、Generator 相当の polyfill コードらしい。上記設定でこのコード変換が抑止され、Node.js の持っている Generator が使用されるようになる。

async / await を使ってみる

後述の zone.js を併用した時の効果を確認するために、次のようなコードで async / await の動作を確認してみた。

まず、日英の言語設定ファイルを定義する。

settings_jp.js

export default {
  users: {
    taro: '太郎',
    jiro: '次郎'
  },
  messages: {
    hello: 'こんにちわ',
    bye: 'さようなら'
  }
};

settings_en.js

export default {
  users: {
    taro: 'Taro',
    jiro: 'Jiro'
  },
  messages: {
    hello: 'Hello',
    bye: 'Bye'
  }
};

それぞれの言語設定と、名前 ID、メッセージ ID を引数に、挨拶文の取得関数を呼ぶ。

import SETTINGS_JP from '../data/settings_jp';
import SETTINGS_EN from '../data/settings_EN';
import fetchGreetMessage from './fetchGreetMessage';

// JP
fetchGreetMessage(SETTINGS_JP, 'taro', 'hello').then(msg => console.log(msg));
fetchGreetMessage(SETTINGS_JP, 'jiro', 'bye').then(msg => console.log(msg));

// EN
fetchGreetMessage(SETTINGS_EN, 'taro', 'hello').then(msg => console.log(msg));
fetchGreetMessage(SETTINGS_EN, 'jiro', 'bye').then(msg => console.log(msg));

これを実行すると一定の間をおいて、次のように表示される。

こんにちわ, 太郎
さようなら, 次郎
Hello, Taro
Bye, Jiro

挨拶文は次のような非同期処理で取得している。

fetchGreetMessage/index.js

import fetchMessage from './fetchMessage';
import fetchName from './fetchName';

export default async (settings, userId, msgId) => {
  const name = await fetchName(settings, userId);
  const message = await fetchMessage(settings, msgId);
  return `${message}, ${name}`;
}

fetchName、fetchMessage は Promise を返す関数のため本来、then(callback) による非同期的な記述が必要だが、ここではそれらの関数に await 構文を適用することで同期的な記述で直接値を得ている。await 構文を利用する場合は、以下例のように、必ずその処理を実行している関数が async 構文で書かれてる必要がある。

async f() => {
  await doSomethingAsPromise();
}

Promise による「名前」と「メッセージ」の取得処理は次のとおり。

fetchGreetMessage/fetchName

export default (settings, userId) => {
  return new Promise(resolve => {
    setTimeout(function(){
      resolve(settings.users[userId]);
    }, 1000);
  });
}

fetchGreetMessage/fetchMessage

export default (settings, msgId) => {
  return new Promise(resolve => {
    setTimeout(function(){
      resolve(settings.messages[msgId]);
    }, 2000);
  });
}

async / await と非同期処理のかゆいところ

await 構文は記述をシンプルにし処理の見通しをかなりよくすることができる一方、Promise().catch() で指定できたエラーハンドリングの記述ができない。これについては await を適用してる関数毎に try〜catch をするしかない。

let name;
try {
  name = await fetchName(settings, userId);
} catch (e) {
  // エラー時の処理
}

let message;
try {
  message = await fetchMessage(settings, msgId);
} catch (e) {
  // エラー時の処理
}

また、上記の処理では言語設定情報を、引数として末端の関数まで運んでいるが、本来、これらの情報は1つのコンテキストデータとして、自由なタイミングで参照できるようにしたいところ。具体的には日本語設定を適用し処理してる間は、日本語の設定情報が取得でき、英語設定で処理してる間は、英語のそれが取得できる、といった具合にしたい。

しかし、見た目上は await 構文により同期的に処理されてるように見えても、日英それぞれの設定で同時に非同期処理が動いている以上、上記のような細分化されたモジュール構成を前提とした場合、これを実現するのは難しい(クロージャを適用できないので)。

そこで zone.js を使用し、これらの問題解決を試みてみる。

Angular のパフォーマンスを支える zone.js

zone.js といえば Angular...、Angular との関係をざっくりと。

  • zone.js は Dart の言語仕様である Zone を JS で動くようにしたもの
  • Angular のプロト的な位置づけとして Angular Dart が存在する
  • Angular Dart は Zone ありきで設計されている
  • Zone の JS における標準仕様化が提案されてる(現在 stage0
  • Zone はあらゆる非同期処理の開始と終了を傍受できる
  • Zone は非同期処理に対してコンテキストを持たすことができる
  • Zone は非同期処理内にて発生したエラーを検出できる
  • Angular のパーフォーマンスにとって大事なことはデータの変更検出をどのように行うか
  • データの変更は非同期処理の中でしか起こらない
  • DOM に変更が必要な事態が発生してるかもしれないというタイミングを Zone が教えてくれる
  • Angaulr は Change Detection という仕組みで親から子へ変更検知を伝え、末端の子まで伝わる1回分の処理をターンと呼ぶ
  • 1ターンの中で1回のダーティーチェッキング(オブジェクトの変更管理)を行う
  • 1系にあったような変更検知が変更検知を呼ぶダイジェストループは起きない
  • Zone はアプリ内で発生する非同期処理の終了時にこのターンを走らせる

aync / await と併用する上で大事なことは

  • Zone はあらゆる非同期処理の開始と終了を傍受できる
  • Zone は非同期処理に対してコンテキストを持たすことができる
  • Zone は非同期処理内にて発生したエラーを検出できる

の3点。

zone.js を使ってみる

まずは

  • Zone は非同期処理に対してコンテキストを持たすことができる
  • Zone は非同期処理内にて発生したエラーを検出できる

について確認してみる。

require('zone.js');

Zone.current.fork({
  properties: {hoge: 'HOGE'}
}).run(function (){
  setTimeout(function(){
    console.log(Zone.current.get('hoge'));
  }, 1000);
});

Zone.current.fork({
  properties: {hoge: 'ほげ'}
}).run(function (){
  setTimeout(function(){
    console.log(Zone.current.get('hoge'));
  }, 500);
});

Zone.current.fork({
  onHandleError: function (delegate, current, target, error){
    console.error('zoneAwareStack', error.zoneAwareStack);
  }
}).run(function (){
  setTimeout(function(){
    throw new Error('エラー!');
  }, 750);
});

この例では 3 つの Zone を定義しており、最初の2つは、コンテキストに相当する properties に任意のデータを設定し、非同期処理内の get() メソッドにてこの設定データを正しく取得できるかを確認している。3 つ目は非同期処理内で発生したエラーを、Zone の定義元でハンドリングできるかの確認。

次のように表示され、いずれも正しいタイミングで正常に処理されていることが分かる。

ほげ
zoneAwareStack Error: エラー!
    at new Error (native)
    at null.<anonymous> (/Users/cyokodog/Dev/github/esnext/zone/dest/02/index.js:38:11) [unnamed]
    at timer [as _onTimeout] (/Users/cyokodog/Dev/github/esnext/zone/node_modules/zone.js/dist/zone-node.js:1739:29) [<root>]
    at Timer.listOnTimeout (timers.js:92:15) [<root>]
HOGE

zone.js と async / await の併用

先の async / await のサンプルコードに zone.js を適用し、冗長だった言語設定情報の受け渡しを省き且つ、async / await のエラーハンドリングも zone.js で行うようにしてみる。

index.js

import SETTINGS_JP from '../data/settings_jp';
import SETTINGS_EN from '../data/settings_EN';
import fetchGreetMessage from './fetchGreetMessage';
import 'zone.js';

Zone.current.fork({
  properties: {
    SETTINGS: SETTINGS_JP
  },
  onHandleError: onHandleError
}).run(() => {
  fetchGreetMessage('taro', 'hello').then(msg => console.log(msg));
  fetchGreetMessage('jiro', 'bye').then(msg => console.log(msg));
});

Zone.current.fork({
  properties: {
    SETTINGS: SETTINGS_EN
  },
  onHandleError: onHandleError
}).run(() => {
  fetchGreetMessage('taro', 'hello').then(msg => console.log(msg));
  fetchGreetMessage('jiro', 'bye').then(msg => console.log(msg));
});

function onHandleError(delegate, current, target, error){
  console.error('エラー発生', error.rejection.stack);
  return false;
};

fetchGreetMessage/index.js

import fetchMessage from './fetchMessage';
import fetchName from './fetchName';

export default async (userId, msgId) => {
  const name = await fetchName(userId) || '';
  const message = await fetchMessage(msgId) || '';
  return `${message}, ${name}`;
}

fetchGreetMessage/fetchName.js

export default userId => {
  const settings = Zone.current.get('SETTINGS');
  if(!settings){
    throw new Error('ZoneからSETTINGSが取得できない...!');
  }
  return new Promise(resolve => {
    setTimeout(function(){
      resolve(settings.users[userId]);
    }, 1000);
  });
}

fetchGreetMessage/fetchMessage.js

export default msgId => {
  const settings = Zone.current.get('SETTINGS');
  if(!settings){
    throw new Error('ZoneからSETTINGSが取得できない...!');
  }
  return new Promise(resolve => {
    setTimeout(function(){
      resolve(settings.messages[msgId]);
    }, 2000);
  });
}

実行すると次のように表示される。

こんにちわ, 太郎
さようなら, 次郎
こんにちわ, Taro
さようなら, Jiro

名前は日英とも正しく取得されてるが、挨拶がすべて日本語で取得されてしまっている。名前取得、挨拶取得の順で await 指定で実行しているが、後発の非同期処理に正しいコンテキストが適用されないっぽい。

以下のように素直に Promise を使えば正常に処理される。

return Promise.all([
  fetchName(userId),
  fetchMessage(msgId)
]).then(result => {
  return `${result[1]}, ${result[0]}`
});

/**
以下のように表示される

こんにちわ, 太郎
さようなら, 次郎
Hello, Taro
Bye, Jiro
*/

コンテキストデータの共有目的で、zone.js と async / await を併用するのはやめたほうがよさそう。 エラー処理については強制的に非同期処理内でエラーを発生させたところ、正常にハンドリングされることが確認できた。

ちなみに require('zone.js') はルートモジュールで一回読み込めば、以降どの子モジュールからでも Zone が参照可能になる。

フロントエンドで zone.js を使ってみる

Zone はあらゆる非同期処理の開始と終了を傍受できる

これについてはフロントエンドの実装で試してみる。得に「こう使ったらうれしいな」って事例が思い浮かばなかったので、以下のように非同期処理の終了を、モデル変更検出のタイミングとして活用してみる。

  1. なにかしらの非同期処理が完了した場合、
  2. 未完了の非同期処理(タスク)が存在しなかったら
  3. 「モデルが変更されてるかもしれないよ!」emitterを投げる

ここまでを zone.js 側の裏の処理として実行させ、アプリ側は 3 の emitter を受けとたら、モデルの状態をチェックして変更されていた場合はビューを更新する。

demo

html
<iframe src="http://www.cyokodog.net/zone_study/demo/03/" style="height:400px"></iframe>

「GET TIME」ボタンを押すと非同期で現在時間を取得し表示するという単純なもの。コンパイルは browserify と babelify で行っている。

browserify ./demo/03/index.js -t babelify -o ./demo/03/bundle.js

実装は次のとおり。

index.js

// 何故か import だと Promise.then がスケジュールされないので、requireで読み込む
// import 'zone.js';
require('zone.js');
import {EventEmitter} from 'events';
import TimeStacker from './TimeStacker';
import zoneSettings from './zoneSettings';

Zone.current.fork(zoneSettings).run(() => {

  const emitter = Zone.current.get('emitter');
  const timeStacker = new TimeStacker();

  let lastHtml = '';

  // モデルが変更されたかもよイベントを受け取ったら...
  emitter.on('checkDataChanged', () => {

    // 変更されていたたら...
    const html = timeStacker.getHtml();
    if(lastHtml !== html){

      // ビューを書き換える
      document.querySelector('.state').innerHTML = html;
      lastHtml = html;
      console.log('----------draw');
    }
  });

  document.querySelector('.start').addEventListener('click', () => {

    // 今の時分秒をモデルにロードする
    timeStacker.loadCurrentTime();

  }, false);
});

裏で動く zone.js 側は次の通り。

zoneSettings.js

import {EventEmitter} from 'events';

export default {
  properties: {
    state: {
      changedPossibility: false,
    },
    emitter: new EventEmitter
  },
  onHasTask: function(parent, current, target, hasTask) {
    const state = Zone.current.get('state');
    const emitter = Zone.current.get('emitter');
    state.changedPossibility = !hasTask.macroTask && !hasTask.microTask;
    if(state.changedPossibility){
      emitter.emit('checkDataChanged');
    }
  },
  onScheduleTask: function(parentZoneDelegate, currentZone, targetZone, task) {
    console.log(`onScheduleTask ${task.source}`);
    parentZoneDelegate.scheduleTask(targetZone, task);
  },
  onInvokeTask: function(parentZoneDelegate, currentZone, targetZone, task, applyThis, applyArgs) {
    console.log(`onInvokeTask ${task.source}`);
    const state = Zone.current.get('state');
    const emitter = Zone.current.get('emitter');
    emitter.emit('checkDataChanged');
    parentZoneDelegate.invokeTask(targetZone, task, applyThis, applyArgs);
  }
}

onXxxTask とあるが、タスクとはコールバック関数のことを指す。例えば以下は、関数 f がタスクであり、setTimeout() によりタスクが登録され、これらタスクが実行待ち状態になってることを指し、スケジュールと呼ぶ。

setTimeout(function f(){}, 1000):

onScheduleTask() はスケジューリングの際に実行され、onInvokeTask() はタスクが実行される直前に実行される。

これらのタスクは非同期で実行されるが故に、複数のタスクが未実行で保持されてる状態にも成りえる。onHasTask() は、これらのタスクの実行待ち状態が変化した時に実行される。上記コードでは hasTask.macroTask、hasTask.microTask を参照し実行待ちタスクの有無を参照してるが、他にも hasTask.eventTask がある。タスクの種類は次のように分類される。

  • MicroTask : キャンセルできない一回だけ実行されるタスク(Promise の resolve 等)
  • MacroTask : キャンセル可能で一回以上遅延実行されるタスク(setTimeout, setInterval 等)
  • EventTask : イベント発生時に実行されるタスク(addEventListener 等)

EventTask については cancelEventListener() を実行しない限り、タスクは常に実行待ち状態のままなので、上記のコードでは得に参照してない。

非同期で取得した時間をストックして、HTML に変換するクラスは次のとおり。非同期処理については、せっかくなので async / await を使ってみる。

TimeStacker.js

export default class TimeStacker {

  constructor(){
    this.times = [];
  }

  async loadCurrentTime (){
    const time = await this.fetchTime();
    if(!this.times.length || this.times[this.times.length-1] !== time){
      this.times.push(time);
    }
  }

  getHtml () {
    if(!this.times.length) {
      return '';
    }
    const list = this.times.map(time => {
      return `<li>${time}</li>`;
    }).join('');
    return `<ul>${list}</ul>`;
  }

  fetchTime (){
    return new Promise(resolve => {
      setTimeout(function(){
        const date = new Date;
        const hours = (date.getHours()+'').padStart(2,'0');
        const minutes = (date.getMinutes()+'').padStart(2,'0');
        const seconds = (date.getSeconds()+'').padStart(2,'0');
        const time = `${hours}:${minutes}:${seconds}`;
        resolve(time);
      }, 500);
    });
  }
}

参考にしたサイト