LoginSignup
34
39

More than 5 years have passed since last update.

Riot.jsによるゆるいコンポーネント分割(応用編)

Last updated at Posted at 2016-09-10

業務用アプリでありがちな、一覧表とグラフ。
オープンソースで様々なライブラリが提供されている。
今回は今まで自分がよく使っていたライブラリがRiot.jsによってどのように分割、連携が行えるかを確認してみた。

使用したライブラリ

  • 一覧表 :

    • HANDSONTABLE
      • MIT License
      • Excelのように入力できる
      • Excelからコピペもできる
      • 最近有償版も出た
    • W2UI GRID
      • MIT License
      • 大量データの描画が高速
      • ソートや表示列切り替え等の機能が豊富
      • W2UIにはGRID以外にも様々なライブラリが提供されているがGRID以外は使ったことがない
  • グラフ :

    • NVD3
      • Apache License, Version 2.0
      • d3の拡張
      • 簡潔な記述できれいなよく使うグラフが描ける

アプリの仕様

  • 一覧表に入力したデータをリアルタイムに集計しグラフ表示する
  • 一覧表は、HANDSONTABLE形式とW2UI GRID形式を切り替えられるようにする
    受講者データを表入力
    (名前,性別,点数)
    点数分布(棒グラフ)
    男女比率(円グラフ)

通信仕様

前回と同様aggreさんのObseriotを利用させてもらった。
image

ちょっと込み入っているが、前回と基本はいっしょ。
下記のポイントを気をつけた。

  • コンポーネント間を直接繋がない
    • コンポーネント単体での試験が困難になり、拡張も気軽にできなくなる
  • ストアを変更できるのはディスパッチャのみ
    • データの整合性は集中管理する
  • 表示コンポーネントにロジックは持たず、表示に必要なデータのみを格納したストアを用意する
    • とりかえがしやすくなる(円グラフから棒グラフへの変更など)
  • コンポーネントがストアを変更したい場合はアクションに通知する

コード

index.html

index.html
<!DOCTYPE html>
<html>
<head>
  <title>riot-handsontable-nvd3</title>
  <meta charset="utf-8">
  <meta http-equiv="X-UA-Compatible" content="IE=edge">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <link rel="stylesheet" href="https://fonts.googleapis.com/css?family=Roboto:300,400,500,700">
  <link rel="stylesheet" href="https://fonts.googleapis.com/icon?family=Material+Icons">
  <link rel="stylesheet" href="https://maxcdn.bootstrapcdn.com/bootstrap/3.3.6/css/bootstrap.min.css">
  <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/bootstrap-material-design/0.5.10/css/bootstrap-material-design.min.css">
  <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/bootstrap-material-design/0.5.10/css/ripples.min.css">
  <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/w2ui/1.4.3/w2ui.min.css">
  <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/nvd3/1.8.4/nv.d3.min.css">
  <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/handsontable/0.27.0/handsontable.min.css">
</head>
<body>

<my-app></my-app>

<script src="https://code.jquery.com/jquery-1.10.2.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/twitter-bootstrap/3.3.6/js/bootstrap.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/bootstrap-material-design/0.5.10/js/material.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/bootstrap-material-design/0.5.10/js/ripples.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/w2ui/1.4.3/w2ui.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/handsontable/0.27.0/handsontable.full.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/d3/3.5.17/d3.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/nvd3/1.8.4/nv.d3.min.js"></script>

<script src="https://rawgit.com/riot/riot/master/riot%2Bcompiler.min.js"></script>
<script type="riot/tag" src="my-app.tag"></script>
<script type="riot/tag" src="my-navbar.tag"></script>
<script type="riot/tag" src="my-frame.tag"></script>
<script type="riot/tag" src="my-w2uigrid.tag"></script>
<script type="riot/tag" src="my-handsontable.tag"></script>
<script type="riot/tag" src="my-pie-chart.tag"></script>
<script type="riot/tag" src="my-bar-chart.tag"></script>

<script src="obseriot.js"></script>

<script>
//エイリアス
var action = obseriot.action,
    store = obseriot.store;
riot.mount('my-app');

</script>

</body>
</html>

ルートコンポーネントをマウントするだけ
利用ライブラリがふくれあがってるのが気になるけど全ページに埋め込むよりいい

ルートコンポーネント

my-app.tag
<my-app>
  <div class="container-fluid main">
    <div class="row">
      <div class="col-md-offset-1 col-md-10">
        <my-navbar></my-navbar>
        <div class="row">
          <div class="col-sm-6">
            <my-frame caption="データ入力 (上のボタンで入力方式切替)">
              <div id="grid"></div>
            </my-frame>
          </div>
          <div class="col-sm-6">
            <div class="row">
              <div class="col-sm-12">
                <my-frame caption="点数分布">
                  <my-bar-chart id="tensubunpu"></my-bar-chart>
                </my-frame>
              </div>
              <div class="col-sm-12">
                <my-frame caption="男女比">
                  <my-pie-chart id="danjohi"></my-pie-chart>
                </my-frame>
              </div>
            </div>
          </div>
        </div>
      </div>
    </div>
  </div>

  <script>
省略
  </script>

</my-app>

省略部ではアクション、ストア、ディスパッチャの定義を行っている。
抜粋して説明すると

アクション定義例

// データレコード変更アクション
// {index, column, value} e
// index: 配列インデックス
// column: 要素名
// value: 値
obseriot.defineAction(
  "changeRecord", // action name
  function(e) { // handler action
    return e;
  }
);

今回はストア内に配列で持たせているため、通知者は配列のインデックスと要素名、値を渡す仕様にした。

通知例(1行目の性別を女に)
obseriot.notify( action.changeRecord, {
index: 0, column: 'seibetsu', value: '',
})

ストア定義例

// 受講者データ
// {records: [{name, tensu, seibetsu},...]}
obseriot.defineStore(
  "data",  // sotre name
  function () { // handler action
    var trimData = [];
    for(rec of store.data.state.records) {
      var isEmpty = function(e){return !e || e == ""}
      if(!isEmpty(rec.name) || !isEmpty(rec.tensu) || !isEmpty(rec.seibetsu))
        trimData.push(rec)
    }
    return {records: trimData}
  },
  {// default state
    records: []
  }
);

行の内容が全て空の場合有効行とは見なさない仕様にしたため、監視者には有効行のみフィルタしたデータを渡す。

ディスパッチャ定義例

// データ変更アクション監視 → 受講者データストア変更
// → 受講者データストア変更通知
obseriot.listen( action.changeRecord, function (e) { // {index, column, value} e
  var record = store.data.state.records[e.index];
  record[e.column] = e.value;
  obseriot.notify( store.data );
});

アクションを監視して、ストアを変更してストアの変更を通知

こんな感じでコンポーネントを繋ぐAPIを組み立てていく

子コンポーネントの切り替え

obseriot.defineAction(
  "changeInputView", // action name
  function(e) { // handler action
    return e;
  }
);

var changeInputView = function() {
  var mode = true; //true: HandsonTable, false: W2UI
  var currentGrid = null;
  return function() {
    if(currentGrid) currentGrid.unmount(true)
    if (mode)
      currentGrid = riot.mount('#grid', 'my-handsontable')[0];
    else
      currentGrid = riot.mount('#grid', 'my-w2uigrid')[0];
    mode = !mode;
  }
}();

obseriot.listen( action.changeInputView, function () {
  changeInputView();
});

riotではriot.mount('#タグのID', 'コンポーネント名')とすることで動的にタグ配置を制御できる。
また、mount()の戻りに対しunmount()を呼ぶとDOMから除去できる。
上記の実装では
obseriot.notify( action.load )を実行する度に<div id='grid'></div>の中身が<my-handsontable><my-w2uigrid>に切り替わる。

HANDSONTABLE

my-handsontable.tag
<my-handsontable>
  <div class="row">
    <div class="col-xs-12">
      <div name="grid" style="height: 365px;"></div>
    </div>
  </div>
  <style>
    .handsontable th {
      background-color: #F0FFF0;
    }
    .handsontable tbody th.ht__highlight, .handsontable thead th.ht__highlight {
      background-color: #F0FFF0;
    }
  </style>

  <script>
  var
    table,
    self = this,
    el = self.grid,
    elContainer = el.parentNode,
    tableSetting = {
      colHeaders: [
          '名前',
          '性別',
          '点数',
          '偏差値',
      ],
      columns: [
        {
           data: 'name',
           type: 'text',
        },
        {
           data: 'seibetsu',
           editor: 'select',
           selectOptions: ['', ''],
           className: 'htCenter'
        },
        {
           data: 'tensu',
           type: 'numeric',
           format: '0',
        },
      ],
      stretchH: 'all',
      autoWrapRow: true,
      colWidths: [150, 50, 50],
      height: 360,
      minSpareRows: 1,
      rowHeaders: true,
      data: $.extend([], store.data.state.records),
      afterCreateRow: function(index, amount) {
        obseriot.notify( action.createRecord, {
          index: index,
          record: this.getData()[index]
        });
      },
      afterChange: function(changes, source) {
        if(changes) {
          for(e of changes) {
            obseriot.notify( action.changeRecord, {
              index: e[0],
              column: e[1],
              value: e[3],
            })
          }
        }
      }
    };

  this.on('mount', function() {
      table = new Handsontable(el, tableSetting);
  });

  function f(data) {
    table.loadData($.extend([], data.records))
  }

  obseriot.listen(store.load, f)

  this.on('unmount', function() {
    obseriot.unlisten(store.load, f)
  })
  </script>
</my-handsontable>

HANDSONTABLEに依存しているのはこのコンポーネントだけ
スタイル調整も閉じられる
HANDSONTABLEのフック可能なイベントからディスパッチャへアクション通知する
HANDSONTABLE.afterCreateRow -> obseriot.action.createRecord
HANDSONTABLE.afterChange -> obseriot.action.changeRecord

W2UI GRID

my-w2uigrid.tag
<my-w2uigrid>
  <div class="row">
    <div class="col-xs-12">
      <div name="grid" style="height: 365px;"></div>
    </div>
  </div>
  <style>
    .w2ui-grid .w2ui-grid-body table .w2ui-head {
      background: #F0FFF0;
    }
    .w2ui-grid .w2ui-grid-body .w2ui-grid-columns {
      box-shadow: none;
    }
  </style>

  <script>
  var
    self = this,
    setting = {
      name: opts.id,
      show: {toolbar: true},
      columns:[
        {
          field: 'recid',
          size: '7%',
          render:'int',
          sortable: true,
        },
        {
          field: 'name',
          caption: '名前',
          size: '36%',
          editable: {type: 'text'},
        },
        {
          field:'seibetsu',
          caption:'性別',
          size:'10%',
          editable:{type: 'select', items: ['', '']},
        },
        {
          field: 'tensu',
          caption: '点数',
          size: '14%',
          render:'int',
          editable: {type:'int', min:0, max:100},
          sortable: true,
        }
      ],
      records: $.extend([], store.data.state.records).map(function(element, index, array) {
        element['recid'] = index + 1;
        return element;
      }),
      onChange: function(e) {
        e.onComplete = w2ui[opts.id].mergeChanges
        obseriot.notify( action.changeRecord, {
          index: e.recid - 1,
          column: w2ui[opts.id].columns[e.column].field,
          value: e.value_new,
        })
      },
    };

  this.on('mount', function() {
    $(self.grid).w2grid(setting);
  });

  function f(data) {
    w2ui[opts.id].clear()
    w2ui[opts.id].add($.extend([], data.records).map(function(element, index, array) {
      element['recid'] = index + 1;
      return element;
    }))
  }

  obseriot.listen(store.load, f)

  this.on('unmount', function() {
    w2ui[opts.id].destroy()
    obseriot.unlisten(store.load, f)
  })

  </script>
</my-w2uigrid>

W2UI GRIDに依存しているのはこのコンポーネントだけ
こちらは行追加できないように設定した
イベントの伝播は
w2grid.onChangeobseriot.action.changeRecord

SPA時は後処理に注意(this.on('unmount')内で処理)

NVD3

my-pie-chart.tag
<my-pie-chart>
  <div><svg name="pie"></svg></div>
  <style>
    svg.nvd3-svg {
      height: 180px;
    }
  </style>
  <script type="javascript">

  var self = this
  var chart = nv.models.pieChart()
    .x(function(d) { return d.label })
    .y(function(d) { return d.value })
    .valueFormat(d3.format(',.0d'))
    .margin({top: 0, bottom: 0})
    .showLabels(true);
  var chartdata = null;

  this.on('mount', function() {
    nv.addGraph(function() {
      chartdata = d3.select(self.pie)
        .datum(store.danjohi.state.records)
      chartdata.transition().duration(500).call(chart);
      return chart;
    });

    obseriot.listen(store.danjohi, function (data) {
      chartdata.datum(store.danjohi.state.records).transition().duration(500).call(chart);
    });

    var resizing = false;
    $(window).resize( function() {
      if (resizing) clearTimeout(resizing);
      resizing = setTimeout(function() {
        chart.update();
      }, 200);
    });
  })
  </script>
</my-pie-chart>

ライブラリに特化した記述のみでよいのですっきり
集計データストアを監視し、変更がある度に再描画している

できあがり

image

入力View切り替え
image

問題なく動いた

デモおよびソース

所感

今回利用したライブラリは流行っているだけあって副作用が少ないし、イベントのフックもしやすいため、Riotとの相性は悪くなかった。
というより、HTMLをほぼそのまま分割しただけで実装できてしまったため、Riot導入のハードルは非常に低いと感じた。
コンポーネントの分割に伴う設計は行う必要があるが、イベント通知だけでAPIを組み立てることで、一見面倒に思える処理も単純化できるし、各コンポーネントの独立性を意識することで、もっとよいライブラリがでてきたときや、自作コンポーネントを作ったときに部分的にばっさり捨てて置き換えることもできる。
かといって、ガチガチに分割しなくてもそれなりに使えてしまうところがRiotのよいところだと思うので気軽に使って欲しいと思います。

34
39
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
34
39