Vue.js でダッシュボードを作成④(データバインディング編)



前回作成した Vueアプリケーションに、表示対象の月を変更できる機能を追加しました。また、合わせて下記機能も追加し、少し躓いた部分があるのでこの記事に書き残しておきます。



  • ナビゲーションバーの月毎/日毎のページ
  • ホーム画面のダッシュボードに月選択できる機能を追加
  • ダッシュボードの折れ線グラフが動的に更新されるよう修正
  • ユーザー数/プレビュー数の値が動的に更新されるよう修正(グラフの合計値)

サイドバーのダッシュボード以外のページと、サーバーサイドの処理については、この記事では未実装です。
現時点では、グラフのデータはランダムで作成しています。



*参考




*作ったもの

画像をクリックすると実際のアプリケーションが起動します。













*環境

  • MacOS
  • vue/cli 3.8.4
  • vue-router 3.0.6
  • npm 6.9.0


*目次

  1. 月毎/日毎のダッシュボード
  2. 月選択の矢印を追加
  3. 子コンポーネントの変更を親で受け取る
  4. 補足
  5. Herokuで公開
  6. 所感


*月毎/日毎のダッシュボード

ホーム画面のダッシュボードと同様に、月毎と日毎のダッシュボードのコンポーネントをそれぞれ追加します。
  • 既存:src/components/pages/Dashboard.vue
  • 追加:src/components/pages/MonthlyDashboard.vue
  • 追加:src/components/pages/DailyDashboard.vue

次にナビゲーションバーの項目をクリックすると、その項目がアクティブになり項目名の色が変わるようにします。そのためにはNavbar.vue側でクリックされた項目を判別し、その項目のstyleCSSで変更します。




まず、ダッシュボード画面を区別できるよう<navbar>baseNameの値をpropsで渡します。
<src/components/pages/Dashboard.vue>
<template>
  <div>
    <navbar baseName="daily"></navbar>
    <sidebar></sidebar>
    ...
  </div>
</template>
...

<src/components/pages/MonthlyDashboard.vue>
<template>
  <div>
    <navbar baseName="monthly"></navbar>
    <sidebar></sidebar>
    ...
</template>

<src/components/pages/DailyDashboard.vue>
<template>
  <div>
    <navbar baseName="daily"></navbar>
    <sidebar></sidebar>
    ...
</template>

Navbar.vueのコンポーネントにsetActive()を追加してクラスとバインドさせ、propsとして渡された値が一致したliタグのクラスを変更するようにします。
<src/components/organisms/Navbar.vue>
<template>
  <nav class="nav-header">
    <ul class="nav-header__menu">
      <app-logo value="Web Analysis"></app-logo>
      <!---- ↓setActive()を追加 ---->
      <li :class="setActive('home')"><a href="/">ホーム</a></li>
      <li :class="setActive('monthly')"><a href="/monthly">月毎</a></li>
      <li :class="setActive('daily')"><a href="/daily">日毎</a></li>
      <login-link value="admin"></login-link>
    </ul>
  </nav>  
</template>
<script>
import AppLogo from "../atoms/AppLogo";
import LoginLink from "../atoms/LoginLink";
export default {
  components: {
    AppLogo,
    LoginLink
  },
  props: {
    baseName: String
  },
  methods: {
    setActive(item) {
      return this.baseName === item ? "current" : "";
    }
  }
};
</script>
<style scoped lang="scss">
.nav-header {
  text-align: center;
  height: 67px;
  background-color: white;
  position: fixed;
  width: 100%;
  user-select: none;
}
.nav-header__menu {
  padding: 20px 0;
  margin: 0;
  text-align: left;

  & li {
    list-style: none;
    display: inline-block;
    width: 200px;
    min-width: 90px;
    text-align: center;
  }
  & li a {
    text-decoration: none;
    color: #333;
  }
  & li.current a {
    color: #3280f5;
  }
  & li a:hover {
    color: #b1b6be;
  }
}
</style>


*月選択の矢印を追加






新しくSelectMonth.vueというコンポーネントを作成し、ダッシュボード画面から呼び出すようにします。月の前後を選択する矢印はCSSで作成しています。また、選択している年月は下記はまだ固定にしていますが、後に動的に修正します。
<src/components/molecules/SelectMonth.vue>
<template>
  <div>
    <div class="select-date">
      <a class="arrow back"></a>
      <div class="month">2019/07</div>
      <a class="arrow next"></a>
    </div>
  </div>
</template>
<script>
export default {
};
</script>
<style scoped lang="scss">
.select-date {
  text-align: center;
  max-width: 840px;
}
.month {
  font-size: 35px;
  color: grey;
  font-weight: bold;
  margin-top: 40px;
  display: inline-block;
  user-select: none;
}
.arrow {
  position: relative;
  display: inline-block;
  padding: 0 0 0 24px;
  color: #000;
  vertical-align: middle;
  text-decoration: none;
}
.arrow::before,
.arrow::after {
  position: absolute;
  top: 0;
  bottom: 0;
  left: 0;
  margin: auto;
  content: "";
  vertical-align: middle;
}
.back::before {
  bottom: 11px;
  width: 8px;
  height: 8px;
  border-top: 3px solid #00000052;
  border-left: 3px solid #00000052;
  -webkit-transform: rotate(-45deg);
  transform: rotate(-45deg);
}
.next::before {
  left: 10px;
  bottom: 11px;
  width: 8px;
  height: 8px;
  border-top: 3px solid #00000052;
  border-right: 3px solid #00000052;
  -webkit-transform: rotate(45deg);
  transform: rotate(45deg);
}
</style>

ダッシュボード画面から月選択コンポーネントを読み込みます。
<src/components/pages/Dashboard.vue>
<template>
  <div>
    <navbar baseName="home"></navbar>
    <sidebar></sidebar>
    <div class="top">
      <div>
     <!---- ↓追加 ---->
        <select-month></select-month>
        <value-card name="ユーザー数 合計" :value="userTotal"></value-card>
        <value-card name="プレビュー数 合計" :value="previewTotal"></value-card>
      </div>
      <graph-card name="ユーザー数 推移" :data="userData" :label="labelData" :min="200" :max="600"></graph-card>
      <graph-card name="プレビュー数 推移" :data="previewData" :label="labelData"  :min="200" :max="600"></graph-card>
      <list-card name="データ一覧"></list-card>
    </div>
  </div>
</template>
<script>
import Navbar from "../organisms/Navbar";
import Sidebar from "../organisms/Sidebar";
import ValueCard from "../molecules/ValueCard";
import GraphCard from "../molecules/GraphCard";
import ListCard from "../molecules/ListCard";
//---- ↓追加 ----
import SelectMonth from "../molecules/SelectMonth";
export default {
  components: {
    Navbar,
    Sidebar,
    ValueCard,
    GraphCard,
    ListCard,
    SelectMonth      // ← 追加
  },
  ...
};
</script>
<style scoped lang="scss">
.top {
  padding: 66px 0 0 200px;
}
</style>


*子コンポーネントの変更を親で受け取る

月選択の表示はSelectMonth.vueで行いますが、DashBoard.vueのグラフデータも選択した月のデータに更新されるようにします。
DashBoard.vue <--年月-- SelectMonth.vue
DashBoard.vue --年月--> GraphCard.vue --> LineChart.vue

子の変更を親で受け取るには$emitを使います。子コンポーネントで変数名と値を$emitに登録すると、親コンポーネントでその値を受け取ることができます。別の名前で複数登録することも可能です。
  • 使い方
<子コンポーネント>
<script>
export  default  {
  data() {
    return {
   realDate:  null
 }
  },
  methods: {
    set() {
   // $emit(名前,値)に登録
   this.$emit("date",  this.realDate);
 }
  }
}
</script>

親コンポーンネントでは、子のカスタムタグにv-onディレクティブ(省略形@も可)を追加して$emitの値を受け取るようにします。こうすることで、子から渡された値が更新されたときにsetMonthData()が呼び出され、$emitで登録した値を引数として受け取ることができます。
<親コンポーネント>
<template>
 <select-month @date="setMonthData"></select-month>
</template>
<script>
export  default  {
 ...
 methods:  {
     setMonthData(date) {
       this.daysNum = this.getDays(date);
     }
 }
}
</script>

実際のコードに追加します。
矢印をクリックしたときに月を変更し、その値を$emitに登録すれば親コンポーネントに年月を渡すことができます。クリックしたときの処理は、タグに@clickを追加し動作させたいメソッドを指定します。
<src/components/molecules/SelectMonth.vue>
<template>
  <div>
    <div class="select-date">
   <!---- ↓「<」をクリックすると beforeMonth()を呼び出し ---->
      <a class="arrow back" @click="beforeMonth"></a>
      <div class="month">{{ dispDate }}</div>
      <!---- ↓「>」をクリックすると afterMonth()を呼び出し ---->
      <a class="arrow next" @click="afterMonth"></a>
    </div>
  </div>
</template>
<script>
export default {
  created() {
    this.selectDate = this.thisMonth;
    this.dispDate = this.getYearMonth(this.selectDate);
  },
  data() {
    return {
      realDate: null,
      selectDate: null,
      dispDate: null
    };
  },
  computed: {
    thisMonth() {
      this.realDate = new Date();
      const date = new Date();
      date.setMonth(date.getMonth() + 1);
      return date;
    }
  },
  methods: {
    getYearMonth(date) {
      if (date.getMonth() === 0) {
        return date.getFullYear() - 1 + "/12";
      }
      return date.getFullYear() + "/" + ("0" + date.getMonth()).slice(-2);
    },
    beforeMonth() {
      this.realDate.setDate(1);
      this.realDate.setMonth(this.realDate.getMonth() - 1);
      this.selectDate.setDate(1);
      this.selectDate.setMonth(this.selectDate.getMonth() - 1);
      this.dispDate = this.getYearMonth(this.selectDate);
      this.$emit("date", this.realDate);
    },
    afterMonth() {
      this.realDate.setDate(1);
      this.realDate.setMonth(this.realDate.getMonth() + 1);
      this.selectDate.setDate(1);
      this.selectDate.setMonth(this.selectDate.getMonth() + 1);
      this.dispDate = this.getYearMonth(this.selectDate);
      this.$emit("date", this.realDate);
    }
  }
};
</script>
<style scoped lang="scss">
.select-date {
  text-align: center;
  max-width: 840px;
}
.month {
  font-size: 35px;
  color: grey;
  font-weight: bold;
  margin-top: 40px;
  display: inline-block;
  user-select: none;
}
.arrow {
  position: relative;
  display: inline-block;
  padding: 0 0 0 24px;
  color: #000;
  vertical-align: middle;
  text-decoration: none;
}
.arrow::before,
.arrow::after {
  position: absolute;
  top: 0;
  bottom: 0;
  left: 0;
  margin: auto;
  content: "";
  vertical-align: middle;
}
.back::before {
  bottom: 11px;
  width: 8px;
  height: 8px;
  border-top: 3px solid #00000052;
  border-left: 3px solid #00000052;
  -webkit-transform: rotate(-45deg);
  transform: rotate(-45deg);
}
.next::before {
  left: 10px;
  bottom: 11px;
  width: 8px;
  height: 8px;
  border-top: 3px solid #00000052;
  border-right: 3px solid #00000052;
  -webkit-transform: rotate(45deg);
  transform: rotate(45deg);
}
</style>

月が更新されたら、ダッシュボード画面からチャートのデータを更新する処理を追加します。
setMonthData()でグラフのデータとラベルを設定しています。
<src/components/pages/Dashboard.vue>
<template>
  <div>
    <navbar baseName="home"></navbar>
    <sidebar></sidebar>
    <div class="top">
      <div>
        <select-month @date="setMonthData"></select-month>
        <value-card name="ユーザー数 合計" :value="userTotal"></value-card>
        <value-card name="プレビュー数 合計" :value="previewTotal"></value-card>
      </div>
      <graph-card name="ユーザー数 推移" :data="userData" :label="labelData" :min="200" :max="600"></graph-card>
      <graph-card name="プレビュー数 推移" :data="previewData" :label="labelData"  :min="200" :max="600"></graph-card>
      <list-card name="データ一覧"></list-card>
    </div>
  </div>
</template>
<script>
import Navbar from "../organisms/Navbar";
import Sidebar from "../organisms/Sidebar";
import ValueCard from "../molecules/ValueCard";
import GraphCard from "../molecules/GraphCard";
import ListCard from "../molecules/ListCard";
import SelectMonth from "../molecules/SelectMonth";
export default {
  components: {
    Navbar,
    Sidebar,
    ValueCard,
    GraphCard,
    ListCard,
    SelectMonth
  },
  data() {
    return {
      daysNum: 0,
      userData: [],
      previewData: [],
      labelData: []
    };
  },
  created() {
    this.setMonthData(new Date());
  },
  computed: {
    userTotal() {
      return this.userData.reduce((prev, current) => prev + current);
    },
    previewTotal() {
      return this.previewData.reduce((prev, current) => prev + current);
    }
  },
  methods: {
    getUserData() {
      // TODO: fix getting from server data
      return [...Array(this.daysNum)].map(
        () => 300 + Math.floor(Math.random() * Math.floor(500 - 300))
      );
    },
    getPreviewData() {
      // TODO: fix getting from server data
      return [...Array(this.daysNum)].map(
        () => 300 + Math.floor(Math.random() * Math.floor(500 - 300))
      );
    },
    getDays(date) {
      return new Date(date.getFullYear(), date.getMonth() + 1, 0).getDate();
    },
    getLabelList(date) {
      date.setDate(0);
      return [...Array(this.daysNum)].map(() => {
        date.setDate(date.getDate() + 1);
        return date.toLocaleDateString();
      });
    },
    setMonthData(date) {
      this.daysNum = this.getDays(date);
      this.userData = this.getUserData();
      this.previewData = this.getPreviewData();
      this.labelData = this.getLabelList(date);
    }
  }
};
</script>
<style scoped lang="scss">
.top {
  padding: 66px 0 0 200px;
}
</style>


*補足

  • Date型
JavaScriptDateについて補足です。
getMonth()は 1〜12月 が 0〜11 の値で取得されるので、今月の月を取得したい場合はgetMonth() + 1をします。
const date = new Date();
date.setMonth(date.getMonth() + 1);

また、月を0埋めの文字列で取得したかったので、0を連結してslice()を使って最後の2桁を取得するようにしました。
const date = new Date();
const month = ("0"  +  date.getMonth()).slice(-2);

  • Vue-chartのチャートが更新されないとき
月の選択を変更した際にチャートも再描画したかったのですが、Chart.jsにはリアクティブでデータの更新をする機能が備わっていないため、vue-chartについても再描画してくれません。
このため、vue-chartからmixinsをインポートし、mixins.reactiveDataをミックスインすることでデータを監視し、リアクティブに再描画してくれるようになります。
reactiveDataを使う際はchartDataという変数名が定義されるので、この変数名を使ってpropsで親から値を受け取り、renderChart()に渡す必要があります。
<script>
import { Line, mixins } from "vue-chartjs";
export default {
  extends: Line,
  mixins: [mixins.reactiveProp],
  props: ["chartData", "options"],
  mounted() {
    this.renderChart(this.chartData, this.options);
  }
};
</script>

  • 数値リストの合計値を取得
リストに対してreduce()を使うと、各要素の値を1つの累積した値にすることができます。[1, 2, 3].reduce((prev, current) => {prev + current})とすることで、リストの合計値を算出できます。
下記ではリストの値をランダムで作成し、その値を合計しています。
<script>
export default {
  data() {
    return {
      userData: [],
      previewData: []
    };
  },
  computed: {
    getUserData() {
      return [...Array(this.daysNum)].map(
        () => 300 + Math.floor(Math.random() * Math.floor(500 - 300))
      );
    },
    userTotal() {
      return this.userData.reduce((prev, current) => prev + current);
    }
  }
};
</script>

  • 3桁区切りの数値
NumberオブジェクトのtoLocaleString()を使うことで、3桁区切りのカンマフォーマットにすることができます。
const num = 123456;
console.log(num.toLocaleString())

=> 123,456


*Herokuで公開

こちらの記事を参考にさせていただきました。
.gitignore/distを削除しておきます。
また、expressをインストールしておきます。
$ npm install --save express

package.jsonscript"start": "node server.js"を追加します。
{
  "name": "manage-app",
  "version": "0.1.0",
  "private": true,
  "scripts": {
    "serve": "vue-cli-service serve",
    "build": "vue-cli-service build",
    "lint": "vue-cli-service lint",
    "start": "node server.js"
  },
  ...

プロジェクト直下にserver.jsを新規作成します。
コンパイルしたファイルをdictディレクトリに作成するようにしています。
<server.js>
const express = require("express");
const port = process.env.PORT || 8080;
const app = express();

app.use(express.static(__dirname + "/dist/"));

app.get(/.*/, function(req, res) {
  res.sendfile(__dirname + "/dist/index.html");
});

app.listen(port);

console.log("Server is up!!");

下記コマンドを実行してコンパイルするとdictディレクトリが作成されます。
$ npm run build

ちなみに私の環境ではビルド時にエラーになったため、下記コマンドを実行してからビルドする必要がありました。(`terser`ライブラリに作り込まれたバグが原因なようです)
$npm i terser --save

Herokuにアプリを作成します。
$ heroku create {任意のアプリ名}

ここまでをコミットしてから、下記コマンドを実行して Heroku にデプロイします。
$ git push heroku master

アプリケーションの削除は下記コマンドを実行します。
$ heroku apps:destroy --app {アプリ名} --confirm {アプリ名}


*所感

computedにグラフデータを作成したメソッドを定義したら、前の月と日数が変わらない場合は実行されない事象が発生しました。computed()は使うデータに変更がないと実行せず、無駄な処理を行わないようになっているようです。今回はデータの変更ありなしに関わらず実行させたかったので、methodsにメソッドを定義することで解決しました。
Vue.js は学習コストが比較的低く簡単に実装できますが、機能を理解していないと重大なバグにつながったりするので、よく理解して使えるようにしたいです。


Previous
Next Post »

人気の投稿