13
13

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 5 years have passed since last update.

俺たちのMithril.jsがこんなに遅いわけがない

Last updated at Posted at 2016-12-24

昨日、雑に書きなぐった記事が200はてブ超えててびびってますが、最後のMithrilアドベントカレンダーです。参加された方も、読んでくださった方々も、みなさんお疲れ様でした。

Mithrilが、実際のDOMを更新するまでのライフサイクルは次の通りです。

  1. ラウター/ルートコンポーネントのマウント&登録
  2. 初期表示コンポーネントのコントローラの初期化
  3. 初期表示コンポーネントのビューの仮想DOM作成(子コンポーネントがなければおしまい)
  4. 仮想DOMを実際のDOMに反映して表示
  5. もし、子供のコンポーネントがあれば、子供のコンポーネントのコントローラの初期化
  6. 初期化が終わったら親のビューの仮想DOMを作成
  7. 子供のコンポーネントのビューの仮想DOM作成
  8. 仮想DOMを実際のDOMに反映して表示

何かイベントが発生したらルートのコンポーネントから描画だけを実行します。上記のリストでいうと、子コンポーネントがなければ3と4だけ、子コンポーネントがあれば、6, 7, 8ですね。

再レンダリングの直接のトリガーとしては2つあり、上記のサンプルコードにある、m.startComputation()、m.endComputation()呼び出し、あるいはm.redraw()での強制再描画です。

間接のトリガーとしては、仮想DOM内で作成したイベントハンドラの起動、m.request()によるサーバアクセスのレスンポンスが返ってきた、m.route()でURLを変更した、の3つあります。

何かレスポンスが異様に遅いのだけど?

原因はいくつか考えられます。

☆☆☆ビューの中でサーバアクセス

まずワーストケースですが、ビューの中でサーバアクセスしていると、再描画プロセスが無限ループします。サーバレスポンスが返ってきた直後に再度サーバアクセスが発生します。サーバアクセスはコントローラ内、もしくはコントローラから初期化されるビューモデルやモデルに移動します。

☆☆ビューの中で重い処理

二番目としては、基本的にレスポンスが重いのはビュー関数が重いケースです。モデルに対して何度もインスタンスを作っては破棄をして・・・みたいなオブジェクトを作るのが必要なコードがたくさん入っていると重いです。ビューの呼び出しはイベントハンドラがコールされたタイミングでカジュアルに発生します。

あと、サードパーティのライブラリを埋めこんだり、手動で再描画を行う際ですが、m.redraw()ではなく、次のように連続で呼び出す方が良いです。

もし、サーバアクセス中だった場合には、レスポンスが帰ってきた時に一度レンダリングが実行されます。m.redraw()を使うと、二度実行されますが、下記の呼び出しであれば、サーバアクセス中でも、そうでなくても、一度だけしか実行されないのでムダが減ります。

m.startComputation();
m.endComputation();

☆☆サーバアクセスが何度も発生

モデルの読み込みのサーバアクセスが何度も発生しているケースも遅い原因になります。コンポーネントがいくつもの階層にわたって使われている場合などに、それぞれのレイヤーでサーバアクセスが発生すると重いです。

サーバアクセスが必要ということは知覚できるレベルでの遅延が発生します。ページ切り替えの初動が極めて遅いなどの時はこれが原因でしょう。

☆〜☆☆m.route(URL)の呼び出し

URLを変更するにはm.route(URL)呼び出しを行いますが、使うときに気をつけないとパフォーマンスが著しく落ちます。

それ以外では、m.route()呼び出しが問題です。クライアント側にデータが全てダウンロードされていて、ブラウザ上で簡単にフィルタリングをしたい・・・というケースを考えます。URLを他の人に渡して同じ結果が得られるようにしたいと思うのでクエリーはGETパラメータで持たせるようにしたとします。現行のMithrilでは、クライアント側だけで検索とかフィルタを行うときに、?以降のクエリーだけを更新したくても、フルの再描画が発生します。フル描画どころか、コントローラも再作成されてしまいます。

ラウターが変更されたときの動作は、

  • コントローラが再作成。
  • Mithril内部で、allを設定して強制再描画。仮想DOMを破棄の上実行されてしまう。

コントローラの再作成は押さえる方法はありません。行うとしたら、コントローラ再作成の抑制は諦めて、そのコスト削減を行うのがてっとり早いでしょう。ビューモデルやモデルなどは、キャッシュしておいて即座に返す、というのも手があります。

Mithril 1.0では再描画などのタイミングはより柔軟になるようになるようです(それについて言及されているissueがMithrilにあったが、どれかは失念)。

☆再描画のコスト削減

再描画のコストを強制的に下げる方法は3つあります。

1つ目は、m.redraw.strategy("diff")です。仮想DOM完全破棄後に再実行というケースでは、効果があります。具体的にはroute設定後ですね。もちろん、変化後のコンテンツの差が小さいのを分かって実行する必要があります。

m.redraw.strategy("diff");

2つ目は部品単位での再作成の抑制でcontext.retain=trueを使う方法です。

この手法にはさらに強力な3つ目の手法があって、subtreeディレクトリを使う方法もあります。ただし、ドキュメントにも書かれていますが、これは本当に最終の時だけ選ばれる最終手段です。

パフォーマンス問題の検知は原始的な方法で良い?

さて、どうやってこれらの問題を知覚すればいいでしょうか?
まあ、難しいことを考えずにprintfデバッグというか、console.logでいいかなぁという気はしています。

  • m.routeとかm.mountで呼ばれるコンポーネントのコンストラクタ
  • サーバアクセス
  • & モデルクラスのコンストラクタの起動される様子

これらをログに出すだけでだいたいはいけるでしょう。

Mithrilのベンチマーク、他のフレームワークよりも遅い?

他のフレームワークの中身をすべて知っているわけではないので、だいたい分かるVue.js 2.0と比較します。

最初に紹介したMithrilの更新フローがポイントです。子どものコンポーネントだけの状態が変わって描画される時に親のコンポーネントの仮想DOMも再作成されてしまいます。

Vue.jsの場合はコンポーネントはデータの流れをカッチリ定義します。Vue.jsのドキュメントからサンプルを引用します。

<div id="app-7">
  <ol>
    <!-- todo オブジェクトによって各 todo-item を提供します。それは、内容を動的にできるように表します。-->
    <todo-item v-for="item in groceryList" v-bind:todo="item"></todo-item>
  </ol>
</div>
Vue.component('todo-item', {
  props: ['todo'],
  template: '<li>{{ todo.text }}</li>'
})
var app7 = new Vue({
  el: '#app-7',
  data: {
    groceryList: [
      { text: 'Vegetables' },
      { text: 'Cheese' },
      { text: 'Whatever else humans are supposed to eat' }
    ]
  }
})

Vue.jsはルートにgroceryListというデータがあるのを知っており、その各データが<todo-list>タグのtodoプロパティに接続されていることを知っています。データの流れを完全にVueの作法で記述しているため、Vueはどのコンポーネントの更新が必要かが完璧に分かっています。そのため、コード量は増えますが、その分無駄な計算を省いています。

Mithrilはデータのフローの定義はせず、proxyみたいな仕組みも持たず、POJOで記述していきます(プロパティやストリームはありますが汎用の構造に置換可能)ので、このような高度な束縛はできません。

Mithrilでも、仮想DOMから外れた直接DOMを触る仕組みがあって、この中で自分で管理することで差分更新を減らすこともできないことはないですが、他のフレームワーク製のGUI部品の更新処理を減らす、ぐらいの使い方しかできないでしょう(context.retain=trueで調べてください)。

上流から流れるようなデータフロー以外のデータをどのように扱うか?に関してはいろいろ意見があるところだと思いますが、ステートフルなコンポーネント自体はどちらも認められています。

そもそも、Mithrilはデータが来るのが上か下かもフレームワークは規定していないので、pub/subみたいな仕組みを組み込むのも自由ですし、子コンポーネントが自分でサーバにアクセスしにいってもかまいません。また中央集権的なステートを用意しておいて、パスやキーのようなものを使って自由に任意のデータを取得しにいくなど、バケツリレーをやらない仕組みも自由に使えます。Vue.jsだとイベントを使うんでしょうかね。

よくマイクロベンチマークで使われるTodoの要素1000個をまるっと更新みたいなのはMithrilはそこまで得意ではないことが分かります。が、そこまで結果が酷いわけでもないし、そんな画面に収まらないほどの個数を一度にまるっと更新、というのは、避ける方法がいくらでもあるので、そこに一喜一憂する必要はないと思います。コンピュータサイエンスには絶対の正解はなく、最適解しかないし、ユースケースによって最適解は変わるので、メリットとデメリットの両方をきちんと理解することが一番大切です。

Mithrilの特徴は、コード行数は短くてすみ、ソースコードの構成やデータフローなどで縛られるルールはほとんどなく、何も考えなくてもそこそこ早い。表示されている範囲の画面の書き換え個所が比較的多いなら得意だけど、画面に収まらないような大量の更新は要注意な、というあたりですね。

もちろん、最悪ケースでも遅くない、というのも大事な特性なので、きちんと理解して吟味した上で選択するならなんでも良いと思います。

仮想DOM?逐次更新の方が早いって記事を見たんだけど

Fun hacks for faster content

このサイトによると、JSONで一度にまとめてコンテンツを返すよりも、行ごとに区切れるJSON(ND-JSON)にするだけで、初回コンテンツ表示が4倍(2秒強から0.5秒強)に、最終的な表示時間も0.5秒短くなる、ということが書かれています。

benchmark_render.png

まとめて表示というのは仮想DOMがやってきたことで、「実DOMアクセスは遅いので、仮想DOMは超高速」みたいなのと対照的な結果に見えます。

まあ結論から言ってしまえば、前の段落で紹介した「コンテンツが1画面に入り切らない規模である」案件です。それなら逐次で表示していった方が、初回表示は早くなりますよね、と。

仮想DOMであっても、このエッセンスを利用することは可能です。たとえば、200個データがあり、1画面に表示できるのはせいぜい10個で全画面に収まらないことがわかっているとします。まずは1画面に収まる量のコンテンツだけは完全に表示。残りのコンテンツは表示しないか、サイズを固定する外枠だけを表示します。そして、requestAnimationFrame()かなにかで、少しずつ仮想DOMを追加していくという方法が考えられますね。あるいは、スクロール位置を見て画面に表示されそうな範囲だけ表示でもいいでしょう。そちらの方がスマートです。

13
13
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
13
13

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?