フロントエンド開発Blog

オレには鈍器がある

CSS Modules , ES2015 , JavaScript , Vue.js , Vuex , babel , webpack

前回に続いてYearlyコンポーネントについて解説します。

Yearly.vue以下のコンポーネント群


Yearly.vue
 KkbYearlyChart
  KkbYearlyChartCategory
  KkbYearlyChartFuufu
 KkbDataCalc
 KkbDataChart
  KkbDataChartFuufu
  KkbDataChartCategory
 KkbDataTable
 Notification
 Loading

Monthly.vueの上に年間棒グラフがくっついたような構成。Monthly.vueと同じコンポーネントは説明を割愛します。

KkbYearlyChartに関して、KkbDataChartとほぼ同じで子コンポーネントにpropsとしてStore.state.yearlyデータを渡しています。Monthlyとの違いは「グラフをクリックしたら、該当する月のデータを取得して表示する」という点だけです。これはVue.jsというよりChart.jsの使い方ですが、ハマったので解説・・例としてKkbYearlyChartCategory.vueを見てみます。

KkbYearlyChartCategory.vue

fillData関数以外はほかのグラフ系コンポーネントと同じなので割愛。

fillData関数の冒頭で、AJAXで取得したyearlyデータをグラフ表示用に整形しています。カテゴリー毎に月毎の支出金額を計算しています。本当ならCakePHP側で良い感じに整形したほうが楽になるんですがそこは気にせずにお願いします。

そして今回のミソ、クリックイベントです。


this.renderChart({
        labels: ['1月', '2月', '3月', '4月', '5月', '6月', '7月', '8月', '9月', '10月', '11月', '12月'],
        datasets: datasets
      }, {responsive: true, maintainAspectRatio: false, onClick: function(e){
        // グラフ内のポイントをクリックした場合の挙動
        if(this.chart.getElementsAtEvent(e).length){
          let clickedIdx = this.chart.getElementsAtEvent(e)[0]._index;
          let month = "" + (parseInt(clickedIdx, 10) + 1);
          let year  = "" + _this.$route.params.year;
          let yearmonth = year + month;

          // クリックした月のデータを取得
          _this.getMonthlyData(yearmonth);
        }
      }});

onClickというオプション項目内でthis.chart.getElementsAtEvent(e)とすると「どの月のポインタをクリックしたか」という情報が取得できます。lengthが0の時はポインタ以外をクリックしたことになります。そのthis.chart.getElementsAtEvent(e)の中に_indexというキーがあり、それが月配列のindex番号と紐づきます。その情報をもとに年月を割り出し、actionsのgetMonthlyDataをたたきます。

こうして年グラフにクリックイベントを追加することができました。知らないとはまるポイントなので、メモ。

一通りのコードは解説し終えましたのでこれにて一連のVue.js版家計簿に関する解説記事は終了となります。コード量は少なくはありませんが、個々のメソッド単位では難しいこともなく、Vuexのactions→mutations→stateの流れさえ掴めれば大きくハマることもなく開発が進められるんじゃないかと思います。

また、CSSModulesなどの便利技術との組み合わせとも親和性が高いのでVue.js、文句なしにオススメできます。

CSS Modules , ES2015 , JavaScript , Vue.js , Vuex , babel , webpack

前回に続いてEditコンポーネントについて解説します。

Edit.vue以下のコンポーネント群


Edit.vue
 KkbForm
 Notification
 Loading

KkbFormが肝で、残りは共通コンポーネントです。

KkbForm

いろんなギミックが詰まっています。

このコンポーネントは新規追加と既存データの編集2通りで使われます。router.jsを見ると、URLパターンが/editの場合と/edit/:idの2通りの場合にマウントされることが分かります。


// router.js 10行目付近

var routes = [
  { path: '/', component: Monthly },
  { path: '/monthly/:yearmonth', component: Monthly },
  { path: '/edit/', component: Edit }, //★ココ
  { path: '/edit/:id', component: Edit }, //★ココ
  { path: '/yearly/:year', component: Yearly }
];

mountされたとき、routerのparamsが空なら新規、そうでないなら編集として動作します。


  /**
   * マウント時に毎回実行
   */
  mounted: function(){
    if(this.$route.params.id){
      // 編集モード
      // IDと一致するデータ取得
      let targetData = this.getTargetDataFromMonthlyState(this.$route.params.id);

      // localstateに値を登録
      this.edit = targetData;
      //this.$vue.set(this, "edit", targetData);

      // datepickerのtimeに該当に時間を代入
      this.time = targetData.year + "-" + targetData.month + "-" + targetData.date;
    }else{
      // 新規追加モード VuexのStoreではなくlocalStateを利用したことで初期化処理が不要になった(マウント時に初期化されるため)

    }

    // 商品名候補リスト取得
    if(this.formTitleList.length === 0){
      this.getFormTitleList();
    }
  },

このコンポーネントのmethodsに定義されたgetTargetDataFromMonthlyState関数でStore.state.monthlyデータから該当IDのデータを取得します。そのデータをlocalstateのeditに登録することでForm要素の個々の値が既存の情報が表示されます。Store.state.monthlyからデータを引用する関係上、月別データは取得済みである前提の処理となっています。


// ./components/KkbForm.vue 160行目付近

    /**
     * 月別データからIDを指定して該当するデータを返却
     * @param  {Number} id 記事ID
     * @return {Object}    該当データオブジェクト
     */
    getTargetDataFromMonthlyState: function(id){
      // Storeのmonthlyデータから編集対象となるデータを取得
      let targetData = this.monthly.detail.filter(function(d){
        return d.Form.id == id;
      });
      // IDからふるい分けして取得したデータが空でないならデータを整形して返却
      if(targetData && targetData.length){
        targetData = targetData[0]["Form"];
        // 金額などで数字がnullで送られた場合は空文字列に初期化
        targetData["price"]     = targetData["price"]     == null ? "" : targetData["price"];
        targetData["pay_danna"] = targetData["pay_danna"] == null ? "" : targetData["pay_danna"];
        targetData["pay_yome"]  = targetData["pay_yome"]  == null ? "" : targetData["pay_yome"];
        // データ構造をlocalstate用に変換
        targetData = this.convertAjaxDataToState(targetData);//★
      }
      return targetData;
    },

getTargetDataFromMonthlyStateの中で使われているconvertAjaxDataToStateという関数は、AJAXで取得したデータ構造をlocalstateのeditデータ向けに変換して返すメソッドになります。処理は単純にキー名の変更や不要データのdeleteだけでシンプルですのでコード掲載は割愛。

また、Formの送信時は逆のことをしています。convertStateToAjaxDataというメソッドがそれに相当します。sendFormというactionsに引数としてconvertStateToAjaxData関数を渡し、actions内でAJAXレスポンスデータに対して使用されます。


// ./components/KkbForm.vue 100行目付近

    <div class="formBtnArea">
      <p><button @click="sendForm({edit, clearEditState, convertStateToAjaxData})" :class="style.btnSubmit">SUBMIT</button><button @click="allDanna" :class="style.btnA">全額旦那</button><button @click="allYome" :class="style.btnB">全額嫁</button><button @click="diff" :class="style.btnGeneral">差額反映</button></p>
    </div>

SUBMITボタンのclickイベントにsendForm({edit, clearEditState, convertStateToAjaxData})のように関数をオプションとして渡す。


// ./store/actions.js 150行目付近
/**
 * フォーム入力内容を送信
 * @param  {Object}     Vuexオブジェクト
 * @param  {Object}     option連想配列
 */
export const sendForm = function({commit}, {edit, clearEditState, convertStateToAjaxData}){
  // 商品名と価格が入力済みなら送信処理を実行
  if(edit.title && edit.price){
    let _this = this;
    let sendData = Object.assign({}, edit);

    // localstateからAJAXパラメータのデータ構造に変換
    sendData = convertStateToAjaxData(sendData);

actions内にて関数を使用している。

次いで日付入力部分ですが、inputvue-datepicker-localが連携して動作しています。inputはedit.year edit.month edit.dateという3つのlocalなデータとv-modelで紐づいています。対してdatepickertimeというlocalなデータをv-modelとして使用しています。

本来ならedit.year edit.month edit.dateを取りまとめた値がtimeにアサインされ、timeを変更したらedit.year edit.month edit.dateにそれぞれバラされてアサインされるのが理想ですが、そのやり方が分からない。

二種類の別々のdataをそれぞれイイ感じに同期する方法が見つからなかったのでごり押しの手を使ってます。

input(edit.year edit.month edit.date)からdatepicker(time)への同期はblurイベントに紐づけてtimeを更新します。


    /**
     * 年月日をblurした際の挙動
     * @param  {String} type year|month|date
     */
    dateBlur: function(flag){
      // blur字に0埋め処理
      if(flag === "month"){
        this.edit.month = ("0" + this.edit.month).slice(-2);
      }else if(flag === "date"){
        this.edit.date = ("0" + this.edit.date).slice(-2);
      }

      // datepickerのtimeに入力値をsync
      this.time = this.edit.year + "-" + this.edit.month + "-" + this.edit.date;
    }

datepicker(time)からinput(edit.year edit.month edit.date)への同期はwatchでtimeデータを監視し、変更後に同期処理を実行します。


watch: {
    // datepickerの値の変動をedit値に反映
    'time' (changedDate) {
      if(typeof changedDate !== "string"){
        this.edit.year = "" + changedDate.getFullYear();
        this.edit.month = ("0" + (changedDate.getMonth() + 1)).slice(-2);
        this.edit.date = ("0" + changedDate.getDate()).slice(-2);
      }
    }
}

なかなかの力業ですが、意図通りのものができました。

次に商品名のオートコンプリートですが、こちらはAJAXで取得したデータをdatalistに取得して表示するようにしました。


    <dl class="formInputList">
      <dt class="titleArea"><label for="edit_title">商品名</label></dt><dd class="inputArea"><input type="text" v-model="edit.title" class="text" id="edit_title" autocomplete="on" list="formTitleList" />
        <datalist id="formTitleList">
          <option v-for="(item, idx) in formTitleList" :value="item">{{item}}</option>
        </datalist>
      </dd>
    </dl>

v-forで展開されるformTitleListというデータはVuexのStateの値です。コンポーネントのマウント時、Store.state.formTitleListが空ならactionsのgetFormTitleList関数が実行され、mutations経由で更新されます。


  // ./components/KkbForm.js 300行目付近

  /**
   * マウント時に毎回実行
   */
  mounted: function(){
    if(this.$route.params.id){
      // 編集モード
      // IDと一致するデータ取得
      let targetData = this.getTargetDataFromMonthlyState(this.$route.params.id);
      // localstateに値を登録
      this.edit = targetData;
      //this.$vue.set(this, "edit", targetData);
      // datepickerのtimeに該当に時間を代入
      this.time = targetData.year + "-" + targetData.month + "-" + targetData.date;
    }else{
      // 新規追加モード VuexのStoreではなくlocalStateを利用したことで初期化処理が不要になった(マウント時に初期化されるため)
    }
    // 商品名候補リスト取得
    if(this.formTitleList.length === 0){
      this.getFormTitleList(); //★ココ
    }
  },

何度もfetchする必要はないので、formTitleListが空なら実行。


// ./store/actions.js 280行目付近
/**
 * FormTitleListを取得
 * @param  {Object}     Vuexオブジェクト
 */
export const getFormTitleList = function({commit}){
  // productionもdevもGETで取得
  let ajaxUrlString = ajaxUrl.formTitleList;
  let method = "GET";

  let headers = {'Accept': 'application/json'}

  let ajaxOption = {
    method,
    headers,
    credentials: 'include'
  }

  fetch(ajaxUrlString, ajaxOption)
    .then(function(res){
      if(res.ok){
        return res.json();
      }
    }).then(function(json){
      commit(types.UPDATE_FORM_TITLE_LIST, json.list); // ★mutationsへ
    });
}

// ./store/mutations.js 130行目付近

  /**
   * Formの商品名部分のオートコンプリート候補データを更新
   * @param  {Object} state  state
   * @param  {Array}  list   候補文字列リスト
   */
  [types.UPDATE_FORM_TITLE_LIST]: function(state, list){
    state.formTitleList = list;
  }

これで静的なJSONファイルで項目の増減が可能になりました。

続いて全額旦那、全額嫁、差額反映ボタンのクリックイベントにそれぞれ簡単な処理が書かれています。処理自体はとてもシンプルですのでコードは割愛。。

actionsのsendFormについてもう少し補足しておくと、JSONにisNewという値があり、1なら新規追加として、入力内容を全てクリアする処理を実行するようにしています。


// ./store/actions.js 200行目付近

        if(json.isNew == 1 && json.status == 1){
          // 新規入力だったらフォーム入力内容を初期化
          clearEditState();
          //edit = Object.assign({},DEFAULT_EDIT_STATE);
        }

clearEditStateという関数はコンポーネントから渡されたコールバック関数です。KkbFormコンポーネントの以下の記述のように、オプション変数として渡されています。


@click="sendForm({edit, clearEditState, convertStateToAjaxData})"

CSS Modules , ES2015 , JavaScript , Vue.js , Vuex , babel , webpack

前回はwebpackとかファイル構造とかStoreについて書きました。今回からは3回に分けてcomponents周りを解説していきたいと思います。まずはcomponentsの構成から。

components構成

routerから直接呼ばれる上位コンポーネントと、それにぶら下がる子コンポーネントに大別できます。構成は以下の通りです。


Monthly.vue
 KkbDataCalc
 KkbDataChart
  KkbDataChartFuufu
  KkbDataChartCategory
 KkbDataTable
 Notification
 Loading

Edit.vue
 KkbForm
 Notification
 Loading

Yearly.vue
 KkbYearlyChart
  KkbYearlyChartCategory
  KkbYearlyChartFuufu
 KkbDataCalc
 KkbDataChart
  KkbDataChartFuufu
  KkbDataChartCategory
 KkbDataTable
 Notification
 Loading

Monthly.vue、Edit.vue、Yearly.vueの3種類に大別でき、それぞれに子コンポーネントがぶら下がっているようなイメージです。Monthly.vueから個々に見ていきます。


Monthly.vue以下のコンポーネント群


Monthly.vue
 KkbDataCalc
 KkbDataChart
  KkbDataChartFuufu
  KkbDataChartCategory
 KkbDataTable
 Notification
 Loading

templateの冒頭にdiv.monthlySelectという前月来月へ移動するボタンを持つ要素があります。それぞれprevYearMonth、nextYearMonthという変数を用いてリンクが生成されています。これらはcomputedによる演算整形後のデータを使用しています。Store.state.monthly.currentYearMonthの値を基準に前後月を計算します。このStore.state.monthlyは子コンポーネントKkbDataTableからactionsを発行し、AJAXで取得したデータをmutations経由で更新されるデータになります(後述)

KkbDataCalc

Store.state.monthlyデータから、トータル支出・旦那支出・嫁支出の表示と差額表示を行っています。

Store.state.monthlyデータをこのコンポーネントで使用できるように、VuexのmapState関数を使用します。


import { mapState } from 'vuex'

//~~途中割愛~~
  computed: {
    // VuexのStateを展開
    ...mapState({
      monthly: 'monthly'
    }),

これでStore.state.monthlyの値がmonthlyという命名で使用できるようになりました。

このコンポーネントの前半部分(div.sumStatus要素。月の支出・差額表示部分)はかなりシンプルです。stateの内容をmain.jsで定義したfilterで整形して表示したり、computedを使って差額計算をして表示したりしてます。スタンダードで分かりやすいVue.jsの使い方で特に難しくないと思います。

後半のカテゴリー別の支出表示(div.categoryStatus以下の部分)にはv-forを使用しています。配列を受け取り、繰り返し表示ができます。ここでは以下のようなlistデータを繰り返し処理しています。


"list": [
        {
            "category_id": "1",
            "category_name": "食料品",
            "sum_danna": "1000",
            "sum_price": "1200",
            "sum_yome": "200"
        },
        {
            "category_id": "2",
            "category_name": "雑貨",
            "sum_danna": "1000",
            "sum_price": "2000",
            "sum_yome": "1000"
        },
        {
            "category_id": "3",
            "category_name": "生活消耗品",
            "sum_danna": "1000",
            "sum_price": "1000",
            "sum_yome": null
        },

また、CSSModulesの使い方もここで覚えておくと良さそうです。CSSをインポートし、template上で:classとしてバインドします。

script

import Style from '../style/common.css'

export default {
  name: 'kkb-data-calc',
  data: function(){
    return {
      style: Style,
    }
  },
template

{{payPrice}}円

class名にハイフンが使われている場合は以下のように記述します。そうしないとマイナス記号として認識され、エラーになります。


<span :class="sprite01['icon-left']"></span>

KkbDataChart

KkbDataChartFuufuKkbDataChartCategoryStore.state.monthlyをpropsとして渡しているだけです。

KkbDataChartFuufuKkbDataChartCategory

vue-chartjsの記法に則って記述しています。わざわざStore.state.monthlyをpropsとして渡したのは以下の記述のためです。


watch: {
    'chartState' () {
      this.fillData()
    }
  },

watchとは、値の変更を監視して何らかの変更を検知したら処理を実行するオプションになります。ここで親から渡されたpropsを指定し、変更を検知したらグラフを再描画しています。

ここを直接Store.state.monthlyをwatchするようにしたところ上手く動作しなかったためprops経由で渡されたデータを監視するようにしました。(やり方が悪いだけかもしれませんが・・・)

KkbDataTable

template自体はシンプルですが、このコンポーネント内でactionsを発行している箇所があるため侮れません。

VuexのmapActionsという関数を使うことで、このコンポーネントからactionsが叩けるようになります。


  methods: {
    // Vuexのactionsを展開
    ...mapActions ({
      getMonthlyData: 'getMonthlyData',
      delEntry: 'delEntry',
    }),

発行したいアクションは月別データを取得するgetMonthlyDataと、登録データを削除するdelEntryの二つです。

getMonthlyDataこのコンポーネントがマウントされたときとrouterのparamsに変更があったときに発火させています。


  /**
   * マウント時に毎回実行
   */
  mounted: function(){
    // 当コンポーネントがマウントされたらAJAXでデータ同期
    let yearmonth = this.getYearmonth(this.$route.params.yearmonth);
    this.getMonthlyData(yearmonth);
  },
  // routerの変更を監視
  watch: {
    '$route' (to, from) {
      // 当コンポーネントがマウントされた状態でrouterのパラメータが変更されたらデータ同期
      let yearmonth = this.getYearmonth(to.params.yearmonth);
      this.getMonthlyData(yearmonth);
    }
  }

delEntryは@clickイベントで発行します。削除ボタンに直接actions関数を指定しています。


<button @click="delEntry({d, idx, category})" :class="style.btnB">削除</button>

actionsにオプション値を送っています。./store/actions.jsを見ると分かりますが、オブジェクトとして受け取っています。


// ./store/actions.js 70行目付近

/**
 * 該当データ削除ボタンの挙動
 * @param  {Object}     Vuexオブジェクト
 * @param  {Object}     option連想配列(d: 現在の月別データ(store.state.monthly.detailと同じ形式, idx: 削除対象の配列番号, category: 支払いカテゴリーリスト)
 */
export const delEntry = function({commit}, {d,idx,category}){
  let _this = this;
  let data = d.Form;

また、AJAX全般そうですが、actionsの中で本番用Buildと開発用BuildでAJAXオプションの指定を変えている箇所があります。本番では認証情報をぶら下げてPOSTとして送信しますが、開発時は静的なstubをjsonファイルとして用意してGETするようにします。以下のようにifで分岐します。


  // AJAXオプション設定
  let ajaxUrlString = ajaxUrl.delete;
  let ajaxOption    = {};

  // AJAXオプションをproductionとdevで分ける
  if(process.env.NODE_ENV === 'production'){
    let method = "POST";
    //let body = JSON.stringify({start: yearmonth})
    // FormDataオブジェクト形式でPOSTデータを生成
    var body = new FormData();
    body.append("id", data.id);

    let headers = {'Accept': 'application/json'}

    ajaxOption = {
      method,
      headers,
      body,
      credentials: 'include'
    }
  }else{
    ajaxUrlString = ajaxUrlString + "?id=" + data.id;
  }

// ~略~
  fetch(ajaxUrlString, ajaxOption)

これらactionsの最後にcommit(types.UPDATE_MONTHLY_DATA, json)などのようにmutationsにjsonデータを引き渡しています。

jsonを受け取ったmutationsはstateを更新する流れになります。

NotificationLoading

ほかの上位コンポーネントでも読み込んでいます。どちらもシンプルで、Store.state.notificationStore.state.isLoadingの値に応じて表示非表示を切り替えているだけです。

actionsのAJAX実行シーケンス内で使用されます。


  // ./store/actions.js の一例

  // loadingアニメーション再生 //★ココ
  commit(types.UPDATE_LOADING, true);

  fetch(ajaxUrlString, ajaxOption)
    .then(function(res){
      // loadingアニメーション停止 //★ココ
      commit(types.UPDATE_LOADING, false);

      if(res.ok){
        return res.json();
      }else{
        // error //★ココ
        commit(types.UPDATE_NOTIFICATION, "(network error)月別データ取得に失敗しました。\nインターネットに接続しているか確認してください。");

Notificationはトーストメッセージを一定時間表示したあと、自動で閉じるようにするためにmutations上でタイマーを仕込んであります。以下./store/mutations.jsの該当の記載です。


// ./store/mutations.js 70行目付近

  /**
   * 注記メッセージを更新
   * @param  {Object} state state
   * @param  {String} msg   表示メッセージ文字列
   */
  [types.UPDATE_NOTIFICATION]: function(state, msg){
    state.notification.msg = msg;

    if( timerId != null ){
      clearTimeout(timerId);
      timerId = null;
    }

    // 指定秒数表示した後に消す //★ココ
    timerId = setTimeout(function(){
      timerId = null;
      state.notification.msg = "";
    }, 1500);
  },

今回はcomponentsの構成とMonthly.vueに関する解説でした。次回に続きます。

CSS Modules , ES2015 , JavaScript , Vue.js , Vuex , babel , webpack

前回「Vue.jsで家計簿管理システムを作った」ではサーバやDBなどの環境周りの話でした。今回はwebpackを使った開発環境の話・大枠のファイル構造・Store周りを解説します。

htdocs/kakeibo/app/webroot/package/ ディレクトリ

Vue.jsでの開発はこのディレクトリを基準に作成します。package.jsonを見ていただくとどんなライブラリを使用しているかわかります。

package.json

利用できるnpmコマンドは2つ。


"scripts": {
    "dev": "cross-env NODE_ENV=development webpack-dev-server --open --hot",
    "build": "cross-env NODE_ENV=production webpack --progress --hide-modules"
  }

それぞれnpm run devnpm run buildというコマンドを実行します。

devフロントエンド開発に特化した開発用Buildで、webpack-dev-serverが立ち上がります。bundleしたJSの書き出しは実行されません。また、AJAX周りも本番とは違ってstubのjsonを使用するためLAMP環境を必要としませんのですぐさまフロントエンド開発ができます。

buildは本番用Buildです。bundleしたJSが./static/に書き出されます。

これらコマンドはwebpack.config.jsの設定に準じた動作をします。

webpack.config.js

Buildの仕様には以下のような特徴があります。

  • 環境変数Productionを指定すると本番用のbuild設定になり、それ以外は開発用のbuildになる
  • 開発用Buildは原則webpack-dev-serverをHMRで使用することを想定している
  • 開発用Buildでは./static/bundle.jsに全てのJSコードがbundle化されます(ファイルの書き出しはされない)
  • 開発用Buildのwebpack-dev-serverでは./index.htmlが表示される(ちなみに本番はCakePHPのFormコントローラのindexアクション(forms/indexテンプレート)が使用される)
  • 本番用Buildは./static/以下にベンダー系のJSをまとめたlib.bundle.jsと、家計簿アプリ用の実行系のJSをまとめたbundle.jsを書き出す設定
  • process.env.NODE_ENVという変数がJSファイル内で使用でき、本番用BuildではString型でproductionとなる。よってJS作成時に本番用と開発用でソースコードを分岐できる(./src/config/ajaxUrl.jsの例)

process.env.NODE_ENVの設定はwebpack.config.jsの以下の部分の記述がそれに当たる。


// webpack.config.js 100行目付近

  module.exports.plugins = (module.exports.plugins || []).concat([
    // jsファイル内で process.env.NODE_ENV === 'production' で分岐させるために変数を定義
    new webpack.DefinePlugin({
      'process.env': {
        NODE_ENV: '"production"'
      }
    }),

CSS周りの仕様

  • CSS Spriteの生成にはwebpack-spritesmithを使用しているが、本番用Buildでしか実行されない
  • spritesmithは./src/sprite/以下のpng画像を対象とし、画像を/static/sprite01.pngに、CSSを./src/style/sprite01.cssに出力
  • spritesmithで生成したCSSは個々のJSファイルでimportされて使用する(CSS Modules こんな感じ
  • import対象のCSSファイルはcssnextの記法が使用できる
  • .vueファイル内のscopedCSSでもcssnextの記法が使用できる

css-loaderでも.vueファイル内でもcssnextを使用するためにはそれぞれ別々の設定が必要でした。


//webpack.config.js 20行目付近

// 使用モジュール設定
  module: {
    rules: [
      // .vueファイル style内でcssnextの記法を使用するためpostcss-cssnextをロード
      {
        test: /\.vue$/,
        loader: 'vue-loader',
        options: {
          cssModules: {
            localIdentName: '[path][name]---[local]---[hash:base64:5]',
            camelCase: true
          },
          postcss: [require('postcss-cssnext')()]
        },
        exclude: "/node_modules/"
      },

まずvue-loaderの設定。postcss: [require('postcss-cssnext')()]とすることでcssnextの記法が使えるようになります。


      //webpack.config.js 40行目付近

      // .css CSSModulesを使用 かつ cssファイル内でcssnextの記法を使用
      {
        test: /\.css$/,
        use: [
          { loader: "style-loader" },
          { loader: "css-loader?modules" },
          { loader: "postcss-loader" }
        ],
        exclude: "/node_modules/"
      },

css-loader?modulesとすることでCSS ModulesとしてJSからCSSをimportできるようにしています。postcss-loaderは同梱のpostcss.config.jsの設定に準じてcssnext記法が使えるようにしています。

正直な話、この規模でCSS Modulesはベスト解ではないと思いますが使ってみたかったのでこのような構成にしています。


CSS周りの概観

vueファイル内のCSS
    ↓
vue-loader + postcss-cssnext


.cssファイル群(./src/style/) ← spritesmithで生成したCSS(./src/sprite/)
    ↓
CSS Modules + postcss-loader + cssnext
    ↓
個々のJSファイルにimportされる

開発用Build向けのProxy

CakePHPで表示する際の静的ファイルパスが/kakeibo/package/static/であるのに対してwebpack-dev-serverは/static/になります。その差異があるとスプライト画像が404になったためdev用に以下を追記しました。


  //webpack.config.js 70行目付近

  // sprite画像のパスをproductionとdevで共用できるようにproxy設定
  devServer: {
    historyApiFallback: true,
    noInfo: true,
    overlay: true,
    proxy: {
      '/kakeibo/package/static/sprite01.png': {
        target: 'http://localhost:8080/static/sprite01.png',
        secure: false,
        pathRewrite: {'^/kakeibo/package/static/sprite01.png' : ''}
      }
    }
  },

もっといい方法はありそうですが、手っ取り早くproxyにしちゃいました。


htdocs/kakeibo/app/webroot/package/src/

ファイル構成概要

main.jswebpackのエントリーポイント。routerなどのグローバル設定をここに記載。
App.jsOPレベルコンポーネント。router-viewと共通ヘッダを持つ。
router.jsvue-routerの設定ファイル。URLパターンとそれに対応するcomponentを紐づける。
style/CSS Modules用のCSSファイル(sprite.cssはwebpack-spritesmithで生成し、吐き出されたもの)
sprite/スプライト化する前のバラの画像
assets/サードパーティ製のアセットが何かあればここに置いて使用。
config/ajaxurlやカラースキームなどのコンフィグを配置
store/VuexのStoreを配置
components/Vue.jsのコンポーネントを格納

いくつか見ていきます。

main.js

主な仕事はAppのマウントです。またここではfilterという、.vueファイルのtemplate内に出力する変数を任意の形に整形して表示する処理をここで書いています。似たようなものに.vueファイル内で定義する computed がありますが、それをどのcomponent上でも使用可能にしたもの、という認識です。

router.js

React等でもお馴染みのrouterです。URLに応じてマウントするcomponentを出し分けてくれます。URLパターンとコンポーネントを紐づけるだけの簡単な記述です。

大量のコメントアウトが書かれていると思いますが、それはwebpackの設定で「routerで設定したコンポーネント毎に吐き出すJSファイルを分割したい」ときに必要な記述です(チャンク分割というそうです)。今回は分割しなくてもgzip圧縮転送で150KBくらいに収まったのと、このくらいならログイン画面にlinkタグpreloadを仕込んでおけばパフォーマンス上さほどクリティカルにならないと判断してベンダーとロジックの分割にだけに留めました。

ログイン画面のテンプレートhtdocs/kakeibo/app/views/users/login.ctpに仕込んだpreloadの記述例


<!-- bundleのpreload -->
<script type="text/javascript">
var link = document.createElement('link');
link.rel = 'preload';
link.as = 'script';
link.href = '/kakeibo/package/static/lib.bundle.js';
document.head.appendChild(link);
</script>
<!-- /bundleのpreload -->

./style/

common.cssとsprite01.cssの2つ。sprite01.cssはwebpack-spritesmithで吐き出されたものです。コマンドでnpm run buildを実行するとここにCSSが吐き出されます。common.cssもsprite01.cssもjsからimportして使用します。詳しくはこの記事の冒頭「CSS周りの仕様」を参照ください。

./config/

カラー設定、AJAXURL設定が格納されています。個々のJSファイルからimportして使用します。

./store/

Vuexの肝です。以下のような構成になっています。

index.js他のファイルを取りまとめ、main.jsでマウント時に登録される。Vuexにおけるエントリーポイント的なファイル。
mutations.jsstateとmutationsを保持。mutationsとはStoreのstateを更新する際の受付窓口になります。後述のactionsから値を引き受け、stateを更新します。
actions.js主にAJAX処理が書かれている。ユーザ操作やアプリケーションの変動をトリガーに実行され、AJAXで取得したデータを整形してmutationsに値を渡します。
mutations-types.jsどういうmutationsがあるか一覧できる。mutations呼び出し名の定義。
getters.jsstateを整形してcomponentなどに渡すらしいですが今回は使いませんでした。

Reduxに慣れていると何でもないんですが、最初はとっつきにくいかもしれません。componentからactionsが実行され、AJAX結果がmutationsに渡されmutationsはstateを更新する。という1方向の流れになります。

何をStoreにするか

VuexのStoreは誤解を恐れずに言うならどのコンポーネントからも読み書きできるグローバルなオブジェクトです。グローバル化が不要なデータは、個々のコンポーネントのdata関数にローカルなstateとして用意できます。

考え方としては、コンポーネントをまたいで使用されそうだったり、コンポーネントのマウント/アンマウントの影響を受けたくないデータなんかを格納するといいと思います。

Loading表示、トーストメッセージもStoreに

AJAX中のLoading表示や、ユーザへのメッセージ通知要素も様々なコンポーネントから呼び出せることが必要条件になります。それぞれLoadingNotificationという独立したコンポーネントにし、routerで表示する上位コンポーネントにそれぞれ読み込ませて使用する想定です。よって「コンポーネントをまたぐ」という条件を満たしますのでStoreにデータを用意しました。


//./store/mutations.js
export const state = {
  startYear: 2013, // アプリケーションの開始年
  category: {},    // カテゴリーIDとカテゴリ名が対になったデータ
  monthly: {
    detail: [],           //月別データを格納
    currentYearMonth: "", //現在の年月
  },
  yearly: {
    categoryDataset: [],
    fuufuDataset: [],
    total: {
      dannna: 0,
      yome: 0,
      sum: 0
    }
  },
  today: today, // 今日のリアル年月
  notification: {// ★コレ
    msg: ""
  },
  isLoading: false, // ★コレ // AJAX中フラグ
  formTitleList: [], // 入力フォームのオートコンプリート値
}

次回からいよいよ個々のコンポーネントについてお話できたらと思います。

CSS Modules , ES2015 , JavaScript , Vue.js , Vuex , babel , webpack

過去二回にわたって家計簿WEBアプリをgithubに公開してきました。

今回はVue.jsバージョンを別途作ってみました。

webpackを使用しました。コマンド等はgithubをご覧いただき、当ブログでは個々のコードの補足をさっとやりたいと思います。

_confディレクトリ

家計簿アプリケーションのバックエンド部分はCakePHPを使用しています。CakePHPが動作するLAMP環境を構築する際の参考にconfigファイルを設置しています。

./conf/httpd.conf

apacheの基本設定です。ほぼ、以前Blogで紹介した内容と同一です。

大枠の設定はNameVirtualHost *:80を有効にし、VirtualHostの設定を/etc/httpd/conf/vhost/以下のconfファイルで行うようにincludeしています。

Vagrant用の設定で、静的ファイルが延々とキャッシュを返してしまう事象を避けるために以下の2行を末尾に追記してあります。


EnableMMAP Off
EnableSendfile Off

詳細は当ブログの過去記事「windowsでvagrantを導入するを参照ください。

./conf/vhosts/kkb.conf

特に変わったこともなく、一般的な記述です。とりあえずServerNameにkkbと指定していますが、VirtualHost設定が1つしかない場合はホストコンピュータのブラウザからhttp://localhost:10080でアクセスできるはずです。

php.ini、my.cnf

文字コードをUTF-8で使用できるような設定になっています。

.sqlファイルについて

githubにも書きましたが、ダミーデータです。MySQL用とSQLite用にそれぞれ用意しました。ダミーデータが沢山入っているバージョンとまっさらなバージョンの2種ありますが、いずれもアプリケーションにログインするためのデフォルトのユーザはadmin / adminです。

htdocs/kakeibo/app/ディレクトリ

フロントエンド系のソースコードはhtdocs/kakeibo/app/webroot以下に含まれています。他はCakePHPのソースコードになります。

htdocs/kakeibo/app/sqlite3_db/kkb.db_conf/sqlite_kakeibo_testdata.sqlを適用した直後のsqlite3データベースになります。htdocs/kakeibo/app/config/database.phpを見ていただくと分かりますがデフォルトではこのDBを使用するようになっています。MySQLに切り替えたい場合はdatabase.phpを編集します。

ひとまずフロントエンド以外の解説を記載しました。次回以降はフロントエンド実装部分を解説いたします。

JavaScript , 検索

JavaScriptを書いてて「文字列の中に特定の文字が含まれるか否か」を判定したいとき、皆さんだったらどうやって書きますか?私は長いこと「ただの文字列ならindexOf」「正規表現なら.test」を使ってました。

これは5年位前に計測したときにindexOfが早かったのを覚えていてそのときの経験則からそうしていただけに過ぎません。

ここらでもう一回計測してみようと思い立って試験的にコードを書いてみました。

performance.nowを使って計測しています。なお、計測結果はconsoleに出力されます。

ES2015 , JavaScript , Material-ui , React , Redux , babel , webpack

以前、LAMP環境で動作するcakePHP製の家計簿アプリのフロントエンドを刷新しました。(以前の記事:WEB上で出費を管理「ふたりの家計簿」を公開しました

掲題の通りReact + Redux + Material-UIを組み合わせてみました。コンパイルにはWebpack, Babel、文法チェックはESLintを使用しました。

コンパイル環境にはtakanabe様の「Material UIを使ってカッコいいUIのReactアプリケーションを作ってみた」をベースにしました。1から自分で作っていたらもっと大変だったと思います。この場を借りて謝辞とさせていただきます。ありがとうございます!

少し解説

Reduxということで

  1. action creatorがactionを発行
  2. reducerがactionから新しいstateを発行し、containerに渡す
  3. containerから各componentにpropsとしてstate情報を配給

という基本型を意識して作りました。かいつまんで説明していきます。

エントリーポイント

src/index.jsx

webpackのエントリーポイントです。reactのマウント、routerの設定、初回アクセス時に発行するアクションの設定を行っています。

ページにアクセスしたばかりの状態だとstateは空の状態なので、AJAXで現在年月データを取得し、stateに反映する必要があります。

import { fetchMonthlyData } from '../actions/monthly';
const pageHistory = syncHistoryWithStore(browserHistory, store);
pageHistory.listen(function(location){
  // 月別データ表示
  if(location.pathname.match(/monthly/)){
    store.dispatch(fetchMonthlyData({start: location.pathname.slice(-6)}));
  }
  // インデックスに戻ってきた際、PHP側でtplに吐き出された現在年月から該当月データを取得
  if(location.pathname === '/kakeibo/forms/index'){
    store.dispatch(fetchMonthlyData({start: startYear + startMonth}));
  }
});

このように、/kakeibo/forms/indexというトップページにアクセスしたら月データ取得AJAXを走らせるようにしています。

このlistenというのが肝になりますね。特定のURLパターンにマッチしたら最短で実行したい処理をここに書きます。

今回は月別アーカイブ、トップの現在月データ取得の2つのパターンでlistenを用意しています。

エントリーポイント配下に複数のコンポーネントがぶら下がっている形になりますが、全てを説明しているときりがないのでTOPでも使っている月間データ取得部分に焦点を絞って解説します。

action

actions/monthly.jsx

(これがベストな方法かはさておき)非同期処理はActionで書く方針にしました。取得中、取得成功、取得失敗の3パターンのアクションクリエイターを用意し、reducerにactionを渡します。

/**
 * 月別データ取得AJAXの通信アクション
 * @return {Object} 削除通信中アクション->通信完了アクション
 */
export function fetchMonthlyData(data) {
  let params = new URLSearchParams();
  let i;

  // AJAXパラメータオブジェクトを生成
  for(i in data){
    params.append(i, data[i]);
  }

  return dispatch => {
    // 通信中アクションを実行
    dispatch(fetchMonthlyDataRequest(data));

    // AJAX
    Axios.post(
      ajaxUrl.getMonthData,
      params,
      { headers: { 'Content-Type': 'application/x-www-form-urlencoded' } }
    ).then(
      response => dispatch(fetchMonthlyDataResult(response.data))
    ).catch(
      () => dispatch(fetchMonthlyDataResult({status: "0", msg: "月別データの取得に失敗しました。通信状況を確認してください"}))
    )
  }
}

exportするのはこの関数だけです。AxiosというAJAXライブラリを使用しています。Axios自体の使い勝手は賛否あるようですが、単純にPOSTするだけなら問題は・・・ひとつだけありました。

headersを指定しないとpayloadとして送信するため、jQuery感覚で送信してもだめでした。PHP側での値の取得部分に手を加える必要がでてしまい、今回のガワ変更という趣旨から外れます。(PHPに手を入れるのは最低限にしたかった)

そこでheadersにContent-Typeを指定したのですが、そうすると今度は送信データをObject作って渡してもString型になってしまいました。URLSearchParamsを使うと解決できるようですがmobileブラウザでまったく動きません。

最後まで悩みましたがpolyfillを使わせていただきました。

これであっさり解決しちゃいました!

monthly以外にもAJAXを絡めたaction creatorはありますが、どれも構造自体は一緒です(del.jsx、edit.jsx、year.jsx)

reducer

reducers/monthly.jsx

超完結です。action.typeに応じて決まったstateを返すだけです。

FETCHED_POSTS_SUCCESSとFETCHED_POSTS_FAILUREにはAJAXで取得したdataをぶら下げてstateとして発行しています。reducerの処理はシンプルなのでコードは割愛します。

Container

containers/App.jsx

コンポーネントを包んでいるコンテナです。routerなどで直接マウントされる対象、ともいえます。

function mapStateToProps(state, ownProps) {
  return {
    monthly: state.monthly,
    drawmenu: state.drawmenu,
    del: state.del
  };
}

function mapDispatchToProps(dispatch) {
  return {
    actionsMonthly: bindActionCreators(Monthly, dispatch),
    actionsDrawMenu: bindActionCreators(DrawMenu, dispatch),
    actionsDel: bindActionCreators(Del, dispatch)
  };
}

export default connect(
  mapStateToProps,
  mapDispatchToProps
)(App);

先ほどreducerで発行したstateは各コンポーネントに渡す必要があります。mapStateToPropsでオブジェクト形式でstateをpropsに変換して返します。こうすることでAppコンテナ内ではstateをthis.propsの形で参照できるようになります。

action creatorも同様に、コンポーネントに渡すことが可能です。mapDispatchToPropsの部分が該当部分になります。

reduxのデータの流れは以上になります。コンポーネント以下はviewの機能を担うreactがメインになります。コンポーネント全てを解説していると長大になるので割愛しますが、基本的には上記のconnectで割り当てたstateを使ってrenderを制御し、ユーザー操作を受けたらaction creator経由でstateを更新する流れになります。

Container

components/DetailTable.jsx

DetailTableを例にしてみてみると、renderの中で先ほどconnectで割り当てたstateをprops経由で表示制御に使用しています。また、ボタンイベントにはaction creatorを叩く処理が割り当てられています。

基本的なComponentの作りは皆一緒です。

実際に使ってみて

実は今回reactもreduxもmaterial-uiも初めてでした。とっつきやすくはありませんが、action、reducer、container、componentのように役割がしっかり分かれているため、コードを読むときに便利だなーと思います。特に人のソースコードを見るときはいいかもしれません。お互いが同じルールブックを元にコードを書いたり読んだりできるから。

reduxやreact自身のパワーもさることながら、統一化・画一化された規約という意味でもreduxやreactは有益かもしれません。

FIleAPI , JavaScript

フォルダをドラッグアンドドロップするとファイル一覧を出力するだけのWEBアプリです。
ロジック的には単純で、

  1. dataTransfer.itemsから最初のFileをエントリーポイントと定義
  2. エントリーポイントの該当階層を探査し、ファイルならパスを取得、ディレクトリなら更にエントリーポイントとして再帰処理
  3. ディレクトリが見つからなくなるまで処理を続け、最後に出力

という感じです。

ただ、全ファイルの探査が完了したか否かのロジックのためにPromiseを入れ子にしている点がちょっと変わっているなーと自分事ながら思います。母数が不明な非同期処理における終端を判定するひとつの解としてみていただければと。。

docker , dockerfile , frontend , gulp , webpack

前回、VagrantとAnsibleでフロントエンド開発環境を作るで既に仮想マシン上に開発環境を整えることができました。

今度はDockerで同じようなことができないか試してみました。

参考にさせていただきました

ぱいそんにっきさんの「Docker でフロントエンド開発も楽できるか?」を超参考にさせていただきました。ありがとうございます!とても分かりやすくて助かりました!

Dockerfile

ほぼほぼぱおそんにっきさんのDockerfileまんまです。とりあえず、Ruby2.1が元々入っているimageをcloneして作りました。rubyのバージョンは使いたいバージョンなら何でもOKなはずです。マシンの設定はこざっぱりとDockerfileだけで完結させました。

### Dockerfile

# Pull base image.
FROM ruby:2.1

# Timezone Setting
RUN echo "Asia/Tokyo" > /etc/timezone

# Install Debian
RUN apt-get update
RUN apt-get dist-upgrade -y

# Install Ruby.

# Install Gems
RUN gem install compass

# Install Node.js
RUN \
  cd /tmp && \
  wget http://nodejs.org/dist/v4.4.5/node-v4.4.5.tar.gz && \
  tar xvzf node-v4.4.5.tar.gz && \
  rm -f node-v4.4.5.tar.gz && \
  cd node-v* && \
  ./configure && \
  CXX="g++ -Wno-unused-local-typedefs" make && \
  CXX="g++ -Wno-unused-local-typedefs" make install && \
  cd /tmp && \
  rm -rf /tmp/node-v* && \
  npm install -g npm && \
  echo -e '\n# Node.js\nexport PATH="node_modules/.bin:$PATH"' >> /root/.bashrc

# Install Node modules
RUN \
  npm install -g grunt-cli gulp webpack bower

# Define working directory.
WORKDIR /data

# set up


# Define default command.
CMD ["bash"]

### IMPORTANT !! ###
# you can build image this command
# docker build -t nodejs:v4.4.5 ./

# you can run this
# docker run -itd -v /c/Users/{username}/works/:/data/ nodejs:v4.4.5 /bin/bash

# if you run npm i on docker container on windows host, you must choose option --no-bin-links
# https://docs.npmjs.com/cli/install

vagrant + ansibleのときと比べると、rbenvを使っていないからかもしれませんが随分と短くてすみました。

使ってみた所感としては、Dockerの場合は基本的にhostとの共有フォルダをuserフォルダ以下にしか作れない点が大きな制約になりますね。Dockerは作っては壊す、というルーチンがカンタンで起動や破棄が高速な点が良く、トライアルアンドエラーがvagrantほどしんどくないのが最大の強みかなと思います。

ページトップへ